任务 6:跑通 epoll 版 echo server,500 个连接全部回显
先用一个小实验看懂“非阻塞”,再逐行讲解 epoll 版 echo server 的每一行,最后用测试脚本对比:阻塞版 3 个连接只有 1 个收到回显,epoll 版 500 个全部收到。
前提: 完成 任务 5,知道阻塞版卡在
accept和read上。这篇做完你会: 理解非阻塞 I/O 和
EAGAIN;会用epoll_create1、epoll_ctl、epoll_wait;写出一个单线程同时服务几百个连接的服务器;看懂并会用测试脚本。预计用时: 4 到 5 小时。这是阶段 0 最重要的一篇,概念部分可以配合 I/O 模型 一起看。
思路:两个改变
阻塞版的问题是“等一个客户端时,什么别的都做不了”。解决它需要两个改变,缺一不可:
- 把所有 fd 改成非阻塞的。 没有数据时,
read和accept不再卡住,而是立刻返回一个“现在没有,稍后再试”的结果。 - 找一个“前台”帮我们盯着所有 fd。 只有改成非阻塞还不够:如果程序不停地对每个连接轮流调用
read,绝大多数调用都会返回“没有数据”,CPU 白白空转到 100%。我们需要一个机制:程序睡着等,只要任何一个 fd 有事了,就叫醒程序,并告诉它是哪几个。这个前台就是 epoll。
打个比方:阻塞版是一个服务员守着一桌客人,这桌不点菜他就一直站着。epoll 版是服务员坐在前台,每桌都有一个呼叫铃,哪桌按铃他就去哪桌,处理完回前台继续等。
第 1 步:先做个小实验,看懂“非阻塞”
建目录:
mkdir -p ~/code/cpp-practice/task6
cd ~/code/cpp-practice/task6
新建 nonblock_demo.cpp:
nonblock_demo.cpp 下载
// 演示非阻塞 read:没数据时立刻返回 -1,errno 是 EAGAIN;有数据时正常读到。
#include <fcntl.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
#include <cstring>
int main() {
// socketpair 创建一对互相连通的 socket:往 sv[0] 写的,能从 sv[1] 读到
int sv[2];
socketpair(AF_UNIX, SOCK_STREAM, 0, sv);
// 把 sv[1] 设成非阻塞
int flags = fcntl(sv[1], F_GETFL);
fcntl(sv[1], F_SETFL, flags | O_NONBLOCK);
char buf[100];
ssize_t n = read(sv[1], buf, sizeof(buf)); // 还没人写,阻塞的话会永远卡在这里
printf("1st read returned %zd, errno = %d (%s)\n", n, errno, strerror(errno));
printf("EAGAIN = %d, so this means: no data right now, try again later\n", EAGAIN);
write(sv[0], "hello", 5);
n = read(sv[1], buf, sizeof(buf));
printf("2nd read returned %zd: %.*s\n", n, static_cast<int>(n), buf);
close(sv[0]); // 关闭另一端
n = read(sv[1], buf, sizeof(buf));
printf("3rd read returned %zd (peer closed)\n", n);
close(sv[1]);
return 0;
}
编译运行:
g++ -Wall -Wextra -o nonblock_demo nonblock_demo.cpp
./nonblock_demo
实际输出:
1st read returned -1, errno = 11 (Resource temporarily unavailable)
EAGAIN = 11, so this means: no data right now, try again later
2nd read returned 5: hello
3rd read returned 0 (peer closed)
读懂它:
fcntl(fd, F_SETFL, flags | O_NONBLOCK)是把 fd 设成非阻塞的标准写法:先用F_GETFL取出原来的标志,加上O_NONBLOCK,再用F_SETFL设回去。不能直接写fcntl(fd, F_SETFL, O_NONBLOCK),那样会把原来的其他标志清掉。- 第一次
read: 还没有数据。如果是阻塞的,程序会永远卡在这里。非阻塞的read立刻返回 -1,并把全局变量errno设成EAGAIN(值是 11,意思是“资源暂时不可用,请再试一次”)。这不是错误,只是“现在没数据”。 - 第二次
read: 另一端写入了hello,这次读到了 5 个字节。 - 第三次
read: 另一端关闭了,返回 0。
所以,非阻塞 read 的返回值有四种情况,要全部处理:
| 返回值 | errno | 含义 | 怎么办 |
|---|---|---|---|
> 0 |
读到数据 | 处理 | |
0 |
对方关闭了连接 | close | |
-1 |
EAGAIN(或 EWOULDBLOCK,在 Linux 上两者相等) |
现在没数据 | 什么都不做,等下次通知 |
-1 |
其他 | 真的出错了 | close |
accept 也一样:非阻塞的监听 fd 上没有新连接时,accept 返回 -1,errno 是 EAGAIN。
第 2 步:认识 epoll 的三个函数
epoll 只有三个函数:
epoll_create1(0):开一个前台
int epfd = epoll_create1(0);
创建一个 epoll 实例,返回它的 fd。之后所有操作都通过这个 epfd 进行。参数一般传 0。
epoll_ctl:告诉前台要盯哪个 fd、盯什么事
epoll_event ev{};
ev.events = EPOLLIN; // 关心什么事件
ev.data.fd = some_fd; // 事件发生时,把这个值原样还给我
epoll_ctl(epfd, EPOLL_CTL_ADD, some_fd, &ev);
- 第二个参数是操作:
EPOLL_CTL_ADD(开始盯)、EPOLL_CTL_MOD(修改关心的事件)、EPOLL_CTL_DEL(不盯了)。 ev.events是关心的事件,常用的有两个:EPOLLIN(可读:有数据可以读了,或者对方关闭了,或者对监听 fd 来说有新连接了)和EPOLLOUT(可写:发送缓冲区有空间了,可以写了)。ev.data是一个联合体,你放什么,事件发生时 epoll 就原样还给你什么。我们放 fd 本身,这样就知道是哪个 fd 有事了。
epoll_wait:在前台等铃响
epoll_event events[1024];
int n = epoll_wait(epfd, events, 1024, -1);
- 程序在这里睡眠,不占 CPU,直到有至少一个 fd 发生了关心的事件。
- 醒来后返回发生事件的 fd 个数
n,并把它们的信息填到events[0]到events[n-1]里。 - 第三个参数是数组大小,第四个参数是超时时间(毫秒),
-1表示一直等。
关键在于: 程序只在 epoll_wait 一个地方等待。不管有多少个连接,醒来后只处理有事的那几个,没事的连接完全不用管。
第 3 步:把代码敲出来
新建 epoll_echo.cpp:
epoll_echo.cpp 下载
// 第二课:epoll 版 echo server,单线程同时服务成千上万个客户端
#include <arpa/inet.h>
#include <fcntl.h>
#include <netinet/in.h>
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <cerrno>
#include <cstdio>
// 把 fd 设成非阻塞:read/accept 没数据时立刻返回,而不是卡住
static void set_nonblock(int fd) {
fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK);
}
int main() {
int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
int opt = 1;
setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
sockaddr_in addr{};
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_ANY);
addr.sin_port = htons(8888);
if (bind(listen_fd, (sockaddr*)&addr, sizeof(addr)) < 0) { perror("bind"); return 1; }
listen(listen_fd, 1024);
set_nonblock(listen_fd);
// 1. 创建 epoll 实例:一个“前台”,帮你盯住所有 fd
int epfd = epoll_create1(0);
// 2. 把监听 fd 交给前台盯着:有新连接时通知我(EPOLLIN = 可读)
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
printf("listening on 8888 (epoll)\n");
epoll_event events[1024];
char buf[4096];
while (true) {
// 3. epoll_wait:没事就睡,一旦有 fd 就绪就醒来,返回就绪的个数
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
if (fd == listen_fd) {
// 4a. 监听 fd 就绪 = 有新客人:把排队的连接全部接进来
while (true) {
int conn_fd = accept(listen_fd, nullptr, nullptr);
if (conn_fd < 0) break; // EAGAIN:已经接完了
set_nonblock(conn_fd);
epoll_event cev{};
cev.events = EPOLLIN;
cev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
}
} else {
// 4b. 普通连接就绪 = 客人说话了:读出来再写回去
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r); // 简化:假设一次写得完,阶段 1 用 Buffer 解决
} else if (r == 0 || errno != EAGAIN) {
// 客人挂断或出错:从 epoll 里移除并关闭
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
}
}
}
编译:
g++ -Wall -Wextra -o epoll_echo epoll_echo.cpp
如果你加了 -O2,会看到一个警告:
warning: ignoring return value of 'ssize_t write(int, const void*, size_t)' declared with attribute 'warn_unused_result' [-Wunused-result]
编译器在提醒你:write 的返回值没检查。这是一个真问题,我们故意留着,任务 7 的练习 4 会解决它。
第 4 步:逐行看懂
准备部分(第 11–26 行)
set_nonblock 就是第 1 步实验里的写法,封装成一个函数。
第 17–25 行和阻塞版完全一样:socket、setsockopt、bind、listen。唯一的区别是 listen 的 backlog 从 128 改成了 1024,因为我们要同时接很多连接。
第 26 行把监听 fd 也设成非阻塞的。原因见下面的 4a。
创建 epoll 并注册监听 fd(第 29–35 行)
int epfd = epoll_create1(0);
epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listen_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, listen_fd, &ev);
开一个前台,然后告诉它:“盯着 listen_fd,它可读的时候叫我。”
监听 fd “可读”是什么意思? 就是有新连接完成了三次握手,正在队列里等着被 accept。
事件循环(第 40–42 行)
while (true) {
int n = epoll_wait(epfd, events, 1024, -1);
for (int i = 0; i < n; ++i) {
int fd = events[i].data.fd;
这就是事件循环,整个程序永远在做三件事:等事件、看是哪个 fd、处理它。这个模式有一个名字,叫 Reactor(反应堆)模式:程序不主动去做事,而是对发生的事件做出“反应”。Redis、Nginx、Node.js、Python 的 asyncio,底层都是这个结构。
events[i].data.fd 取回我们注册时放进去的 fd。
4a:监听 fd 有事 = 有新连接(第 46–56 行)
if (fd == listen_fd) {
while (true) {
int conn_fd = accept(listen_fd, nullptr, nullptr);
if (conn_fd < 0) break;
set_nonblock(conn_fd);
epoll_event cev{};
cev.events = EPOLLIN;
cev.data.fd = conn_fd;
epoll_ctl(epfd, EPOLL_CTL_ADD, conn_fd, &cev);
}
}
为什么要用 while (true) 循环 accept? 一次 epoll_wait 醒来时,队列里可能已经排了好几个新连接(比如测试脚本一下子连了 500 个)。只 accept 一个的话,剩下的要等下一轮。所以要一直 accept,直到返回 -1(EAGAIN,队列空了)为止。
这就是监听 fd 必须是非阻塞的原因: 如果它是阻塞的,队列空了之后最后一次 accept 会卡住,整个程序就又被卡死了。
每接到一个新连接:设成非阻塞,然后用 epoll_ctl 把它也交给前台盯着,关心 EPOLLIN(有数据来了叫我)。
注意:这里没有 read。 接完连接就回去了,不等客户端说话。这就是和阻塞版的根本区别。
4b:普通连接有事 = 客户端发数据了,或者关闭了(第 57–66 行)
} else {
ssize_t r = read(fd, buf, sizeof(buf));
if (r > 0) {
write(fd, buf, r);
} else if (r == 0 || errno != EAGAIN) {
epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
close(fd);
}
}
- 读到数据(
r > 0):原样写回。 - 对方关闭(
r == 0)或真的出错(r < 0且不是EAGAIN):先从 epoll 里删掉,再 close。 r < 0且是EAGAIN:什么都不做,等下次通知。(在这份代码里几乎不会发生,因为 epoll 只在有数据时才叫我们。)
为什么先 EPOLL_CTL_DEL 再 close? 其实 close 一个 fd 时内核会自动把它从 epoll 里移除,但显式删掉是好习惯,而且在 fd 被复制过(dup)的情况下,不删的话 epoll 还会继续报告它。
这份代码故意留下的两个坑
write可能写不完。write(fd, buf, r)返回实际写进去的字节数,可能小于r。对方接收慢、内核发送缓冲区满了的时候,就会只写一部分,甚至返回 -1(EAGAIN)。我们没检查,剩下的数据就丢了。echo 的数据很少,测试时碰不到,但真实服务器一定会碰到。任务 7 的练习 4 会解决。- 所有逻辑都堆在
main里。 加一个功能就要改这一大坨。阶段 1 会把它拆成EventLoop、Channel、Poller等几个类,做成一个可以复用的网络库。
第 5 步:用 nc 手动测试
终端 1:
./epoll_echo
输出 listening on 8888 (epoll)。
终端 2 和终端 3 分别运行 nc 127.0.0.1 8888,两个都不退出,轮流输入文字。这次两个都能立刻收到回显。和阻塞版对比一下。
第 6 步:用测试脚本跑 500 个连接
看懂测试脚本
新建 test_clients.py:
test_clients.py 下载
# 测试 echo server:先同时建立 N 个连接,再挨个发一条消息,检查能不能收到原样的回显。
# 用法:python3 test_clients.py 名字 连接数,例如 python3 test_clients.py epoll 500
import socket
import sys
name = sys.argv[1] # 只是打印时用的名字,比如 blocking 或 epoll
count = int(sys.argv[2]) # 要建立多少个连接
# 第一步:一口气建立 count 个连接,都先不发数据,模拟“很多人连着但不说话”
conns = []
for i in range(count):
s = socket.create_connection(("127.0.0.1", 8888))
s.settimeout(1) # recv 最多等 1 秒,等不到就算失败
conns.append(s)
# 第二步:挨个发一条不同的消息,看能不能收到一模一样的回显
ok = 0
for i, s in enumerate(conns):
msg = f"hi{i}".encode()
s.sendall(msg)
try:
if s.recv(100) == msg:
ok += 1
except socket.timeout:
pass # 1 秒内没收到回显
print(name, "echo ok:", ok, "/", count)
它分两步:
- 先建立 N 个连接,一个字都不发。 这模拟了“很多客户端连着但不说话”的情况,正好是阻塞版的死穴。
- 再挨个发一条消息,等 1 秒看回显。 每个连接发的内容不同(
hi0、hi1……),这样能确认收到的是自己的回显,而不是别人的。
跑阻塞版
终端 1 运行任务 5 的阻塞版(先按 Ctrl + C 停掉 epoll 版):
./blocking_echo
终端 2:
python3 test_clients.py blocking 3
实际输出(大约要等 2 秒):
blocking echo ok: 1 / 3
只有第一个连接收到了回显。服务器卡在 read(第一个连接) 上,第二个、第三个连接一直在 accept 队列里排队,它们的消息没人读,每个都等满 1 秒超时。
跑 epoll 版
终端 1 换成 epoll 版:
./epoll_echo
终端 2:
python3 test_clients.py epoll 500
实际输出(不到 0.1 秒):
epoll echo ok: 500 / 500
500 个全部收到。
想试更多连接?
先看看你的进程最多能开多少个 fd:
ulimit -n
WSL 和很多 Linux 默认是 1024。每个连接在服务器和测试脚本里各占一个 fd,超过这个数会报 Too many open files。临时调大(只对当前终端有效,两个终端都要执行):
ulimit -n 20000
然后可以试 python3 test_clients.py epoll 5000。
交作业
cd ~/code/cpp-practice
git add task6
git commit -m "Add epoll echo server and test script"
git push
检查清单
- 跑过
nonblock_demo,能说出非阻塞read的四种返回情况 - 能说出 epoll 三个函数各自的作用
- 能解释为什么 accept 要放在循环里,以及为什么监听 fd 也要非阻塞
- 能说出这份代码留下的两个坑
- 测试脚本:阻塞版
1 / 3,epoll 版500 / 500
自测
只把 fd 改成非阻塞、不用 epoll,自己写个循环轮流 read 所有连接,行不行?
能工作,但 CPU 会一直跑满 100%。绝大多数连接大部分时间都没有数据,轮询它们的 read 几乎都返回 EAGAIN,全是白做的系统调用。epoll 让程序在没有事件时睡眠,有事件时只处理有事的 fd。
为什么用 epoll 时 fd 要设成非阻塞?
epoll 只告诉你“现在可以读了”,但你读的时候不一定能读到你期待的那么多,循环 accept 或循环 read 到最后一次时也一定会遇到“没有了”。如果 fd 是阻塞的,这最后一次调用就会卡住,整个事件循环就停了,其他所有连接都得不到处理。
epoll_wait 的最后一个参数为什么传 -1?什么时候需要传别的值?
-1 表示没有事件就一直等。如果程序还需要定期做别的事(比如每秒检查一次有没有超时的连接),就传一个超时时间,比如 1000 毫秒,这样即使没有网络事件,epoll_wait 最多 1 秒也会返回一次。阶段 1 实现定时器时会用到另一种更好的办法(timerfd)。
面试常问
select、poll、epoll 的区别是什么?
参考回答: select 用位图存 fd,默认上限 1024,每次调用都要把整个集合从用户态拷到内核,返回后还要遍历所有 fd 找出就绪的,复杂度 O(n),而且集合会被修改,每次都要重新设置。poll 用数组代替位图,去掉了数量上限,但拷贝和遍历的问题还在。epoll 把注册和等待分开:epoll_ctl 注册一次一直有效,epoll_wait 只返回就绪的 fd,开销和活跃连接数有关,和总连接数无关,所以适合大量连接、少量活跃的场景。
epoll 的 LT 和 ET 有什么区别?你用的哪个?
参考回答: LT(水平触发,默认)只要 fd 上还有数据没读完,每次 epoll_wait 都会报告;ET(边缘触发)只在状态变化时报告一次,比如新数据到达,所以必须一次循环读到 EAGAIN,否则剩下的数据不会再有通知。我用的是 LT,逻辑简单不容易漏数据,Redis 和 muduo 也是 LT;Nginx 用的是 ET。
什么是 Reactor 模式?
参考回答: Reactor 是一种事件驱动的设计:用 I/O 多路复用(比如 epoll)同时等待很多 fd 上的事件,事件发生时分发给对应的处理函数,处理完再回去等。它让一个线程就能处理大量连接。我的 echo server 里那个 while (true) { epoll_wait; 分发 } 的循环就是最简单的 Reactor,阶段 1 会把它封装成 EventLoop、Channel、Poller 三个类。