
前几天有个朋友问我他写了一个单线程的TCP服务端前端调用时一卡一卡的问我是不是TCP三次握手太慢导致的。我说跟三次握手没关系握手是内核帮你完成的你的问题多半出在accept之后你还在串行处理请求。这句话点醒了他。说白了一台Linux服务器要同时服务成百上千个客户端最经典的解法就是多进程并发监听socket只负责接收新连接每来一个连接就fork一个子进程让这个子进程单独伺候这一个客户端。这就是Linux多进程TCP并发服务器也是从大学课程到面试八股都绕不开的模型。这篇文章我会从零带你把这套模型完整写出来。不光贴代码还会把socket、fork、文件描述符、僵尸进程这些背后原理掰开揉碎最后附上我在实际项目中踩过的坑和排查手段。适合三种人看刚学完C语言想搞网络编程的、准备Linux后端相关面试的、以及想自己搭一个小型并发服务但不想一上来就上框架的。1. 先搞清楚多进程并发到底解决什么问题1.1 一个TCP连接从建立到关闭的完整旅程写并发服务器之前得先把TCP连接的生命周期摸清楚。很多新手以为accept就是“建立连接”其实不对。TCP连接建立是在内核里完成的accept只是把已经建好的连接从队列里取出来。一次完整的连接大概是这样客户端调用connect内核发出SYN报文服务端内核收到后返回SYNACK客户端再回一个ACK。三次握手结束连接进入ESTABLISHED状态。这时候服务端内核把连接放进一个叫“全连接队列”的地方应用层的accept就是从队列里取这个已经就绪的连接。换句话说你的服务器进程还没来得及调用accept客户端那边已经在等待连接就绪了。只要内核的accept队列没满TCP三次握手对应用层来说几乎是“无感”的你根本不用管SYN、ACK这些细节socket API帮你全包了。连接用完之后还有四次挥手。主动关闭的一方进入TIME_WAIT状态这个状态在服务端编程里特别容易坑人后面我会专门讲SO_REUSEADDR。理解了这个模型你就知道单进程服务器为什么卡了。如果只有一个进程accept到一个连接后你read、处理业务、write返回整个过程是串行的。只要有一个客户端发完数据后迟迟不关闭、或者网络很慢你的服务器就卡在这个连接的read上后面的所有客户端都得排队。这显然不能忍。1.2 单进程服务器为什么撑不住并发单进程串行模型的问题说穿了就是阻塞。TCP的read默认是阻塞的客户端不发数据read就一直卡着。如果你的业务逻辑是“读请求、算结果、回响应”那个慢客户端就变成了一个路障把所有用户都堵死在门口。我见过有人用多线程解决这个问题来一个连接开一个线程。线程确实比进程轻但线程共享地址空间一个线程崩溃可能把整个进程带走而且线程安全问题需要自己小心处理。对于学习网络编程模型来说多进程反而是更直观、更隔离的选择。多进程模型的做法很朴素主进程负责accept每接受一个连接就fork一个子进程子进程继承这个连接的文件描述符然后处理这个连接的所有读写处理完就退出。主进程继续accept下一个连接。每个客户端都对应一个独立的进程互相不干扰谁卡住就卡住谁不影响别人。1.3 多进程模型的适用边界与选型理由多进程不是万能的但它在很多场景下依然值得选。它的优点首先是健壮性子进程崩溃了主进程不受影响这对服务端来说太重要了。其次是模型简单不需要考虑锁、条件变量这些并发同步问题每个进程只管自己的连接。再就是和现有系统结合好很多老旧项目、嵌入式环境、面试题里都在用这个模型。边界也很明显。fork一个进程的开销比创建线程大如果每秒来几千个短连接频繁fork会带来不小的CPU开销。另外进程数量受系统PID限制和内存限制一台机器跑几千个进程已经是极限压根不适合做百万长连接那种场景。真到那个量级模型要换成epoll事件驱动、线程池、协程之类的方案。但我不建议你跳过这一步直接上epoll。多进程模型里涉及的accept、fork、信号处理、文件描述符管理是理解更高阶网络编程的地基。把地基打牢了后面学Reactor模型、学Nginx的进程架构会快很多。2. 从socket到fork的核心细节2.1 socket、bind、listen、accept背后的内核状态这四个函数不是凭空虚造每一步都有对应内核数据结构变化。socket函数创建一个套接字文件描述符本质是在内核里创建了一个socket对象包含发送缓冲区、接收缓冲区、以及连接状态。bind负责把socket绑定到一个本地地址和端口上。listen做了两件大事一是把socket从“未连接”状态切换成“监听”状态二是在内核里分配半连接队列和全连接队列。这里的队列概念要建立起来。半连接队列存放的是收到SYN但还没完成三次握手的连接全连接队列存放的是已经完成握手、等待accept取走的连接。listen的backlog参数在Linux上实际控制的是全连接队列的长度上限。accept做的事情就是从全连接队列里取出一个已经握手完成的连接返回一个新的文件描述符。注意这个新fd和监听socket的fd不是同一个。监听fd一直不变专管接受新连接accept返回的fd才是真正用来读写数据的。有个细节很多人不知道如果全连接队列满了新的连接请求会被内核直接丢弃或者延迟处理客户端表现为“连接超时”或“连接被重置”。不过一般情况下Linux默认的backlog足够用只有高并发下才会暴露问题。2.2 fork之后文件描述符发生了什么这是多进程服务器最核心的考点也是最多人栽跟头的地方。fork会复制整个进程地址空间包括文件描述符表。子进程和父进程的fd指向内核里同一个file结构所以监听socket的fd、已连接socket的fd在fork之后父子进程都有。但我们要想清楚对于监听fd谁负责accept新连接主进程。子进程如果永远不用监听fd就应该把它关掉否则这个fd一直占着引用计数。对于已连接fd谁负责读写数据子进程。主进程如果不需要读写就应该把它关掉否则这个连接不会真正释放文件描述符会泄漏。这是整个模型最容易出错的地方。正确的代码模式是fork之后父进程close(conn_fd)子进程close(listen_fd)。父进程继续accept子进程用conn_fd和客户端交互处理完再close(conn_fd)然后exit。为什么必须这样因为内核的socket对象有一个引用计数fork让引用计数增加。只有所有进程都关闭了这个socket的fd连接才会真正释放。父进程如果不close(conn_fd)那这个连接的引用计数永远不会归零。连接建立又关闭后如果父进程没关你就眼睁睁看着fd数量往上飙直到进程打满文件描述符上限。2.3 SO_REUSEADDR与TIME_WAIT的恩怨TCP连接关闭时主动关闭方会进入TIME_WAIT状态要等2MSLMaximum Segment Lifetime通常是60秒左右才能彻底消失。为什么有TIME_WAIT为了保证迟到的报文不会出现在新连接里让旧连接的所有报文在网络里彻底消失。服务器上这个问题特别常见。你的服务进程跑着跑着崩了重启时直接bind原来端口结果报Address already in use。原因就是上次的主动关闭连接还在TIME_WAIT状态端口还没放出来。解决办法是在bind之前设置SO_REUSEADDRint on 1; setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on));这行代码允许端口在TIME_WAIT状态下被重新bind。很多网络编程教材里都会写但很多人不理解为什么只知道“加上能解决重启报错”。现在你知道了它解决的就是TIME_WAIT和端口重用的问题。顺带一提如果服务器端主动断开连接会更容易积累TIME_WAIT。所以生产环境里通常建议服务器不要主动先关连接尽量由客户端先断开。不过在一些协议场景下服务器必须主动断开那TIME_WAIT多也正常靠SO_REUSEADDR兜底就好。2.4 两个必须处理的信号SIGPIPE和SIGCHLD多进程服务器里有两个信号不处理一定会出问题。第一个是SIGPIPE。当进程向一个已经关闭的socket写入数据时内核会发送SIGPIPE信号这个信号的默认行为是终止进程。你想象一个场景客户端发来一个请求服务器处理到一半客户端就断开了服务器这边往连接里写响应结果直接触发SIGPIPE整个服务进程就没了。单进程代码里这个信号崩的是整个进程多进程模型里崩的是那个子进程。虽然不影响主进程但会在日志里留下一堆莫名其妙的中断记录。处理方式有两种一是忽略SIGPIPE用signal(SIGPIPE, SIG_IGN)这样write会返回错误码你要自己检查返回值二是在send时加MSG_NOSIGNAL标志。我一般选择忽略SIGPIPE然后检查write/read返回值逻辑上更直观。第二个是SIGCHLD。子进程退出时会变成僵尸进程直到父进程调用wait/waitpid回收它的状态信息。如果父进程不处理僵尸进程会一直留在系统进程表里越积越多。多进程服务器里子进程天天在退出不回收SIGCHLD用不了多久你就会发现系统里全是Z状态进程。标准做法是在父进程里注册SIGCHLD信号处理函数里面用waitpid配合WNOHANG循环回收void sigchld_handler(int sig) { while (waitpid(-1, NULL, WNOHANG) 0); }这里用while而不是if是因为信号处理函数执行期间可能有多个子进程同时退出要一次性全部收割完。3. 从零写一个完整的多进程并发TCP服务器3.1 代码骨架与监听socket创建说了一堆理论现在动手写代码。我用的环境是Linux GCC纯C语言不依赖任何第三方库。整个程序的核心思路创建监听socket进入accept循环来一个连接fork一个子进程子进程负责收发数据。先写一个文件server.c从监听socket创建开始#include stdio.h #include stdlib.h #include string.h #include unistd.h #include errno.h #include signal.h #include sys/socket.h #include sys/wait.h #include sys/types.h #include netinet/in.h #include arpa/inet.h #define PORT 8888 #define BACKLOG 128 int create_listen_socket(void) { int fd socket(AF_INET, SOCK_STREAM, 0); if (fd 0) { perror(socket); exit(EXIT_FAILURE); } int on 1; if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, on, sizeof(on)) 0) { perror(setsockopt); close(fd); exit(EXIT_FAILURE); } struct sockaddr_in addr; memset(addr, 0, sizeof(addr)); addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); addr.sin_port htons(PORT); if (bind(fd, (struct sockaddr *)addr, sizeof(addr)) 0) { perror(bind); close(fd); exit(EXIT_FAILURE); } if (listen(fd, BACKLOG) 0) { perror(listen); close(fd); exit(EXIT_FAILURE); } return fd; }两点说明。BACKLOG我设成128这个值决定了内核全连接队列的长度对于学习场景完全够用。SO_REUSEADDR在前面讲过是为了服务端重启时端口还能复用。INADDR_ANY表示监听本机所有网卡地址这样无论是127.0.0.1还是局域网IP都能连上来。3.2 主循环里最重要的三行代码接下来是主函数。不用写太多核心逻辑全在循环里int main(void) { signal(SIGCHLD, sigchld_handler); signal(SIGPIPE, SIG_IGN); int listen_fd create_listen_socket(); printf(server listening on port %d, pid%d\n, PORT, getpid()); while (1) { struct sockaddr_in cli_addr; socklen_t cli_len sizeof(cli_addr); int conn_fd accept(listen_fd, (struct sockaddr *)cli_addr, cli_len); if (conn_fd 0) { if (errno EINTR) { continue; } perror(accept); continue; } char ip_str[INET_ADDRSTRLEN]; inet_ntop(AF_INET, cli_addr.sin_addr, ip_str, sizeof(ip_str)); printf(connection from %s:%d\n, ip_str, ntohs(cli_addr.sin_port)); pid_t pid fork(); if (pid 0) { perror(fork); close(conn_fd); continue; } if (pid 0) { // 子进程不再关心监听socket关掉 close(listen_fd); handle_client(conn_fd); close(conn_fd); exit(0); } // 父进程这个已连接fd交给子进程了自己关掉 close(conn_fd); } return 0; }代码就这么多。每次循环就做三件事accept拿新连接、fork创建子进程、父子进程各自关闭不需要的fd。注意accept返回后立刻forkfork之前临时打印一下客户端IP。我在实际调试时发现这个打印特别有用能直观看到连接是不是真的并发进来了。关于崩溃和错误处理我这版代码故意写得保守。fork失败时close(conn_fd)是必须的不然这个连接fd就会泄漏在主进程里。accept被信号打断会返回EINTR这在有SIGCHLD信号处理函数的情况下特别容易发生要continue重试。3.3 子进程里的请求处理函数子进程业务处理函数我写的是最简单也最经典的“回显服务”客户端发什么就原样返回什么。这样方便验证功能不牵扯业务逻辑。void handle_client(int conn_fd) { char buf[1024]; ssize_t n; while ((n read(conn_fd, buf, sizeof(buf))) 0) { write(conn_fd, buf, (size_t)n); } if (n 0) { if (errno ! EINTR errno ! EPIPE) { perror(read); } } printf(client disconnected, child %d exiting\n, getpid()); }这里的read会一直阻塞直到客户端关闭连接或者出错。客户端关闭后read返回0循环退出子进程关闭fd并exit。如果对端突然断开write可能触发SIGPIPE不过我们在main里已经SIG_IGN了write会返回-1errno是EPIPE。真实业务里handle_client可能是解析HTTP请求、查询数据库、返回JSON。不管业务多复杂模型不变一个子进程从头到尾处理一个连接。3.4 信号处理函数补全再补上前面提到的信号处理函数放在main函数之前void sigchld_handler(int sig) { while (waitpid(-1, NULL, WNOHANG) 0); }SIGPIPE的处理直接在main里面signal(SIGPIPE, SIG_IGN)就行。到这里完整代码就算写完了。整个程序大概七八十行麻雀虽小五脏俱全。监听socket、并发、信号回收、fd管理全都有非常适合作为学习模板。3.5 编译、运行与用curl验证编译命令很简单gcc -o server server.c -Wall -Wextra启动./server看到输出server listening on port 8888之后另开一个终端测试curl -v telnet://127.0.0.1:8888然后随便输入几个字符回车会看到内容被原样回显。这是因为telnet协议在curl里会透明传输输入而我们的服务器把数据回显了。再验证多客户端并发。可以开三个终端都执行上面那条curl命令然后同时输入数据。你会发现服务器端打印出了三次不同的客户端IP每个连接都有独立进程在服务。用ps查看进程关系ps -ef | grep server会看到主进程下面挂着一堆子进程状态都是S休眠等待read。这就是多进程并发模型最直观的样子。3.6 用压测工具看看并发能力验证完基本功能我用ab做了一次小型压力测试。虽然ab是HTTP压测工具但我们的服务是回显服务ab发来HTTP请求后服务端原样返回ab程序会因为响应格式不对而报错不过连接建立、并发这些指标还是能看的。如果只是想看连接建立能力可以写一段简单的Python脚本模拟并发连接import socket import threading def connect_test(): for _ in range(200): try: s socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.connect((127.0.0.1, 8888)) s.close() except Exception as e: print(e) threads [threading.Thread(targetconnect_test) for _ in range(20)] for t in threads: t.start() for t in threads: t.join() print(done)这个脚本模拟4000次快速短连接。在我的笔记本上跑完服务器端进程一个个创建又退出系统没有报错PID数量在合理范围波动说明这个模型扛几千个短连接没问题。但我也要说实话新连接来了才fork连接结束就exit这么高频的进程创建销毁会让CPU波动很大。如果压测指标里有QPS要求这个模型会吃不少亏。这时候就要考虑预fork了。3.7 进阶用预fork消除频繁创建进程的开销标准多进程模型的进化版叫预fork思路是启动时一次性fork出N个子进程每个子进程都去accept同一个监听fd。谁抢到连接谁处理处理完继续回来accept不退出。#define WORKER_NUM 4 int main(void) { int listen_fd create_listen_socket(); for (int i 0; i WORKER_NUM; i) { pid_t pid fork(); if (pid 0) { // 子进程进入工作循环 worker_loop(listen_fd); exit(0); } } // 父进程只负责监控 wait(NULL); return 0; } void worker_loop(int listen_fd) { while (1) { int conn_fd accept(listen_fd, NULL, NULL); if (conn_fd 0) continue; handle_client(conn_fd); close(conn_fd); } }预fork的好处是省掉了每连接创建进程的开销同时也带来了竞争多个子进程同时调用accept会互相抢。Linux老版本里这是典型的惊群问题多个子进程被唤醒但只有一个能成功。现代内核已经在accept层面解决了一部分问题不会把所有人都唤醒但想严格限制还是得自己加锁或者用SO_REUSEPORT。对这个模型来说有一个新问题子进程处理完连接后继续accept。如果某个子进程业务处理慢它accept的频率就低。整体吞吐取决于子进程数量和处理速度的匹配关系。生产环境里Nginx用的多进程模型就是这个思路的进化版本只是把accept之后的处理换成了事件驱动。4. 实战中我踩过的坑与排查清单4.1 bind报Address already in use这是新手最常碰到的第一个坑。服务端程序写完run一次CtrlC杀掉再run一次结果bind直接报错。原因就是前面说的TIME_WAIT。第一次运行的服务端如果主动关闭了一些连接这些连接会在TIME_WAIT状态里待上一两分钟端口被占住。第二次启动时bind同一端口就失败了。解决办法有两个层面。代码层面加上SO_REUSEADDR这也是我代码里为什么要写setsockopt的原因。实践层面调试时尽量不要让服务端主动断开连接或者无所谓的话直接换端口测试也行。排查的时候用ss命令确认ss -tlnp | grep 8888 ss -tan | grep TIME_WAIT能看到端口处于LISTEN状态还是TIME_WAIT状态心里就有数了。4.2 客户端连接超时或连接被重置客户端connect的时候卡住很久最后超时或者一连接就被RST这种情况往往和accept队列有关。如果并发连接数瞬间很大超过了全连接队列长度内核会丢弃新的SYN请求。客户端表现就是“连接超时”因为SYN一直没人理。检查当前监听socket的队列情况ss -lnt | grep 8888看Recv-Q和Send-Q列。Recv-Q接近backlog值的时候说明队列快满了。要么增加listen的backlog参数要么让accept循环处理得更快。对于多进程模型如果fork本身很慢连接来得又太快队列就会堆积。预fork能明显缓解这个问题。另外连接被重置也可能是对端没有进程在accept或者地址端口根本没监听。排查时先确认服务器进程还活着端口在监听再逐步看队列。4.3 僵尸进程与文件描述符泄漏我之前在项目里就遇见过服务器跑了几天之后ps一看全是Z状态进程。其实原因很简单SIGCHLD信号没处理或者处理得不彻底。如果你在代码里注册了signal(SIGCHLD, sigchld_handler)但处理函数里只调了一次waitpid还是会有僵尸。因为多个子进程几乎同时退出一个信号处理函数可能只回收了一个。必须用while循环收割void sigchld_handler(int sig) { while (waitpid(-1, NULL, WNOHANG) 0) ; }文件描述符泄漏是另一个隐蔽问题。如果父进程忘记close(conn_fd)每次连接建立并退出后父进程的fd计数就涨一点。时间一长进程会报Too many open files。排查方法lsof -p server_pid | wc -l正常情况fd数量稳定不变。如果一直上涨基本可以断定是fd泄漏。再看是哪类fd如果是TCP连接很可能就是父进程没close连接fd。还有一点要注意Linux对单个进程有fd上限默认1024生产环境一般都要调大。临时调整ulimit -n 65535永久调整要改/etc/security/limits.conf。不然你并发测试到一千多个连接主进程先把自己折腾死了。4.4 accept惊群问题惊群这个词听起来高深其实理解起来很简单多个进程同时阻塞在accept上等待同一个监听socket一个连接到来时内核唤醒所有等待进程但最后只有一个进程accept成功其他进程白被唤醒白白消耗CPU。传统多进程模型在Linux老内核上惊群很明显。现代内核已经把accept的唤醒改为只唤醒一个等待者这个问题在多数场景已经不严重了。但我还是要提一下因为面试经常问而且如果你用的不是accept而是epoll_wait惊群问题会重新变得明显。如果要做严格优化有几种方案各个子进程用独立的epoll实例并共享监听fd配合EPOLLEXCLUSIVE标志或者在用户态加锁只有一个进程在accept或者干脆用SO_REUSEPORT让每个进程创建独立监听socket内核做负载均衡。4.5 压测数据抖动先看fork和系统日志我压测时遇到过一种情况ab测试的Requests per second忽高忽低甚至有时候会有一两秒的停顿。排查了半天发现是虚拟机环境下CPU调度抖动引起的另外频繁fork也会造成这类不规律延迟。多进程模型在创建进程时要复制页表、设置进程控制块这些操作不是微秒级的高频短连接场景下会很累。所以压测的时候如果CPU的sy占比很高多半是fork和调度开销。另外留意一下dmesg输出如果进程数量超过pid_max会有fork失败日志。无限制地增加并发进程数绝不靠谱最终会触发系统上限。4.6 一套顺手的排查命令平时调试这类服务我习惯把这几个命令挂在嘴边ss -tlnp # 查看监听端口和对应进程 ss -tan | grep 8888 # 看连接状态分布 ps -ef --forest # 看进程树结构 lsof -p pid # 查看进程打开的fd pstree -p pid # 更直观的进程树 netstat -s # 看协议栈统计信息多进程服务器调试时pstree特别直观一眼就能看到主进程下面挂了多少个子进程。如果子进程数量不对说明fork逻辑有bug。ss命令看连接状态时重点关注ESTABLISHED、TIME_WAIT、SYN_RECV的数量哪个异常多就往哪个方向查。5. 面试追问与我的实操体会5.1 高频追问快速作答这套多进程模型在面试里被问到的概率极高我把常见追问整理了一下准确背下来不吃亏。问父进程fork之后父子进程连接fd处理有什么讲究。答子进程关闭监听fd父进程关闭已连接fd否则引用计数无法归零会导致fd泄漏。底层原因就是fork复制文件描述符表每个fd的引用计数增加了。问为什么子进程处理完要exit。答如果不exit子进程会继续回到accept循环抢新连接那就变成预fork模式了。在“每连接一进程”模型里子进程的任务是只处理一个连接处理完就该退出让父进程回收。问client退出后子进程怎么知道。答read函数返回0表示对端关闭了写端。如果对端直接断开read返回0或者-1配合errno区分。此时子进程关闭连接fd并退出。问端口被占怎么办。答设置SO_REUSEADDR原因是对端或自身存在TIME_WAIT状态的连接需要允许端口复用。问父进程崩溃了怎么办。答子进程变成孤儿进程会被init进程收养。这种模型里最好有一个监控机制比如父进程崩溃后由外部守护进程重新拉起。但这已经不是单个程序的问题了涉及到进程守护和系统管理。5.2 我踩过坑之后的几点真实体会这套代码我在不同项目里写过很多遍每一次踩坑都会对模型理解加深一层。第一点体会是文件描述符管理永远是多进程服务器的头号敌人代码逻辑可能全对但一个close没写对服务跑几天就挂了而且这种问题特别隐蔽日志上看不出任何异常只能靠lsof不断观察fd数量变化。第二点体会是别在高频短连接场景里硬用这个模型。如果每个连接存活时间很短进程不断fork-exitCPU时间全花在进程管理上了。这种场景更适合预fork加事件循环或者直接换epoll。多进程模型最适合的是连接存活时间较长、数量又不是极端大的场景比如小型游戏服务器、设备接入网关、教学演示。第三点体会和TCP协议有关。写并发服务器时一定要牢记TCP不是一个“发一次收一次”的同步协议它是字节流。处理socket数据时不能用“一次read等于一条消息”的思维方式要自己考虑消息边界问题。我这种回显服务无所谓但真实业务里如果直接按read到的数据当完整消息处理大概率会出bug。最后分享一个个人习惯每写一个网络服务我都会先用Python写个几十行的压力脚本专门测试极端行为比如同时开一千个连接、大量短连接突刺、服务端主动断连等。这些小测试能暴露很多教科书上不会讲的问题。多进程模型本身不难难的是把它做稳、做稳、做得能扛事。希望这篇文章能帮你把这块地基踏踏实实打好。