news 2026/10/22 3:33:57

从零手写多进程TCP服务器:fork、信号处理与僵尸进程避坑指南

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从零手写多进程TCP服务器:fork、信号处理与僵尸进程避坑指南

1. 为什么还需要亲手写一个多进程TCP服务器

做Linux后端开发的人,迟早都会撞上这道坎:别人问你TCP并发服务器怎么实现,你张口能说出epoll、Reactor、协程这些词,但真要你从零手写一个基于多进程的TCP服务器,很多人反而会卡在socket、bind、listen、accept这些最基础的调用上,更别提fork之后的进程回收、信号处理这些连环坑。

我最早学网络编程时也是这样,看过一堆框架源码,背过一堆"高性能"名词,但自己动手写第一个多进程服务器时,光是搞明白为什么会有僵尸进程、为什么accept会返回EINTR,就折腾了两天。这篇文章我想把这些经验原原本本梳理一遍:从零开始,用多进程模型实现一个能扛并发连接的TCP服务器,把每一步的原理、代码、容易踩的坑都讲透。

这篇文章适合这几类人看:正在学Linux系统编程的学生、刚接触服务端开发想搞懂底层模型的初级工程师、以及那些用腻了框架想回头补一补基本功的后端开发者。读完你不仅能有能跑的代码,更重要的是真正理解"进程如何同时服务多个连接"这件事的本质。

2. 基础TCP服务器:先把单进程版本跑通

2.1 五个核心系统调用各司其职

TCP服务器的基础流程,说穿了就是五个系统调用:socket、bind、listen、accept、recv/send。很多人一开始就被这五个函数搞晕,其实用一个生活例子就能讲清楚。

socket创建socket就像是开了一部电话机——它只是把设备申请下来,还没插电话线,也没分配号码。bind是把这部电话机绑定到一个具体的电话号码上,对应到网络世界就是IP地址加端口号,比如0.0.0.0:8080。listen是让电话机进入待机状态,告诉系统"我可以接电话了",内核会为这个socket维护一个连接队列。accept则是真正去接听,电话铃响了(有客户端连接进来),accept就从队列里取出一个连接,返回一个新的socket文件描述符。之后recv和send就是对着这个新socket说话。

// 第一步:创建socket int listen_fd = socket(AF_INET, SOCK_STREAM, 0); if (listen_fd < 0) { perror("socket"); exit(1); }

这里有个新手容易懵的点:socket返回的listen_fd只是监听用的,它不负责具体数据传输。真正收发数据的是accept返回的conn_fd。一个程序里可以同时存在很多conn_fd,但监听socket一般只有一个。

2.2 bind和listen阶段的三个关键抉择

bind阶段最容易犯的错是不设置SO_REUSEADDR,导致服务器崩了之后立刻重启时报"Address already in use"。这背后的原因是TCP连接还有TIME_WAIT状态。如果服务端主动关闭连接,那个socket会进入TIME_WAIT状态,持续2MSL(一般一两分钟),期间端口被占用。设置SO_REUSEADDR就是告诉内核:"这个端口的TIME_WAIT连接别拦着我重新绑定"。

int opt = 1; setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));

这条配置应该成为TCP服务器的标配,没有特殊理由不要省略。

listen函数的第二个参数backlog也值得琢磨。在Linux 2.4之后,backlog表示的是全连接队列(已完成三次握手、等待accept取走)的长度上限,默认值通常是128或更大。如果并发连接数瞬间暴涨,backlog太小会让多余的连接被内核直接丢弃,客户端表现就是connect成功但服务端收不到、或者干脆连接失败。

struct sockaddr_in server_addr; bzero(&server_addr, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 server_addr.sin_port = htons(8080); if (bind(listen_fd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); exit(1); } if (listen(listen_fd, 128) < 0) { perror("listen"); exit(1); }

INADDR_ANY表示监听本机所有IP地址。如果服务器有多个网卡,客户端可以通过任意一个IP连进来。指定具体IP也是一种选择,但INADDR_ANY在绝大多数服务器场景下是更省心的方案。

2.3 单进程accept循环为什么不够用

while (1) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { if (errno == EINTR) continue; perror("accept"); break; } // 处理这个连接,直到对端关闭 char buf[1024]; int n = read(conn_fd, buf, sizeof(buf)); if (n > 0) { write(conn_fd, "hello from server\n", 19); } close(conn_fd); }

这个循环的逻辑很直白:accept一个连接,处理完,再accept下一个。问题是处理连接的过程是串行的——如果某个客户端连上来之后一直不发数据,read就会一直阻塞,后面的客户端全都堵在队列里干等。

这就是并发服务器要解决的核心矛盾:一个进程同时只能做一件事,但网络连接天然是异步的、并发的。解决方案无非三条路:开多个进程、开多个线程、或者用I/O多路复用让一个进程同时盯着多个连接。多进程方案最符合我们的直觉——每个进程只管一个连接,互不干扰,即使某个连接出问题,也只影响它自己的进程。

3. 多进程并发改造:把每个连接交给一个子进程

3.1 fork之后到底复制了什么

fork创建子进程时,子进程几乎是父进程的"复印本":内存里的代码段、数据段、堆、栈全部复制一份,文件描述符表也完整复制。注意"复制"这个措辞,不是共享。复制之后,父子进程各自有独立的内存空间,互不影响——子进程改了变量,父进程看不到。

文件描述符表复制则带来一个微妙的问题:fork之前打开的socket,父子进程都能通过同一个fd编号访问。这意味着,如果我们在accept之前fork,父子进程会同时竞争accept同一个监听socket,这时多个进程同时阻塞在accept上,连接来了谁抢到算谁的,这就是经典的prefork模型的雏形。

但在最常用的方案里,我们是先accept拿到conn_fd,再fork。这样子进程继承的是已经建立好的连接,父进程继续回头accept下一个。父子进程各司其职,不会打架。

while (1) { int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { if (errno == EINTR) continue; perror("accept"); break; } pid_t pid = fork(); if (pid < 0) { perror("fork"); close(conn_fd); continue; } if (pid == 0) { // 子进程:处理这个连接 close(listen_fd); // 子进程不需要监听socket handle_connection(conn_fd); close(conn_fd); exit(0); // 处理完必须退出,否则会掉进while循环 } else { // 父进程:关掉conn_fd,继续accept close(conn_fd); } }

3.2 子进程里的两个死亡陷阱

这个代码有两个地方是新手必栽的。

第一,子进程里没有exit(0)。如果不写exit,子进程处理完一个连接后不会停在原地,而会继续执行while循环,继续accept。这时候父子进程一起accept同一个监听socket,虽然不至于立刻崩溃,但逻辑已经完全错了。更糟的是子进程可能无限fork下去,马上触达系统进程数上限。所以fork之后,一定要用pid判断分流,然后让子进程处理完立刻exit。

第二,父子进程都要close一次conn_fd。这背后是文件描述符引用计数的机制。conn_fd这个文件描述符在fork之后,父子进程各持有一次引用。如果父进程不close,它就一直占着这个连接,连接即使被子进程处理完,也不会真正关闭,因为内核认为还有进程持有它。反过来,子进程如果继承了listen_fd又不关,虽然短期没大事,但子进程量大了之后,那些不再需要的监听socket引用就不会释放,同样会导致资源泄漏。

一个简单的检查方法:在后端日志里统计服务器持有的文件描述符数量,如果随着连接数增长不断增大且不回落,第一个怀疑对象就是close漏了。

3.3 一个完整的handle_connection实现

void handle_connection(int conn_fd) { char buf[1024]; ssize_t n; while (1) { n = read(conn_fd, buf, sizeof(buf)); if (n < 0) { if (errno == EINTR) continue; perror("read"); break; } if (n == 0) break; // 对端关闭 // 这里假装在处理业务,实际项目里会解析协议 printf("received %zd bytes: %s\n", n, buf); write(conn_fd, "ok\n", 3); } close(conn_fd); }

read返回0表示对端正常关闭连接,这个判断是TCP编程的基本功。read返回-1要再看errno,如果是EINTR表示被信号打断,应该重试,其他错误才是真出问题了。

到这里,一个能跑通的多进程TCP服务器已经成型。接下来才是真正的重头戏——进程管理。

4. 进程回收、信号处理与那些必踩的坑

4.1 僵尸进程是怎么冒出来的

运行上面那段代码,用ps -ef一看,你可能吓一跳:满屏的Z状态进程,也就是僵尸进程(Zombie)。

僵尸进程的成因很简单:子进程退出后,内核不会直接把它清理干净,而是保留一个最小的退出状态结构,等父进程来"收尸"(调用wait或waitpid)。如果父进程一直不理会,这个退出状态就一直在内核里挂着,占据一个进程表项。僵尸进程不占CPU也不占内存,但它不释放进程号,数量一多,系统能创建的新进程数就枯竭了。

有人会觉得"反正子进程也不干活了,让它挂着呗"。这想法在量小的时候问题不大,但对一个面向并发的服务器来说,每天成千上万的连接就会产生成千上万的僵尸,总有一天会撞上fork: Resource temporarily unavailable。

正确的做法是父进程负责回收子进程。回收手段有两种:阻塞等待(wait),或者用信号通知。

4.2 SIGCHLD信号和waitpid的正确姿势

子进程退出时会自动向父进程发送SIGCHLD信号。如果父进程不处理这个信号,默认行为是忽略它,子进程就变成僵尸。所以我们需要注册一个信号处理函数:

static void sigchld_handler(int sig) { int saved_errno = errno; while (waitpid(-1, NULL, WNOHANG) > 0) { // 循环回收所有已退出的子进程 } errno = saved_errno; }

这里有几个细节很有意思。

为什么要用while循环而不是只用一次waitpid?因为SIGCHLD是标准信号,Linux下标准信号不排队。如果几百个子进程同时退出,父进程可能只收到一次SIGCHLD信号,如果我们在handler里只调用一次waitpid,只能回收一个僵尸,剩下的还是要堆积。

为什么要加WNOHANG?因为waitpid在没有任何子进程退出时会阻塞。在信号处理函数里阻塞,会让整个程序卡住,这是绝对不允许的。WNOHANG让waitpid变成非阻塞模式,如果没有已退出的子进程,立即返回0。

为什么开头要保存errno?信号处理函数可能在任意系统调用中途被打断,而errno是全局的,如果我们改了它,会让被中断的系统调用误判错误类型。保存再恢复是个好习惯。

4.3 EINTR:被信号打断的accept

处理了SIGCHLD之后,僵尸问题解决了,但新问题又来了——accept开始间歇性报错,错误信息是Interrupted system call。

原因是这样的:当子进程退出、父进程收到SIGCHLD信号时,如果父进程正阻塞在accept调用里,内核会强制让accept返回-1,并把errno设置为EINTR。这意味着"系统调用被信号打断了"。

很多人的第一反应是"accept失败了就退出程序"。这个选择会酿成事故:服务器会在运行一小段时间后神秘崩溃,日志里只有一行accept: Interrupted system call。

正确的处理方式在前面代码里已经出现了:看到EINTR就continue,重新调用accept。回到第2.3节看那段代码,if (errno == EINTR) continue;那行当时可能觉得多余,现在知道它是必修课了。

int conn_fd = accept(listen_fd, NULL, NULL); if (conn_fd < 0) { if (errno == EINTR) continue; if (errno == ECONNABORTED) continue; perror("accept"); break; }

ECONNABORTED是另一个容易漏掉的错误码:客户端在建立连接之后、accept之前就断开,内核会清理掉这个连接,accept返回这个错误。同样需要continue而不是退出。

4.4 让signal_handler更稳重的选择sigaction

教材和小项目里常用signal函数注册信号处理,但signal在不同Unix版本上行为有差异。更推荐用sigaction,它有明确的语义,可以精确控制阻塞标志:

struct sigaction sa; sa.sa_handler = sigchld_handler; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; sigaction(SIGCHLD, &sa, NULL);

这里SA_RESTART是个可选项。设置SA_RESTART后,被信号打断的阻塞系统调用(比如read、write)会自动重启,而不是返回EINTR。但这不保证所有系统调用都会重启,所以业务代码里依然要做好EINTR的重试准备。SA_NOCLDSTOP表示只有子进程终止才发信号,暂停/恢复不停,避免不必要的信号打扰。

5. 实测中遇到的典型问题与排查笔记

5.1 客户端连不上?先看这三件事

第一,确认服务器有没有在监听。ss -lntp一条命令就能看到端口和进程号。如果监听列表里没有8080,检查bind有没有失败,bind失败最常见的又是端口被占用。第二,确认防火墙有没有放行端口。很多开发环境本地测试没问题,一到云服务器就出现"服务起了但外部永远连不上",八成是安全组或防火墙的策略问题。第三,确认监听地址是不是127.0.0.1。只监听回环地址的话,只有本机能连。

我把这三件事做成了一张自查清单,遇到连接问题最先跑一遍,能省下大量排错时间:

现象第一个排查命令常见原因
connect被拒ss -lntp服务没起、端口没绑定
连接超时telnet IP 8080防火墙拦截、路由不通
本机能连远程不行cat /proc/net/tcp绑定127.0.0.1、防火墙

5.2 多进程服务器频繁创建进程的代价

多进程模型最被人诟病的是fork开销。每次fork都要复制页表、复制文件描述符表,虽然现代Linux有copy-on-write机制,fork已经不拷贝物理内存,但相对于线程创建,成本依然更高。实测下来,在普通机器上每秒创建数百上千个子进程是能扛住的,但到了上万级别,fork本身就会成为瓶颈。

一个折中思路是控制进程数量。不让每个连接都fork一个进程,而是启动时预先创建一定数量的空闲子进程,它们提前阻塞在accept上,连接来了直接复用,这就是prefork模型。它把"每连接一fork"的创建开销变成了"空闲进程池",吞吐量提升非常明显。我自己的项目里,prefork模型配合合理的进程数配置,比每连接一fork的版本QPS能翻两三倍。

5.3 系统进程数和文件描述符上限

Linux默认对单用户进程数、单进程打开文件数都有上限。多进程服务器非常容易撞上这两堵墙。

第一堵墙是每进程文件描述符上限,默认是1024也可能更小。如果子进程要处理大量并发连接,每个连接至少占用一个socket fd,到几百个连接就会触顶。用ulimit -n 65535可以在当前shell提高,持久化改法是修改limits.conf里nofile配置。

第二堵墙是系统总进程数上限,由kernel.pid_max控制,默认一般是32768或更大。每连接一个进程的模型,进程数增长和连接数成正比,一旦触顶,fork直接返回失败。服务器端代码里if (pid < 0)分支一定要写好日志,否则排查起来毫无头绪。

还有个细节容易被忽略:父进程accept之后如果fork失败,一定要记得close(conn_fd),否则这个连接没人处理,客户端会一直傻等,最终超时。

6. 多进程方案的价值边界与后续扩展方向

6.1 多进程、多线程、多路复用怎么选

多年经验下来,我的选择标准很简单:连接数少、业务处理耗时不稳定,选多进程或多线程;连接数巨大、大部分连接只是挂着不干活,选I/O多路复用(select、poll、epoll)。

多进程最强的优势是隔离性。一个子进程崩溃了,父进程和其他子进程毫发无损,这在处理不可靠的第三方协议时特别舒心。多线程虽然更轻量,但线程崩溃往往拖着整个进程一起死。多进程最大的劣势是资源开销和进程间通信成本,共享数据要么用IPC要么用共享内存,比起线程共享地址空间繁琐不少。

如果只是想快速做一个能承载上千长连接的服务,又不希望陷入epoll的复杂度里,多进程prefork是一个非常实用的中间选择。进程数控制在CPU核数的1到2倍,没有锁竞争,没有复杂的回调,代码逻辑和单进程几乎一样简单。

6.2 从多进程到更高性能的三条路

第一是改prefork。提前fork固定数量的子进程,全都阻塞accept。这里要小心的坑是"惊群"问题:多个进程同时阻塞在同一个listen_fd上,一个连接过来时所有进程都会被唤醒,但最终只有一个能accept成功,其他进程空跑一趟。Linux 2.6版本之后这个现象有所缓解,但如果想彻底解决,可以给accept加锁,或者改用SO_REUSEPORT配合多进程各自listen同一个端口,让内核做负载均衡。

第二是切换成epoll多路复用。一个进程里同时管理几千个socket,配合线程池处理业务,这是目前高性能后端最主流的架构。理解了多进程的每连接一进程模型,再去看epoll+线程池,思路会顺畅很多——本质上都是在解决"如何同时服务多个连接"这个老问题。

第三是往上走一层,选择合适的网络框架。理解了底层原理之后,用框架就是"降维打击",你能看懂框架在做什么优化,遇到问题也知道从哪下手排查,而不是只会背配置。

7. 从一次真实压测聊聊多进程服务器的性能底牌

有些概念光看代码体会不深,还是要跑数据说话。我拿前面这段代码做了一个简单压测:一台4核虚拟机,客户端用并发工具同时保持200个连接,每个连接不断发送小数据包,观察服务端表现。

第一轮压测发现CPU负载极高,全都耗在fork和waitpid上。日志里seen的是每秒钟创建和回收了上千个进程,这部分系统调用开销占据了接近40%的CPU。后来我加上prefork,启动时固定创建8个子进程,连接来了不fork,直接复用已阻塞在accept的子进程。性能曲线立刻平滑了很多,CPU占用降了一半以上,吞吐提升了将近三倍。

这个结果说明一个很朴素的道理:多进程服务器能不能扛住并发,核心不在于"进程多不多",而在于"进程复不复用"。每连接一fork是最直观的思路,但它把最高成本的操作放在了每条连接的关键路径上。prefork的目标恰恰是把fork从关键路径上挪走,只留下accept和读写。

还要多说一句:我这轮测试里没有加任何协议解析,纯回显数据。真实业务里,如果每个连接的处理函数里有大量计算、磁盘I/O或外部API调用,多进程模型的隔离优势会更加明显——某个连接卡在慢外部服务上,完全不影响其他连接从accept队列里被取走。

8. 经验之外的一些提醒

如果你照着这篇文章搭出了自己的多进程TCP服务器,我建议你做一串小实验,能帮你把底层机制串起来。

先在handle_connection里故意sleep 30秒,然后同时开几个客户端连上去,观察进程表里发生了什么。如果你用ps -ef --forest查看进程树,会清楚看到父子进程的层级,被子进程继承的listen_fd和conn_fd在proc文件系统里都能查到。再试着手动kill掉某个子进程,看父进程的SIGCHLD处理函数有没有正确回收它,同时注意accept有没有因此被打断——亲眼看一次EINTR的日志,比背十遍理论都有用。

我在实际项目里最深的体会是:调试多进程程序时,不要凭感觉猜"进程应该做了什么"。直接打开strace -p看系统调用序列,或者用gdb attach上去看调用栈,往往一眼就能定位问题。网络编程的坑大多是确定的,不是玄学,只要愿意一层层扒,总能找到原因。

写这篇文章的时候我翻出了当年的第一版代码,一个accept下面挂七八个坑,现在看全是教训。但正是从这些坑里爬出来的过程,才会真正理解TCP、进程、文件描述符这三件事是如何交织运作的。希望这篇记录能帮你少走一些我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/22 3:33:28

Java WebSocket实现即时通讯单聊:从连接管理到集群扩展的工程实践

即时通讯功能在现在的业务系统里几乎成了标配&#xff0c;不管是客服工单、协同办公还是社交类产品&#xff0c;单聊都是最基础也最核心的一环。很多同学一听到"即时通讯"四个字&#xff0c;第一反应是去接第三方IM SDK&#xff0c;或者干脆上Netty自己撸一套协议栈。…

作者头像 李华
网站建设 2026/10/22 3:33:04

前后端分离房产销售系统实战:SpringBoot+Vue+MyBatis从设计到部署全解析

这是一个典型的前后端分离项目&#xff0c;属于JAVA开发者练手、毕业设计或者小型CRM系统原型的高频题材。我不直接贴整份10万字的源码&#xff0c;而是以"架构师带徒弟"的口吻&#xff0c;把从表结构设计到前端联调、再到服务器部署的完整链路讲透&#xff0c;并给出…

作者头像 李华
网站建设 2026/10/22 3:33:03

TensorFlow张量操作实战:从shape报错到性能优化

做了几年深度学习&#xff0c;我发现自己大部分的调试时间其实不在网络结构上&#xff0c;而是在张量操作上。框架装好、数据到位、模型搭起来之后&#xff0c;真正拦路的反而全是shape对不上、维度交换错了、广播不按预期走这种基础问题。TensorFlow里的张量操作&#xff0c;从…

作者头像 李华
网站建设 2026/10/22 3:32:37

AI生成成熟男人头像全攻略:从翻车到稳定出图的优化路径

最近接了个不大不小的任务&#xff1a;给一个做商务内容实验的虚构账号&#xff0c;做一张“成熟男人”的微信头像。我一开始觉得这事简单——写提示词、出图、裁一裁&#xff0c;半小时收工。结果一上手就翻车了&#xff0c;连续两天都在跟“二十多岁男模脸”作斗争&#xff0…

作者头像 李华
网站建设 2026/10/22 3:32:25

LangChain Agent 不稳定?试试 Harness Engineering 这套外壳

很多人在 LangChain 里写 Agent&#xff0c;写完第一版感觉特别顺&#xff0c;模型一调、工具一接&#xff0c;好像什么都能干。但一旦丢到真实业务里&#xff0c;问题就全冒出来了&#xff1a;任务稍微绕一点就跑偏&#xff0c;同样的输入有时候对有时候错&#xff0c;Token 消…

作者头像 李华
网站建设 2026/10/22 3:32:00

原生Java高仿短视频APP双端源码解析:音视频链路与工程落地

简介&#xff1a;原生Java高仿短视频APP双端源码是一份完整的移动端短视频项目&#xff0c;面向Java/Android开发者及音视频技术学习者&#xff0c;适合用来理解App从界面到媒体处理再到服务端交互的完整实现。资源包含Android端Java代码、iOS端Objective-C代码&#xff0c;以及…

作者头像 李华