6. 多进程并发服务器:fork()系统调用、孤儿进程与僵尸进程、多进程服务器实现
说实话,单进程服务器在真实生产环境中基本就是个玩具。你想想看,一个客户端连上来,处理请求的时候,其他客户端全得排队等着。这谁受得了?我早年刚入行时,就写过这样的"串行服务器",结果被老大叫到办公室,指着监控图上那条长长的响应时间曲线问我:"这就是你写的并发?"
嗯,从那以后,我就老老实实开始研究多进程并发模型了。今天咱们就聊聊这个——用 fork() 来创建子进程,让每个客户端都有自己的"专属服务员"。
6.1 fork() 系统调用——进程复制工厂
fork() 是 Unix/Linux 系统里最神奇的系统调用之一。它干的事很简单:复制当前进程,创建一个几乎一模一样的子进程。
调用一次,返回两次。这句话我当年背得滚瓜烂熟。为什么返回两次?因为父进程和子进程都会从 fork() 返回的地方继续执行。区别在于返回值:
- 父进程收到的是子进程的 PID(大于0)
- 子进程收到的是 0
- 如果出错,返回 -1
来看个最基础的例子:
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
}
if (pid == 0) {
// 子进程
printf("我是子进程,PID=%d,父进程PID=%d\n", getpid(), getppid());
} else {
// 父进程
printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
}
return 0;
}
运行后你会看到两个进程各自打印了一行。注意,父子进程的执行顺序是不确定的,谁先跑完全看调度器的心情。我在项目里就踩过这个坑——以为子进程一定先执行,结果数据同步出了问题。
6.2 孤儿进程与僵尸进程——两个"鬼"
这两个概念,我当年学的时候总觉得差不多。后来在线上环境亲眼看到一堆僵尸进程占着进程表,才真正理解了它们的区别。
孤儿进程
父进程先挂了,子进程还在跑。这时候子进程就成了"孤儿"。别担心,系统不会让它流浪——init 进程(PID=1)会收养它,成为它的新父进程。
我遇到过的情况:服务器父进程意外崩溃,子进程还在处理客户端请求。结果这些子进程全被 init 收养了,日志里查不到父进程信息,排查了半天才找到根因。
僵尸进程
这个更坑。子进程先结束,父进程没有调用 wait() 或 waitpid() 来回收它的退出状态。子进程的进程描述符就留在内核里,变成了"僵尸"。
僵尸进程的特点:
- 几乎不占内存(只保留进程表项)
- 杀不死(kill -9 也没用)
- 数量多了会耗尽系统进程表
6.3 多进程服务器实现——实战代码
好了,理论说完了,咱们直接上代码。这是一个典型的多进程并发服务器骨架:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <signal.h>
#include <sys/wait.h>
#define PORT 8888
#define BACKLOG 10
// 处理 SIGCHLD 信号,回收子进程
void sigchld_handler(int sig) {
while (waitpid(-1, NULL, WNOHANG) > 0);
}
void handle_client(int client_fd) {
char buf[1024];
int n;
// 这里处理客户端请求
n = read(client_fd, buf, sizeof(buf) - 1);
if (n > 0) {
buf[n] = '\0';
printf("子进程 %d 收到: %s\n", getpid(), buf);
write(client_fd, "OK", 2);
}
close(client_fd);
exit(0); // 子进程处理完就退出
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
// 注册 SIGCHLD 信号处理
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
// 创建 socket
server_fd = socket(AF_INET, SOCK_STREAM, 0);
if (server_fd == -1) {
perror("socket");
exit(1);
}
// 设置地址复用
int opt = 1;
setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 绑定地址
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_addr.s_addr = INADDR_ANY;
server_addr.sin_port = htons(PORT);
if (bind(server_fd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind");
exit(1);
}
// 监听
if (listen(server_fd, BACKLOG) == -1) {
perror("listen");
exit(1);
}
printf("服务器启动,PID=%d,监听端口 %d\n", getpid(), PORT);
while (1) {
client_fd = accept(server_fd, (struct sockaddr*)&client_addr, &client_len);
if (client_fd == -1) {
perror("accept");
continue;
}
// fork 子进程处理
pid_t pid = fork();
if (pid == -1) {
perror("fork");
close(client_fd);
continue;
}
if (pid == 0) {
// 子进程
close(server_fd); // 子进程不需要监听 socket
handle_client(client_fd);
} else {
// 父进程
close(client_fd); // 父进程不需要客户端 socket
}
}
close(server_fd);
return 0;
}
这段代码有几个关键点,我挨个说一下:
- SIGCHLD 处理:用
sigaction而不是signal,因为signal在不同系统上行为不一致。我习惯用sigaction,更可靠。 - SA_RESTART 标志:让被信号中断的系统调用自动重启。不然
accept()可能被 SIGCHLD 打断返回 EINTR。 - 关闭不需要的文件描述符:子进程关掉 server_fd,父进程关掉 client_fd。这是为了防止文件描述符泄漏。
6.4 知识体系图
下面这张图把多进程服务器的核心流程和关键概念串起来了:
6.5 避坑指南
多进程服务器看着简单,实际跑起来坑不少。我把自己踩过的坑列出来,你遇到了直接对照:
| 问题 | 现象 | 解决方案 |
|---|---|---|
| 僵尸进程 | ps 看到一堆 defunct 进程 | 注册 SIGCHLD 信号处理,用 waitpid() 回收 |
| 文件描述符泄漏 | 服务器运行一段时间后 accept() 返回 EMFILE | 子进程关 server_fd,父进程关 client_fd |
| accept() 被信号中断 | accept() 返回 -1,errno = EINTR | 使用 SA_RESTART 标志,或手动重试 |
| 子进程竞争条件 | 多个子进程同时写日志,内容错乱 | 加文件锁,或用独立日志进程 |
好了,多进程并发服务器就聊到这儿。核心就三件事:fork() 创建子进程、SIGCHLD 回收僵尸、关闭不需要的文件描述符。把这三点记牢,你的服务器就能扛住成百上千的并发连接了。