同步/异步说的是数据由谁拷贝,阻塞/非阻塞说的是调用后线程是否被挂起,这两件事经常被混成一句「异步非阻塞」。
一个 Node 服务挂着两万条空闲 WebSocket 连接,内存占用不高、CPU 几乎为 0,靠的就是 epoll 不用为每条连接开线程。同一台机器上如果你的代码写了 readFileSync,两万并发会瞬间把事件循环堵死。差别不在语言,在 IO 模型。
网络 IO 分两步:等数据就绪(从网卡到内核缓冲区)和把数据从内核拷到用户态。五种模型的区别就在于这两步由谁等、谁拷。
| 模型 | 等数据阶段 | 拷贝阶段 | 典型接口 |
|---|---|---|---|
| 阻塞 BIO | 线程挂起等待 | 阻塞 | read、accept |
| 非阻塞 NIO | 立即返回 EAGAIN,需轮询 | 阻塞 | read + O_NONBLOCK |
| IO 多路复用 | 阻塞在 select/epoll 上等一批 fd | 阻塞 | select、poll、epoll、kqueue |
| 信号驱动 | 内核就绪后发 SIGIO | 阻塞 | fcntl + O_ASYNC |
| 异步 AIO | 内核全程负责 | 内核拷贝完才通知 | io_uring、IOCP |
POSIX 定义的「异步 IO」只有最后一种。前四种在数据拷贝阶段都会阻塞调用线程,所以都算同步 IO —— 包括被前端奉为圭臬的 epoll。
组合起来有四种:同步阻塞(默认 read)、同步非阻塞(轮询)、同步非阻塞但用多路复用等待(epoll,本质仍是同步)、异步非阻塞(io_uring)。Node 里 fs.readFile 的回调风格常被说成「异步」,但 Linux 上它是线程池里的同步阻塞调用,只是对 JS 主线程表现为异步。
| 维度 | select | poll | epoll |
|---|---|---|---|
| 数据结构 | fd_set 位图 | pollfd 数组 | 内核红黑树 + 就绪链表 |
| fd 上限 | 1024(FD_SETSIZE) | 无硬上限 | 无硬上限 |
| 每次调用 | 全量 fd 从用户态拷进内核,O(n) 遍历 | 同样 O(n) | 只需 epoll_ctl 注册一次,epoll_wait 取就绪链表 |
| 返回后 | 要自己遍历找出哪个就绪 | 同左 | 直接拿到就绪的 fd 列表 |
| 复杂度 | O(n) | O(n) | O(1)(相对就绪数) |
epoll 快在哪:
epoll_create 在内核建一个 eventpoll 对象,里面一棵红黑树存所有注册的 fd,增删改是 O(log n)epitem 挂到就绪链表 rdllistepoll_wait 只看这条链表,有多少就绪返回多少,不需要遍历全量 fd,也不用每次把集合拷进内核所谓 O(1),准确说是「与总连接数无关,只与就绪数有关」。连接都很活跃时,epoll 和 select 的差距并不大。
epoll_wait 都会通知。写起来安全,忘了读也只会在下一轮再被提醒,代价是可能重复通知。read 到返回 EAGAIN 为止,否则剩下的数据不会再触发事件,连接就卡住了。Nginx 默认用 ET 减少唤醒次数;Redis 用的是 LT 风格,逻辑简单不易错。选 ET 是在拿实现复杂度换系统调用次数。
| 平台 | 机制 | 语义 |
|---|---|---|
| Linux | epoll | 就绪通知 |
| macOS / BSD | kqueue | 就绪通知 |
| Windows | IOCP | 完成通知(真异步) |
| Solaris | event ports | 就绪通知 |
libuv 在 uv__io_poll 里按平台编译不同实现,上层只认 uv_loop_t。注意 Windows 的 IOCP 是「完成端口」,回调时数据已经拷好,语义比 epoll 更接近真异步;Linux 上的普通文件读写则被扔进线程池,因为 epoll 管不了普通文件(它永远就绪)。
事件循环的六个阶段,本质是把 epoll 的等待点嵌进 poll 阶段:
| 阶段 | 做什么 | 对应接口 |
|---|---|---|
| timers | 执行到期的 setTimeout/setInterval | 最小堆 |
| pending callbacks | 上一轮延迟的 IO 错误回调 | — |
| idle / prepare | 内部使用 | — |
| poll | 阻塞在这里等 IO,执行就绪回调 | epoll_wait |
| check | 执行 setImmediate | — |
| close callbacks | socket.on('close') 之类 | — |
每个阶段之间会清空 process.nextTick 和微任务队列,这就是「微任务优先于下一阶段」的实现位置。
// 阻塞版:整个事件循环停在这,期间所有请求排队
import { readFileSync } from 'node:fs'
const t0 = performance.now()
const buf = readFileSync('./access.log') // 文件多大就占多大内存
console.log('sync', (performance.now() - t0).toFixed(1), 'ms', buf.length)
// 非阻塞版:交给 libuv 线程池,主线程立刻继续
import { readFile } from 'node:fs/promises'
readFile('./access.log').then((buf) => console.log('done', buf.length))
console.log('这一行先打印') // 一定先于 done
网络 IO 则是另一条路径,不需要线程池:
import net from 'node:net'
// 单线程处理上万连接:连接不占线程,epoll 告诉你哪个 fd 可读
const server = net.createServer((socket) => {
socket.on('data', (chunk) => socket.write(chunk))
})
server.listen(3000)
createServer 把 listening fd 注册进 epoll,epoll_wait 返回可读事件时才回调 connection。这一步如果换成阻塞的 accept 循环,并发数就直接等于线程数。
页面里写 fetch 时,DNS、TCP、TLS、HTTP 解析全在网络进程里完成,那里有自己的线程池和事件循环。渲染进程只拿到一个 Promise,网络进程通过 IPC 把响应体送过来再 resolve。
分层的好处:epoll/kqueue 的平台差异、连接池复用、HTTP/2 多路复用都被关在浏览器内部;坏处是你无法控制连接调度,max-connections-per-host(Chrome 是 6)这类限制只能靠域名分片或升级协议绕开。
很多人以为 epoll 一定比 select 快。 实际上连接数少且全部活跃时,两者都是 O(n) 且常数很小,epoll 的优势只在「大量连接里只有少量活跃」的场景,比如 IM、推送网关。
很多人以为 Node 的异步 IO 都是内核异步。 实际上 Linux 上普通文件读写是 libuv 线程池里的阻塞调用,只有网络走 epoll,真正的内核异步要等 io_uring(Node 尚未默认启用)。
同步/异步与阻塞/非阻塞的区别?
阻塞与否看调用后线程是否被挂起,同步与否看数据拷贝由谁完成。epoll 是同步非阻塞:等待阶段不占线程,但 read 拷贝数据时仍要自己阻塞完成。只有 io_uring、IOCP 这类拷贝也由内核完成后通知的才算异步 IO。
epoll 为什么高效?
内核用红黑树保存注册的 fd,只在 epoll_ctl 时增删一次;fd 就绪时由回调挂进就绪链表,epoll_wait 只取链表,开销与总连接数无关。select/poll 每次调用都要把整个集合拷进内核并线性扫描。
LT 和 ET 怎么选?
LT 有数据就重复通知,代码简单不易漏读;ET 只在状态变化时通知一次,必须非阻塞读直到 EAGAIN,能减少唤醒次数。高并发网关常用 ET,业务服务用 LT 更稳。