【Linux】epoll 真的是 O(1) 吗?从阻塞 I/O、select/poll 到 LT/ET 与 Reactor 一次讲透

🔥个人主页:爱和冰阔乐

📚专栏传送门:《数据结构与算法》C++《Linux操作系统》

🐶学习方向:C++方向学习爱好者

⭐人生格言:得知坦然 ,失之淡然


🏠博主简介

文章目录

    • 前言
    • [一、从阻塞 I/O 到 select / poll](#一、从阻塞 I/O 到 select / poll)
    • [二、epoll 的基本模型与事件注册](#二、epoll 的基本模型与事件注册)
      • [epoll 的思路发生了什么变化](#epoll 的思路发生了什么变化)
      • [创建 epoll](#创建 epoll)
      • [注册监听 socket](#注册监听 socket)
      • 等待事件
      • [select / poll / epoll 放一起看](#select / poll / epoll 放一起看)
      • [epoll 内部可以怎么理解](#epoll 内部可以怎么理解)
      • [监听 fd 可读以后为什么要 accept](#监听 fd 可读以后为什么要 accept)
      • [一个最小 LT epoll 服务器](#一个最小 LT epoll 服务器)
    • [三、LT、ET 与非阻塞 I/O](#三、LT、ET 与非阻塞 I/O)
    • 四、写事件、缓冲区与协议处理
      • [EPOLLOUT 为什么不能一直注册](#EPOLLOUT 为什么不能一直注册)
      • [非阻塞 write 为什么会"只写一部分"](#非阻塞 write 为什么会"只写一部分")
      • [epoll 只告诉你"可以 I/O",不会替你处理协议](#epoll 只告诉你"可以 I/O",不会替你处理协议)
    • [五、从 epoll 到 Reactor 与线程模型](#五、从 epoll 到 Reactor 与线程模型)
      • [从 epoll 到 Reactor](#从 epoll 到 Reactor)
      • [一个简化 Reactor 结构](#一个简化 Reactor 结构)
      • [单 Reactor 单线程能不能做业务](#单 Reactor 单线程能不能做业务)
    • [六、epoll 的边界、常见坑与连接状态机](#六、epoll 的边界、常见坑与连接状态机)
    • 七、把高并发网络模型完整串起来
    • 总结

前言

第一次写 TCP 服务器,代码通常很直观:

cpp 复制代码
int clientfd = accept(listenfd, nullptr, nullptr);

char buf[1024];
int n = read(clientfd, buf, sizeof(buf));
write(clientfd, buf, n);

一个连接进来,读数据,再写回去。客户端少的时候,这套逻辑完全够用。

连接一多,问题就一个接一个冒出来:

  • 一个连接一直不发数据,read 会不会把整个线程卡住?
  • 一个连接一个线程,几万个连接是不是就要几万个线程?
  • selectpollepoll 到底差在哪?
  • epoll 所谓的"事件通知",到底通知了什么?
  • LT 和 ET 只是触发方式不同,为什么代码写法差那么多?
  • ET 为什么总强调非阻塞、强调一直读到 EAGAIN
  • Reactor 和 epoll 又是什么关系?

很多文章会把这些压成一句话:

epoll 比 select 快,因为 epoll 是 O(1)。

这句话太粗,而且容易把重点带偏。O(1) 这个说法本身就不太准确,后面会具体讲。

真正想分清这几种模型,别从复杂度口诀开始,盯住同一个问题就行:

程序到底怎么知道"现在该处理哪个 fd"?

下面就从最朴素的阻塞服务器开始,看这个问题是怎么一步步演变到 epoll 和 Reactor 的。


一、从阻塞 I/O 到 select / poll

最朴素的阻塞服务器,问题出在哪

先写一个非常简单的服务端:

cpp 复制代码
int listenfd = socket(AF_INET, SOCK_STREAM, 0);

bind(listenfd, ...);
listen(listenfd, 128);

while (true)
{
    int clientfd = accept(listenfd, nullptr, nullptr);

    char buffer[1024];
    int n = read(clientfd, buffer, sizeof(buffer));

    if (n > 0)
        write(clientfd, buffer, n);

    close(clientfd);
}

流程大概是这样:

text 复制代码
accept
  ↓
等客户端连接
  ↓
read
  ↓
等客户端发数据
  ↓
write
  ↓
close

问题很明显。客户端 A 连上来却一直不发数据,服务器就卡在这一行:

cpp 复制代码
read(clientfd, ...);

这时候客户端 B 就算已经发起连接,主线程也回不到 accept

阻塞 I/O 最大的问题不是"阻塞"本身,而是一个执行流阻塞在某个连接上之后,就没法再照顾其他连接。


一连接一线程,能解决吗

最直接的办法:主线程只负责 accept,每个 clientfd 丢给一个线程处理。

cpp 复制代码
while (true)
{
    int clientfd = accept(listenfd, nullptr, nullptr);

    std::thread([clientfd]() {
        handleClient(clientfd);
        close(clientfd);
    }).detach();
}

这样 A 卡在 read 只卡自己的线程,B 照样能被新线程处理。连接数不高的服务,这个模型完全能工作。

不是线程慢,是规模上来后成本开始明显

线程要有自己的栈、调度状态,要参与上下文切换,还要考虑同步和生命周期管理。

麻烦的是这个场景:10000 个连接里,同一时刻真有数据的可能只有 100 个。为了这 100 个活跃连接长期养着 10000 个线程,就不太划算了。

于是问题变成:

能不能用少量线程管住大量 fd,只处理当前真正就绪的那几个?

这就是 I/O 多路复用要解决的问题。


I/O 多路复用到底"复用"了什么

这张图看的就是一件事:一个执行流,怎么同时盯住多个 fd 的状态变化。

"多路复用"听起来抽象,其实就两层意思:

  • 一个执行流
  • 同时等待多个 fd 的 I/O 状态

不是"线程阻塞在 fd1 上",而是:

  • 我这儿有 fd1、fd2、fd3、fd4......
  • 你告诉我现在谁可读、谁可写

内核返回 fd2 可读、fd7 可写、fd9 可读,程序就只处理这三个。

I/O 多路复用的重点,是把"等待某一个 fd"变成"等待一批 fd 中的事件"。

Linux 上常见的接口有三个:selectpollepoll。思路一脉相承,差别主要在"怎么表达关注集合"和"怎么拿到就绪事件"。


select:先把"等多个 fd"这件事做起来

核心接口:

cpp 复制代码
int select(int nfds,
           fd_set *readfds,
           fd_set *writefds,
           fd_set *exceptfds,
           struct timeval *timeout);

先准备读集合:

cpp 复制代码
fd_set readfds;
FD_ZERO(&readfds);
FD_SET(listenfd, &readfds);
FD_SET(client1, &readfds);
FD_SET(client2, &readfds);

然后等待:

cpp 复制代码
select(maxfd + 1, &readfds, nullptr, nullptr, nullptr);

返回以后再逐个判断:

cpp 复制代码
if (FD_ISSET(client1, &readfds))
{
    // client1 可读
}
一个容易忽略的细节:select 会修改集合

select 返回后,集合里只剩下就绪的 fd,所以通常要保留一份主集合:

cpp 复制代码
fd_set master;
fd_set ready;

每轮重新拷一份再传进去:

cpp 复制代码
ready = master;
select(..., &ready, ...);

也就是说,每一轮都要重新准备关注集合。这点后面会和 epoll 形成明显对比。

select 的几个限制
  • fd 数量受 FD_SETSIZE 限制(Linux/glibc 上通常是 1024,这是 fd_set 的实现限制,不是 POSIX 规定死的通用上限)
  • 每次调用都要把集合从用户态拷进内核,返回时再拷回来
  • 返回后集合被改写,下一轮必须重新填充
  • 哪些 fd 就绪要自己遍历找出来,连接多时扫描成本明显

但不能因此说 select 没用。连接数不大、又要求跨平台,它依然是最直观的一个选择。


poll:换了种写法,但仍然要扫

poll 用结构体数组代替 fd_set

cpp 复制代码
struct pollfd
{
    int fd;
    short events;   // 我关心的事件
    short revents;  // 实际发生的事件
};

用起来像这样:

cpp 复制代码
std::vector<pollfd> fds;

fds.push_back({listenfd, POLLIN, 0});
fds.push_back({client1, POLLIN, 0});
fds.push_back({client2, POLLIN, 0});

poll(fds.data(), fds.size(), -1);

返回以后同样要遍历:

cpp 复制代码
for (auto &p : fds)
{
    if (p.revents & POLLIN)
    {
        // 可读
    }
}

数组由调用者自己分配,所以不再受 FD_SETSIZE 那种固定上限约束,动态增删也更自然。

但核心问题没变:

text 复制代码
每次 poll 返回
 ↓
程序遍历整个数组
 ↓
找 revents != 0 的项

10000 个连接里只有 20 个活跃,应用还是得把整个数组扫一遍,而且每轮调用同样要把整个数组交给内核。


二、epoll 的基本模型与事件注册

epoll 的思路发生了什么变化

epoll 常见的三个接口:

cpp 复制代码
epoll_create1(...)
epoll_ctl(...)
epoll_wait(...)

逻辑上可以拆成四步:

text 复制代码
创建一个 epoll 实例
      ↓
把关心的 fd 注册进去
      ↓
等待事件
      ↓
直接拿到当前发生事件的 fd

关键变化在第一步和第二步:关注集合是长期注册在内核里的,不用每轮重新递交。 而 select/poll 每调用一次,都得把整份集合搬一遍。


创建 epoll

cpp 复制代码
int epfd = epoll_create1(0);

得到一个 epoll 实例对应的 fd。

注册监听 socket

cpp 复制代码
struct epoll_event ev{};
ev.events = EPOLLIN;
ev.data.fd = listenfd;

epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

等于告诉内核:我关心 listenfd 的可读事件。

对监听 socket 来说,"可读"不是收到业务数据,而是:

连接队列里有连接可以 accept

等待事件

cpp 复制代码
struct epoll_event events[1024];

int n = epoll_wait(
    epfd,
    events,
    1024,
    -1
);

返回以后:

cpp 复制代码
for (int i = 0; i < n; ++i)
{
    int fd = events[i].data.fd;
    // 这里处理真正发生事件的 fd
}

这就是 epoll 在使用体验上最重要的变化。

应用循环的主要工作,从"扫描所有 fd 找谁就绪",变成了"遍历 epoll_wait 返回的就绪事件"。


select / poll / epoll 放一起看

三个接口放到一起对比更清楚:

select poll epoll
关注集合的表达 fd_set 位图 pollfd 数组 内核维护,用 epoll_ctl 增删改
数量上限 FD_SETSIZE 限制(Linux/glibc 通常 1024) 无此固定限制,取决于内存和 fd 上限 无此固定限制
每轮是否重新递交集合 是,且返回后被改写 否,注册一次长期有效
怎么拿到就绪 fd 遍历全部被监控的 fd 遍历整个数组 epoll_wait 直接返回就绪事件
就绪判定的主要成本 O(N),N 是被监控 fd 总数 O(N) 与这一轮就绪的数量相关,与 N 基本无关
可移植性 POSIX,跨平台 POSIX,跨平台 Linux 专有

顺便把开头那句"epoll 是 O(1)"说清楚。准确一点的表达应该是:

epoll_wait 拿到就绪事件的成本,和你监控了多少个 fd 基本无关;而 select/poll 每轮都要把全部 fd 过一遍。

差别不在常数大小,而在于扫描对象从"全部 fd"变成了"就绪事件"

这也解释了为什么 epoll 的优势要在"连接多、活跃少"的场景才明显------如果每轮几乎所有 fd 都活跃,那应用本来就要处理大量 I/O,事件通知本身只占总成本的一小部分。


epoll 内部可以怎么理解

不追具体内核版本源码,先建立一个够用的模型。一个 epoll 实例至少要解决两个问题:

1. 我关心哪些 fd

用户通过 epoll_ctl(...) 注册:

text 复制代码
fd A → EPOLLIN
fd B → EPOLLOUT
fd C → EPOLLIN | EPOLLET

内核得维护这份"关注关系"。

2. 当前哪些 fd 已经就绪

当某个 fd 状态变化,内核需要让 epoll 实例知道:

这个 fd 现在有你关心的事件

epoll_wait 返回的就是当前就绪事件的集合。

所以理解 epoll 时,不必把重点放在背一句"红黑树 + 就绪链表"。真正要分清的是两个角色:

  • interest set:长期关注谁
  • ready set:当前谁有事件

这比单纯记内部数据结构更容易接到编程模型上。

不同内核版本的具体实现会有差异,上面只是帮助理解的逻辑模型,不是源码结构。


监听 fd 可读以后为什么要 accept

主循环里:

cpp 复制代码
if (fd == listenfd)
{
    int clientfd = accept(listenfd, ...);
}

因为监听 socket 上的 EPOLLIN 意味着:"有连接可取",而不是"收到普通数据"。

accept 拿到新 clientfd 后,还要把它注册进 epoll:

cpp 复制代码
struct epoll_event clientEv{};
clientEv.events = EPOLLIN;
clientEv.data.fd = clientfd;

epoll_ctl(
    epfd,
    EPOLL_CTL_ADD,
    clientfd,
    &clientEv
);

这样后续客户端数据到达时,epoll_wait 才会通知这个 clientfd


一个最小 LT epoll 服务器

这张图对应下面这份最小实现,重点看主循环:epoll_wait 返回之后,只处理返回数组里的那 n 个事件。

先写 Level Triggered,也就是 LT,它是 epoll 的默认模式。

cpp 复制代码
#include <sys/epoll.h>
#include <sys/socket.h>
#include <unistd.h>
#include <fcntl.h>
#include <cerrno>
#include <cstring>

void loop(int listenfd)
{
    int epfd = epoll_create1(0);

    epoll_event ev{};
    ev.events = EPOLLIN;
    ev.data.fd = listenfd;
    epoll_ctl(epfd, EPOLL_CTL_ADD, listenfd, &ev);

    epoll_event events[1024];

    while (true)
    {
        int n = epoll_wait(epfd, events, 1024, -1);

        for (int i = 0; i < n; ++i)
        {
            int fd = events[i].data.fd;

            if (fd == listenfd)
            {
                int clientfd = accept(listenfd, nullptr, nullptr);

                epoll_event cev{};
                cev.events = EPOLLIN;
                cev.data.fd = clientfd;
                epoll_ctl(epfd, EPOLL_CTL_ADD, clientfd, &cev);
            }
            else if (events[i].events & EPOLLIN)
            {
                char buf[4096];
                ssize_t len = read(fd, buf, sizeof(buf));

                if (len > 0)
                {
                    write(fd, buf, len);
                }
                else if (len == 0)
                {
                    // 对端关闭,读到了 EOF
                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                    close(fd);
                }
                else
                {
                    if (errno == EINTR)
                    {
                        // 被信号打断,不是错误。
                        // LT 模式下下一轮还会再通知,这轮跳过即可
                        continue;
                    }

                    epoll_ctl(epfd, EPOLL_CTL_DEL, fd, nullptr);
                    close(fd);
                }
            }
        }
    }
}

这里有个容易写错的地方:read 返回 -1 时不能直接当连接出错处理,得先判断 errno == EINTR。被信号打断是正常现象,直接 close 会把一个好连接关掉。上面代码里单独处理了。

这份代码只是为了看模型,里面的 fd 还是阻塞的,真实网络程序还要处理:

  • 各种错误码
  • 半包 / 粘包
  • 部分写(write 返回值小于期望长度)
  • 非阻塞 fd
  • 信号
  • 连接关闭与对象生命周期
  • 事件组合(EPOLLIN | EPOLLOUT | EPOLLERR | EPOLLHUP

三、LT、ET 与非阻塞 I/O

这张图对比的就是 LT 和 ET 的提醒时机。看的时候注意区分:LT 看的是"条件是否成立",ET 看的是"状态是否变化"。


LT:只要"条件还成立",就会继续提醒

LT 可以理解成:

text 复制代码
fd 现在仍然可读?
是 → epoll_wait 还会继续告诉你

假设 socket 接收缓冲区有 1000 字节,你只读了 100 字节,还剩 900 字节。

下一轮 epoll_wait 仍然可能返回这个 fd,因为它还是"可读"的。

所以 LT 的容错相对高:一次没读完,后面还有机会继续处理。这也是为什么刚上手时先用 LT 更稳。


ET:更像"状态发生变化时提醒你一下"

ET 需要显式注册:

cpp 复制代码
ev.events = EPOLLIN | EPOLLET;

可以近似理解成:

text 复制代码
从"没数据"变成"有数据"
→ 通知一次

这里有个地方容易想岔: 不是说缓冲区里剩着数据就永远不通知了。如果之后又有新数据到达,还是会产生一次新的通知。区别在于,你不能指望 epoll 因为你"没读完"就主动再提醒一次------它提醒的是"发生了新事件",不是"你还有活没干完"。

所以 ET 的经典写法是:

text 复制代码
收到可读事件
 ↓
不停 read
 ↓
直到 read 返回 -1 且 errno == EAGAIN / EWOULDBLOCK

ET 下最危险的错误,就是收到一次事件只 read 一次,然后以为剩余数据以后还会自动再提醒。


LT 和 ET 的直接对比

LT(水平触发,默认) ET(边沿触发)
通知依据 条件成立就通知 状态发生变化才通知
一次没读完 下一轮还会再通知 不会再通知,除非有新事件
对阻塞 fd 的容忍度 相对高(但依然不推荐) 极低,必须非阻塞
代码复杂度 低,适合入门 高,读写都要 drain
是否需要读到 EAGAIN 不强制 强制
典型使用场景 连接数不大、追求稳妥 高并发、配合非阻塞 Reactor

为什么 ET 强烈要求非阻塞 fd

假设 ET 里这样写:

cpp 复制代码
while (true)
{
    int n = read(fd, buf, sizeof(buf));

    if (n > 0)
        handle(buf, n);
}

如果 fd 是阻塞的:

text 复制代码
第一次 read → 有数据
第二次 read → 还有数据
第三次 read → 暂时没数据 → 阻塞在这里

第三次直接把整个事件循环卡死了。

所以先设非阻塞:

cpp 复制代码
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);

然后配合循环读:

cpp 复制代码
while (true)
{
    char buf[4096];
    ssize_t n = read(fd, buf, sizeof(buf));

    if (n > 0)
    {
        handle(buf, n);
    }
    else if (n == 0)
    {
        // 对端关闭写方向,读到 EOF
        closeConnection(fd);
        break;
    }
    else
    {
        if (errno == EAGAIN || errno == EWOULDBLOCK)
        {
            // 当前数据已经读干净
            break;
        }

        if (errno == EINTR)
        {
            continue;
        }

        closeConnection(fd);
        break;
    }
}

这几个 errno 的含义要分清:

返回值 / errno 含义 ET 下该怎么做
n > 0 读到了 n 字节 处理,然后继续循环读
n == 0 对端关闭,读到 EOF 关闭连接,退出循环
n < 0EAGAIN / EWOULDBLOCK 当前非阻塞下无数据可读 正常退出循环,不是错误
n < 0EINTR 被信号打断 continue 重试
n < 0 且其他 真正的错误 关闭连接

在 ET 模式里,EAGAIN 不是"读取失败",它是在告诉你:当前可立即读取的数据已经被你处理干净了。

这和 Linux epoll(7) 文档强调的行为一致:使用 EPOLLET 时应该用非阻塞 fd,并且只在 read/write 返回 EAGAIN 之后再等待下一次事件。


监听 socket 在 ET 下也要循环 accept

很多人只记得客户端 fd 要读到 EAGAIN,却忘了监听 fd。

假设一瞬间有多个连接进入 backlog,而 ET 只通知一次。如果只写:

cpp 复制代码
int clientfd = accept(listenfd, ...);

拿一个就结束,队列里可能还剩连接。更合适的是:

cpp 复制代码
while (true)
{
    int clientfd = accept4(
        listenfd,
        nullptr,
        nullptr,
        SOCK_NONBLOCK
    );

    if (clientfd >= 0)
    {
        addToEpoll(clientfd);
        continue;
    }

    if (errno == EAGAIN || errno == EWOULDBLOCK)
        break;

    if (errno == EINTR)
        continue;

    break;
}

accept4 是 Linux 专有接口(glibc 2.10+,编译时需要 _GNU_SOURCE),好处是拿到的 fd 直接就是非阻塞的,省一次 fcntl

核心还是同一个原则:

把当前已经准备好的东西处理到"暂时没有"为止。

无论是 read 还是 accept,ET 下的处理方式是一样的。


四、写事件、缓冲区与协议处理

EPOLLOUT 为什么不能一直注册

这张图看的是发送缓冲区的状态:发送缓冲区有空闲空间时,socket 通常长期处于"可写"状态 ,这就是 EPOLLOUT 满天飞的原因。

很多初学代码给所有 clientfd 注册:

cpp 复制代码
EPOLLIN | EPOLLOUT

然后发现 epoll_wait 不停返回可写事件。原因是:TCP socket 只要发送缓冲区有空间,就基本一直是可写的。没数据要发却一直关注 EPOLLOUT,事件循环会被大量无意义通知占满,CPU 空转。

更常见的做法是:

  • 默认只关注 EPOLLIN
  • write/send 没写完、遇到 EAGAIN 时,把剩余数据放进发送缓冲区,再注册 EPOLLOUT
  • 可写时继续发,全部发完就取消 EPOLLOUT

EPOLLOUT 更适合按需开启,而不是所有连接永久监听。

顺带一提,ET 模式下 EPOLLOUT 的处理原则和读是对称的:可写之后也要一直写,直到写完或者 send 返回 EAGAIN,不能写一次就撒手。


非阻塞 write 为什么会"只写一部分"

假设要发 100KB:

cpp 复制代码
send(fd, data, 100 * 1024, 0);

返回值可能不是 100KB,比如返回 16384,意思是当前只成功写入了一部分。

剩下的数据不能直接丢,所以要给每个连接维护一个输出缓冲区:

cpp 复制代码
struct Connection
{
    int fd;
    std::string inBuffer;
    std::string outBuffer;
};

发送逻辑变成:

text 复制代码
能写多少写多少
 ↓
写不完
 ↓
剩余放 outBuffer
 ↓
关注 EPOLLOUT
 ↓
下次可写继续发

网络服务器的复杂度,基本就是从这里开始明显上升的。


epoll 只告诉你"可以 I/O",不会替你处理协议

假设 TCP 先收到:

text 复制代码
GET /api/u

下一次才收到:

text 复制代码
sers HTTP/1.1\r\n...

epoll 不知道这个 HTTP 请求有没有完整。它只知道:

socket 现在有数据可以读

所以"消息边界"这件事得应用层自己解决,还需要:

  • 输入缓冲区
  • 协议解析器
  • 消息边界判断(长度字段 / 分隔符 / 固定长度)
  • 业务处理
  • 输出缓冲区

这也是为什么真正的网络库都会维护一个 Connection 对象,而不是直接在事件循环里 read 完就 write


五、从 epoll 到 Reactor 与线程模型

从 epoll 到 Reactor

这张图的重点是分层:EventLoop 只管分发事件,业务处理在下面各层。

现在主循环已经变成:

text 复制代码
epoll_wait
 ↓
拿到事件
 ↓
根据 fd 找到对应对象
 ↓
根据事件类型调用对应处理函数

这已经非常接近 Reactor 了。可以把每个 fd 封装成一个 Channel

cpp 复制代码
class Channel
{
public:
    void handleEvent();

    void setReadCallback(std::function<void()> cb);
    void setWriteCallback(std::function<void()> cb);
    void setCloseCallback(std::function<void()> cb);

private:
    int _fd;
    uint32_t _events;
    std::function<void()> _readCallback;
    std::function<void()> _writeCallback;
    std::function<void()> _closeCallback;
};

epoll 返回:

text 复制代码
fd=10, EPOLLIN

EventLoop 找到:

text 复制代码
Channel(fd=10)

然后:

cpp 复制代码
channel->handleEvent();

再进入:

cpp 复制代码
_readCallback();

这样网络事件和具体业务就解耦了。

Reactor 不是 epoll 的另一个名字。epoll 是 Linux 提供的事件等待机制,Reactor 是程序如何围绕事件组织对象、回调和业务处理的一种架构方式。


一个简化 Reactor 结构

可以画成这样:

text 复制代码
                 ┌───────────────┐
                 │   EventLoop   │
                 │ epoll_wait()  │
                 └──────┬────────┘
                        ↓
                  Active Events
                        ↓
                 ┌──────┴──────┐
                 │   Channel   │
                 └──────┬──────┘
          ┌─────────────┼─────────────┐
          ↓             ↓             ↓
      onRead         onWrite       onClose
          ↓
      Connection
          ↓
      Protocol
          ↓
      Business

EventLoop 不需要知道:

  • 这是聊天业务
  • 这是 HTTP
  • 这是游戏服务器

它只负责事件分发。这是 Reactor 最核心的价值:把"什么时候处理"和"怎么处理"分开。


单 Reactor 单线程能不能做业务

可以,但取决于业务耗不耗时。

假设 EventLoop 收到请求后直接:

cpp 复制代码
queryBigDatabase();
runHeavyCalculation();

耗时 2 秒。这 2 秒里 EventLoop 无法及时处理其他 fd,所有连接的响应都会跟着抖。

所以 Reactor 最怕的是:

事件循环线程里做长时间阻塞任务

常见拆法

一种是把业务丢给线程池:

text 复制代码
I/O 线程
  ↓
解析请求
  ↓
投递到业务线程池
  ↓
业务完成
  ↓
把响应交回 I/O 线程
  ↓
发送

另一种是拆成多 Reactor:

text 复制代码
Main Reactor
负责 accept
    ↓
Sub Reactor 1
Sub Reactor 2
Sub Reactor 3
负责连接 I/O

具体选哪种要看业务规模,不是线程越多越高级。单 Reactor 单线程在很多内部服务里已经完全够用。


六、epoll 的边界、常见坑与连接状态机

epoll 并不意味着"所有场景都比 poll 快"

如果总共只有 5 个 fd,select/poll 和 epoll 的实际差距大概率不是项目瓶颈。

epoll 的优势更容易在这些条件下体现:

  • 大量连接
  • 活跃连接只占一小部分
  • 事件长期注册、反复等待

如果每轮几乎所有 fd 都活跃,那应用本来就要处理大量 I/O,事件通知本身只是总成本的一部分。

所以别把 epoll 神化成"用了就自动百万并发"。连接规模同时还受这些限制:

  • fd 数量限制(ulimit -n 和系统级上限)
  • 内存
  • socket 收发缓冲区大小
  • 网卡带宽和 PPS
  • 协议处理效率
  • 业务线程数
  • 数据库等下游依赖
  • 内核参数

想看当前 shell 的 fd 上限,可以直接查:

bash 复制代码
ulimit -n

几个非常容易写错的地方

这些都是写 epoll 时高频踩的坑,整理成表方便对照:

会发生什么 正确做法
只读 EPOLLIN 连接出错 / 对端异常断开时收不到通知 一并处理 EPOLLERREPOLLHUP
忽略 read() == 0 对端已关闭,连接却还留在 epoll 里空转 收到 0 就走关闭流程
EINTR 当错误 被信号打断就误关连接 判断 errno == EINTR 后重试
ET 不读到 EAGAIN 数据长期留在 socket buffer,却没有新的边沿通知 循环读到 EAGAIN 为止
在事件循环里做阻塞业务 一个慢查询让所有连接响应一起抖 业务丢线程池,I/O 线程只做 I/O
fd 关闭后对象没清理 悬垂指针、事件仍指向已关闭的 fd 从 epoll 和映射表里同步移除

EPOLLONESHOT 是干什么的

多线程同时处理同一个 fd 时,可能出现:

  • 线程 A 正在处理 fd=10
  • 线程 B 又拿到了 fd=10 的事件

EPOLLONESHOT 让事件触发一次后暂时失效,处理完再通过 epoll_ctl MOD 重新激活:

cpp 复制代码
ev.events = EPOLLIN | EPOLLONESHOT;

处理完重新武装:

cpp 复制代码
epoll_ctl(epfd, EPOLL_CTL_MOD, fd, &ev);

注意:用了 EPOLLONESHOT 之后如果忘记重新 MOD,这个 fd 就再也收不到事件了。

它不是所有程序都必须用,但在多线程 Reactor 设计里很有价值,常和 EPOLLET 一起出现。


accept 惊群是什么思路上的问题

如果多个线程/进程同时等待同一个监听 socket,一个新连接到来时,如何避免不必要地唤醒大量等待者,是高并发服务器必须考虑的问题。

Linux 内核 4.5 之后提供了 EPOLLEXCLUSIVE,用于多个 epoll 实例监听同一个 fd 的场景:有事件时只唤醒其中一个(文档措辞是 "one or more",并不严格保证只有一个)。

这里最重要的不是背具体 flag,而是理解一件事:

  • 并发越高,"唤醒谁"本身也会变成成本

所以事件系统除了"通知就绪",还要考虑并发调度。


为什么高并发服务器经常强调状态机

非阻塞 I/O 下,一个请求不会在一次函数调用里全部完成。例如:

  • 状态 1:读取请求头
  • 状态 2:读取 body
  • 状态 3:处理业务
  • 状态 4:准备响应
  • 状态 5:发送响应
  • 状态 6:等待继续写

一次 EPOLLIN 可能只推进一部分,一次 EPOLLOUT 又推进另一部分。所以每个 Connection 实际上都在维护自己的协议状态。

这也是事件驱动编程和传统"一个线程从头跑到尾"最大的思维差异之一:代码不再是一个线性流程,而是被切成一段段由事件驱动的状态转移。


七、把高并发网络模型完整串起来

从一个客户端到大量连接,模型是怎样一步步变的

把前面的演进收一下。

阻塞单线程

text 复制代码
accept → read → write

问题:一个连接阻塞,所有连接都等着。

一连接一线程

text 复制代码
accept
  ├→ thread A
  ├→ thread B
  └→ thread C

问题:大量长连接带来线程资源和调度成本。

select / poll

text 复制代码
一个线程等待多个 fd

问题:fd 多了以后仍要频繁传递集合、扫描全部 fd。

epoll

text 复制代码
注册关注 fd → 等待就绪事件 → 只处理返回的事件

Reactor

text 复制代码
EventLoop
 ↓
Event Demultiplexer (epoll)
 ↓
Channel / Handler
 ↓
Connection / Business

到这里,一条高并发网络服务器的基本主线就接起来了。


如果自己练 epoll,建议按什么顺序写

不要第一份代码就上"ET + 多线程 + Reactor + 定时器 + HTTP",那样很容易连 bug 在哪都分不清。

  1. 阻塞 echo server ------ 先把 socket 那一套跑通。
  2. select echo server ------ 真正体会"一个线程等多个 fd"是什么感觉。
  3. epoll LT ------ 先保证连接的建立、读取、关闭都正确。
  4. 全部 fd 改非阻塞 ------ 处理 EAGAINEINTR、部分写。
  5. 切 ET ------ 验证 readaccept 是不是都 drain 到了 EAGAIN
  6. 封装 Connection / Channel / EventLoop ------ 最后自然形成 Reactor。

这样每一步都知道自己为什么要改,而不是照着一份"最终版"抄完却不知道哪行是干嘛的。


总结

从阻塞 I/O 到 select/poll,再到 epoll,真正变化的是程序等待和管理大量 fd 的方式。

epoll 把"长期关注哪些 fd"和"这一次哪些 fd 已经就绪"拆开了,应用循环主要处理 epoll_wait 返回的事件,不再每轮从头扫描全部连接。

LT 和 ET 决定的是提醒方式:LT 在条件仍成立时会继续提醒,ET 更强调状态变化后的主动处理。

ET 不是"更高级就一定更快"的开关。用 ET 时,fd 通常要配合非阻塞,并把 accept/read/write 的可处理数据推进到 EAGAIN

再往上,Reactor 解决的是程序结构:EventLoop 等事件,Channel/Connection 保存连接状态,业务逻辑只处理已经被分发到自己的事件。

epoll 负责高效告诉你"谁现在有事",而非阻塞 I/O、缓冲区、状态机和对象生命周期,决定"这件事能不能被正确处理完"。

所以练 epoll 时,与其一直背接口,更值得反复检查三个细节:

  • 可读以后,是否读干净了
  • 可写以后,是否处理了部分写
  • 连接关闭后,状态和对象是否同步释放

如果你在写 epoll 的时候踩过别的坑,欢迎在评论区补充,我再整理进这篇里。

资源分享:

【Vue3 + TypeScript】接口报错别再到处 try/catch:从请求封装、错误分类到全局兜底,搭一套可维护的错误处理体系

【Linux】pthread_t 到底是什么?从线程控制块、线程栈到 clone,把 NPTL 一次追到底

【Linux】多线程打印为什么会乱?pthread 创建、等待、退出、取消与分离全实战

相关推荐
11路没有终点2 小时前
Docker 化测试环境:一致性交付
运维·docker·容器
风景的人生2 小时前
Linux虚拟机网络故障排查与解决方案
运维·网络·ssh
..Dauntless..2 小时前
【Linux】权限问题——拥有者、所属组和其他用户的协调
linux·运维·服务器
平行云2 小时前
实时云渲染信创架构解析:从GPU池化到全栈适配的技术演进
linux·unity·docker·ue5·webgl·数字孪生·实时云渲染
handler012 小时前
【Linux】信号:内核的“敲门声”
linux·运维·服务器·c++·c·信号·signal
T1mzhou2 小时前
ARM64 Linux 6.10内核启动流程6-psci.c和ATF/U-Boot 的电源接口
linux·服务器·c语言
lucybean013 小时前
食品加工行业污水处理曝气装置采购避坑与合规筛选指南
运维
harmony&3 小时前
DevOps进阶:SonarQube 代码审计与 Harbor 镜像仓库实战
运维·devops
Neighbor_OldY3 小时前
CSRF跨站请求伪造攻击检测与应急处置实战:伪造请求识别与防护落地
运维·安全·web安全