{T}

阻塞I/O + 进程模型:传统并发方案

在前文中,我们讨论了 C10K 问题(Concurrent 10K Connections Problem),并引入了多种解决方案。其中,最直接且有效的方案之一是为每个连接(connection)创建一个独立的进程(process)进行服务。本文将深入探讨该模型的实现机制,涵盖父子进程(parent-child process)的创建与协作、僵尸进程(zombie process)的回收,以及基于进程模型的并发服务器程序设计。

父进程与子进程

进程(process)是操作系统资源分配的最小单位,拥有独立的地址空间(address space)、程序计数器(program counter)等运行时上下文。在 Linux 中,通过 fork() 系统调用创建新进程。

c
pid_t fork(void);
// 返回值:子进程中返回 0,父进程中返回子进程 PID,出错返回 -1

fork() 的语义较为特殊:调用一次,在父进程和子进程中各返回一次。父进程获得新派生子进程的 PID,子进程获得返回值 0。通过返回值即可区分当前执行流所处的进程上下文。

fork() 执行时,内核将父进程的完整运行状态克隆至子进程,包括地址空间、打开的文件描述符(file descriptor)、程序计数器等。子进程的表现行为与父进程近乎一致,区别仅在于 fork() 的栈返回值不同。这形成了经典的编程范式:

c
if (fork() == 0) {
    do_child_process();  // 子进程执行路径
} else {
    do_parent_process(); // 父进程执行路径
}

僵尸进程与资源回收

子进程退出后,内核仍保留其部分信息(如退出状态),直至父进程通过 wait()waitpid() 读取。若父进程未执行回收操作,子进程将转变为僵尸进程(zombie process),被挂载至 PID 为 1 的 init 进程下。僵尸进程占用内核进程表项及内存资源,大量累积将耗尽系统资源。

回收子进程的两种方式:

c
pid_t wait(int *statloc);
pid_t waitpid(pid_t pid, int *statloc, int options);
  • wait():阻塞等待任意一个子进程终止,返回其 PID,并通过 statloc 输出终止状态(正常终止、被信号杀死等)。若存在未终止子进程,则阻塞至第一个子进程退出。
  • waitpid()wait() 的增强版本,支持指定等待的子进程 PID(-1 表示等待任意子进程),以及通过 options 参数控制行为(如 WNOHANG 实现非阻塞轮询)。

生产环境中,通常通过注册 SIGCHLD 信号处理函数来异步回收子进程资源。SIGCHLD 在子进程退出或被信号中断时由内核向父进程发送,默认行为为忽略。注册方式如下:

c
signal(SIGCHLD, sigchld_handler);

阻塞 I/O 的进程模型

架构概述

阻塞 I/O + 进程模型(process-per-connection model)的核心思想是:主进程(master process)在监听套接字(listening socket)上阻塞等待新连接,每接受一个连接便 fork() 出一个子进程(child process)专门处理该连接的数据读写,而主进程继续监听后续连接。

图表渲染中…
图表渲染中…

该模型的关键设计要点:

  • 子进程:关闭不需要的监听套接字,专注于已连接套接字(connected socket)的 I/O 操作
  • 父进程:关闭不需要的连接套接字,专注于接受新连接
  • 文件描述符引用计数fork() 后,所有打开的文件描述符引用计数加 1;close() 使引用计数减 1,仅当计数归零时才真正释放资源

程序实现

以下为基于进程模型的完整服务器端程序:

c
#include "lib/common.h"

#define MAX_LINE 4096

char rot13_char(char c) {
    if ((c >= 'a' && c <= 'm') || (c >= 'A' && c <= 'M'))
        return c + 13;
    else if ((c >= 'n' && c <= 'z') || (c >= 'N' && c <= 'Z'))
        return c - 13;
    else
        return c;
}

void child_run(int fd) {
    char outbuf[MAX_LINE + 1];
    size_t outbuf_used = 0;
    ssize_t result;

    while (1) {
        char ch;
        result = recv(fd, &ch, 1, 0);
        if (result == 0) {
            break;
        } else if (result == -1) {
            perror("read");
            break;
        }

        if (outbuf_used < sizeof(outbuf)) {
            outbuf[outbuf_used++] = rot13_char(ch);
        }

        if (ch == '\n') {
            send(fd, outbuf, outbuf_used, 0);
            outbuf_used = 0;
            continue;
        }
    }
}

void sigchld_handler(int sig) {
    while (waitpid(-1, 0, WNOHANG) > 0);
    return;
}

int main(int c, char **v) {
    int listener_fd = tcp_server_listen(SERV_PORT);
    signal(SIGCHLD, sigchld_handler);
    while (1) {
        struct sockaddr_storage ss;
        socklen_t slen = sizeof(ss);
        int fd = accept(listener_fd, (struct sockaddr *) &ss, &slen);
        if (fd < 0) {
            error(1, errno, "accept failed");
            exit(1);
        }

        if (fork() == 0) {
            close(listener_fd);
            child_run(fd);
            exit(0);
        } else {
            close(fd);
        }
    }

    return 0;
}

关键实现分析

SIGCHLD 信号处理:程序注册了 sigchld_handler 信号处理函数,在循环中调用 waitpid(-1, 0, WNOHANG) 回收所有已终止子进程。WNOHANG 选项确保即使存在未终止子进程也不会阻塞。此处不可使用 wait(),因为 wait() 在存在未终止子进程时必然阻塞。

文件描述符管理:子进程关闭 listen_fd,父进程关闭 connected_fd。由于 fork() 会复制文件描述符表,使引用计数加 1,若不执行 close(),将导致套接字资源泄漏——即使连接已关闭,内核仍保持引用计数不为零,无法回收资源。

业务逻辑child_run() 函数通过循环逐字节读取客户端数据,执行 ROT13 转码,遇到换行符时将缓冲区内容通过连接套接字发送回客户端。

运行验证

启动服务器,监听端口 43211:

code
./fork01

启动两个 telnet 客户端连接至 43211 端口,验证并发处理能力:

code
$ telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
afasfa
nsnfsn
]
telnet> quit
Connection closed.
code
$ telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
agasgasg
ntnftnft
]
telnet> quit
Connection closed.

客户端退出后,服务器端正常运行,新连接仍可成功建立并完成数据传输。该程序实现了多连接的并发处理,各连接互不干扰。

模型评估

维度评估
实现复杂度低,编程模型直观
隔离性高,进程间地址空间独立
资源开销高,每个进程占用独立地址空间
可扩展性差,进程创建与上下文切换代价高
适用场景并发量较低、对隔离性要求高的服务

阻塞 I/O + 进程模型实现简单、隔离性好,但难以满足高性能场景需求。实现时需重点关注:

  • 文件描述符关闭:父子进程必须关闭各自不需要的套接字,避免资源泄漏
  • 子进程回收:必须通过 SIGCHLD 信号处理及时回收僵尸进程,防止系统资源耗尽

思考题

  1. 有哪些知名的生产级程序采用了 process-per-connection 模型?请分析其选择该模型的合理性。
  2. sigchld_handler 中使用循环调用 waitpid() 而非单次调用的原因是什么?若仅调用一次会产生什么后果?

版本信息

项目说明
更新日期2026-06-09
目标内核Linux 7.0
关键 APIfork() (POSIX.1, Linux 2.0+); wait()/waitpid() (POSIX.1, Linux 2.0+); SIGCHLD 信号 (POSIX.1); accept() (POSIX.1, Linux 2.0+)
备注fork() 在 Linux 7.0 中语义未变;pidfd 系列系统调用(Linux 5.4+ 引入)提供了更安全的进程管理机制,可作为 SIGCHLD + waitpid() 方案的替代