阻塞I/O + 进程模型:传统并发方案
在前文中,我们讨论了 C10K 问题(Concurrent 10K Connections Problem),并引入了多种解决方案。其中,最直接且有效的方案之一是为每个连接(connection)创建一个独立的进程(process)进行服务。本文将深入探讨该模型的实现机制,涵盖父子进程(parent-child process)的创建与协作、僵尸进程(zombie process)的回收,以及基于进程模型的并发服务器程序设计。
父进程与子进程
进程(process)是操作系统资源分配的最小单位,拥有独立的地址空间(address space)、程序计数器(program counter)等运行时上下文。在 Linux 中,通过 fork() 系统调用创建新进程。
pid_t fork(void);
// 返回值:子进程中返回 0,父进程中返回子进程 PID,出错返回 -1fork() 的语义较为特殊:调用一次,在父进程和子进程中各返回一次。父进程获得新派生子进程的 PID,子进程获得返回值 0。通过返回值即可区分当前执行流所处的进程上下文。
fork() 执行时,内核将父进程的完整运行状态克隆至子进程,包括地址空间、打开的文件描述符(file descriptor)、程序计数器等。子进程的表现行为与父进程近乎一致,区别仅在于 fork() 的栈返回值不同。这形成了经典的编程范式:
if (fork() == 0) {
do_child_process(); // 子进程执行路径
} else {
do_parent_process(); // 父进程执行路径
}僵尸进程与资源回收
子进程退出后,内核仍保留其部分信息(如退出状态),直至父进程通过 wait() 或 waitpid() 读取。若父进程未执行回收操作,子进程将转变为僵尸进程(zombie process),被挂载至 PID 为 1 的 init 进程下。僵尸进程占用内核进程表项及内存资源,大量累积将耗尽系统资源。
回收子进程的两种方式:
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 在子进程退出或被信号中断时由内核向父进程发送,默认行为为忽略。注册方式如下:
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,仅当计数归零时才真正释放资源
程序实现
以下为基于进程模型的完整服务器端程序:
#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:
./fork01启动两个 telnet 客户端连接至 43211 端口,验证并发处理能力:
$ telnet 127.0.0.1 43211
Trying 127.0.0.1...
Connected to localhost.
Escape character is '^]'.
afasfa
nsnfsn
]
telnet> quit
Connection closed.$ 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信号处理及时回收僵尸进程,防止系统资源耗尽
思考题
- 有哪些知名的生产级程序采用了 process-per-connection 模型?请分析其选择该模型的合理性。
sigchld_handler中使用循环调用waitpid()而非单次调用的原因是什么?若仅调用一次会产生什么后果?
版本信息
| 项目 | 说明 |
|---|---|
| 更新日期 | 2026-06-09 |
| 目标内核 | Linux 7.0 |
| 关键 API | fork() (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() 方案的替代 |