一、问题:为什么需要并发服务器?
1.1 顺序服务器的"排队"困境
回忆我们之前实现的迭代回声服务器,它一次只能服务一个客户端,等这个客户端断开后才能服务下一个。假设每个客户端平均服务时间为 0.5 秒,那么第 100 个客户端要等待 50 秒才能被受理。这显然让人无法接受。
1.2 理想的服务器模式
有两种改进思路:
-
"排队受理快" :受理时间短,但服务时间长。
例如:第一个连接请求受理时间为 0 秒,第 50 个为 50 秒,但只要受理,服务只需 1 秒。
→ 这种其实还是顺序处理,只是受理快,对后面用户依然不公平。
-
"并发服务" :所有连接请求的受理时间不超过 1 秒,平均服务时间 2~3 秒。
→ 同时服务多个客户端,让每个客户端都感觉自己被"秒接"。
这才是我们想要的目标。
1.3 并发服务器的实现方法
常见的并发服务器模型有三种:
| 模型 | 说明 |
|---|---|
| 多进程服务器 | 为每个客户端创建一个子进程,父进程继续等待新连接。 |
| 多路复用服务器 | 使用 select/epoll 统一管理多个 I/O 对象。 |
| 多线程服务器 | 为每个客户端创建一个线程,共享进程资源。 |
本文讲解第一种:多进程服务器。
二、什么是进程?
2.1 程序 vs 进程
- 程序 :存储在硬盘上的可执行文件,如
a.out,它只是静态的指令集合。 - 进程 :
程序被加载到内存中运行后,就变成了进程,它是操作系统调度和资源分配的基本单位。
类比:
- 程序 = 乐谱(写在纸上)
- 进程 = 正在演奏的音乐(占用舞台、乐器)
一个程序可以同时运行多个实例,产生多个进程。例如打开两个记事本,就会有两个 notepad.exe 进程。
2.2 CPU 核数与进程
CPU 有多少个核,就能同时执行多少个进程。如果进程数超过核数,操作系统会通过分时调度让进程轮流使用 CPU,由于切换极快,用户感觉所有进程在"同时"运行。
2.3 进程 ID(PID)
每个进程都有一个唯一的整数标识符,称为进程 ID(PID)。
- PID 从 2 开始(PID 1 是系统启动的第一个进程 init/systemd)。
- 使用命令
ps au可以查看当前所有进程的 PID。
三、创建进程:fork() 函数
3.1 fork 的作用
fork() 是创建新进程的唯一系统调用(在 Linux 中)。它会复制 调用它的进程,产生一个几乎完全相同的子进程。
c
#include <unistd.h>
pid_t fork(void);
- 成功时,父进程返回子进程的 PID,子进程返回 0。
- 失败时返回 -1。
3.2 fork 后的内存独立性
调用 fork 后,父子进程拥有独立的地址空间 ,修改各自的内存互不影响。

看下面的例子:
c
#include <stdio.h>
#include <unistd.h>
int gval = 10;
int main() {
int lval = 20;
pid_t pid;
gval++; lval += 5; // 父进程修改
pid = fork(); // 复制进程
if (pid == 0) // 子进程
gval += 2, lval += 2;
else // 父进程
gval -= 2, lval -= 2;
if (pid == 0)
printf("Child Proc: [%d, %d]\n", gval, lval);
else
printf("Parent Proc: [%d, %d]\n", gval, lval);
return 0;
}
输出(可能因系统而异):
Parent Proc: [9, 23]
Child Proc: [13, 27]
可见父子进程的 gval 和 lval 互不影响,各自独立。
四、僵尸进程:死而不僵的孩子
4.1 什么是僵尸进程?
子进程终止后,操作系统会保留其一些信息(如退出状态),直到父进程主动读取。如果父进程一直不读取,子进程就会变成 僵尸进程(Zombie),占用系统资源。
4.2 为什么会产生僵尸进程?
子进程结束时会向父进程发送 SIGCHLD 信号,并将退出状态保存在内核中。父进程需要通过 wait 或 waitpid 来获取这些信息,操作系统才会彻底销毁子进程。如果父进程从不调用这些函数,子进程就会永远以僵尸状态存在。
下面的程序故意让父进程 sleep 30 秒,期间子进程已结束,成为僵尸进程:
c
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
puts("Hi I'm a child process");
} else {
printf("Child Process ID: %d\n", pid);
sleep(30); // 父进程休眠30秒,期间子进程已结束
}
if (pid == 0)
puts("End child process");
else
puts("End parent process");
return 0;
}
在父进程 sleep 期间,用 ps au 查看,会看到子进程状态为 Z(僵尸)。
4.3 如何消灭僵尸进程?
父进程必须调用 wait 或 waitpid 来回收子进程的退出状态。但是,如果父进程很忙,不可能一直阻塞等待子进程结束,就需要信号处理机制。
五、信号处理:让操作系统通知我们
5.1 信号的概念
信号是操作系统向进程发送的异步通知。我们可以注册一个函数,当特定信号发生时,由操作系统自动调用该函数。
常用的信号:
SIGALRM:定时器到时SIGINT:用户按 Ctrl+CSIGCHLD:子进程终止(最关键)
5.2 注册信号:signal 函数
c
#include <signal.h>
void (*signal(int signo, void (*func)(int)))(int);
参数 signo 是信号编号,func 是处理函数的指针。
示例:每隔 2 秒打印一次 "Time out!",并按 Ctrl+C 时打印 "CTRL+C pressed"。
c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
void timeout(int sig) {
if (sig == SIGALRM) {
puts("Time out!");
alarm(2); // 再次预约 2 秒
}
}
void keycontrol(int sig) {
if (sig == SIGINT) {
puts("CTRL+C pressed");
}
}
int main() {
signal(SIGALRM, timeout);
signal(SIGINT, keycontrol);
alarm(2); // 2 秒后产生 SIGALRM
for (int i = 0; i < 3; i++) {
puts("wait...");
sleep(100); // 等待 100 秒
}
return 0;
}
注意 :sleep 会被信号中断,因此实际程序很快结束(约 6 秒),因为每隔 2 秒信号唤醒进程一次。
5.3 更稳定的 sigaction 函数
sigaction 是 signal 的现代替代品,在不同 UNIX 系统上行为一致。
c
#include <signal.h>
int sigaction(int signo, const struct sigaction *act, struct sigaction *oldact);
sigaction 结构体:
c
struct sigaction {
void (*sa_handler)(int); // 处理函数指针
sigset_t sa_mask; // 信号掩码(一般初始化为0)
int sa_flags; // 选项(一般设为0)
};
使用示例(与上面功能相同):
c
struct sigaction act;
act.sa_handler = timeout;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGALRM, &act, 0);
5.4 用信号处理回收子进程
子进程终止时会发送 SIGCHLD 信号,我们可以在处理函数中调用 waitpid 回收。
c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void read_childproc(int sig) {
int status;
pid_t id = waitpid(-1, &status, WNOHANG); // 非阻塞等待任意子进程
if (WIFEXITED(status)) { // 子进程正常退出
printf("Removed proc id: %d\n", id);
printf("Child send: %d\n", WEXITSTATUS(status));
}
}
int main() {
struct sigaction act;
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0); // 注册 SIGCHLD 处理函数
pid_t pid = fork();
if (pid == 0) {
puts("Hi! I'm child process");
sleep(10);
return 12; // 子进程结束,返回 12
} else {
printf("Child proc id: %d\n", pid);
pid = fork();
if (pid == 0) {
puts("Hi! I'm child process");
sleep(10);
exit(24); // 子进程结束,返回 24
} else {
printf("Child proc id: %d\n", pid);
for (int i = 0; i < 5; i++) {
puts("wait...");
sleep(5); // 父进程每 5 秒被信号唤醒一次
}
}
}
return 0;
}
关键点:
waitpid(-1, &status, WNOHANG)表示不阻塞,如果有已结束的子进程就处理,否则立即返回。WIFEXITED(status)判断子进程是否正常退出。WEXITSTATUS(status)获取退出码。
这样,子进程结束后,父进程的 SIGCHLD 处理函数会立刻回收,避免了僵尸进程。
六、完整示例:多进程回声服务器
结合上述知识,我们可以实现一个简单的多进程回声服务器:父进程负责监听,每当有新连接,就 fork 一个子进程来处理该连接,父进程继续等待下一个连接。
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUF_SIZE 1024
void error_handling(char *message);
void read_childproc(int sig);
int main(int argc, char *argv[]) {
int serv_sock, clnt_sock;
struct sockaddr_in serv_adr, clnt_adr;
socklen_t adr_sz;
int str_len;
char buf[BUF_SIZE];
if (argc != 2) {
printf("Usage: %s <port>\n", argv[0]);
exit(1);
}
// 注册 SIGCHLD 信号处理函数,防止僵尸进程
struct sigaction act;
act.sa_handler = read_childproc;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, 0);
// 创建套接字
serv_sock = socket(PF_INET, SOCK_STREAM, 0);
if (serv_sock == -1)
error_handling("socket() error");
// 绑定地址
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
// 监听
if (listen(serv_sock, 5) == -1)
error_handling("listen() error");
while (1) {
adr_sz = sizeof(clnt_adr);
clnt_sock = accept(serv_sock, (struct sockaddr*)&clnt_adr, &adr_sz);
if (clnt_sock == -1)
continue;
else
puts("New client connected...");
// 创建子进程处理这个客户端
pid_t pid = fork();
if (pid == 0) { // 子进程
close(serv_sock); // 子进程不需要监听套接字
while ((str_len = read(clnt_sock, buf, BUF_SIZE)) != 0)
write(clnt_sock, buf, str_len);
close(clnt_sock);
puts("Client disconnected");
return 0; // 子进程结束
} else {
close(clnt_sock); // 父进程不需要客户端套接字
}
}
close(serv_sock);
return 0;
}
void read_childproc(int sig) {
int status;
pid_t id = waitpid(-1, &status, WNOHANG);
if (WIFEXITED(status)) {
printf("Removed proc id: %d\n", id);
printf("Child send: %d\n", WEXITSTATUS(status));
}
}
void error_handling(char *message) {
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
代码解析:
- 父进程调用
accept后立即fork出子进程,子进程处理该客户端,父进程继续accept下一个连接。 - 子进程中要关闭监听套接字 (
serv_sock),父进程中要关闭客户端套接字 (clnt_sock),避免资源浪费。 - 信号处理函数
read_childproc负责回收子进程,防止僵尸进程。
七、总结
7.1 进程相关函数速查
| 函数 | 作用 |
|---|---|
fork() |
创建子进程 |
waitpid() |
等待子进程终止,回收资源 |
signal() / sigaction() |
注册信号处理函数 |
alarm() |
设置定时器,到时发送 SIGALRM |
7.2 多进程服务器的优缺点
| 优点 | 缺点 |
|---|---|
| 实现简单,易于理解 | 进程创建开销较大 |
| 进程间隔离性好,一个崩溃不影响其他 | 需要处理信号回收子进程 |
| 充分利用多核 CPU | 进程间通信较复杂 |
7.3 下一步拓展
- 多线程服务器:用线程代替进程,减少资源开销。
- I/O 多路复用 :使用
select或epoll在一个进程中管理多个连接。 - 进程池:预先创建一定数量进程,避免频繁 fork。