开篇:先别急着写代码,想清楚为什么是多进程
说句实话,现在一聊到Linux服务器编程,很多人第一时间想到的是epoll、io_uring、Reactor模型这些"高级货",觉得多进程模型太"老古董"了。但我在实际项目里摸爬滚打这么多年,接手的线上服务、写过的网络中间层、给嵌入式Linux设备做协议适配,发现一个很扎心的事实:绝大多数的互联网服务、内部工具、设备服务器,其实根本用不着epoll,一个干净利落的多进程模型能解决90%的问题,而且它更简单、更不容易出bug、排查起来也更直观。
这篇博文,我就以"从零实现一个TCP并发服务器"为目标,把Linux多进程服务器编程这条线上的所有关键点都拆开揉碎讲清楚:从socket的基本API到fork()的进程模型,从SIGCHLD收尸到SO_REUSEADDR的坑,从"一连接一进程"到进程池预派生优化。看完之后,你不仅能独立写出一个能扛住真实压力测试的多进程TCP服务器,还能搞清楚这一套设计背后每个选择的原因。如果你正打算入门Linux网络编程,或者备战面试、面试造火箭实际拧螺丝,这篇文章应该能帮上忙。
我自己最早接触多进程TCP服务端,是在做嵌入式Linux的一个采集网关。当时的场景很简单:终端设备通过TCP上报数据,数量不大,峰值几十个连接,但每个连接要保持很久,而且需要独立隔离。当时我第一个版本用单线程循环,结果一个设备断连重传就能把整个服务卡死;后来改成多进程,整个世界清净了。现在回头看,那次"被逼无奈"的改造,反而是我把多进程模型吃透的开始。下面我先把设计思路和原理讲透,再给你一套能跑的完整代码,最后把压测和排坑的实战经验也一并交代了。
1. 选型思考与整体设计思路
1.1 为什么在并发场景里,多进程依然是硬通货
先把话放这儿:多进程模型、多线程模型、事件驱动(Reactor/Proactor)模型,三者没有绝对的谁替代谁,只看你的业务场景允许牺牲什么。
多进程模型的核心思想很朴素:主进程只负责accept新连接,每来一个连接就fork()一个子进程,让子进程独占这个连接,主进程继续回头等下一个。连接之间天然隔离——某个子进程崩溃、卡死、内存泄漏,顶多影响它手里的那一个连接,主进程和其他连接毫发无损。这句话就是多进程模型最值钱的地方。
为什么我会在不少场景里优先选多进程?
- 隔离性与稳定性:子进程之间各自拥有独立的地址空间,一个子进程被业务逻辑搞崩(segfault)不会拖垮整个服务器。多线程模型里一个线程崩掉整个进程就没了,这种惨痛教训我经历过不止一次。
- 利用多核CPU:子进程可以在不同CPU核心上并行运行,没有GIL这种全局锁的牵制。对计算密集型的业务处理来说,多进程的扩展性比多线程干净得多。
- 编程模型简单:不需要考虑互斥锁、条件变量、线程安全。子进程拿到连接描述符后整个世界是它一个人的,读写逻辑完全可以按单线程的方式写,这极大降低了心智负担。
- 天然契合"长连接"业务:如果你面对的场景是几十上百个长连接,每个连接的逻辑很重且相对独立,那么"一连接一进程"的模型几乎零额外开销地满足了需求。
当然,多进程模型也有原生的短板:进程资源开销比线程大,频繁fork()在极端高并发下会成为瓶颈;进程间通信(IPC)比线程间共享内存要麻烦;如果连接数上千,每来一个连接就fork一次,系统负担会急剧上升。所以常听人说"多进程不适合高并发",这话对也不对——准确说法是:多进程不适合"短连接高并发"和"海量连接"场景,但它特别适合"中等数量的长连接+重型业务逻辑"场景。我后面会讲到进程池预派生,那是在保留多进程优势的同时,把fork开销压下去的成熟方案。
所以我给初学者的建议是:先老老实实把多进程模型吃透,再去玩epoll。因为多进程模型涉及的知识点(socket生命周期、文件描述符、信号、进程管理、TCP状态切换)是Linux服务端编程的地基,地基不牢,玩什么模型都是空中楼阁。
1.2 经典"一连接一进程"模型的完整运转链条
看一张"概念图"(手绘的,没有工具画图,意思到位就行):主进程启动,创建监听socket,绑定端口,进入accept死循环;每拿到一个已连接的socket fd,立马fork();子进程关闭对监听socket的引用,专心处理这一个连接的业务;父进程关闭对已连接fd的引用,继续回到accept。子进程处理完(客户端主动断开或业务结束),exit退出,父进程捕获SIGCHLD信号并回收。
整个模型运转的关键点,其实是文件描述符在fork前后的引用关系。很多人第一次写多进程服务端都会踩同一个坑:fork之后不关闭不需要的fd。你以为子进程手里只有那个新连接,其实它还继承了父进程的监听socket引用;你以为父进程可以把连接交给子进程就不用管了,但父进程如果一直握着那个已连接fd不关,连接就永远不会真正释放。这些细节我后面会逐一扣。
模型的适用边界也得说清楚:建议把这种模式用在"同时在线连接数在几百以内、单连接生命周期较长、业务处理比较重"的服务上。比如设备接入网关、游戏房间服务器、某些内部长连接服务,都是多进程模型的舒适区。如果哪天你的需求变成"10万个客户端保持心跳连接",这篇文章的模型就不太合适了,那个量级需要换事件驱动思路。
2. 动手前必会的TCP服务器原理储备
2.1 三次握手、accept与listen backlog
TCP三次握手是TCP协议栈层面完成的,应用程序没有参与握手的过程,但你作为服务器编写者,必须理解握手和accept的配合关系。当客户端发起connect时,内核协议栈会跟客户端完成SYN、SYN+ACK、ACK三轮交互,然后在服务器的内核里建立起一个"已完成握手"的连接队列。你的服务程序调用accept(),其实只是从这个队列里取一个已经建好的连接出来,交给你的程序使用。
所以你要搞清楚一个关键概念:accept不参与三次握手,握手在内核里就做完了。这也是为什么listen的backlog参数很重要——它决定了"已完成握手但还没被accept取走"的连接队列能排多长。如果服务进程处理得慢,accept来不及取,队列满了,客户端connect就会表现为连接超时或者被拒。
// listen的第二个参数backlog是重点 if (listen(listenfd, 64) < 0) { perror("listen"); exit(1); }在较新的Linux内核中,backlog参数的语义已经是"全连接队列的最大长度",也就是已完成握手的连接能排多长。建议至少设到64或128,除非你有把握服务的accept速度极快。我见过有人设成1,结果压测一上来,大量客户端connect失败,排查半天才发现是backlog太小,很经典的坑。
还有一个关于accept的细节:accept返回的是一个全新的文件描述符,它和监听socket是两个不同的对象。监听socket只负责接受新连接,已连接socket才承载实际的收发数据。理解这个区分,你才能理解后面"父子进程为什么要各关各的fd"。
2.2 socket、bind、listen、accept四个系统调用的关系
这四个函数就是TCP服务端的骨架。socket()创建运维句柄,bind()绑定地址和端口,listen()把socket改成被动监听状态,accept()从已完成握手的队列里取出连接。
bind这一步有几个值得注意的细节:
- 端口号要去查清楚再用,不要用已经被系统服务占用的端口。1024以下的一般需要root权限,自己开发调试建议用8000以上的高位端口。
- 地址用INADDR_ANY,也就是0.0.0.0,表示监听本机所有网卡。如果你只想让某个内网网卡提供服务,可以用inet_pton精确指定。
- bind之后的端口是"正在占用"状态,这个状态会持续到服务进程退出。如果服务崩溃后立刻重启,你会遇到"Address already in use",这时候SO_REUSEADDR就派上用场了。
accept会阻塞吗?默认会。所以单线程模型里,accept一阻塞,后续所有连接都得排队等,这就是为什么单线程TCP服务端无法真正处理并发。多进程模型做的事情其实很直白:主进程阻塞在accept上,一旦有连接就fork,处理连接的是子进程,主进程马上回到accept继续等下一个。
3. 从零实现:一个能跑起来的TCP多进程服务器
3.1 基础框架:socket、bind、listen、accept这一步不能省
我直接给出一个完整的、能在Linux下编译运行的基础版本。不需要太多花哨的东西,先把整个链路跑通,后面再逐步加固。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <errno.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 PORT 8888 #define BACKLOG 64 int main() { int listenfd, connfd; pid_t pid; struct sockaddr_in server_addr, client_addr; socklen_t client_len = sizeof(client_addr); // 1. 创建监听socket listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd < 0) { perror("socket"); exit(1); } // 2. 设置SO_REUSEADDR,避免TIME_WAIT导致的端口占用问题 int reuse = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); // 3. bind memset(&server_addr, 0, sizeof(server_addr)); server_addr.sin_family = AF_INET; server_addr.sin_addr.s_addr = htonl(INADDR_ANY); server_addr.sin_port = htons(PORT); if (bind(listenfd, (struct sockaddr *)&server_addr, sizeof(server_addr)) < 0) { perror("bind"); exit(1); } // 4. listen if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); } printf("Server listening on port %d, pid=%d\n", PORT, getpid()); // 5. accept循环 while (1) { connfd = accept(listenfd, (struct sockaddr *)&client_addr, &client_len); if (connfd < 0) { if (errno == EINTR) { continue; // 被信号中断,重新accept } perror("accept"); exit(1); } // 6. fork子进程处理连接 pid = fork(); if (pid < 0) { perror("fork"); close(connfd); continue; } if (pid == 0) { // 子进程:处理连接 // 子进程不再需要监听socket,立刻关闭 close(listenfd); handle_client(connfd); close(connfd); exit(0); } else { // 父进程:关闭已连接fd,继续accept close(connfd); } } close(listenfd); return 0; } // 连接处理函数:这里只做回显 void handle_client(int fd) { char buf[1024]; int n; while ((n = read(fd, buf, sizeof(buf))) > 0) { // 回显给客户端 write(fd, buf, n); } if (n < 0) perror("read"); printf("Client disconnected, fd=%d\n", fd); }这版代码是整个多进程服务器的骨架,但我强烈建议你别直接拿它上生产,因为少了信号处理和进程回收,跑一会儿你就会看到一堆僵尸进程。别急,这正好是下一节的内容。
3.2 子进程的连接处理逻辑与fd关闭的坑
上面代码里有一段很容易被忽略但极其重要的逻辑:子进程close(listenfd),父进程close(connfd)。
为什么必须是这个操作?因为fork()会把父进程的整个地址空间、包括所有打开的文件描述符全部复制一份。listenfd和connfd的引用计数在fork之后都会+1,父子进程各自拥有一份指向同一个内核文件对象的引用。如果不做关闭:
- 子进程一直握着listenfd,虽然它不会去accept,但这个引用让listenfd永远不会被真正释放。哪天主进程要关掉重开,你会发现listenfd迟迟释放不了。
- 父进程一直握着connfd,就算子进程处理完连接并close(connfd),由于父进程手上的引用还在,这个TCP连接不会真正断开。客户端看着连接还在,资源白白占着不释放。这是最常见也最隐蔽的bug。
我再多说一句:一定是在fork之后再关闭,不能在fork之前关。fork之前listenfd和connfd都还是父进程在用的,关了就全没了。顺序错了,整个并发模型直接崩掉。
3.3 编译运行与基础验证:用telnet和nc做连通性测试
这段代码保存成server.c,编译命令如下:
gcc -o server server.c ./server然后另开一个终端,用telnet或者nc连上去测试:
telnet 127.0.0.1 8888 # 或者 nc 127.0.0.1 8888随便敲点字符,看是否原样回显。多开几个终端,连上不同的连接,每个连接应该都能正常工作且互不影响。
验证并发时顺便做两件事:
- 看进程树:
ps -ef | grep server。你会发现一个主进程加若干个server子进程,每个活跃连接对应一个子进程。 - 看连接状态:
netstat -anp | grep 8888。你可以看到LISTEN状态的主进程端口,ESTABLISHED状态的连接,它们分别对应不同的子进程Pid。
如果这两步表现正常,说明你的多进程并发模型已经从"写出来了"变成"跑起来了"。
4. 三座大山:僵尸进程、惊群和端口复用
4.1 子进程的收尸线程:SIGCHLD信号与waitpid
基本框架跑起来之后,你很快就会遇到一个问题:子进程处理完连接退出,父进程如果不回收,它就变成僵尸进程。僵尸进程不占CPU也不占内存,但会占着进程表的一个条目,进程表满了系统就再也fork不出新进程了。你在ps里看到的僵尸进程,就是因为父进程没有调用wait/waitpid去收尸。
解决僵尸进程的标准姿势是:父进程注册SIGCHLD信号处理函数,在信号处理里调用waitpid()回收子进程。
void sig_chld(int signo) { pid_t pid; int stat; // WNOHANG: 如果没有子进程退出,立刻返回,不阻塞 while ((pid = waitpid(-1, &stat, WNOHANG)) > 0) { printf("child %d terminated\n", pid); } }注册信号处理的方式,我建议用sigaction,而不是signal(),因为它语义更可控、更可移植:
struct sigaction sa; sa.sa_handler = sig_chld; sigemptyset(&sa.sa_mask); sa.sa_flags = SA_RESTART; // 让被信号打断的accept自动重启 if (sigaction(SIGCHLD, &sa, NULL) < 0) { perror("sigaction"); exit(1); }这就有个很有意思的连锁反应了:你在accept的时候,如果此时恰好有SIGCHLD信号递达,accept会被信号中断返回EINTR错误。没有SA_RESTART标志的话,你得自己在代码里判断EINTR并继续accept;有了SA_RESTART,内核会自动重启动accept,省去这个判断。代码里就算写了EINTR判断也不矛盾,双保险没问题。
关于收尸还有几个容易踩的坑,记录下来给你避雷:
- waitpid要用循环,不能用单次wait。因为多个子进程同时退出时,父进程可能只收到一次SIGCHLD信号(信号会合并),不用循环waitpid的话会漏掉一部分子进程的回收。
- 信号处理函数里不要调用printf这类非异步信号安全的函数。这是为了显示方便才在示例里用,线上代码建议只用waitpid,日志另想办法。过个两秒可能看不出问题,但万一信号风暴来了,整个服务就变得不稳定了。
4.2 accept惊群问题与多进程下的处理策略
在多进程模型里,还有一个"桌面级经典现象"叫惊群。假设你开了多个子进程,不是"一连接一fork",而是启动时就fork了一堆子进程,让所有子进程都阻塞在accept同一个listenfd上。这时候如果来一个新连接,内核唤醒的往往不止一个子进程,而是所有阻塞在accept上的子进程都被唤醒,但最终只有一个能成功accept,其余的都扑了个空,白白被唤醒一次,造成不必要的上下文切换。
历史上Linux内核在accept层面做过一些优化,比如2.6内核以后,阻塞在同一个fd上的多个进程,在accept时通常只有一个会被真正唤醒(相比早期已经有了改进),但这并不是完备保证,也不意味着你可以高枕无忧。如果你的设计就是多个子进程同时accept,为了守住底线,可以考虑在accept外面加一把进程互斥锁,让同一时刻只有一个进程在accept。这在Nginx等高性能服务里有类似的做法。
不过我必须说清楚:"一连接一fork"的主进程accept模型,本身不存在惊群问题,因为只有一个进程在accept。惊群只会在"预派生子进程、子进程并发accept"的进程池模型里才需要认真考虑。后面我会展示进程池模型的代码,到时候再回到这个问题上。
4.3 SO_REUSEADDR:TIME_WAIT状态的端口复用
在TCP服务端编程里,你早晚会碰见一个让人抓狂的错误:
bind: Address already in use原因很简单:你暴力关闭了服务器,但还有连接处于TIME_WAIT状态(主动关闭方进入的等待状态),内核认为这个端口还处于被占用的状态,不允许立即bind复用。
解决办法就是开头我写的那一行setsockopt:
int reuse = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse));这个选项的作用是允许端口处于TIME_WAIT状态时也能立即重新绑定。理解一下场景区别:
- 服务器主动关闭连接:如果服务器的子进程在处理完业务后自己close了连接(比如服务端先断开),该连接会进入TIME_WAIT状态。这时候如果服务器重启,bind就会失败。
- 客户端主动断开:TIME_WAIT发生在客户端那边,服务器这边通常不影响重启。
所以在服务器端写代码时,listenfd上无条件加上SO_REUSEADDR是安全的、推荐的做法。但注意,这只是让服务器能重启,它不会让处于TIME_WAIT的连接凭空消失,这是TCP协议的机制,不是bug。
如果你遇到更诡异的情况:明明用了SO_REUSEADDR还是bind失败,先检查netstat -anp | grep 端口,看看这个端口到底被谁占着,八成是另一个进程还活着,那就不是TIME_WAIT的问题了。
5. 加固与进阶:从能跑到好用的三大改动
5.1 进程池预派生:让连接处理更稳定
"边accept边fork"虽然能跑,但在连接突增的瞬间,fork这个昂贵的系统调用会成为瓶颈。而且频繁fork/exit会带来进程创建销毁的开销。成熟的优化思路是进程池预派生:服务启动时一次性fork出N个子进程,每个子进程自己调accept,谁抢到连接谁处理。
预派生方案的好处非常明显:
- 子进程启动成本与连接到达时机解耦,连接来时不用现场fork。
- 子进程数量可控,不会因为短时间大量连接而疯狂fork导致系统过载。
- 可以利用多核CPU,多个子进程并行accept。
核心代码结构大概是这样:
void process_pool(int listenfd, int nproc) { pid_t pid; for (int i = 0; i < nproc; i++) { pid = fork(); if (pid < 0) { perror("fork"); exit(1); } if (pid == 0) { // 子进程:直接进入accept循环 while (1) { int connfd = accept(listenfd, NULL, NULL); if (connfd < 0) { if (errno == EINTR) continue; perror("accept"); exit(1); } handle_client(connfd); close(connfd); } } } // 父进程只负责监控子进程状态 while (1) { pause(); // 等待信号 } }这个方案里,子进程们共享同一个listenfd,不需要关listenfd(因为没有"父亲"还在accept它)。每个子进程accept后,连接就是它独占的,不存在共享connfd的问题。
问题来了:刚才说的惊群在这里就变得现实了。一个连接到达时,内核确实可能唤醒多个accept子进程。不过实测下来,如果子进程个数控制在CPU核数左右(比如4到8个),惊群开销可以接受;如果你需要更极致的性能,可以考虑在各子进程accept前加互斥锁,但我不建议在入门阶段就去优化这个,先把基础模型跑稳,理解清楚每个API的作用,再去感知性能差异。
5.2 超时控制与优雅退出:不搞一锤子买卖
服务器程序里最容易被忽视的就是**"连接只读不写"或者"连接只在异常时才断"**这样的场景。如果客户端建立连接后不发数据、不断开也不关闭,你的子进程会一直阻塞在read上,连接资源就白白挂着。
给read加上超时控制主要有两种方式:
方式一:用setsockopt设置SO_RCVTIMEO和SO_SNDTIMEO
struct timeval tv; tv.tv_sec = 30; tv.tv_usec = 0; setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); setsockopt(fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv));设置之后,如果在指定时间内没有数据到达,read返回-1,errno是EAGAIN或EWOULDBLOCK。你的代码就可以判断出"这个连接超时了",主动收尾。
方式二:用alarm信号或者select/poll做超时控制。这种更细腻但代码复杂一些,入门阶段用SO_RCVTIMEO就够了。
再有就是优雅退出。收到退出信号(比如SIGTERM)时,不要直接exit,先释放资源、记录日志、尽量让子进程处理完手头数据再退出。这需要在信号处理里做文章,但如果你只想要"稳",至少要做到父进程被子进程回收干净、退出时关闭listenfd、不产生僵尸进程。
5.3 用一个连接处理变体的完整示例:HTTP静态文件服务
回显服务器演示了"连接处理逻辑"的框架,但实际项目中你往往要针对每一种业务类型写不同的处理函数。我举个非常实用的例子——在子进程里实现一个极简HTTP静态文件服务,返回服务器本地的文件内容。
void handle_http_client(int connfd) { char req[4096]; int n = read(connfd, req, sizeof(req) - 1); if (n <= 0) return; req[n] = '\0'; // 极简解析:只处理GET请求,提取URI // 源码出于篇幅省略完整解析,思路如下: char method[8], path[1024]; sscanf(req, "%7s %1023s", method, path); // 去掉开头的/,打开本地文件 if (strcmp(method, "GET") == 0) { FILE *fp = fopen(path + 1, "rb"); if (!fp) { const char *resp = "HTTP/1.1 404 Not Found\r\n\r\n"; write(connfd, resp, strlen(resp)); } else { fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); char header[256]; int len = snprintf(header, sizeof(header), "HTTP/1.1 200 OK\r\nContent-Length: %ld\r\n\r\n", size); write(connfd, header, len); char buf[4096]; size_t r; while ((r = fread(buf, 1, sizeof(buf), fp)) > 0) { write(connfd, buf, r); } fclose(fp); } } close(connfd); }核心思路就是:解析请求路径,映射到本地文件,用HTTP响应头加上Content-Length返回内容。这个版本缺失了对并发连接数的限制、HTTP头部的完整解析、错误状态码的细化,但足以作为"子进程业务逻辑多样化"的示例。
实际写业务型TCP服务器时,我的建议是把连接处理函数拆成模块:读取请求、解析协议、业务处理、组装响应、日志上报,各干各的。子进程的逻辑复杂起来以后,这个习惯能帮你省下大量的调试时间。
6. 常见问题与排查实录
6.1 EADDRINUSE:端口明明关了还是报占用
遇到这个报错的第一反应应该是:查状态,别慌着重启。使用:
netstat -anp | grep 8888 lsof -i :8888看看端口是处于LISTEN还是TIME_WAIT。如果是TIME_WAIT,加了SO_REUSEADDR就基本能解决;如果是LISTEN状态,说明还有进程活着,用kill关掉它或者换个端口调试。
避坑补充:还有一种情况是你在一个进程里同时bind了多个不同socket,它们互相冲突。排查时把监听端口错开,或者统一规划端口段,能在源头上少很多麻烦。
6.2 accept返回EINTR:被信号打断,别直接退出
这是多进程服务里极容易遇到的一个情况:子进程在处理完连接退出时,父进程的accept被SIGCHLD信号打断,返回EINTR。不少新手在这一步直接perror+exit,然后发现服务器跑一会就莫名其妙死掉,简直是经典开局。
解决方式就是我在代码里写的:
if (connfd < 0) { if (errno == EINTR) continue; perror("accept"); exit(1); }另外,有的系统上accept还有可能返回EMFILE(文件描述符耗尽),这个错误不能直接退出,而应该短暂等待或者重试,因为可能是短时间连接数过多。一个可行的处理是sleep一小段时间再continue。遇到EMFILE最好的做法是配合系统级调优(调大文件描述符上限)。检查当前上限用:
ulimit -n如果这个值是1024,你的服务器最多同时打开一千个左右的fd,包括监听socket、管道、日志文件等,这显然不够用。改到65535或者更大:
ulimit -n 65535注意ulimit只对当前shell和它的子进程有效,要持久化得去改limits.conf配置文件。
6.3 排查工具清单:除了gdb,这些命令更源气
多进程服务出问题,先别急着上调试器,用命令看全局往往更快:
- 查看进程树:
ps -ef --forest或pstree -ap | grep server,一眼看出父子关系。 - 查看连接状态:
ss -tanp,比netstat信息更全更现代,可以看到连接对应的进程号。 - 查看端口和收发包:
netstat -i查网卡统计。 - 实时跟踪系统调用:
strace -p <pid>查看某个进程正在做什么系统调用,用在"某个子进程卡住了"的场景非常管用。 - 查看进程打开的文件描述符:
ls -l /proc/<pid>/fd/,可以确认某个子进程是否还握着不该有的fd。
排查时我习惯用三步走:先确认进程状态和连接状态,再用strace判断阻塞点,最后用lsof看fd泄漏。线上排障大部分问题在这三步之内就能定位。
6.4 调试压测心得:并发数上不去时先检查这四件事
我做并发压测时,最常遇到的问题就是"连接数稍微一多,成功率就掉"。排查顺序供参考:
- 文件描述符上限(ulimit -n)。这是99%问题的根源。
- backlog大小。客户端connect失败但服务端没看到连接,很多时候是backlog满了。
- 子进程数量与CPU核数。进程池子进程不是越多越好,超过CPU核数反而因为上下文切换降低吞吐。
- TCP内核参数。tcp_tw_reuse和tcp_fin_timeout等参数按需调整,但这不是必须的,只有在海量短连接场景才需要碰这些。
压测工具方面,自己写个简单的多线程压测客户端也可以,图个快速;专业的推荐wrk或ab,但wrk对长连接场景支持的深度一般,也有Apache Bench可以做HTTP层的简单压测。
最后补一段实操心得
多说一句我自己的个人体会:多进程模型的好处,很多时候不是用压力测试测出来的,而是在真实运行一段时间后才慢慢显现的。进程隔离意味着你可以在子进程里放心地跑第三方库、调不稳定模块,崩溃了大不了重启那个子进程;我见过很多团队花大量精力在单进程里处理各种异常分支,反而绕过了最简单的隔离屏障。
如果你是从零开始学,我建议的路径是:把基础框架敲一遍,故意制造一些场景去观察——比如不处理SIGCHLD会怎样,父子进程不关闭fd又会怎样,SO_REUSEADDR不加会怎样。这样主动地"踩坑",比看十遍教程都有用。把多进程模型真正吃透了,再去接触多线程和epoll,你会发现网络编程的整个知识体系都串起来了。到时候你回过头来看这份代码,可能会觉得它朴素,但它的每一个设计决策,都是Linux服务器编程领域不会被淘汰的核心智慧。