多进程TCP并发服务器实战:从fork到信号处理 1. 为什么选择多进程模型并发服务器的基础认知经常有朋友问我写一个TCP并发服务器到底该用多进程、多线程还是事件驱动我的回答通常是先看清楚你的业务场景再谈技术选型。如果你接手的是一个传统Linux服务器项目或者你的业务逻辑里有大量阻塞式操作那么多进程模型至今仍是最稳妥、最不容易出错的方案之一。所谓多进程TCP并发服务器核心思路其实很简单父进程负责监听客户端连接每来一个连接就 fork 一个子进程去单独处理。子进程和处理逻辑绑定父子进程互不干扰一个子进程崩溃了不会拖垮整个服务。这个模型天然契合Linux的进程模型fork 加上 socket 这对黄金搭档几乎零学习成本就能搭建出可用的并发架构。在深入代码之前我觉得有必要先把多进程模型为什么在2024年依然不过时说清楚。首先进程之间天然隔离内存地址空间独立一个子进程因为野指针崩溃其他子进程照常服务。相比之下多线程模型一旦一个线程段错误整个进程直接挂掉。其次多进程模型可以充分利用多核CPULinux内核天然会把进程调度到不同核心上执行。第三fork 的成本其实没有传说中那么高现在Linux使用写时拷贝COW子进程初始只共享父进程的地址空间真正访问到相关内存页时才会复制所以 fork 一个进程的实际开销远小于一次性全部拷贝。当然多进程模型并不是银弹。每个连接一个进程意味着进程数量会随着连接数线性增长。如果一台机器要扛几万个并发连接进程调度和内存占用会迅速失控。这种场景应该考虑线程池加事件驱动或者干脆用 epoll 配合有限工作进程的模型。但从学习循序来说我强烈建议先搞懂多进程模型因为它是理解一切后续并发模型的基础。这篇文章会带着你从零开始手写一个可运行的TCP多进程并发服务器并拆解每一步的底层逻辑。读完你会明白 listen 和 accept 之间到底发生了什么fork 为什么改变了整个世界以及僵尸进程是如何悄悄吃光你的内存的。2. 动手前必须搞清的三件套socket、listen与accept的真实工作方式很多人写过socket代码但问他“accept 返回的 fd 到底是什么它和 listen fd 有什么区别”能讲清楚的却不多。不搞懂这些底层机制后面调试多进程并发服务会处处碰壁。2.1 TCP三次握手在你调用accept之前就已经完成了关于TCP三次握手网上已经写烂了。这里我只强调和服务器编程直接相关的一个事实三次握手主要由内核协议栈完成你写的应用代码根本感知不到握手过程。当客户端发起 SYN服务器内核自动回复 SYNACK客户端回 ACK 后连接进入 ESTABLISHED 状态然后被放入 accept 队列。也就是说在你调用 accept 之前TCP连接已经建立成功了。accept 只是从一个已完成握手的队列里取出一条连接记录并生成一个新的文件描述符供你读写。这个新 fd 和应用层之间是一对一的关系。强调这一点是因为多进程模型的很多理解和它相关每个连接需要一个独立的 fd需要独立的进程去读数据、写数据否则两个进程同时操作同一个 fd 会导致数据错乱。2.2 服务端的标准初始化流程一个标准的TCP服务器初始化流程是这样的socket() 创建套接字 - bind() 绑定地址和端口 - listen() 开始监听 - accept() 循环接受连接。我见过太多新手在 bind 时直接填端口号而不绑定 IP或者忘记处理结构体清零结果出现各种诡异的连接失败。下面是初始化部分的参考实现#include stdio.h #include stdlib.h #include string.h #include unistd.h #include sys/socket.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BACKLOG 64 int main(int argc, char *argv[]) { int listen_fd; struct sockaddr_in server_addr; listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } // 关键允许端口快速重用解决 TIME_WAIT 状态下重启失败的问题 int on 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); server_addr.sin_addr.s_addr htonl(INADDR_ANY); // 监听所有网卡接口 if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); close(listen_fd); exit(1); } if (listen(listen_fd, BACKLOG) 0) { perror(listen); close(listen_fd); exit(1); } printf(server listening on port %d\n, SERVER_PORT); // 后续逻辑在这里展开 // ... return 0; }这里有几个细节值得展开。SO_REUSEADDR 这个选项是我每次写服务端代码都会加上的。它解决的是 TIME_WAIT 状态下重启服务端口被占用的问题。什么场景会触发 TIME_WAIT当服务端主动关闭连接时会进入 TIME_WAIT 状态停留约2个报文最大生命周期通常约60秒。如果不设置 SO_REUSEADDR你线上重启服务可能直接报 Address already in use。另外listen 的第二个参数 BACKLOG 是已完成三次握手但还没被 accept 的连接队列长度。Linux内核2.2以后这个参数实际表示 accept 队列的最大长度超过后新连接会被拒绝。在并发量不大的场景下设置 64 足够了后续如果需要扛量可以调大。2.3 经典的 accept 循环长什么样基础版的 accept 循环是每个新手都会写到的逻辑就是死循环每来一个连接就处理它的请求处理完关闭连接继续等下一个while (1) { struct sockaddr_in client_addr; socklen_t addr_len sizeof(client_addr); int conn_fd accept(listen_fd, (struct sockaddr *)client_addr, addr_len); if (conn_fd 0) { perror(accept); continue; } // 处理客户端请求阻塞式读取 char buf[1024]; int n read(conn_fd, buf, sizeof(buf)); if (n 0) { write(conn_fd, buf, n); // 回显给客户端 } close(conn_fd); }这段代码的问题一眼就能看出来同一时刻只能处理一个连接。如果第一个客户端连接后不发数据服务器就阻塞在 read 上其他客户端排队等死。这就是所谓串行服务器。要让服务器同时服务多个客户端就必须在 accept 返回后立即分裂出人手去处理连接而主循环继续 accept。这正好引出 fork。3. 核心进化引入fork让服务器真正并发3.1 fork() 的语义理解一次调用两次返回fork() 是Linux下创建进程的核心系统调用。它最反直觉的地方在于调用一次返回两次。父进程返回子进程的PID子进程返回0返回-1表示创建失败。fork 之后子进程几乎是父进程的完整拷贝包括打开的文件描述符表。这意味着父进程的 listen_fd 和 conn_fd 在子进程里都可见、可操作。这里就产生了一个非常关键的设计问题子进程是否需要 listen_fd严格来说不需要它只需要 conn_fd。但 fork 之后子进程继承了所有文件描述符也就是说子进程的 fd 表里也有 listen_fd。如果子进程不显式关闭它listen_fd 会一直保持打开状态。这本身不会立即引起错误但存在一个隐患如果子进程长时间不退出listen_fd 一直被引用即使父进程意外关闭了它监听端口也不会真正释放。标准的做法是子进程里关闭 listen_fd父进程里关闭 conn_fd。其实我需要多说一句fork 之后父进程和子进程的操作边界。父进程负责的还是刚才那件事继续 accept继续 fork。它会把当前这个连接的文件描述符传给子进程后自己立即关闭这份拷贝。子进程拿到 conn_fd 后唯一的任务就是和这个客户端交互直到对方断开或业务结束然后自己退出。这种各司其职的分工可以画成下面这个流程来理解父进程socket - bind - listen - accept - fork - close(conn_fd) 循环子进程close(listen_fd) - 读写 conn_fd - close(conn_fd) - exit(0)3.2 第一版多进程回显服务器的完整实现下面这份代码是完整的TCP多进程并发回显服务器我在实验环境里跑过可以直接保存编译验证#include stdio.h #include stdlib.h #include string.h #include unistd.h #include signal.h #include sys/types.h #include sys/socket.h #include sys/wait.h #include netinet/in.h #include arpa/inet.h #define SERVER_PORT 8888 #define BACKLOG 64 #define MAX_BUFSIZE 1024 void handle_client(int conn_fd) { char buf[MAX_BUFSIZE]; int n; while ((n read(conn_fd, buf, sizeof(buf))) 0) { write(conn_fd, buf, n); } close(conn_fd); } int main(int argc, char *argv[]) { int listen_fd, conn_fd; struct sockaddr_in server_addr, client_addr; socklen_t addr_len sizeof(client_addr); pid_t pid; listen_fd socket(AF_INET, SOCK_STREAM, 0); if (listen_fd 0) { perror(socket); exit(1); } int on 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)); memset(server_addr, 0, sizeof(server_addr)); server_addr.sin_family AF_INET; server_addr.sin_port htons(SERVER_PORT); server_addr.sin_addr.s_addr htonl(INADDR_ANY); if (bind(listen_fd, (struct sockaddr *)server_addr, sizeof(server_addr)) 0) { perror(bind); exit(1); } if (listen(listen_fd, BACKLOG) 0) { perror(listen); exit(1); } printf(multi-process tcp server listening on port %d\n, SERVER_PORT); while (1) { conn_fd accept(listen_fd, (struct sockaddr *)client_addr, addr_len); if (conn_fd 0) { perror(accept); continue; } pid fork(); if (pid 0) { perror(fork); close(conn_fd); continue; } if (pid 0) { // 子进程关闭派生的监听套接字只处理当前连接 close(listen_fd); handle_client(conn_fd); exit(0); } // 父进程关闭连接套接字的拷贝 close(conn_fd); } return 0; }编译方式非常简单gcc -o tcp_server tcp_server.c没有额外的依赖库。跑起来之后可以用nc或者telnet测试nc 127.0.0.1 8888输入任意字符串服务器会原样返回。多开几个 nc 窗口同时发数据你会发现每个连接都被独立的子进程处理互不阻塞。3.3 这份初版代码最大的隐患僵尸进程上面这份代码虽然能跑却藏着一个资深开发者一眼就能看出的问题子进程退出后没有处理。子进程执行 exit(0) 之后它不会立刻从系统里消失而是残留为僵尸进程直到父进程调用 wait/waitpid 回收它的退出状态。什么是僵尸进程简单说就是一个进程已经运行结束但它的进程描述符还在内核进程表里占着一个位置让父进程可以获知它的退出码。僵尸进程本身不消耗CPU但占着进程表项。如果父进程一直不回收放任子进程源源不断地变成僵尸最终进程号耗尽fork 就会失败服务整体瘫痪。这个问题在长期运行的服务上非常致命。验证方法也很简单跑上面的服务器用 nc 连接并退出然后敲ps aux | grep tcp_server你会看到一堆显示为defunct的僵尸进程。要修复这个问题核心手段就是信号处理也就是下一节的内容。4. 信号处理与资源回收让子进程生命周期可控4.1 SIGCHLD 信号子进程退出的通知机制Linux内核在子进程终止时会向父进程发送 SIGCHLD 信号。父进程如果既不忽略也不处理这个信号默认行为是什么都不做于是僵尸进程就产生了。解决办法有两种主流思路第一种最简单signal(SIGCHLD, SIG_IGN)。直接告诉内核我对子进程的退出状态不感兴趣你自动回收吧。后果是子进程不会变成僵尸但你也永远无法知道子进程是正常退出还是崩溃退出。对业务简单、子进程自生自灭的服务来说够用。第二种是在信号处理函数里调用 waitpid。这样能在回收子进程的同时获取退出状态。这里有个容易踩的坑如果多个子进程在同一时刻退出信号处理函数只调用一次 waitpid 只能回收其中一个。所以我更建议用 waitpid(-1, status, WNOHANG) 配合循环把所有已退出的子进程都回收干净。4.2 完整版的信号处理实现在实际工程里我建议用 sigaction 而不是 signal因为 signal 在不同Unix系系统上的语义有差异。下面是推荐写法void sigchld_handler(int sig) { int status; pid_t pid; // 循环回收所有已退出的子进程-1 表示不限定具体哪个子进程 while ((pid waitpid(-1, status, WNOHANG)) 0) { // 可以在这里记录日志子进程 pid 已退出状态为 status } } void setup_sigchld_handler() { struct sigaction sa; memset(sa, 0, sizeof(sa)); sa.sa_handler sigchld_handler; sigemptyset(sa.sa_mask); sa.sa_flags SA_RESTART; // 重要让被信号中断的accept自动重启 sigaction(SIGCHLD, sa, NULL); }需要特别解释一下 SA_RESTART 这个标志。没有它的话当子进程退出、信号处理函数执行完毕后父进程如果正阻塞在 accept 调用上accept 会被信号强行打断返回 EINTR 错误。加了 SA_RESTART 后内核会自动重启被打断的系统调用accept 继续阻塞等待业务代码里不用再判断 EINTR。这个细节在写服务端时非常重要没有经验的开发者常常在这里卡很久。把 setup_sigchld_handler() 放进 main 函数、listen 之后调用再重新编译运行你再用 nc 测试就会发现僵尸进程消失了。4.3 除了信号处理还应该做的两件小事第一子进程里最好调用 setsid() 或忽略一些终端相关信号避免子进程被键盘中断等终端信号误伤。在调试场景中你给父进程发 SIGINTCtrlC默认会传到整个前台进程组所有子进程一起退出。但如果你不想这样就需要注意信号处理的设计。第二子进程开始处理业务前最好关闭从父进程继承的无关文件描述符尤其是listen_fd。虽然代码里已经 close(listen_fd)但如果项目里有日志文件、数据库连接等其他fd也要逐个关闭否则一旦子进程长期运行这些资源会一直不能释放。这里的原则是子进程只保留它真正需要的东西。5. 从回显到真实业务处理粘包、长连接与高并发下的细节跑通回显服务器只是一个开始。真实业务场景下你会立刻遇到几个绕不开的问题TCP粘包、半包以及一个连接长时间占着进程的消耗问题。5.1 TCP粘包与半包问题数据边界由应用层定义TCP是流式协议它只保证字节顺序不保证消息边界。你可能感觉调用一次 read 就能收到一整条消息但实际情况是一条消息拆成两段发、多条消息合并成一段到都完全正常。这就是所谓的粘包和半包问题。解决方案没有银弹只能靠应用层协议来约定边界。常见做法有三种方案原理优点缺点固定长度每条消息固定 N 字节不足补零实现最简单浪费带宽不适合变长数据特殊分隔符消息末尾加 \n 或特殊标记实现容易调试直观数据里不能包含分隔符需转义长度前缀前4字节存消息长度后接消息体通用性好效率高需要处理半包逻辑稍复杂我更推荐长度前缀方案尤其是消息种类较多的场景。比如约定前4个字节是网络字节序的消息总长后面跟着消息体。接收方完整读满4字节后解析出长度再循环读取直到拿够整个消息体。配合 read 循环来避免一次 read 拿不全的情况int readn(int fd, char *buf, int len) { int left len; int n; while (left 0) { n read(fd, buf, left); if (n 0) { return -1; // 出错或连接关闭 } buf n; left - n; } return len; }在真实业务里每个子进程处理一个连接时readn 这种按长度读取的辅助函数几乎是标配提前准备好能省下不少调试时间。5.2 多进程下每个连接占用多少资源一个子进程对应一个连接带来的直接代价是内存消耗。我实测过一个最小化的 fork 子进程大约占用几MB的虚拟内存其中大部分是共享的代码段和只读数据按写时拷贝机制真正私有的内存可能只有几十KB。但要注意如果子进程里加载了较大的运行时、初始化了大块堆内存私有内存就会上升。所以如果目标是支持几千个连接多进程模型会显得吃力。在真实业务里我更倾向于用主从进程池来缓解这个问题提前 fork 出固定数量的子进程每个子进程自己调用 accept 去抢连接或者由父进程分发连接。这样进程数量可控不会随连接数无限增长。下面是预 fork 模型的粗粒度示意配合锁或内核的 accept 惊群处理这是一个相对成熟的架构。5.3 子进程处理完连接后如何优雅退出子进程退出前应该做好三件事关闭 conn_fd、刷新并关闭日志句柄、清理动态分配的内存。如果子进程的业务处理流程中直接调用 exit这些清理工作会被系统接管一部分内核关闭所有 fd但显式清理日志或者执行自定义善后逻辑仍然是你自己的责任。在大型项目里子进程退出前往往还会上报退出原因给监控系统。6. 进阶与实战测压、监控、常见问题排查全指南代码能跑、没有僵尸进程这只是及格线。上线之前你还需要做性能摸底和问题预案。6.1 用压力测试和不经意间连接的坑压测工具我常用 ab、wrk 或者干脆自己写一个多线程压测客户端。以一个简单的回显服务为例我压测时遇到过每秒处理5000个短连接左右的水平单核耗尽开始成为瓶颈。注意短连接场景下 fork 和 waitpid 的开销会被放大因为每处理一个连接就要创建和回收一个进程进程调度成本成了天花板。另外一个常见坑是客户端大量连接后不发送数据。在多进程模型下这等于每个空闲连接白占一个进程造成资源浪费。如果你的业务存在大量这种空闲连接建议设置接收超时struct timeval tv; tv.tv_sec 60; tv.tv_usec 0; setsockopt(conn_fd, SOL_SOCKET, SO_RCVTIMEO, tv, sizeof(tv));超过60秒没有数据的连接read 会返回错误子进程就可以退出回收资源。6.2 一个重要提醒别忘了处理 accept 惊群和连接分发策略多进程版本的父进程 accept、子进程干活逻辑上没问题但多子进程同时 accept 同一个 listen_fd 时Linux会唤醒所有等待的进程最终只有一个能抢到连接其余重新睡眠。这就是所谓的惊群。高并发下惊群会造成CPU浪费。对这个项目级别的服务简单规避方案就是保持父进程独占 accept。如果要上预fork模型Linux 2.6以后的 accept 已经有部分惊群缓解机制但彻底解决需要用 epoll 加 EPOLLEXCLUSIVE 标志或者用互斥锁让一个时刻只有一个子进程在 accept。这一块展开写又是一篇文章了这里先提个醒避免踩坑。6.3 排查工具与定位手段真正排查问题时我习惯的组合拳是netstat -tunap | grep 8888看连接状态ps -eo pid,ppid,stat,cmd | grep tcp_server看进程关系与状态strace -p pid跟踪系统调用看进程当前卡在哪个系统调用上gdb attach pid查看进程内部状态。这几个工具配合起来大部分问题都能快速定位。举一个我实际遇到的例子线上服务运行一段时间后新连接无法建立但老连接一切正常。用 netstat 检查发现大量连接处于 TIME_WAIT 状态而且进程数已经到顶。进一步排查发现是客户端频繁重连、服务端主动关闭连接导致的。当时立刻在代码里检查 SO_REUSEADDR 是否生效同时优化业务层的长连接策略把短连接改成连接复用TIME_WAIT 数量直线下降问题解决。希望你不需要经历比我更曲折的调试过程。7. 写在最后多进程服务器编程的学习路线建议多进程TCP并发服务器是Linux服务端编程的基石。它会让你理解进程模型、文件描述符继承、信号机制、僵尸进程、TCP协议特性这些知识放到多线程、epoll、协程模型里同样适用。我个人的体会是初学者先不要追求用 libevent 或 boost.asio 搭建华丽的框架而是老老实实用原生系统调用写出这个多进程服务跑通、压测、排错把底层堆积的原理彻底吃透。之后再去看 Nginx 的master-worker模型、Redis 的事件驱动模型你会觉得它们各自的取舍一目了然。需要注意的是这个模型适合处理业务逻辑较重、但并发连接数不是特别极端的服务。如果你的业务模型是海量长连接配合少量活跃消息那么建议继续学习 epoll 配合线程池的实现方式。但无论走哪条路多进程服务这份积累都不会浪费。动手试试吧从这份代码开始去感受并行与并发、隔离与通信之间的张力和平衡。