Node.js 中的 I/O:流、句柄与事件循环
前置知识
- ›第 1 篇:architecture-overview
- ›第 3 篇:cpp-object-model-and-bindings(BaseObject/Wrap 层次结构)
- ›了解 libuv 事件循环模型(handles 与 requests 的区别、uv_run 各阶段)
- ›具备从用户视角使用 Node.js streams API 的经验
Node.js 中的 I/O:流、句柄与事件循环
非阻塞 I/O 是 Node.js 存在的根本意义。每一个网络连接、文件操作、定时器和子进程,最终都流经同一套机制:C++ 层的 libuv 句柄与请求、JavaScript 层的流与事件发射器,以及第 3 篇中介绍的 Wrap 层次结构将两者串联在一起。本文将结合实际案例,展示这些部件如何协同工作——从 TCP 连接的完整生命周期,到精妙的定时器系统,再到驱动 process.nextTick() 的微任务队列。
libuv 集成:Handles 与 Requests
正如第 3 篇所述,libuv 有两种核心抽象,Node.js 分别对它们进行了封装:
Handles(uv_handle_t)是长生命周期对象,在其存续期间可以多次触发事件。TCP 服务器、TCP 连接、定时器、文件系统监视器和信号处理器都属于 handle。只要 handle 处于被引用状态,事件循环就会持续运行。
Requests(uv_req_t)是一次性操作。文件读取、DNS 解析、连接尝试——每次操作都会创建一个 request,分发出去,并在完成时收到唯一一次回调。
graph TD
subgraph "Handles — Long-lived"
TCP["uv_tcp_t<br/>TCP socket"]
TIMER["uv_timer_t<br/>Timer"]
PIPE["uv_pipe_t<br/>Unix pipe / Windows named pipe"]
FSE["uv_fs_event_t<br/>File system watcher"]
SIGNAL["uv_signal_t<br/>Signal handler"]
UDP["uv_udp_t<br/>UDP socket"]
end
subgraph "Requests — One-shot"
FSREQ["uv_fs_t<br/>File system operation"]
CONN["uv_connect_t<br/>Connection attempt"]
WRITE["uv_write_t<br/>Stream write"]
DNS["uv_getaddrinfo_t<br/>DNS lookup"]
WORK["uv_work_t<br/>Thread pool work"]
end
subgraph "Event Loop"
LOOP["uv_run()<br/>Process events"]
end
TCP --> LOOP
TIMER --> LOOP
FSREQ --> LOOP
CONN --> LOOP
SpinEventLoopInternal() 中的事件循环会调用 uv_run(UV_RUN_DEFAULT),处理所有待处理的 I/O 事件。UV_RUN_DEFAULT 模式会阻塞,直到有事件需要处理,或者所有 handle/request 都不再活跃为止。每次 uv_run() 迭代之间,platform->DrainTasks(isolate) 会处理 V8 的后台任务,例如优化代码编译和垃圾回收收尾工作。
Wrap 层次结构的实际运作:TCP 连接生命周期
让我们追踪一个真实的 TCP 连接,看看第 3 篇介绍的 C++ Wrap 层次结构是如何运转的。当你调用 net.createServer() 并有客户端接入时:
sequenceDiagram
participant NET as lib/net.js
participant TW as TCPWrap (C++)
participant CW as ConnectionWrap
participant LSW as LibuvStreamWrap
participant HW as HandleWrap
participant UV as libuv
Note over NET: server.listen(port)
NET->>TW: new TCP(TCPConstants.SERVER)
TW->>HW: HandleWrap(env, object, &handle_)
HW->>UV: uv_tcp_init(loop, &handle_)
NET->>TW: bind(address, port)
NET->>TW: listen(backlog)
TW->>UV: uv_listen(&handle_, backlog, OnConnection)
Note over UV: Client connects
UV->>CW: OnConnection(handle, status)
CW->>TW: TCPWrap::Instantiate(env, parent, SOCKET)
CW->>UV: uv_accept(server_handle, &client_handle)
CW->>NET: MakeCallback(onconnection, client_wrap)
Note over NET: Data flows
NET->>LSW: ReadStart()
LSW->>UV: uv_read_start(handle, OnAlloc, OnRead)
UV->>LSW: OnRead(handle, nread, buf)
LSW->>NET: MakeCallback(onread, buffer)
TCPWrap 继承自 ConnectionWrap<TCPWrap, uv_tcp_t>,后者继承自 LibuvStreamWrap,再往上是 HandleWrap、AsyncWrap,最终是 BaseObject。每一层都扩展了特定的功能:
BaseObject:将 C++ 对象与 JavaScript socket 对象关联起来AsyncWrap:提供用于async_hooks追踪的async_idHandleWrap:管理 libuv handle 的生命周期(ref/unref/close)LibuvStreamWrap:实现ReadStart()/ReadStop()及写操作ConnectionWrap:处理OnConnection()和AfterConnect()回调TCPWrap:提供 TCP 专属方法,如bind()、listen()、connect()
StreamBase 值得单独说明——它是一个抽象接口,由 LibuvStreamWrap 实现,为 JavaScript 提供统一的流 API。libuv 流和 TLS 流都实现了 StreamBase,这正是 tls.TLSSocket 能够透明替换普通 net.Socket 的原因。
JavaScript 流架构
在 JavaScript 层,Node.js 流是构建在 EventEmitter 之上的状态机。四种流类型——Readable、Writable、Duplex 和 Transform——均位于 lib/internal/streams/ 目录下。
stateDiagram-v2
[*] --> Flowing: pipe() or resume()
[*] --> Paused: Initial state
Paused --> Flowing: resume() / pipe() / 'data' listener
Flowing --> Paused: pause()
Flowing --> Ended: push(null)
Paused --> Ended: push(null) + drain
Ended --> [*]
state Flowing {
[*] --> Reading
Reading --> Buffering: _read() returns data
Buffering --> Reading: Data consumed below hwm
Buffering --> Backpressure: Buffer > highWaterMark
Backpressure --> Reading: Data consumed
}
Readable 流有两种工作模式:流动模式(数据自动推送给消费者)和暂停模式(需要通过 read() 主动拉取数据)。highWaterMark 控制缓冲行为:当内部缓冲区超过该阈值时,push() 会返回 false,以此发出背压信号。
Writable 流有与之对应的状态机。关键方法是 write()——当内部缓冲区已满时返回 false,调用方应等待 'drain' 事件后再继续写入。
lib/internal/streams/pipeline.js 中的 pipeline() 工具函数负责处理流链路中复杂的错误传播与资源清理逻辑,是连接多个流的推荐方式,优于直接使用 .pipe()。
提示: 生产代码中请始终使用
pipeline()而非.pipe()。pipeline()能正确处理整个链路的错误和清理工作,而.pipe()在发生错误时不会自动清理资源,容易导致资源泄漏。
定时器系统:链表与单个 libuv 定时器
lib/internal/timers.js 中的定时器实现包含了代码库里最精彩的 ASCII 艺术注释之一。其设计思路十分巧妙:Node.js 并不为每次 setTimeout() 调用创建一个 libuv 定时器(当有数千个活跃定时器时开销极大),而是按延迟时长对定时器进行分组。
graph TD
subgraph "Timer Architecture"
MAP["PriorityQueue + Object Map<br/>Keys: durations in ms"]
MAP --> L40["TimersList {duration: 40ms}"]
MAP --> L320["TimersList {duration: 320ms}"]
MAP --> L1000["TimersList {duration: 1000ms}"]
L40 --> T1["Timer A<br/>_onTimeout: cb1"]
T1 --> T2["Timer B<br/>_onTimeout: cb2"]
T2 --> T3["Timer C<br/>_onTimeout: cb3"]
L1000 --> T4["Timer D<br/>_onTimeout: cb4"]
T4 --> T5["Timer E<br/>_onTimeout: cb5"]
end
UV_TIMER["Single libuv timer<br/>Set to earliest expiry"] --> MAP
每个时长桶是一个双向链表(TimersList)。添加定时器的时间复杂度为 O(1)——只需追加到对应时长的链表末尾。删除也是 O(1)——从双向链表中解链即可。定时器触发时,只需检查相关链表的头部节点,因为链表中所有定时器的时长相同,且按插入时间先后排列。
一个优先队列(二叉堆)用于追踪下一个到期的时长桶。系统只维护一个 libuv uv_timer_t,设置为最早的到期时间。触发后,Node.js 处理所有时长桶中已到期的定时器,然后将 libuv 定时器重置为下一个到期时间。
这一设计使 Node.js 能够高效管理数十万个活跃定时器——在 HTTP 服务器中,每个连接都有空闲超时,这种场景极为常见。
微任务、nextTick 与 setImmediate
process.nextTick()、V8 微任务(Promise)与 setImmediate() 的执行时机关系,是 Node.js 中最常被问到的话题之一。它们分别在事件循环的不同阶段执行:
flowchart TD
UV["uv_run() iteration"] --> TIMERS["1. Timers phase<br/>setTimeout / setInterval"]
TIMERS --> NT1["⚡ nextTick queue + microtasks"]
NT1 --> PENDING["2. Pending I/O callbacks"]
PENDING --> NT2["⚡ nextTick queue + microtasks"]
NT2 --> POLL["3. Poll phase<br/>I/O events"]
POLL --> NT3["⚡ nextTick queue + microtasks"]
NT3 --> CHECK["4. Check phase<br/>setImmediate callbacks"]
CHECK --> NT4["⚡ nextTick queue + microtasks"]
NT4 --> CLOSE["5. Close callbacks"]
CLOSE --> NT5["⚡ nextTick queue + microtasks"]
NT5 --> UV
process.nextTick() 使用一个 FixedQueue 和 TickInfo 共享状态(env.h 中的 AliasedFloat64Array,可在 C++ 和 JavaScript 之间直接访问,无需跨越边界)。kHasTickScheduled 标志位通知 C++ 层需要清空 nextTick 队列。
关键在于:nextTick 和微任务会在事件循环每个阶段之间执行,而不是每轮迭代只执行一次。这意味着 process.nextTick() 的回调会在任何 I/O 之前执行——如果使用不当,可能会饿死 I/O,需要格外小心。
setImmediate() 在 "check" 阶段运行,位于 "poll" 阶段之后。因此 setImmediate() 的回调会在 I/O 事件处理完毕后才执行,适合用于延迟工作而不阻塞 I/O。
async_hooks 与 AsyncWrap 追踪
Node.js 中所有异步操作都流经 AsyncWrap(见第 3 篇),这为 async_hooks API 提供了基础支撑。追踪机制通过四个生命周期事件实现:
sequenceDiagram
participant AH as async_hooks
participant AW as AsyncWrap (C++)
participant UV as libuv
Note over AW: Creating a new TCP connection
AW->>AH: init(asyncId, type, triggerAsyncId, resource)
Note over AH: Track: "TCPWrap #7 triggered by #3"
Note over UV: Connection callback fires
AW->>AH: before(asyncId)
Note over AH: Set execution context to #7
AW->>AW: MakeCallback(onconnection)
AW->>AH: after(asyncId)
Note over AH: Restore previous context
Note over AW: Socket closed
AW->>AH: destroy(asyncId)
Note over AH: Cleanup tracking for #7
Provider 类型在 src/async_wrap.h 中通过宏定义列出了所有异步资源类型:TCPWRAP、FSREQCALLBACK、GETADDRINFOREQWRAP、HTTP2SESSION 等数十种。每种类型都对应一个唯一的枚举值,供 async_hooks 的使用方按需过滤事件。
executionAsyncId() 和 triggerAsyncId() 函数暴露了异步上下文链,使 AsyncLocalStorage 等工具能够在不显式传递参数的情况下,跨异步边界传播请求级别的数据。这同样构建在 AsyncWrap 基础设施之上——AsyncLocalStorage 以 async_id 为键存储数据,并通过 init 钩子的 triggerAsyncId 链进行传播。
提示:
async_hooks存在可测量的性能开销。在生产环境中,建议优先使用AsyncLocalStorage(已针对常见场景做了优化),而非直接使用原始的async_hooks。如果确实需要诊断钩子,可以考虑diagnostics_channelAPI,它以发布/订阅模式提供更低的开销。
下一步
至此,我们已经梳理了完整的 I/O 路径:从 libuv 事件,经过 C++ Wrap 层,到达 JavaScript 流,再返回。在本系列的最后一篇文章中,我们将探讨 Node.js 的横切关注点——权限模型、错误系统、Web Platform API 集成、V8 快照、单文件可执行程序,以及内置测试运行器。