6、多进程并发服务器:使用fork()创建子进程处理多客户端连接
讲完了单进程的迭代服务器,你会发现一个很尴尬的问题——一次只能服务一个客户端。第二个客户端想连?等着吧,等第一个处理完再说。这在生产环境里根本没法用。
我记得刚入行那会儿,写了个简单的聊天室demo,用的就是单进程模型。测试的时候开两个客户端,第二个客户端死活连不上,我还以为是代码写错了,查了半天……后来老同事看了一眼说:“你fork一下不就完了?”
嗯,今天我们就来聊聊多进程并发服务器。说白了,就是让父进程负责“接客”,每来一个客户端,就fork一个子进程去专门伺候它。
6.1 多进程模型的核心思想
多进程并发的思路其实很简单:
- 父进程:只做一件事——accept(),等待新连接
- 子进程:处理具体的客户端通信,处理完就退出
- 关键点:子进程要关闭监听socket,父进程要关闭已连接socket
你想想看,如果不关闭会怎样?文件描述符泄漏,资源被白白占用。我在项目中就见过这样的bug——服务器跑了三天,突然连不上新客户端了,一查发现文件描述符用光了。
核心原则: fork之后,父子进程各自关闭不需要的fd。子进程关listen_fd,父进程关conn_fd。
6.2 代码实现:一个完整的多进程并发服务器
直接上代码。我习惯把关键步骤都写在注释里,方便以后回头看。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <signal.h>
#include <sys/wait.h>
#define PORT 8888
#define BACKLOG 10
// 子进程处理客户端请求
void handle_client(int conn_fd, struct sockaddr_in *client_addr) {
char buf[1024];
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr->sin_addr, client_ip, INET_ADDRSTRLEN);
printf("[子进程 %d] 服务客户端 %s:%d\n",
getpid(), client_ip, ntohs(client_addr->sin_port));
while (1) {
memset(buf, 0, sizeof(buf));
int n = read(conn_fd, buf, sizeof(buf) - 1);
if (n <= 0) {
if (n == 0) {
printf("[子进程 %d] 客户端断开连接\n", getpid());
} else {
perror("read error");
}
break;
}
printf("[子进程 %d] 收到: %s", getpid(), buf);
// 回显给客户端
write(conn_fd, buf, strlen(buf));
}
close(conn_fd);
printf("[子进程 %d] 退出\n", getpid());
exit(0);
}
// SIGCHLD信号处理函数——回收僵尸进程
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
int listen_fd, conn_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
// 1. 创建socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket error");
exit(1);
}
// 2. 设置端口复用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 3. 绑定地址
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(listen_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("bind error");
exit(1);
}
// 4. 监听
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen error");
exit(1);
}
// 5. 注册SIGCHLD信号处理
signal(SIGCHLD, sigchld_handler);
printf("[父进程 %d] 服务器启动,监听端口 %d...\n", getpid(), PORT);
// 6. 主循环:accept + fork
while (1) {
conn_fd = accept(listen_fd, (struct sockaddr*)&client_addr, &client_len);
if (conn_fd < 0) {
if (errno == EINTR) {
continue; // 被信号中断,重新accept
}
perror("accept error");
break;
}
// fork子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork error");
close(conn_fd);
continue;
}
if (pid == 0) {
// 子进程
close(listen_fd); // 子进程不需要监听
handle_client(conn_fd, &client_addr);
} else {
// 父进程
close(conn_fd); // 父进程不需要处理连接
printf("[父进程 %d] 创建子进程 %d 处理客户端\n", getpid(), pid);
}
}
close(listen_fd);
return 0;
}
6.3 代码中的几个关键细节
这段代码看起来不长,但坑不少。我一个个说。
6.3.1 端口复用
SO_REUSEADDR 这个选项,我建议你每次都加上。为什么?因为服务器崩溃重启的时候,如果不加这个,bind会失败——端口还被上一个进程占着呢。我曾经在线上环境吃过这个亏,重启服务的时候报错,手忙脚乱地等了两分钟才恢复。
6.3.2 SIGCHLD信号处理
子进程退出时,会向父进程发送SIGCHLD信号。如果父进程不处理,子进程就会变成僵尸进程。僵尸进程虽然不占CPU,但会占用进程表项,进程表满了就fork不出新进程了。
我这里的处理方式是:
- 注册
sigchld_handler函数 - 在handler里用
waitpid(-1, NULL, WNOHANG)循环回收 - 用
WNOHANG是因为可能有多个子进程同时退出,要一次性收完
小技巧: 为什么不用 wait() 而用 waitpid()?因为 wait() 是阻塞的,如果当前没有子进程退出,它会卡住。而 waitpid() 配合 WNOHANG 是非阻塞的,收不到就立即返回。
6.3.3 accept被信号中断
你看代码里有个 if (errno == EINTR) continue;。这是干嘛用的?
当子进程退出时,SIGCHLD信号会打断父进程的accept系统调用。如果不处理这个中断,accept会返回-1,然后父进程就退出了。嗯,我第一次写多进程服务器时就踩了这个坑——客户端一断开,服务器也跟着挂了。
6.4 多进程模型的核心流程图
下面这张图,把整个流程串了一遍。你看完应该就清楚了。
6.5 多进程模型的优缺点
任何技术方案都有两面性。多进程并发也不例外。
| 优点 | 缺点 |
|---|---|
| 编程简单,逻辑清晰 | 进程创建开销大(fork比较重) |
| 进程间隔离性好,一个子进程崩溃不影响其他 | 进程数受系统限制(ulimit -u) |
| 充分利用多核CPU(每个进程独立调度) | 进程间通信(IPC)复杂 |
| 适合计算密集型任务 | 大量客户端时内存占用高 |
注意: 多进程模型虽然简单,但不适合高并发场景。如果客户端数量超过几百个,建议考虑多线程或事件驱动模型(如epoll)。我在一个物联网项目中用过多进程,设备量到500左右时,进程切换开销就开始明显了。
6.6 避坑指南
最后,分享几个我踩过的坑:
- 文件描述符泄漏:每次fork后,父子进程都要记得close不需要的fd。我见过一个同事的代码,父进程没关conn_fd,结果连接数一多,父进程自己先撑不住了。
- 信号处理要谨慎:printf在信号处理函数里是不安全的。我上面的代码为了演示简单用了printf,生产环境建议用write或记录日志。
- 子进程退出状态:如果子进程非正常退出(比如段错误),父进程要能感知到。可以用waitpid的status参数检查退出原因。
- 惊群问题:多个进程同时accept同一个监听socket时,内核会唤醒所有进程,但只有一个能成功。这个问题在Linux 2.6之后有所改善,但高并发场景下还是建议用epoll+非阻塞IO。
我曾经在一个压力测试中,发现服务器CPU使用率异常高。排查了半天,发现是子进程退出太快,父进程频繁处理SIGCHLD信号,导致上下文切换开销巨大。后来加了个简单的进程池,预创建一批子进程,问题就解决了。
嗯,多进程并发服务器就聊到这儿。代码虽然简单,但里面的细节值得你多琢磨琢磨。动手写一遍,跑起来,再压测一下,你会理解得更深。