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,那整个服务器就挂了。

注意: fork() 之后,父子进程共享文件描述符表。子进程会继承父进程打开的所有文件描述符。如果你在子进程里 close() 了监听socket,父进程那边也会受影响——因为引用计数减1了。正确的做法是:子进程关掉监听socket,父进程关掉已连接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 信号可能被合并成一次。
我的习惯: 信号处理函数里尽量少做事。我一般只记录一下 pid,把收尸的逻辑放到主循环里去做。因为信号处理函数里能调用的函数有限,printf 这种都不安全。

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() pid==0? 是(子进程) close(监听fd) 处理客户端请求 exit(0) 退出 否(父进程) close(连接fd) 继续 accept() 每个客户端连接对应一个子进程,子进程处理完就退出,父进程继续等待新连接

这个模型有几个关键点:

  • 父进程只负责 accept:收到新连接后立刻 fork,然后关掉已连接socket,继续 accept。
  • 子进程只负责处理:关掉监听socket,专心处理客户端请求,处理完就 exit。
  • 信号处理收尸:父进程通过 SIGCHLD 信号及时回收子进程资源。
优点:
  • 编程模型简单,容易理解
  • 进程间隔离性好,一个子进程挂了不影响其他
  • 适合 CPU 密集型任务,可以利用多核
缺点:
  • 进程创建开销大,高并发下性能下降
  • 进程间通信复杂,共享数据困难
  • 每个进程独立内存空间,内存占用高
我的建议: 如果并发量在几百以内,多进程模型完全够用。我有个项目跑了三年,就是这种模型,稳定得很。但如果并发量上万,那还是考虑 epoll + 线程池或者协程吧。

嗯,多进程并发服务器就讲到这里。核心就是 fork() 创建子进程,处理好孤儿和僵尸问题,用 SIGCHLD 信号收尸。这个模型虽然老,但它是理解更复杂并发模型的基础。你把这个搞透了,后面学线程池、事件驱动什么的,会轻松很多。