【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

  • 前言
  • [一、线程为什么能直接使用通信 fd](#一、线程为什么能直接使用通信 fd)
    • [1.1 线程共享进程的大部分资源](#1.1 线程共享进程的大部分资源)
    • [1.2 工作线程不能随意关闭监听 fd](#1.2 工作线程不能随意关闭监听 fd)
  • 二、线程入口为什么通常是静态函数
    • [2.1 原生线程函数签名](#2.1 原生线程函数签名)
    • [2.2 把连接信息放进 `ThreadData`](#2.2 把连接信息放进 ThreadData)
    • [2.3 分离线程还是回收线程](#2.3 分离线程还是回收线程)
  • 三、把网络收发与业务处理分开
    • [3.1 回调函数](#3.1 回调函数)
    • [3.2 当前示例的边界](#3.2 当前示例的边界)
  • [四、完整的多线程 TCP 服务器](#四、完整的多线程 TCP 服务器)
  • 五、受限远程命令业务
    • [5.1 为什么必须使用白名单](#5.1 为什么必须使用白名单)
    • [5.2 `popen` 如何取得命令输出](#5.2 popen 如何取得命令输出)
    • [5.3 `Command.hpp`](#5.3 Command.hpp)
    • [5.4 `TcpServer.cc`](#5.4 TcpServer.cc)
    • [5.5 Makefile](#5.5 Makefile)
  • 六、这一版还不能当作正式远程管理工具
  • 七、多线程模型的优缺点
  • 总结

前言

多进程服务器能同时服务多个客户端,但每条连接都创建进程,地址空间和调度成本较高。TCP 服务器也可以为每条连接创建线程:主线程负责 accept,工作线程负责当前通信 fd。

线程版本看起来只是把 fork 换成 pthread_create,实际却有一个重要区别:同一进程中的线程共享文件描述符表。

这意味着工作线程可以直接使用主线程 accept 得到的 fd,但也意味着某个线程执行 close 会影响整个进程。本文先把线程模型写清楚,再在它上面加入一个受限制的远程命令业务。


一、线程为什么能直接使用通信 fd

1.1 线程共享进程的大部分资源

创建线程不会复制一份新的进程地址空间。线程拥有独立的栈、寄存器上下文和调度信息,但会共享进程中的代码、全局数据、堆和文件描述符表。

主线程执行:

cpp 复制代码
int sockfd = accept(listen_sock, ...);

之后创建工作线程,只要把 sockfd 作为参数传进去,工作线程就能直接调用 read 和 write。

这和多进程不同。多进程通过 fork 继承描述符表副本,父子进程分别关闭自己的描述符;线程访问的是同一张描述符表,没有"每个线程自己的一份 fd"。

1.2 工作线程不能随意关闭监听 fd

在多进程版本中,子进程关闭 listen_sock,不会让父进程失去监听能力,因为父进程仍有自己的描述符表项。

在线程版本中,如果工作线程调用:

cpp 复制代码
close(listen_sock);

这个监听 fd 会从整个进程的描述符表中关闭,主线程下一次 accept 就无法继续工作。

所以线程只处理交给它的通信 fd,不要关闭进程仍在使用的监听 fd。

线程共享文件描述符表。一个线程关闭 fd,其他线程也不能再使用它;多进程中的"各关各的引用"不能原样套到多线程模型。

二、线程入口为什么通常是静态函数

2.1 原生线程函数签名

pthread_create 要求线程入口是普通函数指针:

cpp 复制代码
void* Routine(void* args);

普通非静态成员函数自带隐藏的 this 参数,类型不符合要求。因此可以把入口声明成 static,再通过参数把对象地址传进去。

2.2 把连接信息放进 ThreadData

线程至少需要:

  • 当前客户端通信 fd;
  • 客户端地址;
  • 服务器对象指针,用来调用成员函数。
cpp 复制代码
struct ThreadData {
    TcpServer* server;
    int sockfd;
    InetAddr peer;

    ThreadData(TcpServer* s,
               int fd,
               const InetAddr& addr)
        : server(s), sockfd(fd), peer(addr) {}
};

不能把 accept 循环中的局部变量地址直接传给线程。主线程很快进入下一轮循环,局部变量可能被覆盖,工作线程最终读到另一位客户端的数据。

因此每条连接创建独立 ThreadData,线程结束后再释放。

2.3 分离线程还是回收线程

若主线程每创建一个工作线程就立即 pthread_join,它仍要等当前连接结束,服务器会重新变成串行。

一连接一线程的简单版本可以在线程入口中分离自己:

cpp 复制代码
pthread_detach(pthread_self());

分离线程退出后,系统自动回收线程资源,主线程不再 join。

但分离不代表"可以不管资源"。通信 fd、堆上的 ThreadData 以及业务对象生命周期仍然要由代码正确处理。

三、把网络收发与业务处理分开

3.1 回调函数

TCP 服务器负责:

text 复制代码
socket -> bind -> listen -> accept -> read/write

收到字符串以后究竟是回显、翻译还是执行命令,不应该写死在网络框架中。可以定义回调:

cpp 复制代码
using Handler = std::function<std::string(
    const std::string&,
    const InetAddr&)>;

服务器读取请求后调用:

cpp 复制代码
std::string response = handler_(request, peer);

网络层只负责把响应写回去。

3.2 当前示例的边界

目前仍然把一次 read 得到的内容当作一条完整命令,这只是为了先观察 TCP 编程流程。

TCP 是字节流:

  • 一条命令可能被分成多次读取;
  • 多条命令也可能在一次读取中同时到达;
  • 命令执行结果可能超过单次缓冲区大小。

正式业务必须设计应用层协议,例如长度字段、分隔符或固定头部。下一章节"应用层自定义协议与序列化"会专门解决这个问题。

TCP 可靠传输的是字节序列,不负责替应用层保存消息边界。示例能回显,不等于协议设计已经完成。

四、完整的多线程 TCP 服务器

继续复用第一篇的 Common.hpp 和 InetAddr.hpp。下面给出多线程版本的 TcpServer.hpp。

cpp 复制代码
#pragma once

#include "Common.hpp"
#include "InetAddr.hpp"

#include <pthread.h>

#include <functional>
#include <iostream>
#include <memory>
#include <utility>

using Handler = std::function<std::string(
    const std::string&,
    const InetAddr&)>;

class TcpServer : public NoCopy {
private:
    struct ThreadData {
        TcpServer* server;
        int sockfd;
        InetAddr peer;

        ThreadData(TcpServer* s,
                   int fd,
                   const InetAddr& addr)
            : server(s), sockfd(fd), peer(addr) {}
    };

public:
    TcpServer(uint16_t port, Handler handler)
        : port_(port), handler_(std::move(handler)) {}

    bool Init() {
        listen_sock_ = socket(AF_INET, SOCK_STREAM, 0);
        if (listen_sock_ < 0) {
            std::cerr << "socket error: " << strerror(errno) << std::endl;
            return false;
        }

        int enable = 1;
        setsockopt(listen_sock_,
                   SOL_SOCKET,
                   SO_REUSEADDR,
                   &enable,
                   sizeof(enable));

        InetAddr local(port_);
        if (bind(listen_sock_, local.AddrPtr(), local.Length()) < 0) {
            std::cerr << "bind error: " << strerror(errno) << std::endl;
            return false;
        }

        if (listen(listen_sock_, 8) < 0) {
            std::cerr << "listen error: " << strerror(errno) << std::endl;
            return false;
        }
        return true;
    }

    void Run() {
        for (;;) {
            sockaddr_in peer_addr{};
            socklen_t len = sizeof(peer_addr);

            int sockfd = accept(
                listen_sock_,
                reinterpret_cast<sockaddr*>(&peer_addr),
                &len);

            if (sockfd < 0) {
                if (errno == EINTR) {
                    continue;
                }
                std::cerr << "accept error: "
                          << strerror(errno) << std::endl;
                continue;
            }

            InetAddr peer(peer_addr);
            auto* data = new ThreadData(this, sockfd, peer);

            pthread_t tid;
            int ret = pthread_create(&tid, nullptr, Routine, data);
            if (ret != 0) {
                std::cerr << "pthread_create error: "
                          << strerror(ret) << std::endl;
                close(sockfd);
                delete data;
            }
        }
    }

    ~TcpServer() override {
        if (listen_sock_ >= 0) {
            close(listen_sock_);
        }
    }

private:
    static void* Routine(void* args) {
        pthread_detach(pthread_self());

        std::unique_ptr<ThreadData> data(
            static_cast<ThreadData*>(args));

        data->server->Service(data->sockfd, data->peer);
        close(data->sockfd);
        return nullptr;
    }

    static bool WriteAll(int sockfd, const std::string& message) {
        std::size_t offset = 0;
        while (offset < message.size()) {
            ssize_t n = write(sockfd,
                              message.data() + offset,
                              message.size() - offset);
            if (n > 0) {
                offset += static_cast<std::size_t>(n);
            } else if (n < 0 && errno == EINTR) {
                continue;
            } else {
                return false;
            }
        }
        return true;
    }

    void Service(int sockfd, const InetAddr& peer) {
        char buffer[1024];

        for (;;) {
            ssize_t n = read(sockfd, buffer, sizeof(buffer) - 1);
            if (n > 0) {
                std::string request(buffer,
                                    static_cast<std::size_t>(n));
                std::string response = handler_(request, peer);

                if (!WriteAll(sockfd, response)) {
                    break;
                }
            } else if (n == 0) {
                std::cout << peer.StringAddr() << " quit" << std::endl;
                break;
            } else if (errno != EINTR) {
                std::cerr << peer.StringAddr()
                          << " read error: "
                          << strerror(errno) << std::endl;
                break;
            }
        }
    }

private:
    uint16_t port_;
    int listen_sock_ = -1;
    Handler handler_;
};

这里使用 unique_ptr<ThreadData> 接管线程参数。无论 Service 从哪条路径返回,ThreadData 都会自动释放,不再依赖手写 delete。

五、受限远程命令业务

5.1 为什么必须使用白名单

客户端传来的字符串不能直接交给 Shell。若服务器无条件执行:

cpp 复制代码
popen(request.c_str(), "r");

远端用户就能运行任意命令,读取文件、创建进程甚至删除数据。

黑名单很难覆盖所有组合形式。例如只禁止 rm,用户仍可能使用命令连接符、脚本解释器或其他工具绕过。教学示例采用严格白名单:只有完整字符串与允许项完全一致时才执行。

5.2 popen 如何取得命令输出

popen(command, "r") 内部会创建管道并启动 Shell 执行命令。命令的标准输出写入管道,父进程得到 FILE*,可以通过 fgets 读取。

使用完成后要调用 pclose,它不仅关闭流,还会等待子进程并返回终止状态。

5.3 Command.hpp

cpp 复制代码
#pragma once

#include "InetAddr.hpp"

#include <cstdio>
#include <set>
#include <string>

class Command {
public:
    Command()
        : whitelist_({
              "ls",
              "ls -l",
              "pwd",
              "who",
              "whoami"
          }) {}

    std::string Execute(const std::string& command,
                        const InetAddr& peer) const {
        if (whitelist_.find(command) == whitelist_.end()) {
            return "command rejected\n";
        }

        FILE* fp = popen(command.c_str(), "r");
        if (fp == nullptr) {
            return "popen failed\n";
        }

        std::string result;
        char buffer[1024];

        while (fgets(buffer, sizeof(buffer), fp) != nullptr) {
            result += buffer;
        }

        int status = pclose(fp);
        if (status == -1) {
            return "pclose failed\n";
        }

        if (result.empty()) {
            result = "command completed without output\n";
        }

        return peer.StringAddr() + " execute result:\n" + result;
    }

private:
    std::set<std::string> whitelist_;
};

这里没有把 touch 放进白名单,因为文章只需要演示读取型命令。允许写操作会改变服务器状态,风险和排查成本都更高。

5.4 TcpServer.cc

cpp 复制代码
#include "Command.hpp"
#include "TcpServer.hpp"

#include <functional>
#include <iostream>

int main(int argc, char* argv[]) {
    if (argc != 2) {
        std::cerr << "Usage: " << argv[0] << " port" << std::endl;
        return USAGE_ERR;
    }

    uint16_t port = static_cast<uint16_t>(std::stoi(argv[1]));
    Command command;

    TcpServer server(
        port,
        [&command](const std::string& request,
                   const InetAddr& peer) {
            return command.Execute(request, peer);
        });

    if (!server.Init()) {
        return SOCKET_ERR;
    }

    server.Run();
    return OK;
}

5.5 Makefile

makefile 复制代码
.PHONY: all clean

all: tcpclient tcpserver

tcpclient: TcpClient.cc
	g++ -o $@ $^ -std=c++17

tcpserver: TcpServer.cc
	g++ -o $@ $^ -std=c++17 -pthread

clean:
	rm -f tcpclient tcpserver

六、这一版还不能当作正式远程管理工具

即使有白名单,这个示例仍然缺少很多正式系统必须具备的能力:

  • 身份认证和权限控制;
  • 传输加密;
  • 完整的应用层报文协议;
  • 命令执行超时;
  • 输出大小限制;
  • 审计日志和并发数量限制。

因此它只用于学习 TCP、多线程和回调分层,不能直接暴露到公网。

白名单只是最低限度的输入限制,不等于远程执行系统已经安全。没有认证、加密和协议边界的服务不应部署到不可信网络。

七、多线程模型的优缺点

优点:

  • 同一进程内共享数据方便;
  • 创建与切换成本通常低于进程;
  • 每条长连接有独立执行流,写法直观。

缺点:

  • 线程共享地址空间,一个线程越界可能破坏整个进程;
  • 共享数据需要加锁;
  • 连接数量很大时,一连接一线程仍会消耗大量栈和调度资源;
  • 分离线程不便集中管理和优雅退出。

下一篇继续引入线程池。不过线程池不适合无条件处理所有长连接,需要先区分长服务和短服务。

总结

本文完成了多线程 TCP 服务器和受限远程命令业务:

  1. 线程共享文件描述符表,可以直接使用 accept 返回的 fd;
  2. 工作线程不能关闭主线程仍在使用的监听 fd;
  3. 每条连接的参数必须拥有独立生命周期;
  4. 分离线程退出后由系统回收,但通信 fd 和堆对象仍要正确释放;
  5. 网络框架通过回调与业务解耦;
  6. 远程命令必须严格限制,示例只允许只读白名单命令。

下一篇把通信任务交给线程池,并讨论为什么线程池更适合短服务,而不是让少量工作线程长期占在连接上。

往期博客分享

【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池

【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server

【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork

相关推荐
程序员沉梦听雨14 分钟前
WSL2的安装与使用
linux·wsl
微小的鱼24 分钟前
Linux的渲染与显示系统链路
android·java·linux
lisanmengmeng1 小时前
alertmanger 标签 rules 打上分组标签
linux·运维·服务器
严肃小白兔1 小时前
Cloudflare 更新 API Token 管理:从最小权限到自动化凭据安全
网络·安全·自动化
Android系统攻城狮1 小时前
Linux FFmpeg深度解析之av_write_frame调用流程与实战(三十三)
linux·运维·服务器·ffmpeg·音视频进阶·ffmpeg音视频
上火的金鱼妹2 小时前
k8s生产实际问题排查(网关,数据库读写等)
运维·服务器
孙克旭_2 小时前
k8s基础组件与创建资源
运维·docker·容器·kubernetes
孙克旭_2 小时前
云原生基础之Containerd容器运行时
运维·云原生
分布式存储与RustFS2 小时前
RustFS 自建对象存储实战目录:110 篇按场景分类(持续更新)
运维·云原生·开源·对象存储·分布式存储·s3·性能基准