5、多进程并发服务器:fork()创建子进程、孤儿进程与僵尸进程、信号处理SIGCHLD、多进程服务器模型
多进程并发服务器,说白了就是让服务器能同时服务多个客户端。你想想看,如果只有一个进程,那一次只能处理一个连接,其他人都得排队等着——这谁受得了?
我记得刚入行那会儿,写过一个简单的echo服务器,单进程的。测试的时候开三个客户端,结果第二个和第三个一直在那干等。当时我就想,这玩意儿要是上线,估计会被用户骂死。后来学了fork(),才算是打开了新世界的大门。
5.1 fork() 创建子进程——复制一个自己
fork() 是Linux下创建进程的核心系统调用。它的行为很特别:调用一次,返回两次。
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork error");
return -1;
} else if (pid == 0) {
// 子进程
printf("我是子进程,PID=%d,父进程PID=%d\n", getpid(), getppid());
} else {
// 父进程
printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
}
return 0;
}
运行结果大概是这样的:
我是父进程,PID=1234,子进程PID=1235
我是子进程,PID=1235,父进程PID=1234
这里有个细节:fork() 之后,父子进程是并发执行的。谁先跑?不一定。这取决于操作系统的调度策略。我在项目中遇到过一个问题,就是父子进程共享文件描述符,如果不小心,子进程关了父进程的监听socket,那整个服务器就挂了。
5.2 孤儿进程与僵尸进程——两个坑
这两个概念,我当年学的时候觉得挺绕的。其实说白了很简单:
- 孤儿进程:父进程先挂了,子进程还在跑。这时候子进程会被 init 进程(PID=1)收养。
- 僵尸进程:子进程先挂了,但父进程没调用 wait() 收尸。子进程的进程描述符还留在内核里,就成了僵尸。
为什么会这样?因为子进程退出时,内核会保留它的退出状态,直到父进程用 wait() 或 waitpid() 取走。如果父进程一直不取,那这个子进程就永远是个僵尸。
我曾经在一个生产环境里见过僵尸进程泛滥的情况。那是一个多进程服务器,父进程没处理好子进程的退出信号,结果跑了几天后,系统里堆了几百个僵尸进程。虽然僵尸进程不占CPU,但会占进程表项。进程表满了,新进程就 fork 不出来了。
- 孤儿进程:父死子活,被收养,无害。
- 僵尸进程:子死父活,没人收尸,有害。
5.3 信号处理 SIGCHLD——给子进程收尸
解决僵尸问题的标准做法,就是让父进程捕获 SIGCHLD 信号。当子进程退出时,内核会向父进程发送这个信号。父进程在信号处理函数里调用 waitpid(),就能及时收尸。
#include <signal.h>
#include <sys/wait.h>
void sigchld_handler(int sig) {
// 注意:信号处理函数里要避免调用不可重入函数
pid_t pid;
int status;
// 用 while 循环,一次性处理所有退出的子进程
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
printf("子进程 %d 已退出,状态码 %d\n", pid, WEXITSTATUS(status));
}
}
int main() {
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction error");
return -1;
}
// 后续的 fork() 逻辑...
return 0;
}
这里有几个要点:
- 用
sigaction()而不是signal(),因为signal()在不同系统上行为不一致。 SA_NOCLDSTOP标志表示子进程暂停时不要触发 SIGCHLD,只有退出时才触发。WNOHANG参数让waitpid()非阻塞,避免信号处理函数卡住。- 用
while循环是因为多个子进程可能同时退出,但 SIGCHLD 信号可能被合并成一次。
5.4 多进程服务器模型——经典架构
好了,前面铺垫了这么多,现在来看完整的多进程并发服务器模型。这是最经典的网络编程模型之一,简单、稳定、容易理解。
#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
void sigchld_handler(int sig) {
pid_t pid;
int status;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 收尸,啥也不干
}
}
void handle_client(int client_fd) {
char buf[1024];
int n;
while ((n = read(client_fd, buf, sizeof(buf))) > 0) {
write(client_fd, buf, n);
}
close(client_fd);
exit(0);
}
int main() {
int listen_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len;
pid_t pid;
// 1. 注册 SIGCHLD 信号处理
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, NULL);
// 2. 创建监听socket
listen_fd = socket(AF_INET, SOCK_STREAM, 0);
if (listen_fd < 0) {
perror("socket error");
exit(1);
}
// 3. 设置地址重用
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
// 4. 绑定地址
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);
}
// 5. 开始监听
if (listen(listen_fd, BACKLOG) < 0) {
perror("listen error");
exit(1);
}
printf("服务器启动,监听端口 %d...\n", PORT);
// 6. 主循环:接受连接,fork 子进程处理
while (1) {
client_len = sizeof(client_addr);
client_fd = accept(listen_fd, (struct sockaddr *)&client_addr, &client_len);
if (client_fd < 0) {
perror("accept error");
continue;
}
pid = fork();
if (pid < 0) {
perror("fork error");
close(client_fd);
continue;
}
if (pid == 0) {
// 子进程
close(listen_fd); // 子进程关掉监听socket
handle_client(client_fd);
} else {
// 父进程
close(client_fd); // 父进程关掉已连接socket
}
}
close(listen_fd);
return 0;
}
这个模型的核心逻辑,我用一张图来展示:
这个模型有几个关键点:
- 父进程只负责 accept:收到新连接后立刻 fork,然后关掉已连接socket,继续 accept。
- 子进程只负责处理:关掉监听socket,专心处理客户端请求,处理完就 exit。
- 信号处理收尸:父进程通过 SIGCHLD 信号及时回收子进程资源。
- 编程模型简单,容易理解
- 进程间隔离性好,一个子进程挂了不影响其他
- 适合 CPU 密集型任务,可以利用多核
- 进程创建开销大,高并发下性能下降
- 进程间通信复杂,共享数据困难
- 每个进程独立内存空间,内存占用高
嗯,多进程并发服务器就讲到这里。核心就是 fork() 创建子进程,处理好孤儿和僵尸问题,用 SIGCHLD 信号收尸。这个模型虽然老,但它是理解更复杂并发模型的基础。你把这个搞透了,后面学线程池、事件驱动什么的,会轻松很多。