learn.ochresign
阶段 0 · 补前置

任务 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 模型 一起看。

思路:两个改变

阻塞版的问题是“等一个客户端时,什么别的都做不了”。解决它需要两个改变,缺一不可:

  1. 把所有 fd 改成非阻塞的。 没有数据时,read 和 accept 不再卡住,而是立刻返回一个“现在没有,稍后再试”的结果。
  2. 找一个“前台”帮我们盯着所有 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 还会继续报告它。

这份代码故意留下的两个坑

  1. write 可能写不完。 write(fd, buf, r) 返回实际写进去的字节数,可能小于 r。对方接收慢、内核发送缓冲区满了的时候,就会只写一部分,甚至返回 -1(EAGAIN)。我们没检查,剩下的数据就丢了。echo 的数据很少,测试时碰不到,但真实服务器一定会碰到。任务 7 的练习 4 会解决。
  2. 所有逻辑都堆在 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)

它分两步:

  1. 先建立 N 个连接,一个字都不发。 这模拟了“很多客户端连着但不说话”的情况,正好是阻塞版的死穴。
  2. 再挨个发一条消息,等 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 三个类。