Read OSS

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 分别对它们进行了封装:

Handlesuv_handle_t)是长生命周期对象,在其存续期间可以多次触发事件。TCP 服务器、TCP 连接、定时器、文件系统监视器和信号处理器都属于 handle。只要 handle 处于被引用状态,事件循环就会持续运行。

Requestsuv_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,再往上是 HandleWrapAsyncWrap,最终是 BaseObject。每一层都扩展了特定的功能:

  • BaseObject:将 C++ 对象与 JavaScript socket 对象关联起来
  • AsyncWrap:提供用于 async_hooks 追踪的 async_id
  • HandleWrap:管理 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() 使用一个 FixedQueueTickInfo 共享状态(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 中通过宏定义列出了所有异步资源类型:TCPWRAPFSREQCALLBACKGETADDRINFOREQWRAPHTTP2SESSION 等数十种。每种类型都对应一个唯一的枚举值,供 async_hooks 的使用方按需过滤事件。

executionAsyncId()triggerAsyncId() 函数暴露了异步上下文链,使 AsyncLocalStorage 等工具能够在不显式传递参数的情况下,跨异步边界传播请求级别的数据。这同样构建在 AsyncWrap 基础设施之上——AsyncLocalStorageasync_id 为键存储数据,并通过 init 钩子的 triggerAsyncId 链进行传播。

提示: async_hooks 存在可测量的性能开销。在生产环境中,建议优先使用 AsyncLocalStorage(已针对常见场景做了优化),而非直接使用原始的 async_hooks。如果确实需要诊断钩子,可以考虑 diagnostics_channel API,它以发布/订阅模式提供更低的开销。

下一步

至此,我们已经梳理了完整的 I/O 路径:从 libuv 事件,经过 C++ Wrap 层,到达 JavaScript 流,再返回。在本系列的最后一篇文章中,我们将探讨 Node.js 的横切关注点——权限模型、错误系统、Web Platform API 集成、V8 快照、单文件可执行程序,以及内置测试运行器。