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

发布时间:2026/11/6 6:41:56
【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(;;){intsockfd=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

另一种经典做法是:

  1. 父进程fork出中间子进程;
  2. 中间子进程立即再次fork;
  3. 中间子进程马上退出;
  4. 父进程只等待这个生命周期极短的中间子进程;
  5. 孙子进程真正执行Service;
  6. 中间子进程退出后,孙子进程成为孤儿,由系统进程接管并回收。

代码结构如下:

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.1TcpServer.hpp

#pragmaonce#include"Common.hpp"#include"InetAddr.hpp"#include<sys/wait.h>#include<iostream>classTcpServer:publicNoCopy{public:explicitTcpServer(uint16_tport):port_(port){}boolInit(){listen_sock_=socket(AF_INET,SOCK_STREAM,0);if(listen_sock_<0){std::cerr<<"socket error: "<<strerror(errno)<<std::endl;returnfalse;}intenable=1;if(setsockopt(listen_sock_,SOL_SOCKET,SO_REUSEADDR,&enable,sizeof(enable))<0){std::cerr<<"setsockopt error: "<<strerror(errno)<<std::endl;returnfalse;}InetAddrlocal(port_);if(bind(listen_sock_,local.AddrPtr(),local.Length())<0){std::cerr<<"bind error: "<<strerror(errno)<<std::endl;returnfalse;}if(listen(listen_sock_,8)<0){std::cerr<<"listen error: "<<strerror(errno)<<std::endl;returnfalse;}returntrue;}voidRun(){for(;;){sockaddr_in peer_addr{};socklen_t len=sizeof(peer_addr);intsockfd=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;}InetAddrpeer(peer_addr);ProcessConnection(sockfd,peer);}}~TcpServer()override{if(listen_sock_>=0){close(listen_sock_);}}private:staticboolWriteAll(intsockfd,conststd::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);}elseif(n<0&&errno==EINTR){continue;}else{returnfalse;}}returntrue;}staticvoidService(intsockfd,constInetAddr&peer){charbuffer[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;}}elseif(n==0){std::cout<<peer.StringAddr()<<" quit"<<std::endl;break;}elseif(errno!=EINTR){break;}}}voidProcessConnection(intsockfd,constInetAddr&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_tport_;intlisten_sock_=-1;};

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

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

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

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

5.1 TCP 连接关闭后还有状态

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

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

5.2SO_REUSEADDR

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

intenable=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.4fork失败后没有关闭通信 fd

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

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

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

总结

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

  1. 主进程只负责accept,孙子进程负责连接服务;
  2. 子进程继承监听 fd 和通信 fd,必须关闭自己不需要的那一份;
  3. 父进程也要关闭通信 fd,防止引用和描述符泄漏;
  4. 单次 fork 后需要处理僵尸进程;
  5. 双重 fork 让真正工作的孙子进程由系统接管;
  6. SO_REUSEADDR可以改善服务器重启时的地址复用问题。

下一篇继续把进程切换成线程,分析线程为什么不能随意关闭共享 fd,并在多线程 TCP 服务器上实现受限远程命令执行。

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