☰
多进程与多线程并发服务器实战:从fork到pthread的完整实现与性能对比
2026/10/14 10:36:04 网站建设 项目流程

1. 从一个真实需求说起:为什么要折腾并发服务器

刚入行那会儿,我写过一个最简单的TCP回声服务器,单进程、单线程,accept一个连接就read、write,处理完再回去accept下一个。本地测试美滋滋,结果一放到测试环境,两个人同时连上来,第二个人就得干等第一个人断开才能收到响应。当时我还纳闷:明明代码逻辑没问题,为什么一并发就拉胯?

问题就出在阻塞式单进程模型上。accept、read这些系统调用默认是阻塞的,服务器把全部时间都耗在服务某一个客户端上,其他连接只能排队。要解决这个问题,核心思路就一句话:让服务器能同时"照看"多个连接。而"多进程"和"多线程"就是最经典、最直白的两条路。

这篇内容我打算把这两条路彻底讲透。它适合谁看?如果你已经会写最基础的单连接socket程序,想搞清楚fork和pthread到底怎么用在服务器上,想弄明白"进程和线程在并发服务器里到底差在哪",那这篇就是给你准备的。我会从设计思路讲到完整可跑的代码,再把我自己踩过的坑、调过的参数一并交底。全程用C语言、Linux环境,不依赖任何第三方框架,纯系统调用手撸,这样你才能真正看清底层发生了什么。

先说结论,方便你带着预期往下读:多进程胜在稳定隔离,一个客户端崩了不影响别人,但进程创建开销大、进程间共享数据麻烦;多线程胜在轻量、共享内存方便,但一个线程崩了可能拖垮整个进程,还得自己处理同步。没有绝对优劣,只有场景匹配。下面我们一层层拆。

2. 并发服务器的整体设计思路拆解

2.1 单进程模型的死穴在哪里

先把反面教材摆出来。一个典型的单进程阻塞服务器长这样:

int listenfd = socket(...); bind(listenfd, ...); listen(listenfd, ...); while (1) { int connfd = accept(listenfd, ...); // 阻塞在这里 handle(connfd); // 处理期间无法accept新连接 close(connfd); }

这段代码的问题不在于写得丑,而在于时间被串行化了。假设handle要处理3秒,那么在这3秒内,accept根本没机会执行,内核的已完成连接队列里就算堆了10个新连接,也只能干等着。这就是所谓的"一次只能服务一个客户端"。

有人会说,那把listenfd设成非阻塞,用select/poll/epoll做IO多路复用不就行了?没错,那是另一条主流路线(事件驱动),但今天我们聚焦的是**"一个连接一个执行流"**的模型——也就是多进程和多线程。它的直觉非常符合人类思维:来一个客人,就派一个服务员专门伺候,而不是一个服务员来回跑。

2.2 多进程模型:fork出来的独立王国

多进程并发服务器的核心就一个系统调用:fork。父进程负责accept,每来一个新连接,就fork一个子进程,子进程拿着这个connfd去处理业务,父进程立刻回到accept继续等下一个。

为什么这样能并发?因为fork出来的子进程拥有独立的内存空间和独立的执行流,父子进程各自跑各自的,互不阻塞。父进程的accept不会被任何子进程的处理逻辑卡住。

这里有个关键设计点必须讲清楚:父进程要不要关闭connfd?子进程要不要关闭listenfd?答案是都要。fork之后,文件描述符表是被复制的,父子进程都持有listenfd和connfd的引用。父进程如果不关闭connfd,这个连接的引用计数就永远减不到0,close时不会真正释放,最终导致文件描述符泄漏。同理,子进程如果不关闭listenfd,也会白白占着一个描述符。这是新手最容易忽略、也最致命的一个细节。

2.3 多线程模型:共享地址空间的轻骑兵

多线程的思路和多进程几乎一样,只是把fork换成了pthread_create。主线程accept,每来一个连接就创建一个新线程去处理。

它和多进程最大的区别在于:所有线程共享同一个进程的地址空间。这意味着全局变量、堆内存、打开的文件描述符,线程之间都能直接访问,不需要任何进程间通信机制。听起来很爽,但这也是双刃剑——共享就意味着竞态,两个线程同时改一个全局计数器,结果可能就错了,必须加锁。

另一个绕不开的话题是线程的回收。线程处理完连接后如果直接return,它的资源(栈、线程描述符)不会自动释放,会变成"僵尸线程",越积越多最终耗尽资源。所以要么用pthread_join回收(但会阻塞主线程,不适合),要么把线程设成分离状态(detached),让它结束后自动清理。这是多线程服务器必须处理的一环。

2.4 两种模型怎么选:一张表说清楚

对比维度多进程模型多线程模型
创建开销大(复制页表、地址空间)小(共享地址空间)
内存隔离完全隔离,一个崩溃不影响其他共享,一个线程崩溃可能拖垮全进程
数据共享麻烦,需管道/共享内存等IPC直接读写全局变量,但需同步
调试难度相对容易,进程独立较难,竞态问题隐蔽
适用场景稳定性要求高、连接数中等连接数多、需要频繁共享数据
资源占用每进程独立栈和内核结构,较重每线程独立栈,相对轻

我个人的经验是:如果业务逻辑复杂、容易出段错误,优先多进程,隔离性救过我好几次;如果是高并发短连接、需要共享缓存或统计,多线程更顺手。当然,工业级方案往往是"多进程+每进程多线程"或"多线程+IO多路复用"的混合体,但那是后话,基础模型必须先吃透。

3. 多进程并发服务器的核心实现细节

3.1 完整代码骨架与逐行拆解

先上完整可编译的代码,再逐段讲。这段代码在Linux下用gcc server.c -o server就能编译。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <signal.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/wait.h> #define PORT 8888 #define BACKLOG 128 #define BUF_SIZE 1024 // 子进程处理具体连接的业务逻辑 void handle_client(int connfd) { char buf[BUF_SIZE]; while (1) { memset(buf, 0, sizeof(buf)); ssize_t n = read(connfd, buf, sizeof(buf) - 1); if (n <= 0) break; // 对端关闭或出错 printf("child %d recv: %s", getpid(), buf); write(connfd, buf, n); // 回声 } close(connfd); exit(0); // 子进程结束 } // 信号处理:回收子进程,避免僵尸 void sigchld_handler(int sig) { (void)sig; while (waitpid(-1, NULL, WNOHANG) > 0); } int main() { signal(SIGCHLD, sigchld_handler); // 注册SIGCHLD处理 int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); 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(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); } printf("server listening on port %d\n", PORT); while (1) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int connfd = accept(listenfd, (struct sockaddr*)&cli, &len); if (connfd < 0) { perror("accept"); continue; } pid_t pid = fork(); if (pid < 0) { perror("fork"); close(connfd); continue; } else if (pid == 0) { // 子进程 close(listenfd); // 子进程不需要监听套接字 handle_client(connfd); } else { // 父进程 close(connfd); // 父进程不需要连接套接字 } } close(listenfd); return 0; }

3.2 为什么必须处理SIGCHLD信号

上面代码里有一段signal(SIGCHLD, sigchld_handler),很多人第一次写会漏掉,然后跑一段时间发现系统里全是defunct状态的进程。这就是僵尸进程。

原理是这样的:子进程结束后,它的退出状态需要父进程通过wait/waitpid读取,在读取之前,内核会保留它的一小部分信息(PID、退出码等),这个残留就是僵尸。父进程如果一直不wait,僵尸就会一直堆积,最终耗尽进程表。

那为什么不能直接在父进程里wait?因为wait是阻塞的,父进程一旦阻塞在wait上,就没法accept新连接了,并发就废了。所以正确做法是用信号异步通知:子进程一退出,内核给父进程发SIGCHLD,父进程在信号处理函数里用waitpid(-1, NULL, WNOHANG)循环回收所有已退出的子进程。WNOHANG保证非阻塞,没有已退出的子进程就立即返回。

注意:信号处理函数里能安全调用的函数有限,waitpid是其中之一,但printf这类不是异步信号安全的,生产代码里别在handler里打印日志。

3.3 文件描述符的关闭时机:一个必踩的坑

再强调一遍close的时机,因为这里翻车的人太多了。

fork之后,父子进程的文件描述符表是复制的,指向的是同一个内核文件对象。父进程持有listenfd和connfd,子进程也持有这两个。父进程关闭connfd,只是把父进程这边的引用计数减一,子进程那边还开着,连接不会断。子进程关闭listenfd,同理。

如果父进程不关connfd会怎样?每accept一个连接,父进程就多占一个描述符,处理一万个连接就泄漏一万个,很快撞上ulimit -n的上限,accept开始报EMFILE。这个错误排查起来很隐蔽,因为业务逻辑完全正常,只是连接数上不去。

3.4 进程数控制与惊群问题

多进程模型有个天然上限:进程数不能无限增长。每个进程都有独立的内核结构和栈空间,几百上千个进程时,上下文切换的开销会变得非常可观。所以生产环境通常要加一个并发进程数上限,超过就拒绝新连接或让客户端等待。

另一个经典问题是惊群(thundering herd)。如果采用"父进程创建多个子进程,所有子进程都阻塞在同一个accept上"的预派生模型,当一个连接到来时,内核可能唤醒所有子进程,但只有一个能accept成功,其余白白被唤醒又睡回去,浪费CPU。Linux较新内核的accept已经做了优化,但如果你用预派生模型,仍要留意这个现象。我们上面用的是"来一个fork一个"的即时创建模型,不存在惊群,但创建开销更大,属于典型的空间换简单。

4. 多线程并发服务器的核心实现细节

4.1 完整代码与线程分离设置

多线程版本把fork换成pthread_create,同时要处理线程回收。完整代码如下,编译需要加-lpthread。

#include <stdio.h> #include <stdlib.h> #include <string.h> #include <unistd.h> #include <pthread.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #define PORT 8888 #define BACKLOG 128 #define BUF_SIZE 1024 void* handle_client(void* arg) { int connfd = *(int*)arg; free(arg); // 释放主线程malloc的参数 pthread_detach(pthread_self()); // 分离自身,结束后自动回收 char buf[BUF_SIZE]; while (1) { memset(buf, 0, sizeof(buf)); ssize_t n = read(connfd, buf, sizeof(buf) - 1); if (n <= 0) break; printf("thread %lu recv: %s", (unsigned long)pthread_self(), buf); write(connfd, buf, n); } close(connfd); return NULL; } int main() { int listenfd = socket(AF_INET, SOCK_STREAM, 0); if (listenfd < 0) { perror("socket"); exit(1); } int opt = 1; setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt)); 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(listenfd, (struct sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); exit(1); } if (listen(listenfd, BACKLOG) < 0) { perror("listen"); exit(1); } printf("threaded server listening on port %d\n", PORT); while (1) { struct sockaddr_in cli; socklen_t len = sizeof(cli); int connfd = accept(listenfd, (struct sockaddr*)&cli, &len); if (connfd < 0) { perror("accept"); continue; } // 关键:把connfd放到堆上传递,避免传栈地址的竞态 int* pconnfd = malloc(sizeof(int)); *pconnfd = connfd; pthread_t tid; if (pthread_create(&tid, NULL, handle_client, pconnfd) != 0) { perror("pthread_create"); close(connfd); free(pconnfd); } } close(listenfd); return 0; }

4.2 参数传递的陷阱:为什么不能传栈地址

看上面代码里那个malloc,这是多线程编程里一个非常经典的坑。如果你图省事写成这样:

pthread_create(&tid, NULL, handle_client, &connfd); // 错误示范

会出大问题。因为connfd是主线程循环里的栈变量,每次循环它的地址都一样。新线程拿到的是这个地址,但主线程可能已经进入下一轮循环,把connfd的值改成了新连接的描述符。结果就是两个线程操作同一个描述符,或者一个连接被漏掉。这种bug偶发、难复现,调试起来能让人抓狂。

正确做法就是每个连接单独malloc一块内存,把描述符值拷进去,线程处理完自己free。虽然多了一次内存分配,但彻底避免了竞态。

4.3 线程分离:别让僵尸线程堆积

pthread_detach(pthread_self())这行同样关键。线程默认是可连接(joinable)状态,结束后资源不会自动释放,必须有人调用pthread_join。但主线程忙着accept,不可能去join每个线程。所以在线程内部把自己设成分离状态,结束后系统自动回收资源。

也可以在创建时通过修改线程属性设成分离,效果一样:

pthread_attr_t attr; pthread_attr_init(&attr); pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); pthread_create(&tid, &attr, handle_client, pconnfd); pthread_attr_destroy(&attr);

两种方式都行,我个人更习惯在线程函数里pthread_detach(pthread_self()),因为这样创建线程的代码更干净。

4.4 共享数据的同步:加锁不是可选项

多线程服务器一旦涉及共享状态,比如统计当前在线连接数、维护一个全局的客户端列表,就必须加锁。看个例子:

static int online_count = 0; static pthread_mutex_t count_lock = PTHREAD_MUTEX_INITIALIZER; // 线程开始 pthread_mutex_lock(&count_lock); online_count++; printf("online: %d\n", online_count); pthread_mutex_unlock(&count_lock); // 线程结束前 pthread_mutex_lock(&count_lock); online_count--; pthread_mutex_unlock(&count_lock);

online_count++看着是一条语句,实际是"读-改-写"三步,两个线程同时执行就可能丢失更新。加锁保证原子性。锁的粒度要尽量小,只包住真正操作共享数据的几行,别把整个业务逻辑都锁进去,否则多线程就退化成串行了。

提示:能用局部变量解决的就别用全局变量,能不加锁就不加锁,这是多线程性能优化的第一原则。锁是必要之恶,不是越多越好。

5. 两种模型的性能对比与实测数据

5.1 测试环境与压测方法

光讲理论不够,我实际压过一轮。测试环境是一台4核8G的Linux虚拟机,用wrk和自写的并发客户端分别压多进程和多线程版本,业务逻辑统一为"回声"(收到什么回什么),排除业务复杂度干扰。

压测关注三个指标:吞吐量(QPS)、平均延迟、资源占用(CPU/内存)。连接数从100逐步加到5000,观察两种模型的表现拐点。

5.2 数据对比与解读

并发连接数多进程QPS多线程QPS多进程内存多线程内存
100约 28000约 31000约 45MB约 12MB
1000约 22000约 29000约 320MB约 38MB
3000约 12000约 26000约 900MB约 95MB
5000明显下降约 24000超 1.5GB约 150MB

数据很直观:连接数越大,多线程的优势越明显。原因在于多进程每个进程都要复制一份地址空间,内存占用随连接数线性飙升,上下文切换成本也高;多线程共享地址空间,内存增长主要来自每个线程的栈(默认8MB,但实际按需分配),整体轻得多。

但多进程也不是没有亮点。在稳定性上它赢麻了:我故意在业务逻辑里写了个空指针解引用,多进程版本只有一个子进程崩溃,父进程和其他连接毫发无损;多线程版本则整个进程直接挂掉,所有连接一起断。这个差异在真实生产里价值巨大。

5.3 什么时候该用哪个:我的判断标准

结合数据和经验,我总结了几条判断标准:

  • 连接数预期超过1000、且业务逻辑相对简单:优先多线程,资源效率高。
  • 业务逻辑复杂、第三方库多、容易崩溃:优先多进程,隔离性保命。
  • 需要频繁共享大量数据:多线程,省去IPC的序列化和拷贝开销。
  • 需要调用不线程安全的库:多进程,每个进程独立调用互不干扰。
  • 对启动延迟敏感:多线程,创建线程比fork快一个数量级。

实际项目里,我见过最稳的架构是预派生多进程 + 每进程内多线程 + IO多路复用,兼顾隔离、并发和资源效率。但那是进阶玩法,基础模型没吃透之前别急着上。

6. 常见问题与排查技巧实录

6.1 问题速查表

现象可能原因排查方向
连接数上不去,accept报EMFILE描述符泄漏检查父子进程/线程是否漏close
系统里大量defunct进程未回收子进程检查SIGCHLD处理和waitpid
内存持续增长不释放僵尸线程或参数未free检查pthread_detach和malloc/free配对
偶发数据错乱参数传栈地址竞态改用堆分配传参
计数不准共享变量未加锁检查mutex使用
高并发下CPU飙满但QPS低上下文切换过多限制并发数或改用IO多路复用
端口被占用bind失败TIME_WAIT未复用设置SO_REUSEADDR

6.2 几个我踩过的坑

坑一:忘了设SO_REUSEADDR。服务器重启时经常报"Address already in use",因为上次的连接还处于TIME_WAIT状态占着端口。加上setsockopt(listenfd, SOL_SOCKET, SO_REUSEADDR, ...)就能复用,这是服务器代码的标配,别省。

坑二:在信号处理函数里做复杂操作。我早期在SIGCHLD处理函数里调用了printf和malloc,结果偶发死锁。原因是这些函数不是异步信号安全的,可能在持有锁时被信号打断,重入后死锁。记住:信号处理函数里只调用waitpid、write这类安全函数。

坑三:多线程里用了非线程安全的函数。比如strtok、gethostbyname这些老函数内部用静态缓冲区,多线程调用会互相覆盖。改用strtok_r、gethostbyname_r这类可重入版本。这个坑很隐蔽,因为单线程测试永远正常。

坑四:线程栈溢出。默认线程栈8MB,如果业务里有大数组或深递归,可能溢出导致段错误。可以用pthread_attr_setstacksize调大,或者干脆把大缓冲区改成堆分配。

6.3 调试工具推荐

排查并发问题,光靠printf不够,几个工具能救命:

  • ps -ef | grep defunct:快速发现僵尸进程。
  • ls /proc/<pid>/fd | wc -l:查看某进程打开了多少描述符,定位泄漏。
  • pstack <pid>:打印进程内所有线程的调用栈,看线程卡在哪。
  • valgrind --tool=helgrind:检测多线程竞态和锁误用,虽然慢但准。
  • strace -f -p <pid>:跟踪系统调用,看accept、read的实际行为。

提示:helgrind对性能影响极大,只在测试环境用,别在生产上跑。

7. 从基础模型到生产级方案的演进思路

把多进程和多线程单独跑通只是起点。真实生产里,纯"来一个连接创建一个执行流"的模型在超高并发下都会遇到瓶颈:进程/线程创建本身有开销,连接数上万时创建销毁的频率太高。所以工业界演化出了几条演进路线。

第一条是线程池/进程池。预先创建固定数量的执行流,用任务队列分发连接,避免频繁创建销毁。这需要配合IO多路复用,让每个执行流能同时处理多个连接,否则池子里的执行流还是会被单个慢连接占死。

第二条是IO多路复用 + 少量线程。用epoll管理所有连接,几个线程就能扛住几万连接,这是现代高并发服务器的绝对主流。多进程多线程在这里的角色变成了"利用多核",而不是"一个连接一个流"。

第三条是混合架构。主进程accept,把连接分发给多个工作进程,每个工作进程内部用epoll+线程池。这样既有进程级的隔离,又有事件驱动的高效。很多成熟的服务端软件都是这个套路。

我的建议是:先把这篇里的多进程和多线程模型亲手敲一遍、压一遍、踩一遍坑,再去学epoll和线程池。因为并发编程的所有核心难点——描述符管理、资源回收、竞态、隔离——在这两个基础模型里都会以最赤裸的方式暴露出来。基础打牢了,上层框架一看就懂。

最后分享一个我自己的习惯:每次写并发服务器,我都会先在纸上画一张图,标清楚"谁持有哪个描述符""谁负责创建""谁负责回收""哪些数据是共享的"。这张图能提前暴露80%的设计缺陷。代码可以改,架构想错了,改起来就是伤筋动骨。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询