单连接的 Echo 服务器就像一家只有一个服务员的餐馆——客人来了得排队等。今天我们给餐厅招满"服务员"(进程/线程),再给每个人配上一部"智能对讲机"(epoll),让客人们随到随吃,同时还能监控后厨、收银台、外卖窗口,真正实现多面手。
前言
在 Day 22-23 中,我们实现了一个标准的 TCP Echo 服务器。但它有一个致命的缺陷:一次只能服务一个客户端。
通俗类比:想象你开了一家外卖店,店里只有你一个人。你接了一个电话(accept),然后开始等对方报菜名(read)。如果对方磨磨蹭蹭不说要什么,你就一直握着电话干等——后面的电话根本接不进来。这就是"阻塞"的困境。
今天,我们分三步跨越这道坎:
第一部分:为什么单线程/单进程服务器不行?
我们先回顾一下单线程服务器的核心代码:
while (1) {
client_fd = accept(server_fd, …); // 阻塞等待:没人来就一直等
// ⚠️ 只有当前客户端断开后,才能接下一个!
while ((n = read(client_fd, buffer, …)) > 0) {
write(client_fd, buffer, n); // 阻塞等待:对方没发送数据就一直等
}
close(client_fd);
}
1.1 "阻塞"到底是什么?
通俗类比:你去取款机取钱,前面的人操作很慢。你不能同时帮后面的人取钱,只能站着干等。这就是阻塞——你(进程)被"卡"住了。
从操作系统角度看:
| 状态 | 进程在干嘛 | 占CPU吗 |
| 运行态 | 正在CPU上执行代码 | 占 |
| 就绪态 | 准备好了,等CPU轮到我 | 不占 |
| 阻塞态(睡眠态) | 在等某个条件(数据到达、连接到来),条件满足前无法运行 | 不占 |
关键理解:阻塞并不会浪费 CPU(CPU 会去执行其他进程),但它浪费了机会——因为该进程无法去处理其他客户端的请求。这就是并发要解决的根本矛盾。
第二部分:多进程并发服务器(Fork 模型)
2.1 核心思想——一张图看懂
父进程(迎宾员)
│
├── accept() ← 来客人了!
│
├── fork() → 子进程1(服务员1)→ 服务客户A → 退出
│
├── accept() ← 又来一个!
│
├── fork() → 子进程2(服务员2)→ 服务客户B → 退出
…
通俗类比:银行大堂经理(父进程)只管接待和分流,每来一个客户就喊一个柜员(子进程)专门服务。经理自己马上回头接待下一位。
2.2 环境准备
第 1 步 → 确认你的 Linux 环境已经装好 gcc:
# 检查 gcc 是否安装
gcc –version
# 如果没装(Ubuntu/Debian)
sudo apt install gcc -y
# 如果没装(CentOS/RHEL)
sudo yum install gcc -y
第 2 步 → 创建项目目录并进入:
mkdir -p ~/linux_study/day24-25 && cd ~/linux_study/day24-25
2.3 编写代码
第 3 步 → 用编辑器创建 fork_server.c:
vim fork_server.c # 也可用 nano 或 gedit
第 4 步 → 输入完整代码(下面每一行 // 注释都请你认真读,这些注释就是你的"中文说明书"):
#include <stdio.h> // 标准输入输出,printf 需要
#include <stdlib.h> // 标准库,exit 需要
#include <string.h> // 字符串函数,memset 需要
#include <unistd.h> // POSIX API,fork / close / read / write 需要
#include <sys/socket.h> // 套接字函数,socket / bind / listen / accept 需要
#include <netinet/in.h> // sockaddr_in 结构体,存储 IP 和端口
#include <arpa/inet.h> // inet_ntoa,把IP的数字格式转成可读字符串
#include <sys/wait.h> // waitpid,回收子进程用
#include <signal.h> // signal,注册信号处理函数
#define PORT 8080 // 服务器监听端口,可改成你喜欢的
#define BUFFER_SIZE 1024 // 接收缓冲区大小,1KB 够用
// 信号处理函数:当子进程退出时,内核发 SIGCHLD 给父进程
// 如果不处理,子进程会变成"僵尸"(占用系统 PID 名额)
void sigchld_handler(int sig) {
// waitpid:回收所有已退出的子进程
// -1 表示"回收任意子进程",WNOHANG 表示"不阻塞,没死就返回"
while (waitpid(-1, NULL, WNOHANG) > 0);
}
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// ① 注册信号处理——就像给父进程装了个"子进程去世通知铃"
signal(SIGCHLD, sigchld_handler);
// ② 创建套接字(socket:在系统中占一个"电话号码")
server_fd = socket(AF_INET, SOCK_STREAM, 0);
// AF_INET:使用 IPv4
// SOCK_STREAM:使用 TCP(可靠、有连接)
// 0:协议自动选择(TCP 就是 IPPROTO_TCP)
// ③ 设置端口复用——防止重启时 "Address already in use" 报错
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; // 地址家族:IPv4
server_addr.sin_port = htons(PORT); // 端口号,htons 把字节序转成网络序
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听本机所有网卡(0.0.0.0)
// ⑤ 绑定——把"电话号码"和"身份证"绑在一起
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
// ⑥ 开始监听——像餐厅打开门开始迎客,5 是排队上限
listen(server_fd, 5);
printf("多进程服务器启动,PID=%d,监听端口 %d\\n", getpid(), PORT);
// ⑦ 主循环:不停接客,每接一位就喊一个新柜员(fork)
while (1) {
client_fd = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd < 0) {
perror("accept"); // 打印错误原因
continue; // 出错也别崩溃,继续等下一位
}
pid_t pid = fork();
if (pid == 0) {
// ========== 子进程:我就是新柜员 ==========
close(server_fd); // 子进程不迎客,关掉监听套接字
printf("子进程 %d 开始服务 %s:%d\\n",
getpid(),
inet_ntoa(client_addr.sin_addr), // IP 地址
ntohs(client_addr.sin_port)); // 端口号
// Echo:收到什么就回什么(像复读机)
int n;
while ((n = read(client_fd, buffer, sizeof(buffer) – 1)) > 0) {
buffer[n] = '\\0'; // 手动加字符串结尾
printf("子进程 %d 收到: %s", getpid(), buffer);
write(client_fd, buffer, n); // 原样发回
}
close(client_fd);
printf("子进程 %d 下班\\n", getpid());
exit(0); // 子进程任务完成,优雅退出
} else if (pid > 0) {
// ========== 父进程:迎宾员继续迎客 ==========
close(client_fd); // 父进程不服务客户,关掉客户套接字
// 继续循环,等下一位
} else {
perror("fork"); // fork 失败(比如系统进程数满了)
close(client_fd);
}
}
close(server_fd);
return 0;
}
第 5 步 → 编译运行:
# 编译(多线程版本需要 -lpthread,多进程不需要额外链接库)
gcc -Wall -g fork_server.c -o fork_server
# 运行
./fork_server
看到输出 多进程服务器启动,PID=xxxxx,监听端口 8080 就说明成功。
第 6 步 → 测试:在另一个终端用 telnet 连接:
# 另开一个终端
telnet 127.0.0.1 8080
# 输入任意文字,回车,服务器会原样返回
hello # 你会看到服务器回显 "hello"
再开第3个终端重复上述操作——你会发现每个客户端都被独立服务,互不影响。
2.4 多进程模型关键点速查
| 关键点 | 为什么必须这样做 | 不做的后果 |
| 注册 SIGCHLD 信号 | 子进程退出时通知父进程回收 | 僵尸进程堆积,ps aux 显示大量 <defunct> |
| 父进程 close(client_fd) | 子进程 fork 后复制了文件描述符,父进程不关会导致引用计数 > 1 | 子进程退出后连接可能无法完全释放 |
| 子进程 close(server_fd) | 子进程不需要监听新连接 | 资源泄漏,且可能干扰父进程 |
| 资源开销认知 | 每个客户端 = 一个独立进程(内存、页表、PID) | 几百个客户端可能就撑不住了 |
⚠️ 预警:如果你忘了注册 SIGCHLD,运行一段时间后 ps aux | grep fork_server 会发现一堆 <defunct> 僵尸进程。它们占着 PID 不放,最终可能导致系统无法创建新进程。
第三部分:多线程并发服务器(Pthread 模型)
3.1 核心思想——一张图看懂
主线程(迎宾员)
│
├── accept() ← 来客人了!
│
├── pthread_create() → 线程A(分身A)→ 服务客户A → 退出
│
├── accept() ← 又来一个!
│
├── pthread_create() → 线程B(分身B)→ 服务客户B → 退出
…
通俗类比:线程就像"分身术"。你不是雇新员工,而是把自己复制出一份"投影",每个投影去服务一个客人。因为这些投影共享同一个大脑(内存空间),所以创建快、切换快,但一旦有一个投影摔倒了(崩溃),所有人都得跟着倒。
3.2 编写代码
第 1 步 → 创建 thread_server.c:
vim thread_server.c
第 2 步 → 输入完整代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <pthread.h> // 多线程需要这个头文件
#define PORT 8080
#define BUFFER_SIZE 1024
// 线程函数:每个线程都在这个函数里服务自己的客户
// 参数 arg 是一个指向 int 的指针(客户端的文件描述符)
void* client_handler(void *arg) {
int client_fd = *(int*)arg; // 先把参数解出来
free(arg); // 用完立刻释放,防止内存泄漏!
char buffer[BUFFER_SIZE];
int n;
// 打印线程 ID(用 pthread_self() 获取)
printf("线程 %lu 开始服务\\n", pthread_self());
// Echo 循环:读到就回
while ((n = read(client_fd, buffer, sizeof(buffer) – 1)) > 0) {
buffer[n] = '\\0';
printf("线程 %lu 收到: %s", pthread_self(), buffer);
write(client_fd, buffer, n);
}
close(client_fd);
printf("线程 %lu 下班\\n", pthread_self());
return NULL;
}
int main() {
int server_fd, *client_fd_ptr; // ⚠️ 注意:client_fd_ptr 是指针,不是普通 int
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
pthread_t tid;
// 创建套接字、设置端口复用
server_fd = socket(AF_INET, SOCK_STREAM, 0);
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_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
// 绑定 + 监听
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(server_fd, 5);
printf("多线程服务器启动,监听端口 %d\\n", PORT);
// 主循环
while (1) {
// ⚠️ 关键:必须在堆上分配内存,不能传栈上变量!
// 原因:如果传 &client_fd(栈变量),下一次 accept 会覆盖它的值
// 线程可能读到错误的数据
client_fd_ptr = malloc(sizeof(int));
*client_fd_ptr = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (*client_fd_ptr < 0) {
perror("accept");
free(client_fd_ptr); // 出错也别忘释放
continue;
}
printf("新客户端 %s:%d 连接\\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port));
// 创建线程去服务
// 参数说明:&tid=线程ID输出, NULL=默认属性,
// client_handler=线程函数, client_fd_ptr=传给线程的参数
pthread_create(&tid, NULL, client_handler, client_fd_ptr);
// 分离线程:告诉系统"不用等我,我结束后自动回收"
// 这样主线程不需要调用 pthread_join 阻塞等待
pthread_detach(tid);
}
close(server_fd);
return 0;
}
第 3 步 → 编译运行(注意要加 -lpthread):
# 编译——pthread 不是标准 C 库的一部分,必须显式链接
gcc -Wall -g thread_server.c -o thread_server -lpthread
# 运行
./thread_server
⚠️ 最容易犯的错:编译时忘了 -lpthread,链接阶段报一堆 undefined reference to pthread_create。加上就好了。
第 4 步 → 测试:同样用 telnet:
# 终端2
telnet 127.0.0.1 8080
# 终端3
telnet 127.0.0.1 8080
3.3 多线程模型关键点速查
| 关键点 | 为什么必须这样做 | 不做的后果 |
| 参数用 malloc 堆分配 | 栈变量在循环中会被覆盖,线程读到的可能是下个客户端的 fd | 两个线程服务同一个客户端,数据乱套 |
| pthread_detach | 主线程不阻塞等子线程结束 | 如果不 join 也不 detach,线程资源永远不会回收(内存泄漏) |
| 共享数据加锁 | 多线程同时写全局变量会发生竞争 | 计数器错乱、链表断链,且极难复现调试 |
| 崩溃传播 | 一个线程段错误 = 整个进程死 | 不像多进程那样能隔离故障 |
⚠️ 预警:如果你用 malloc 分配了 client_fd_ptr 但忘了在线程函数里 free(arg),每连接一个客户端就泄漏 4 字节(或 8 字节)。看上去很小,跑一天下来可能泄漏几百 MB。
第四部分:多进程 vs 多线程 —— 终极对决
| 维度 | 多进程(Fork) | 多线程(Pthread) |
| 创建开销 | 高(复制页表、文件描述符表等) | 低(只需新建栈和寄存器) |
| 切换开销 | 高(切换页表,TLB 刷新) | 低(只切换寄存器上下文) |
| 内存占用 | 高(独立地址空间) | 低(共享地址空间) |
| 通信方式 | 需要 IPC(管道、共享内存、消息队列) | 直接读写全局变量(但要加锁) |
| 数据隔离/安全性 | 极高(一个崩了不影响其他) | 极低(一个崩了全挂) |
| 最大并发数 | 通常几百~几千(受 PID 和内存限制) | 可达数万(受内存限制) |
| 编程难度 | 较低(信号处理稍麻烦) | 较高(锁、死锁、条件变量) |
| 现实世界谁在用 | Apache Prefork、Chrome 多进程架构 | Nginx、MySQL 连接池、Java 线程池 |
选型口诀
- 求稳(银行/工控/关键业务)→ 多进程。挂了可以重启,不影响其他。
- 求快(Web 服务器/游戏服务器/API 网关)→ 多线程。能支撑更高并发。
- 两全其美(现代主流)→ IO 复用(epoll)+ 少量线程池。详见第五部分。
第五部分:IO 复用技术 —— 一个服务员搞定全场
5.0 阻塞和非阻塞的局限
在讲 IO 复用之前,我们先搞清楚两个概念:
阻塞 I/O(Blocking I/O)——默认模式:
read(fd, buf, size); // fd 没数据?进程就"睡着",直到有数据才醒
通俗类比:打电话时对方不说话,你就在那头等着,不挂电话(进程挂起),当然也不能接别人的电话。
非阻塞 I/O(Non-blocking I/O)——手动设置后:
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK); // 设为非阻塞
int n = read(fd, buf, size); // 没数据?立刻返回 -1,errno=EAGAIN
通俗类比:你每隔一秒看一眼对方有没有说话,没说就扭头看一眼别人。但如果用 for 循环疯狂去检查(轮询),CPU 会飙到 100%,大部分时间都在做无用功——这叫"忙等待"。
IO 复用的解决思路:别自己挨个问了,让内核帮你盯着!你只需要告诉内核"帮我盯着这 100 个文件描述符,谁有数据了叫醒我"。这样你只需要阻塞在一个地方(比如 epoll_wait),而不是在 100 个 read 之间疲于奔命。
一句话总结:IO 复用就是"用一个阻塞,换掉 100 个阻塞"。
5.1 三兄弟登场:select / poll / epoll
时间线:1983 select(老大哥)→ 1997 poll(二哥)→ 2002 epoll(三弟,王者)
| select | poll | epoll | |
| 年代 | 1983 (BSD) | 1997 (SVR4) | 2002 (Linux 2.5.44) |
| 原理 | 内核轮询所有 fd | 内核轮询所有 fd | 事件驱动,内核主动通知 |
| fd 数量限制 | 硬限制 FD_SETSIZE(默认 1024) | 无硬限制 | 无硬限制 |
| 数据结构 | 3 个位图(fd_set) | 动态数组(struct pollfd[]) | 红黑树 + 就绪链表 |
| 每次调用是否重新传入 fd 列表 | 是(从用户态拷贝到内核态) | 是(从用户态拷贝到内核态) | 否(fd 注册在内核,长期有效) |
| 复杂度 | O(n) —— n 为最大 fd 值 | O(n) —— n 为 fd 总数 | O(1) —— 只遍历就绪的 fd |
| 适用场景 | 少量 fd,跨平台 | 中等量 fd | 大规模并发(C10K+),Linux 专属 |
一句话总结:select 和 poll 是"挨个点名",epoll 是"谁举手叫谁"。
5.2 select —— 老大哥的笨办法
5.2.1 核心 API
#include <sys/select.h>
int select(int nfds, // 最大 fd 值 + 1
fd_set *readfds, // 想监控"可读"的 fd 集合
fd_set *writefds, // 想监控"可写"的 fd 集合
fd_set *exceptfds, // 想监控"异常"的 fd 集合
struct timeval *timeout); // 超时时间
// 操作 fd_set 的宏
FD_ZERO(fd_set *set); // 清空集合
FD_SET(int fd, fd_set *set); // 把 fd 加入集合
FD_CLR(int fd, fd_set *set); // 把 fd 从集合移除
FD_ISSET(int fd, fd_set *set); // 检查 fd 是否在集合中(即是否就绪)
5.2.2 工作流程
第 1 步:把所有想监控的 fd 塞进 fd_set
第 2 步:调用 select(),进程阻塞,内核去轮询所有 fd
第 3 步:有 fd 就绪 → select 返回,内核修改 fd_set(只留下就绪的 fd)
第 4 步:遍历所有 fd,用 FD_ISSET 数哪些就绪了
第 5 步:处理就绪的 fd,回到第 1 步
通俗类比:select 就像一个老师,每天早晨把全班 200 个学生名单抄一遍交上去。上课时想知道谁举手——他挨个点名:"张三举手了吗?李四举手了吗?…" 每天还要把 200 人名单重新抄一遍。人少还行,人多了效率奇低。
5.2.3 完整代码:select_server.c
第 1 步 → 创建文件:
vim select_server.c
第 2 步 → 输入代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/select.h> // select 需要这个头文件
#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 1024 // FD_SETSIZE 默认就是 1024
int main() {
int server_fd, client_fd, max_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// 存储所有客户端 fd,方便遍历(弥补 select 的不足)
int client_fds[MAX_CLIENTS];
for (int i = 0; i < MAX_CLIENTS; i++) client_fds[i] = -1; // -1 表示空位
// ① 创建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
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_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(server_fd, 5);
printf("select 服务器启动,监听端口 %d\\n", PORT);
printf("最大支持 %d 个客户端(硬限制)\\n", MAX_CLIENTS);
while (1) {
fd_set read_fds; // 每次循环重新创建集合
FD_ZERO(&read_fds); // 清空
FD_SET(server_fd, &read_fds); // 把 server_fd 加入监控列表
max_fd = server_fd; // 记录最大 fd 值
// ② 把所有客户端 fd 加入监控集合
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] != -1) {
FD_SET(client_fds[i], &read_fds);
if (client_fds[i] > max_fd) max_fd = client_fds[i];
}
}
// ③ 调用 select — 内核帮你盯着所有 fd
// 参数:max_fd+1(最大 fd 编号+1)、读集合、写集合(NULL=不监控)、
// 异常集合(NULL=不监控)、超时(NULL=永远等)
int ready = select(max_fd + 1, &read_fds, NULL, NULL, NULL);
if (ready < 0) {
perror("select");
continue;
}
// ④ 检查 server_fd 是否有新连接
if (FD_ISSET(server_fd, &read_fds)) {
client_fd = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd >= 0) {
printf("新客户端 %s:%d 连接,fd=%d\\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), client_fd);
// 找个空位存起来
for (int i = 0; i < MAX_CLIENTS; i++) {
if (client_fds[i] == -1) {
client_fds[i] = client_fd;
break;
}
}
}
ready–; // 这个事件处理完了
}
// ⑤ 遍历检查每个客户端是否有数据
for (int i = 0; i < MAX_CLIENTS && ready > 0; i++) {
int fd = client_fds[i];
if (fd == -1) continue;
if (FD_ISSET(fd, &read_fds)) {
int n = read(fd, buffer, sizeof(buffer) – 1);
if (n <= 0) {
// 客户端断开连接(read 返回 0)或出错(返回 -1)
if (n == 0) {
printf("客户端 fd=%d 断开连接\\n", fd);
} else {
perror("read");
}
close(fd);
client_fds[i] = -1; // 释放位置
} else {
// 正常收到数据,Echo 回去
buffer[n] = '\\0';
printf("客户端 fd=%d 发来: %s", fd, buffer);
write(fd, buffer, n);
}
ready–;
}
}
}
close(server_fd);
return 0;
}
第 3 步 → 编译运行:
gcc -Wall -g select_server.c -o select_server
./select_server
5.2.4 select 的致命缺陷
| 缺陷 | 影响 |
| fd 数量上限 = FD_SETSIZE(默认 1024) | 改这个值需要重新编译内核,极不友好 |
| 每次调用都要把整个 fd_set 从用户态拷到内核态 | 连接数多时,拷贝开销巨大 |
| 内核遍历所有 fd(不是只遍历就绪的) | O(n),1 万个连接要遍历 1 万次 |
| 返回后用户还要遍历一次找就绪 fd | 又一遍 O(n) |
select 用于学习理解 IO 复用原理很合适,但生产环境绝不要用它处理高并发。
5.3 poll —— 二哥的小改进
5.3.1 核心 API
#include <poll.h>
struct pollfd {
int fd; // 要监控的文件描述符
short events; // 想监控的事件(POLLIN=可读, POLLOUT=可写)
short revents; // 内核返回:实际发生的事件
};
int poll(struct pollfd *fds, // pollfd 数组
nfds_t nfds, // 数组元素个数
int timeout); // 超时(毫秒),-1 表示永远等
5.3.2 poll 相比 select 的改进
| 改进点 | select | poll |
| fd 数量上限 | 硬编码 FD_SETSIZE=1024 | 无硬限制,只受内存限制 |
| 数据结构 | 位图(fd_set),只能用宏操作 | 结构体数组(pollfd),更直观 |
| 事件和结果分离 | 不分离(同一个 fd_set 改来改去) | 分离:events 是你想监控的,revents 是内核返回的 |
通俗类比:poll 不再用"花名册",改用"签到表"。每行写一个学生名字和想要关注的项目,老师不用全部重新抄——可以增删改。但老师还是要全体点名。
5.3.3 完整代码:poll_server.c
第 1 步 → 创建文件:
vim poll_server.c
第 2 步 → 输入代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <poll.h> // poll 需要这个头文件
#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_CLIENTS 10240 // poll 没有硬限制,这里设一个合理的软上限
int main() {
int server_fd, client_fd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// pollfd 数组:存所有要监控的 fd
struct pollfd fds[MAX_CLIENTS + 1]; // +1 给 server_fd
int nfds = 0; // 当前数组中有多少个有效条目
// ① 创建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
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_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(server_fd, 5);
// ③ 把 server_fd 加入监控列表
fds[0].fd = server_fd;
fds[0].events = POLLIN; // POLLIN 表示"可读"(新连接或数据到达)
nfds = 1;
printf("poll 服务器启动,监听端口 %d\\n", PORT);
while (1) {
// ④ 调用 poll:阻塞等待,有事件时返回
// timeout=-1 表示永远等
int ready = poll(fds, nfds, -1);
if (ready < 0) {
perror("poll");
continue;
}
// ⑤ 遍历检查每个 fd
for (int i = 0; i < nfds && ready > 0; i++) {
// revents 中是否有我们关心的事件
if (fds[i].revents == 0) continue;
if (fds[i].fd == server_fd) {
// ===== server_fd:有新连接 =====
if (fds[i].revents & POLLIN) {
client_fd = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd >= 0 && nfds < MAX_CLIENTS + 1) {
printf("新客户端 %s:%d 连接,fd=%d\\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), client_fd);
fds[nfds].fd = client_fd;
fds[nfds].events = POLLIN; // 监控可读
nfds++;
} else if (client_fd >= 0) {
printf("客户端数量已达上限,拒绝新连接\\n");
close(client_fd);
}
}
ready–;
} else {
// ===== 客户端 fd:有数据或断开 =====
int fd = fds[i].fd;
if (fds[i].revents & POLLIN) {
int n = read(fd, buffer, sizeof(buffer) – 1);
if (n <= 0) {
// 断开或出错
if (n == 0) printf("客户端 fd=%d 断开\\n", fd);
else perror("read");
close(fd);
// 从数组中移除:把最后一个元素搬过来,然后 nfds–
fds[i] = fds[nfds – 1];
nfds–;
i–; // 回退一步,因为当前位置现在是新的元素
} else {
buffer[n] = '\\0';
printf("客户端 fd=%d 发来: %s", fd, buffer);
write(fd, buffer, n);
}
}
// 也可以检查 POLLERR(连接出错)和 POLLHUP(客户端挂断)
if (fds[i].revents & (POLLERR | POLLHUP)) {
printf("客户端 fd=%d 异常断开\\n", fd);
close(fd);
fds[i] = fds[nfds – 1];
nfds–;
i–;
}
ready–;
}
}
}
close(server_fd);
return 0;
}
第 3 步 → 编译运行:
gcc -Wall -g poll_server.c -o poll_server
./poll_server
5.3.4 poll 的不足
poll 解决了 select 的"1024 上限"问题,但遍历所有 fd 的 O(n) 问题仍在。当你有 1 万个连接但只有 3 个活跃时,poll 仍然要扫描全部 1 万个。这就是 epoll 要解决的。
5.4 epoll —— Linux 王牌登场
5.4.1 核心思想
select/poll:每次调用都把"全班名单"交上去,老师说"自己去数谁举手"
epoll: 只在学期初登记一次。之后有人举手,老师直接递给你一张
"举手名单"——你只需要处理名单上的人!
epoll 的三个核心 API:
#include <sys/epoll.h>
// ① 创建 epoll 实例(相当于在老师那里注册一个"举手监听系统")
int epoll_create1(int flags); // flags 通常设为 0
// ② 注册/修改/删除要监控的 fd(相当于把学生加入举手监听系统)
int epoll_ctl(int epfd, int op, int fd, struct epoll_event *event);
// op: EPOLL_CTL_ADD(加入)/ EPOLL_CTL_MOD(修改)/ EPOLL_CTL_DEL(删除)
// ③ 等待事件(相当于老师说:"这是举手名单,你处理吧")
int epoll_wait(int epfd, struct epoll_event *events,
int maxevents, int timeout);
struct epoll_event {
uint32_t events; // EPOLLIN(可读)/ EPOLLOUT(可写)/ EPOLLERR(错误)
epoll_data_t data; // 可以存 fd 或自定义指针
};
5.4.2 epoll 为什么快?
| 原因 | 解释 |
| 事件驱动 | 就绪的 fd 会自动挂到内核的就绪链表上,epoll_wait 只返回这些就绪 fd,不扫描全量 |
| 红黑树存储 | 所有注册的 fd 存在红黑树中,增删改查都是 O(log n) |
| 只拷贝就绪事件 | epoll_wait 只把就绪的 fd 信息从内核拷到用户态,不是全部 fd |
| 长期注册 | fd 注册一次后长期有效,不需要每次循环重新传入 |
通俗类比:select/poll 像是你每天把全班名单交给老师,老师每天从头数一遍。epoll 像是老师手里有个"举手铃"——只有举手的学生(就绪 fd)才会触发铃响,老师直接把举手名单给你。
5.4.3 两种触发模式
| 模式 | 行为 | 适用场景 |
| LT(水平触发,默认) | 只要缓冲区有数据,每次 epoll_wait 都会通知你 | 简单可靠,兼容 select/poll 编程模型,不易漏事件 |
| ET(边缘触发) | 只在状态变化时通知一次(从无到有) | 高性能,减少重复通知,但要求 fd 必须设为非阻塞,否则容易卡死 |
ET 模式通俗解释:
LT = "你碗里还有饭,我每次走过都提醒你一下"
ET = "我喊一次'开饭了',你自己记得吃到碗空,我不会再喊"
⚠️ ET 模式的红线:使用 ET 模式时,fd 必须是非阻塞的,且必须循环 read 直到返回 EAGAIN(表示读空了),否则可能漏掉数据。
5.4.4 完整代码:epoll_server.c(LT 模式,即默认模式,最安全)
第 1 步 → 创建文件:
vim epoll_server.c
第 2 步 → 输入代码:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <sys/epoll.h> // epoll 需要这个头文件
#define PORT 8080
#define BUFFER_SIZE 1024
#define MAX_EVENTS 10240 // 一次最多处理这么多就绪事件
int main() {
int server_fd, client_fd, epfd;
struct sockaddr_in server_addr, client_addr;
socklen_t client_len = sizeof(client_addr);
char buffer[BUFFER_SIZE];
// ① 创建套接字
server_fd = socket(AF_INET, SOCK_STREAM, 0);
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_port = htons(PORT);
server_addr.sin_addr.s_addr = INADDR_ANY;
bind(server_fd, (struct sockaddr *)&server_addr, sizeof(server_addr));
listen(server_fd, 5);
// ③ 创建 epoll 实例
// epoll_create1(0) 等价于旧版的 epoll_create(任意正数)
epfd = epoll_create1(0);
if (epfd == -1) {
perror("epoll_create1");
exit(1);
}
// ④ 把 server_fd 注册到 epoll
struct epoll_event ev, events[MAX_EVENTS];
ev.events = EPOLLIN; // 关心"可读"事件(新连接到来)
ev.data.fd = server_fd; // 把 fd 存进去,方便后面取出来
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
printf("epoll 服务器启动(LT 模式),监听端口 %d\\n", PORT);
while (1) {
// ⑤ epoll_wait:阻塞等待,有事件时返回
// 参数:epoll实例、存放事件的数组、数组大小、超时(-1=永远等)
int nfds = epoll_wait(epfd, events, MAX_EVENTS, -1);
if (nfds < 0) {
perror("epoll_wait");
continue;
}
// ⑥ 遍历所有就绪事件——注意!只遍历"就绪"的,不是全部 fd
for (int i = 0; i < nfds; i++) {
int fd = events[i].data.fd;
if (fd == server_fd) {
// ===== server_fd:有新连接 =====
client_fd = accept(server_fd,
(struct sockaddr *)&client_addr,
&client_len);
if (client_fd >= 0) {
printf("新客户端 %s:%d 连接,fd=%d\\n",
inet_ntoa(client_addr.sin_addr),
ntohs(client_addr.sin_port), client_fd);
// 把新客户端也注册到 epoll
ev.events = EPOLLIN;
ev.data.fd = client_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, client_fd, &ev);
}
} else {
// ===== 客户端 fd:有数据或断开 =====
int n = read(fd, buffer, sizeof(buffer) – 1);
if (n <= 0) {
// 断开或出错——从 epoll 中移除这个 fd
if (n == 0) {
printf("客户端 fd=%d 断开连接\\n", fd);
} else {
perror("read");
}
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL); // 从 epoll 移除
close(fd);
} else {
// 收到数据,原样发回
buffer[n] = '\\0';
printf("客户端 fd=%d 发来: %s", fd, buffer);
write(fd, buffer, n);
}
}
}
}
close(server_fd);
close(epfd); // 关闭 epoll 实例
return 0;
}
第 3 步 → 编译运行:
gcc -Wall -g epoll_server.c -o epoll_server
./epoll_server
5.4.5 ET 模式版本(进阶)
如果你想体验 ET 模式的高性能(以及它带来的复杂度),将上述代码中的核心部分改为:
// ① 设置 server_fd 和所有 client_fd 为非阻塞
int set_nonblocking(int fd) {
int flags = fcntl(fd, F_GETFL, 0);
return fcntl(fd, F_SETFL, flags | O_NONBLOCK);
}
set_nonblocking(server_fd);
// ② 注册时加上 EPOLLET 标志
ev.events = EPOLLIN | EPOLLET; // 边缘触发模式
ev.data.fd = server_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, server_fd, &ev);
// ③ 读取数据时必须循环读到 EAGAIN
while (1) {
n = read(fd, buffer, sizeof(buffer) – 1);
if (n == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 读完了,没有更多数据
}
perror("read");
break;
}
if (n == 0) {
// 客户端关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL);
close(fd);
break;
}
buffer[n] = '\\0';
write(fd, buffer, n);
}
⚠️ ET 模式警告:不设非阻塞 + 不循环 read 到 EAGAIN → 数据可能永远丢失。初学者建议先用 LT 模式跑通,再尝试 ET。
5.5 三兄弟终极对决 —— 一表定乾坤
| 维度 | select | poll | epoll |
| fd 上限 | FD_SETSIZE(通常 1024),改它要重编译内核 | 无硬限制,受内存限制 | 无硬限制,受内存限制 |
| 每次调用是否重传 fd 列表 | 是(拷贝整个 fd_set) | 是(拷贝整个 pollfd 数组) | 否(fd 注册在内核空间,一次注册长期有效) |
| 内核行为 | 遍历全部 fd 找到就绪的 | 遍历全部 fd 找到就绪的 | 事件驱动:fd 就绪时自动挂到就绪链表 |
| 复杂度 | O(n),n = 最大 fd 值 | O(n),n = fd 总数 | O(1),只关注就绪 fd 数量 |
| 返回后的工作量 | 遍历所有 fd 用 FD_ISSET 检查 | 遍历所有 fd 检查 revents | 只遍历返回的就绪事件(epoll_wait 返回的 nfds 个) |
| 跨平台 | 是(POSIX,全平台) | 是(POSIX,全平台) | 否(Linux 专属。BSD 有 kqueue,Windows 有 IOCP) |
| C10K 问题 | 无法支撑 | 勉强支撑但效率低 | 轻松应对(Nginx 单机百万连接靠的就是 epoll) |
| 适用场景 | 学习原理、少量 fd、跨平台需求 | 中小规模、跨平台需求 | 高并发生产环境、Linux 服务器 |
📌 术语速查:C10K 问题(1999 年提出)——即"如何在一台服务器上同时服务 1 万个客户端"。epoll 是 Linux 给出的标准答案。现代已进化到 C10M(千万级)。
5.6 epoll 实战:压测对比
我们来直观感受一下三种模型的差距。
第 1 步 → 分别启动三个服务器(每个在单独的终端):
# 终端1
./select_server
# 终端2
./poll_server
# 终端3
./epoll_server
第 2 步 → 查看 CPU 和内存占用(当连接数增多时):
# 新开终端,用 top 或 htop 观察
top -p $(pgrep -d',' select_server)
top -p $(pgrep -d',' poll_server)
top -p $(pgrep -d',' epoll_server)
现象:当有 1000 个空闲连接(连接了但不发数据)时:
- select/poll 每次 select()/poll() 调用都会遍历 1000 个 fd,CPU 使用率明显上升。
- epoll 只返回就绪的 fd 数(可能是 0),CPU 几乎不涨。
第六部分:实战对比 —— 用 telnet 同时连接多个服务器
6.1 模拟多个客户端
在多个终端中同时运行客户端:
# 终端1
telnet 127.0.0.1 8080
# 终端2
telnet 127.0.0.1 8080
# 终端3
telnet 127.0.0.1 8080
6.2 查看进程/线程/fd 数量变化
# 查看多进程服务器的子进程
ps aux | grep fork_server
# 查看多线程服务器的线程数(-T 显示线程,-p PID 指定进程)
ps -T -p $(pgrep thread_server)
# 查看 epoll 服务器打开的文件描述符
ls -la /proc/$(pgrep epoll_server)/fd | wc -l
6.3 用 Python 脚本模拟大量连接
以下脚本可以快速创建 500 个连接来测试你的服务器:
# 保存为 test_client.py,运行:python3 test_client.py
import socket
import time
sockets = []
for i in range(500):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect(('127.0.0.1', 8080))
s.send(f"Hello from client {i}\\n".encode())
sockets.append(s)
print(f"Client {i} connected")
except Exception as e:
print(f"Client {i} failed: {e}")
break
print(f"Total connected: {len(sockets)}")
print("Press Ctrl+C to disconnect all…")
time.sleep(60) # 保持连接 60 秒
第七部分:避坑指南(面试/实战高频)
| 坑 | 现象 | 解决方案 |
| 进程模型僵尸泛滥 | ps aux 看到大量 <defunct> | 注册 SIGCHLD + waitpid 循环回收;或 signal(SIGCHLD, SIG_IGN) |
| 线程模型参数传递错误 | 两个线程服务同一个 fd | 参数必须在堆上 malloc,不能用栈变量地址 |
| 线程忘记 detach/join | 内存持续增长 | 创建后用 pthread_detach,或在线程内 pthread_detach(pthread_self()) |
| select fd 超限 | 连接数超过 1024 后拒绝新连接 | 换成 poll 或 epoll |
| epoll ET 模式卡死 | read 返回后程序不再响应 | ET 必须配合非阻塞 fd + 循环 read 到 EAGAIN |
| select 每次重设 fd_set | 程序行为异常,某些 fd "丢了" | 每次循环必须 FD_ZERO + 重新 FD_SET(select 会修改 fd_set) |
| epoll_ctl 忘记 EPOLL_CTL_DEL | 关闭 fd 后 epoll 仍报告事件 | 关闭 fd 前先 epoll_ctl(epfd, EPOLL_CTL_DEL, fd, NULL) |
| 非阻塞 read 误判 EAGAIN | 程序频繁报错退出 | EAGAIN/EWOULDBLOCK 是正常状态,不是错误,应 continue 等待 |
总结
这两天我们跨越了单用户服务的藩篱,正式进入了高并发网络编程的世界:
| 技术 | 核心思路 | 一句话总结 |
| 多进程(fork) | 每个客户 = 一个子进程 | 稳如泰山,但扛不住海量连接 |
| 多线程(pthread) | 每个客户 = 一个线程 | 轻量快速,但一损俱损 |
| select | 内核轮询 + 位图 | 经典入门,上限 1024,遍历全量 |
| poll | 内核轮询 + 动态数组 | 去掉 1024 限制,但遍历全量问题仍在 |
| epoll | 事件驱动 + 红黑树 + 就绪链表 | Linux 王者,只关注就绪事件,O(1) 效率 |
技术演进路线图
单线程阻塞服务器(排队模式)
│
├──→ 多进程(fork)──→ 线程池(预创建线程复用)
│
├──→ 多线程(pthread)──→ 线程池 + 任务队列
│
└──→ IO 复用 ──→ select(1983)
│
└──→ poll(1997)
│
└──→ epoll(2002)──→ 现代高性能服务器标配
│
└──→ Reactor 模式
└──→ Proactor 模式
现实世界:Nginx 用 epoll + 少量 worker 进程,单机轻松百万并发。Redis 用 epoll 实现单线程处理数万客户端。你今天写的 epoll_server 就是这些顶级开源项目的"种子"。
常见问题速查表(Day 24-25)
| 问题现象 | 原因 | 解决办法 |
| fork_server 运行一段时间后 ps aux 出现大量 <defunct> | 子进程退出后父进程没回收(僵尸进程) | 加上 signal(SIGCHLD, sigchld_handler) 和 waitpid 循环 |
| thread_server 编译报 undefined reference to pthread_create | 没链接 pthread 库 | 编译命令加上 -lpthread:gcc thread_server.c -o thread_server -lpthread |
| 多线程服务器行为怪异,两个客户端收到的内容互相串 | 参数传递用了栈变量地址 | 必须 malloc 在堆上分配,线程内 free |
| select 服务器最多只能连 1024 个客户端 | FD_SETSIZE 硬限制 | 换成 poll 或 epoll |
| epoll_wait 返回了事件,但 read 时没有数据 | 可能是 ET 模式下没循环读到底 | ET 模式必须循环 read 直到 errno == EAGAIN |
| epoll_ctl 报 Bad file descriptor | fd 已经被 close 但没从 epoll 删除 | 关闭 fd 前先 epoll_ctl(…, EPOLL_CTL_DEL, fd, NULL) |
| bind: Address already in use | 上次服务器退出后端口还处于 TIME_WAIT 状态 | 加上 setsockopt(…, SO_REUSEADDR, …) |
| ET 模式服务器跑着跑着不动了 | fd 没设非阻塞,read 阻塞在某个客户端上 | 所有 ET 模式下的 fd 必须先 fcntl(fd, F_SETFL, O_NONBLOCK) |
| 客户端断开后服务器 CPU 飙升 | 没有正确处理 read 返回 0 的情况,空转循环 | 检查 read 返回值:<=0 时关闭 fd 并从监控列表移除 |
知识点关系图
┌─────────────────────────────────────┐
│ 网络编程基础(Day 22-23) │
│ socket / bind / listen / accept │
│ TCP Echo 服务器 │
└──────────────┬──────────────────────┘
│
┌──────────────┴──────────────────────┐
│ 并发服务器(Day 24-25) │
└──────┬──────────────┬───────────────┘
│ │
┌────────────┴──┐ ┌──────┴───────────────┐
│ 多进程 Fork │ │ 多线程 Pthread │
│ 每客户=1进程 │ │ 每客户=1线程 │
└──────┬────────┘ └──────┬───────────────┘
│ │
└─────────┬──────────┘
│ 都无法高效支撑海量连接
│
┌──────────┴─────────────────────────┐
│ IO 复用技术 │
│ │
│ select ──→ poll ──→ epoll(王者) │
│ 1983 1997 2002 │
│ O(n) O(n) O(1) │
└──────────┬─────────────────────────┘
│
┌──────────┴──────────────────────────┐
│ Reactor / Proactor 模式 │
│ (进阶:框架设计模式) │
│ │
│ Nginx、Redis、Netty、libuv 的基石 │
└─────────────────────────────────────┘
📌 作业(Day 24-25 巩固)
并发之路漫漫,从 fork 到 pthread 再到 epoll,你已经掌握了 Linux 网络编程的"三把钥匙"。今天写的 epoll_server.c,就是 Nginx 和 Redis 诞生的第一行代码。我们下期项目见!
网硕互联帮助中心



评论前必须登录!
注册