云计算百科
云计算领域专业知识百科平台

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

封面

🔥个人主页:爱和冰阔乐 📚专栏传送门:《数据结构与算法》 、C++、 《Linux操作系统》 🐶学习方向:C++方向学习爱好者 ⭐人生格言:得知坦然 ,失之淡然

在这里插入图片描述


🏠博主简介 在这里插入图片描述

文章目录

  • 前言
  • 一、单进程服务器为什么不能并发
  • 二、`fork` 后谁能看到哪些文件描述符
    • 2.1 子进程继承父进程的描述符表
    • 2.2 不需要的描述符必须关闭
    • 2.3 为什么父进程必须关闭通信 fd
  • 三、只 fork 一次会出现僵尸进程
    • 3.1 子进程退出后需要回收
    • 3.2 方案一:忽略 `SIGCHLD`
    • 3.3 方案二:双重 fork
  • 四、完整的多进程服务器
    • 4.1 `TcpServer.hpp`
    • 4.2 为什么 `peer` 可以按值传入孙子进程
  • 五、服务器重启为什么可能 bind 失败
    • 5.1 TCP 连接关闭后还有状态
    • 5.2 `SO_REUSEADDR`
    • 5.3 不要把选项写成按位或的名称
  • 六、多进程模型适合什么场景
  • 七、容易写错的地方
    • 7.1 父进程忘记关闭 `sockfd`
    • 7.2 子进程忘记关闭 `listen_sock`
    • 7.3 父进程阻塞等待真正干活的子进程
    • 7.4 `fork` 失败后没有关闭通信 fd
    • 7.5 混淆文件描述符和内核对象
  • 总结

前言

上一篇完成了单进程 Echo Server。它能够正常连接、读取和回显,但只能按顺序服务客户端:主线程进入 Service(sockfd) 后,只要当前连接不退出,就不会回到下一轮 accept。

解决方法很直接:主进程只负责获取连接,子进程负责处理连接。

不过一旦加入 fork,文件描述符继承、引用计数、僵尸进程和连接关闭顺序都会跟着出现。本文从单次 fork 开始,最后整理成双重 fork 的多进程 TCP 服务器,并解释服务器重启时为什么可能暂时绑定失败。


一、单进程服务器为什么不能并发

单进程版本的主循环大致如下:

for (;;) {
int sockfd = accept(listen_sock, ...);
Service(sockfd);
close(sockfd);
}

accept 返回客户端 A 的通信 fd 后,程序进入 Service(A)。只要 A 不关闭连接,read(A) 就会不断等待并处理 A 的数据,主线程无法继续执行 accept。

客户端 B 此时仍可能完成三次握手,因为握手由内核协议栈处理。可是应用程序没有再次调用 accept,B 只能停留在连接队列中,迟迟得不到业务响应。

问题不在 TCP,也不在 accept,而在于只有一条执行流承担了两种职责:

获取新连接 + 服务已有连接

把它们拆开以后,主进程就可以快速回到 accept。

二、fork 后谁能看到哪些文件描述符

2.1 子进程继承父进程的描述符表

父进程完成 accept 后,通常同时持有两个文件描述符:

listen_sock:监听套接字
sockfd:当前客户端通信套接字

这时调用 fork,子进程会继承父进程的文件描述符。父子进程中的描述符编号虽然相同,但它们背后的描述符表项共同引用内核文件对象。

所以子进程既能看到 listen_sock,也能看到 sockfd。

2.2 不需要的描述符必须关闭

职责分工应该是:

  • 父进程继续 accept,不负责当前客户端通信;
  • 子进程只服务当前客户端,不负责接收新连接。

因此:

pid_t id = fork();

if (id == 0) {
close(listen_sock); // 子进程不负责获取新连接
Service(sockfd);
close(sockfd);
exit(0);
}

close(sockfd); // 父进程不负责服务当前客户端

这里的 close 不一定立刻销毁内核 Socket。父子进程都引用通信文件对象时,引用计数至少为 2;父进程关闭自己的 sockfd 后,子进程仍然可以继续使用。只有所有引用都关闭,相关内核资源才会真正释放。

多进程网络程序中,close 的含义是“当前进程放弃这份引用”,不是无条件把其他进程正在使用的连接一起销毁。

2.3 为什么父进程必须关闭通信 fd

如果父进程一直保留每次 accept 返回的通信 fd,即使子进程已经服务完成并关闭连接,内核对象仍然被父进程引用。

后果包括:

  • 文件描述符不断泄漏;
  • 连接无法按预期彻底关闭;
  • 服务器运行时间越长,占用资源越多。

因此,父进程完成 fork 后,应立即关闭自己不使用的通信 fd。

三、只 fork 一次会出现僵尸进程

3.1 子进程退出后需要回收

子进程处理完客户端后会退出。只要父进程仍然存在,已经退出但尚未被 wait 的子进程就会处于僵尸状态。

如果服务器不断接收连接、不断创建子进程,却从不回收,僵尸进程就会累积。

最直接的写法是:

pid_t id = fork();

if (id > 0) {
close(sockfd);
waitpid(id, nullptr, 0);
}

但阻塞式 waitpid 会让父进程等到子进程服务结束,服务器又回到了近似串行状态。

3.2 方案一:忽略 SIGCHLD

在 Linux 上,可以让系统自动处理退出子进程:

signal(SIGCHLD, SIG_IGN);

之后父进程不需要逐个阻塞等待,主循环可以继续执行 accept。

这种写法简洁,但依赖系统对 SIGCHLD 忽略行为的处理。若程序还需要取得子进程退出状态,就不适合直接忽略,而应该安装信号处理器并循环 waitpid(-1, …, WNOHANG)。

3.3 方案二:双重 fork

另一种经典做法是:

  • 父进程 fork 出中间子进程;
  • 中间子进程立即再次 fork;
  • 中间子进程马上退出;
  • 父进程只等待这个生命周期极短的中间子进程;
  • 孙子进程真正执行 Service;
  • 中间子进程退出后,孙子进程成为孤儿,由系统进程接管并回收。
  • 代码结构如下:

    pid_t id = fork();

    if (id == 0) {
    close(listen_sock);

    pid_t grandchild = fork();
    if (grandchild < 0) {
    close(sockfd);
    exit(1);
    }
    if (grandchild > 0) {
    exit(0); // 中间子进程立即退出
    }

    Service(sockfd); // 孙子进程服务客户端
    close(sockfd);
    exit(0);
    }

    close(sockfd);
    waitpid(id, nullptr, 0); // 很快回收中间子进程

    这里最容易写错的是“孤儿进程”和“僵尸进程”的关系。

    只 fork 一次时,如果父进程仍然运行,子进程退出后会成为僵尸,不会自动变成孤儿。只有父进程先退出,仍在运行的子进程才会成为孤儿。

    双重 fork 中,真正干活的是孙子进程。它的直接父进程很快退出,所以它才会被系统接管。

    双重 fork 的目的不是“创建更多进程”,而是让真正处理连接的进程脱离原父进程的回收关系,同时让原父进程快速回到 accept。

    四、完整的多进程服务器

    第一篇中的 Common.hpp、InetAddr.hpp、客户端和 Makefile 可以继续使用,只替换 TcpServer.hpp。

    4.1 TcpServer.hpp

    #pragma once

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

    #include <sys/wait.h>

    #include <iostream>

    class TcpServer : public NoCopy {
    public:
    explicit TcpServer(uint16_t port)
    : port_(port) {}

    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;
    if (setsockopt(listen_sock_,
    SOL_SOCKET,
    SO_REUSEADDR,
    &enable,
    sizeof(enable)) < 0) {
    std::cerr << "setsockopt error: "
    << strerror(errno) << std::endl;
    return false;
    }

    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);
    ProcessConnection(sockfd, peer);
    }
    }

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

    private:
    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;
    }

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

    for (;;) {
    ssize_t n = read(sockfd, buffer, sizeof(buffer) – 1);
    if (n > 0) {
    buffer[n] = '\\0';
    std::string response = "server echo# ";
    response.append(buffer, static_cast<std::size_t>(n));

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

    void ProcessConnection(int sockfd, const InetAddr& peer) {
    pid_t id = fork();
    if (id < 0) {
    std::cerr << "fork error: " << strerror(errno) << std::endl;
    close(sockfd);
    return;
    }

    if (id == 0) {
    close(listen_sock_);

    pid_t grandchild = fork();
    if (grandchild < 0) {
    close(sockfd);
    exit(FORK_ERR);
    }
    if (grandchild > 0) {
    exit(OK);
    }

    Service(sockfd, peer);
    close(sockfd);
    exit(OK);
    }

    close(sockfd);

    while (waitpid(id, nullptr, 0) < 0 && errno == EINTR) {
    }
    }

    private:
    uint16_t port_;
    int listen_sock_ = –1;
    };

    4.2 为什么 peer 可以按值传入孙子进程

    fork 会复制当前进程的用户空间。InetAddr peer 在子进程、孙子进程中各自拥有一份副本,原父进程回到下一轮循环不会改坏孙子进程里的地址对象。

    这和线程不同。线程共享同一个地址空间,若把循环局部变量按引用交给线程,就必须考虑变量何时销毁或被下一轮覆盖。

    五、服务器重启为什么可能 bind 失败

    5.1 TCP 连接关闭后还有状态

    测试时经常出现一个现象:关闭服务器以后立刻重启,bind 报“Address already in use”。这不一定表示旧进程仍然存在,也可能是 TCP 连接关闭后仍处于 TIME_WAIT 等状态。

    TCP 要保证迟到报文不会污染后续新连接,因此连接关闭后,相关四元组不会马上从系统中彻底消失。

    5.2 SO_REUSEADDR

    监听 Socket 创建以后、bind 以前,可以设置:

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

    它允许服务器在符合条件时更快地重新绑定本地地址,常用于服务重启。

    SO_REUSEADDR 不是“允许任何两个进程随便绑定同一端口”,也不能解决仍有另一个正常监听进程占用端口的情况。

    5.3 不要把选项写成按位或的名称

    有些代码会写:

    SO_REUSEADDR | SO_REUSEPORT

    作为 setsockopt 的单个选项名。这种写法不清晰,也不具备通用含义。需要两个选项时,应分别调用两次 setsockopt:

    setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &enable, sizeof(enable));
    setsockopt(fd, SOL_SOCKET, SO_REUSEPORT, &enable, sizeof(enable));

    当前 Echo Server 只设置 SO_REUSEADDR 即可。SO_REUSEPORT 更偏向多个 Socket 共同监听同一端口及负载分配场景,不要因为名字相近就默认一起打开。

    六、多进程模型适合什么场景

    多进程的优点是隔离性强。某个服务进程崩溃,通常不会直接破坏其他连接;每个进程还有独立地址空间,业务代码相互影响较小。

    缺点也明显:

    • 创建进程比创建普通任务更重;
    • 地址空间独立,进程间共享数据需要额外 IPC;
    • 客户端很多时,进程数量会快速增长;
    • 调度和内存成本更高。

    学习 TCP 时,多进程模型非常适合观察文件描述符继承和并发服务原理。真实项目是否采用,还要结合连接数量、任务时长和隔离需求判断。

    七、容易写错的地方

    7.1 父进程忘记关闭 sockfd

    会造成文件描述符泄漏,并让连接对象长期保留额外引用。

    7.2 子进程忘记关闭 listen_sock

    子进程并不负责接收新连接,保留监听 fd 只会增加无意义引用,还可能让服务器退出和端口释放行为更难判断。

    7.3 父进程阻塞等待真正干活的子进程

    这样虽然不会产生僵尸,但父进程会一直等到客户端退出,失去并发效果。双重 fork 中,父进程只等待立即退出的中间子进程。

    7.4 fork 失败后没有关闭通信 fd

    进程创建失败时,这条连接已经被 accept 取出。若不关闭 sockfd,同样会泄漏。

    7.5 混淆文件描述符和内核对象

    父子进程中的 fd 数字可能相同,但“关闭某个 fd”只影响当前进程的描述符表项。内核对象何时释放,由全部引用共同决定。

    总结

    本文把单进程 TCP 服务器改造成了多进程版本:

  • 主进程只负责 accept,孙子进程负责连接服务;
  • 子进程继承监听 fd 和通信 fd,必须关闭自己不需要的那一份;
  • 父进程也要关闭通信 fd,防止引用和描述符泄漏;
  • 单次 fork 后需要处理僵尸进程;
  • 双重 fork 让真正工作的孙子进程由系统接管;
  • SO_REUSEADDR 可以改善服务器重启时的地址复用问题。
  • 下一篇继续把进程切换成线程,分析线程为什么不能随意关闭共享 fd,并在多线程 TCP 服务器上实现受限远程命令执行。

    往期博客分享 【Linux网络】UDP 程序怎么继续封装?从 UdpSocket、回调到网络字典服务器 【Linux网络】UDP 聊天室怎么实现?从在线用户管理、消息广播到双线程收发与线程池 【Linux网络】TCP 服务器怎么写?从 listen、accept 到 read/write,手写完整 Echo Server

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 【Linux网络】TCP 服务器如何并发处理多个客户端?从 fork、僵尸进程到双重 fork
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!