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;
}

运行后你会看到两个进程各自打印了一行。注意,父子进程的执行顺序是不确定的,谁先跑完全看调度器的心情。我在项目里就踩过这个坑——以为子进程一定先执行,结果数据同步出了问题。

我的习惯:在 fork() 之后,父进程最好先 sleep(1) 或者用 wait() 等一下子进程,避免子进程变成孤儿进程。当然,这只是调试时的土办法,生产环境有更优雅的方案。

6.2 孤儿进程与僵尸进程——两个"鬼"

这两个概念,我当年学的时候总觉得差不多。后来在线上环境亲眼看到一堆僵尸进程占着进程表,才真正理解了它们的区别。

孤儿进程

父进程先挂了,子进程还在跑。这时候子进程就成了"孤儿"。别担心,系统不会让它流浪——init 进程(PID=1)会收养它,成为它的新父进程。

我遇到过的情况:服务器父进程意外崩溃,子进程还在处理客户端请求。结果这些子进程全被 init 收养了,日志里查不到父进程信息,排查了半天才找到根因。

僵尸进程

这个更坑。子进程先结束,父进程没有调用 wait()waitpid() 来回收它的退出状态。子进程的进程描述符就留在内核里,变成了"僵尸"。

僵尸进程的特点:

  • 几乎不占内存(只保留进程表项)
  • 杀不死(kill -9 也没用)
  • 数量多了会耗尽系统进程表
我曾经踩过的坑:写多进程服务器时忘了处理 SIGCHLD 信号,结果跑了三天后,服务器突然无法接受新连接了。一查,进程表被几千个僵尸进程塞满了。从那以后,我每次写 fork() 都会顺手把 SIGCHLD 处理加上。

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。这是为了防止文件描述符泄漏。
核心逻辑:父进程只负责 accept(),每来一个连接就 fork() 一个子进程去处理。子进程处理完就 exit(),父进程通过 SIGCHLD 信号回收。这样父进程永远不会被阻塞。

6.4 知识体系图

下面这张图把多进程服务器的核心流程和关键概念串起来了:

多进程并发服务器核心流程 主进程(父进程) socket() → bind() → listen() while(1) { accept() } fork() 创建子进程 父进程 close(client_fd) 子进程 close(server_fd) → 处理请求 → exit() SIGCHLD → waitpid() 注意:避免僵尸进程

6.5 避坑指南

多进程服务器看着简单,实际跑起来坑不少。我把自己踩过的坑列出来,你遇到了直接对照:

问题 现象 解决方案
僵尸进程 ps 看到一堆 defunct 进程 注册 SIGCHLD 信号处理,用 waitpid() 回收
文件描述符泄漏 服务器运行一段时间后 accept() 返回 EMFILE 子进程关 server_fd,父进程关 client_fd
accept() 被信号中断 accept() 返回 -1,errno = EINTR 使用 SA_RESTART 标志,或手动重试
子进程竞争条件 多个子进程同时写日志,内容错乱 加文件锁,或用独立日志进程
我个人的建议:如果你刚开始写多进程服务器,先别急着上 epoll 那些高级货。把 fork() + SIGCHLD 这套模型跑通,理解进程的生命周期管理,后面学多线程、事件驱动都会轻松很多。

好了,多进程并发服务器就聊到这儿。核心就三件事:fork() 创建子进程、SIGCHLD 回收僵尸、关闭不需要的文件描述符。把这三点记牢,你的服务器就能扛住成百上千的并发连接了。


公众号:蓝海资料掘金营,微信deep3321