为什么需要 fork() 来写服务器?
一个简单的网络服务器通常是这样的流程:

(图片来源网络,侵删)
socket(): 创建套接字。bind(): 绑定 IP 和端口。listen(): 开始监听,等待连接。accept(): 阻塞等待客户端连接。- 读取/写入数据。
- 关闭连接,返回第 4 步,等待下一个客户端。
这种模型的核心问题在于 accept() 是一个阻塞函数,当一个客户端连接后,服务器必须处理完这个客户端的所有请求后,才能通过 accept() 的返回去接受下一个客户端的连接,如果某个客户端请求非常耗时(比如下载一个大文件),那么后续的所有客户端都必须排队等待,服务器的并发能力非常差。
fork() 解决了这个瓶颈,其核心思想是:
主进程(父进程)只负责监听和接受新的连接,一旦有新连接到来,就创建一个子进程来专门处理这个客户端的所有通信,父进程则立即返回,继续监听,准备接受下一个连接。
这样,每个客户端连接都有自己独立的子进程来服务,互不干扰,实现了真正的并发。

(图片来源网络,侵删)
fork() 的工作原理
fork() 是一个 Unix/Linux 系统调用,它会复制当前进程(称为父进程),创建一个几乎完全相同的新进程(称为子进程)。
- 返回值:
- 在父进程中,
fork()返回子进程的 PID (Process ID),这是一个大于 0 的整数。 - 在子进程中,
fork()返回 0。 - 如果创建失败,
fork()返回 -1。
- 在父进程中,
这个返回值是父子进程执行后续不同逻辑的关键,我们可以用 if 语句来区分它们:
pid_t pid = fork();
if (pid < 0) {
// fork 失败
perror("fork failed");
exit(EXIT_FAILURE);
} else if (pid == 0) {
// 这是子进程
// 执行处理客户端的代码
handle_client(client_socket);
// 子任务完成,退出
exit(EXIT_SUCCESS);
} else {
// 这是父进程
// pid 是子进程的 ID
// 父进程继续执行,比如返回 accept 循环
}
使用 fork() 的并发服务器代码示例
下面是一个完整的、可运行的并发 Echo 服务器代码,它会将客户端发送的任何消息原样返回。
代码:server.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/wait.h> // 用于 waitpid
#define PORT 8080
#define BUFFER_SIZE 1024
// 信号处理函数,用于处理子进程结束(僵尸进程)
void sigchld_handler(int sig) {
// 使用 WNOHANG 选项,waitpid 不会阻塞,如果子进程已经结束则回收,否则直接返回
while (waitpid(-1, NULL, WNOHANG) > 0);
}
void handle_client(int client_socket) {
char buffer[BUFFER_SIZE];
int read_size;
printf("Child process %d handling new client.\n", getpid());
// 从客户端读取数据
while ((read_size = recv(client_socket, buffer, BUFFER_SIZE, 0)) > 0) {
buffer[read_size] = '\0'; // 确保字符串以 null
printf("Received from client %d: %s", getpid(), buffer);
// 将数据发回给客户端
send(client_socket, buffer, read_size, 0);
}
if (read_size == 0) {
printf("Client disconnected.\n");
} else if (read_size == -1) {
perror("recv failed");
}
// 关闭客户端套接字
close(client_socket);
printf("Child process %d finished.\n", getpid());
exit(EXIT_SUCCESS);
}
int main() {
int server_fd, client_socket;
struct sockaddr_in address;
int opt = 1;
int addrlen = sizeof(address);
pid_t pid;
// 1. 创建套接字
if ((server_fd = socket(AF_INET, SOCK_STREAM, 0)) == 0) {
perror("socket failed");
exit(EXIT_FAILURE);
}
// 2. 设置套接字选项,允许地址重用
if (setsockopt(server_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt))) {
perror("setsockopt");
exit(EXIT_FAILURE);
}
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
address.sin_port = htons(PORT);
// 3. 绑定地址和端口
if (bind(server_fd, (struct sockaddr *)&address, sizeof(address)) < 0) {
perror("bind failed");
exit(EXIT_FAILURE);
}
// 4. 开始监听
if (listen(server_fd, 3) < 0) { // backlog 设为 3
perror("listen");
exit(EXIT_FAILURE);
}
printf("Server listening on port %d...\n", PORT);
// 5. 设置信号处理,防止子进程成为僵尸进程
// 当子进程结束时,内核会发送 SIGCHLD 信号给父进程
struct sigaction sa;
sa.sa_handler = sigchld_handler;
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP; // SA_NOCLDSTOP 表示只在子进程终止时通知,而不是暂停时
if (sigaction(SIGCHLD, &sa, NULL) == -1) {
perror("sigaction");
exit(EXIT_FAILURE);
}
// 6. 主循环,接受客户端连接
while (1) {
if ((client_socket = accept(server_fd, (struct sockaddr *)&address, (socklen_t*)&addrlen)) < 0) {
// accept 可能被信号中断,比如子进程结束触发的 SIGCHLD
// 如果错误是 EINTR,说明是被信号中断,可以继续循环
if (errno == EINTR) {
continue;
}
perror("accept");
exit(EXIT_FAILURE);
}
printf("Parent process %d accepted a new connection.\n", getpid());
// 7. 创建子进程来处理这个客户端
pid = fork();
if (pid < 0) {
perror("fork failed");
close(client_socket); // 关闭新连接的套接字
} else if (pid == 0) {
// 子进程
close(server_fd); // 子进程不需要监听套接字,关闭它以节省资源
handle_client(client_socket);
} else {
// 父进程
close(client_socket); // 父进程不需要与客户端通信的套接字,关闭它
// 父进程继续循环,准备接受下一个连接
}
}
// 代码不会执行到这里,因为 while(1) 是无限循环
// 如果要优雅退出,可以在这里加一个信号处理来终止循环
close(server_fd);
return 0;
}
代码解释
-
main()函数:
(图片来源网络,侵删)- 标准服务器初始化: 创建套接字、绑定端口、开始监听。
sigaction(SIGCHLD, ...): 这是fork()模式下至关重要的一步,子进程结束后,其父进程如果没有正确处理,它会变成一个“僵尸进程”(Zombie Process),仅仅在进程表中占一个位置。sigaction注册了一个信号处理函数sigchld_handler,当子进程结束时,内核会发送SIGCHLD信号,该函数会调用waitpid()来回收子进程,避免僵尸进程的产生。accept()循环: 父进程的核心工作就是不断调用accept()。fork(): 在accept()成功后立即调用。- 父子进程分工:
- 子进程 (
pid == 0):- 调用
handle_client()函数来处理具体的客户端逻辑。 - 关键:
close(server_fd),子进程不需要监听套接字,关闭它可以避免资源浪费和潜在的错误。 - 任务完成后,调用
exit()终止自己。
- 调用
- 父进程 (
pid > 0):- 关键:
close(client_socket),父进程不需要与客户端通信的套接字,关闭它,这个套接字已经被复制到子进程中,子进程会负责关闭它。 - 父进程立即回到
while(1)循环的顶部,准备调用accept()接受下一个连接。
- 关键:
- 子进程 (
-
handle_client()函数:- 这是一个标准的客户端处理逻辑:循环
recv()读取数据,send()回发数据。 - 当
recv()返回 0 时,表示客户端正常关闭了连接。 - 函数执行完毕后,子进程调用
exit()退出。
- 这是一个标准的客户端处理逻辑:循环
如何编译和运行
-
保存代码: 将上面的代码保存为
server.c。 -
编译: 打开终端,使用
gcc进行编译。gcc server.c -o server
-
运行: 执行生成的可执行文件。
./server
你会看到输出:
Server listening on port 8080... -
测试: 你可以使用
telnet或者nc(netcat) 来测试服务器。- 打开另一个终端,使用
telnet:telnet localhost 8080
- 输入任何文本,"hello",然后按回车,服务器会返回 "hello"。
- 再打开一个终端,再启动一个
telnet连接,你会发现两个连接可以同时工作,互不干扰。
- 打开另一个终端,使用
fork() 模式的优缺点
优点
- 逻辑清晰: 父子进程分工明确,代码结构相对简单。
- 资源隔离: 每个客户端进程都有独立的内存空间,一个客户端的崩溃或错误(如段错误)通常不会影响其他客户端和主服务器进程。
- 兼容性好: 是一种非常经典和成熟的模型,在所有 Unix-like 系统上都可用。
缺点
- 资源消耗: 创建进程是一个重量级操作,它会复制父进程的整个地址空间(包括代码、数据段等),这会消耗大量的 CPU 时间和内存,如果并发连接数非常大(比如数万),创建和管理大量子进程会非常消耗资源,可能导致系统性能下降。
- 进程间通信 复杂: 如果父进程需要向子进程传递复杂的数据,或者子进程需要将结果返回给父进程,实现起来比较麻烦(需要使用管道、共享内存、消息队列等 IPC 机制)。
- 僵尸进程风险: 如果没有正确处理
SIGCHLD信号,子进程结束后会成为僵尸进程,消耗系统资源。
现代替代方案:poll(), select(), epoll()
由于 fork() 模型在高并发场景下的资源消耗问题,现代高性能服务器通常采用I/O 多路复用技术。
select()和poll(): 允许你监视多个文件描述符(套接字),任何一个就绪(可读、可写、出错)时,select()或poll()会返回,它们用一个进程或线程就可以处理成千上万的连接。epoll()(Linux 特有): 是select()和poll()的升级版,性能更高,尤其在连接数非常多时,它采用事件驱动的模型,效率极高,Nginx、Redis 等高性能服务器都基于epoll。
| 模型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
fork() |
逻辑简单,隔离性好 | 资源消耗大,不适合高并发 | 连接数不多(几百到几千),对逻辑清晰度要求高的场景,教学示例。 |
I/O 多路复用 (epoll等) |
资源消耗极小,并发能力极强 | 编程模型相对复杂,有学习曲线 | 高并发服务器,如 Web 服务器、数据库代理等。 |
对于学习 C 语言网络编程,fork() 是一个必须掌握的经典模型,它能帮助你深刻理解操作系统进程和并发的基本概念,但在实际生产环境中,尤其是在追求极致性能的场景下,epoll 等现代技术是更优的选择。
