Files
cs-note/hhs/NETWORK/06-Socket编程/02-epoll深度解析ETvsLT.md
2026-05-24 11:42:38 +08:00

11 KiB
Raw Permalink Blame History

tags, create time
tags create time
计算机网络
epoll
select
poll
ET模式
LT模式
I/O多路复用
2026-05-18 04:35

I/O 多路复用:select → poll → epoll 深度解析

概述

I/O 多路复用是让一个线程管理成千上万个 Socket 连接的核心技术。它经历了 select → poll → epoll 三阶段进化,每一代的核心理念都在解决上一代的瓶颈。理解它们的区别,是在高并发场景下做出正确选型的前提。

I/O 模型全景图

flowchart LR
    BIO["Blocking I/O<br/>阻塞在读/写系统调用<br/>每连接一个线程"] -->|"问题:"| Scalability["扩展性差<br/>万级连接就需要万级线程"]
    
    AIO["Non-blocking +<br/>I/O 多路复用<br/>单线程管理万级 FD"] -->|"优势:"| HighConc["高并发 ✅"]
    
    AsyncIO["真正的 Async I/O<br/>aio / io_uring<br/>内核完成通知"] -->|"终极形态:"| ZeroWait["零等待 ✅✅"]
    
    style BIO fill:#FF6B6B,color:#fff
    style AIO fill:#98FB98,color:#000
    style AsyncIO fill:#B0C4DE,color:#000
模型 说明 典型框架
Blocking (BIO) read()/write() 阻塞,每连接一线程 Python threading, PHP-FPM
Non-blocking + 多路复用 注册多个 FD,等待就绪事件 Nginx, Node.js, Go runtime
Async I/O (aio/io_uring) 内核完成 I/O 后回调通知 Linux io_uring, Windows IOCP
Signal-driven (SIGIO) 数据就绪时 SIGIO 信号通知 较少使用

[!NOTE] Go 的独特路线 Go 选择了第三条路:goroutine + epoll 后台轮询。每个请求一个 goroutine(看起来像 BIO),但底层由少量 OS 线程通过 epoll 调度。这被称为 "M:N 调度" —— M 个 goroutine 映射到 N 个 OS 线程(通常 N=GOMAXPROCS)。

select —— 第一代多路复用

C API 原型

int select(int nfds, fd_set *readfds, fd_set *writefds,
           fd_set *exceptfds, struct timeval *timeout);
// 返回值:就绪的 fd 总数;-1 = 错误

核心缺陷

┌─────────────────────────────────────┐
│ select 三大缺陷                      │
├─────────────────────────────────────┤
│ 1. FD_SETSIZE 固定 1024             │
│    必须 recompile 内核才能扩大       │
│                                     │
│ 2. O(n) 线性扫描                    │
│    每次调用都要遍历全部注册的 fd     │
│                                     │
│ 3. 用户态 ↔ 内核态拷贝整个 fd_set   │
│    n=10000 时每次拷贝 12.5KB        │
└─────────────────────────────────────┘
// Go 中几乎不会直接用 select 做多路复用
// Go 的 select 关键字是语言级语法糖,底层走的是自己的 netpoller
select {
case <-ch1:
    // channel ch1 可读
case <-ch2:
    // channel ch2 可读
default:
    // 都没有就绪
}

poll —— 第二代改进

C API 原型

struct pollfd {
    int fd;         /* file descriptor */
    short events;   /* 关注的事件: POLLIN/POLLOUT/POLLERR */
    short revents;  /* 返回的实际事件 */
};
int poll(struct pollfd *fds, nfds_t nfds, int timeout);

改进了什么?

✅ 不再有固定的 FD_SETSIZE 上限(改用动态数组)
❌ 仍然是 O(n) 线性扫描
❌ 每次仍需将整个 fds 数组拷贝到内核
❌ 返回时需要逐个检查 revents 位

结论:适合中等规模(几百连接),不适合万级

poll 事件类型速查

宏 数值 含义
POLLIN 0x001 有数据可读
POLLRDNORM 0x040 正常可读(等效 POLLIN)
POLLOUT 0x002 可写
POLLERR 0x008 错误条件
POLLHUP 0x010 挂起(对端关闭)
POLLNVAL 0x020 无效请求(fd 未打开)

epoll —— Linux 王者方案

三大系统调用

// 1. 创建 epoll 实例
int epfd = epoll_create(1);   // size 参数已被忽略,传 1 即可

// 2. 增删改 fd 的事件注册
int epoll_ctl(epfd, EPOLLADD, fd, &event);  // 添加
int epoll_ctl(epfd, EPOLLMOD, fd, &event);  // 修改
int epoll_ctl(epfd, EPOLLDEL, fd, NULL);    // 删除

// 3. 等待就绪事件(只返回活跃的 fd!)
int ready = epoll_wait(epfd, events, maxevents, timeout);
// 返回就绪数量,events 数组被填充

内核数据结构 —— O(1) 的秘密

flowchart TD
    subgraph "内核空间 (Kernel)"
        RBTree["红黑树 rb_root<br/>管理所有注册的 fd<br/>O(log n) 增删查"]
        
        CallbackList["就绪链表 ready_list<br/>只放真正活跃的 fd<br/>O(1) 插入"]
        
        DeviceCallback["中断回调函数<br/>当网卡收到数据包时<br/>自动将 fd 加入 ready_list"]
    end
    
    subgraph "用户空间 (User)"
        App["用户应用<br/>epoll_wait()"] -->|"仅复制就绪项"| EventArray["epoll_event events[]<br/>只包含就绪 fd"]
    end
    
    DeviceCallback -.->|"fd 有数据时触发"| CallbackList
    
    style RBTree fill:#DDA0DD,color:#000
    style CallbackList fill:#98FB98,color:#000
    style App fill:#FFD700,color:#000
┌──────────────────────────────────────────┐
│ epoll 三大优势                              │
├──────────────────────────────────────────┤
│ ✅ O(1) 时间复杂度                       │
│    就绪事件通过回调链表直接给出             │
│                                          │
│ ✅ 内存拷贝极小                           │
│    只拷贝就绪事件的个数 × sizeof(event)   │
│                                          │
│ ✅ fd 无上限                              │
│    只受系统可用文件描述符数限制 (~百万级)   │
│                                          │
│ ❌ Linux 专属                             │
│    macOS/BSD 用 kqueue, Windows 用 IOCP   │
└──────────────────────────────────────────┘

ET vs LT 模式深度对比

epoll 支持两种触发模式,这是面试和生产中最常涉及的话题。

LT(Level Triggered)—— 默认模式

📥 fd 上有数据到达
  ↓
epoll_wait() 返回 ✅ 通知一次
  ↓
应用只读了部分数据(还剩一半)
  ↓
下次 epoll_wait() 仍然返回 ✅ 继续通知
  ↓
一直到该 fd 上的数据全部读完毕才停止
// LT 模式下可以安全地只读一部分
n = read(fd, buf, sizeof(buf));  // 可能还有剩余数据
if (n > 0) handle(buf, n);
// 下次 epoll_wait 还会提醒你有数据没读完

[!tip] LT 类比 LT 就像消息通知——只要你没读完,消息就一直亮着未读红点。

ET(Edge Triggered)—— 边缘触发

📥 fd 状态从「无数据」变为「有数据」 ← 仅在此刻通知!
  ↓
epoll_wait() 返回 ✅
  ↓
你必须一次性读完所有数据!⚠️
  ↓
如果有剩余?下次不通知了!🚨
// ET 模式的标准 read 循环模板
while (1) {
    n = read(fd, buf, sizeof(buf));
    if (n < 0) {
        if (errno == EAGAIN || errno == EWOULDBLOCK) {
            break; // 数据已全部读完,退出循环
        }
        perror("read error");
        close(fd);
        break;
    } else if (n == 0) {
        close(fd);  // EOF = 对端关闭
        break;
    }
    // 处理 n 字节数据
}

[!warning] ET 模式的常见陷阱

  1. 必须配合 non-blocking fd —— blocking 模式下 read 会在无数据时永久阻塞,破坏 ET 语义
  2. 必须用 while 循环读到底 —— read() 返回 EAGAIN 前一直读
  3. write 也要同理 —— 写缓冲区满时要重新注册 POLLOUT
┌────────────────────────────────────────────┐
│ ET vs LT 决策表                              │
├────────────────┬────────────────┬───────────┤
│ 特性            │ LT             │ ET        │
├────────────────┼────────────────┼───────────┤
│ 默认模式         │ ✅ 是           │ ❌ 否      │
│ 编程难度         │ 简单             │ 较复杂     │
│ 性能            │ 略低             │ 更高 ⭐    │
│ 误通知          │ 无(有数据就通知)│ 无          │
│ 遗漏风险         │ 低              │ 高(漏读即丢失)│
│ 推荐场景         │ 大多数场景       │ 极致性能追求 │
└────────────────┴────────────────┴───────────┘

Go 的 netpoller 使用哪种模式?

Linux:  epoll 的 ET 模式
macOS:  kqueue 的边缘触发语义
Windows: IOCP 本身就是边缘触发

Go 的 net 包在底层做了大量封装,你不需要关心 ET/LT 的区别。但当你在 Go 中写自定义网络框架(如 RPC 框架、游戏服务器)时,就会直面这个问题。

实战:最小可用的 epoll 框架

package main

import (
    "fmt"
    "golang.org/x/sys/unix"
    "time"
)

func main() {
    // 1. 创建 epoll 实例
    epfd, _ := unix.EpollCreate1(0)
    defer unix.Close(epfd)

    // 2. 打开文件并注册 EPOLLIN
    fd, _ := unix.Open("/tmp/test.txt", unix.O_RDONLY|unix.O_NONBLOCK, 0)
    ev := unix.EpollEvent{Events: unix.EPOLLIN, Fd: int32(fd)}
    unix.EpollCtl(epfd, unix.EPOLL_CTL_ADD, fd, &ev)

    // 3. 等待就绪
    events := make([]unix.EpollEvent, 128)
    ready, _ := unix.EpollWait(epfd, events, 5000) // 5s timeout
    for i := 0; i < ready; i++ {
        fmt.Printf("fd %d is ready!\n", events[i].Fd)
    }
}

[!tip] 实际项目建议 除非有特殊需求,否则直接使用 Go 的 net、bufio、http 包即可。epoll 的细节已被这些成熟库封装好了。

关联笔记