Skip to content
签到签到

第 3 章:Node.js 运行时、事件循环与后端异步

本章定位

本章不是完整 Node.js 后端教程,而是让前端工程师理解 Node.js 如何调度 I/O、流和子进程。AI 应用前端需要知道 SSE 服务、工具执行和会话日志背后的运行模型。

必须掌握清单

必须掌握

  • 能解释 libuv 和 Node.js Event Loop 的阶段。

    相关答案: Node.js 把定时器、部分文件与网络 I/O 等能力交给 libuv 协调,事件循环再按 timers、pending callbacks、poll、check、close callbacks 等阶段处理就绪回调。JavaScript 回调仍在主线程执行,因此异步 I/O 不等于回调可以并行运行。

    具体例子: 文件读取交给系统或线程池等待时,主线程还能处理其他连接;读取完成后回调进入队列,真正执行 JSON 解析的 JavaScript 仍会占用主线程。

  • 能区分 process.nextTicksetImmediate 和 Promise 微任务。

    相关答案: process.nextTick 使用 Node.js 专有队列,会在当前操作结束后优先于其他微任务继续清空;Promise 回调进入微任务队列;setImmediate 在事件循环的 check 阶段执行。递归安排 nextTick 会饿死 I/O。

    具体例子: 在 I/O 回调中同时注册三者,process.nextTick 通常最先,Promise 微任务随后,setImmediate 在 check 阶段运行;若正确性依赖偶然时序,应重构为显式的异步控制。

  • 能解释 Stream backpressure。

    相关答案: 背压是消费者速度低于生产者时的流量控制机制。写入流的 write() 返回 false 表示内部缓冲达到阈值,生产者应暂停并等待 drain,否则数据持续堆积会造成内存上涨。

    具体例子: 服务端把大文件写入 HTTP 响应时,res.write(chunk) 返回 false 后先停止读取上游,收到 drain 再继续;也可以使用自动传递背压的 pipeline()

  • 能区分 worker_threadschild_process

    相关答案: worker thread 在同一进程内提供独立 JavaScript 线程,可传输数据或使用共享内存,适合 CPU 密集计算;child process 拥有独立进程和内存边界,隔离更强,适合运行外部命令、不同运行时或高风险任务。

    具体例子: 图片像素计算可放进 worker;调用 Git、Python 或可能崩溃的第三方 CLI 更适合 child process,并设置超时、输出上限和退出码处理。

  • 能正确处理 SSE 连接断开、进程退出和错误传播。

    相关答案: SSE 是长连接,必须监听请求关闭并取消上游任务、释放心跳定时器;进程收到退出信号时应停止接收新请求并给存量连接有限的收尾时间。异步错误要沿调用链转换和记录,不能成为未处理拒绝。

    具体例子: 客户端关闭页面后,服务端在 request.close 中调用对应的 AbortController.abort() 并清理 interval;收到 SIGTERM 时先关闭监听端口,再结束或超时中断剩余任务。

原理讲解

Node.js Event Loop 由 timers、pending callbacks、poll、check 和 close callbacks 等阶段组成。process.nextTick 会在当前阶段结束后、进入下一阶段前优先执行,因此不能随意在循环中使用。

Stream 的 backpressure 解决生产者快于消费者的问题。Readable 流在消费者没有及时读取时会暂停,避免内存无限增长。SSE 服务端写响应时也要考虑客户端读取速度。

worker_threads 适合共享内存的 CPU 密集任务,child_process 适合隔离进程、执行外部命令和运行沙箱。工具执行如果可能崩溃,通常需要进程级隔离。

代码示例或模板

ts
import { createServer } from "node:http";

const server = createServer((req, res) => {
  res.writeHead(200, {
    "Content-Type": "text/event-stream",
    "Cache-Control": "no-cache",
    Connection: "keep-alive",
  });

  let index = 0;
  const timer = setInterval(() => {
    if (res.destroyed) {
      clearInterval(timer);
      return;
    }

    index += 1;
    res.write(`data: token-${index}\n\n`);

    if (index >= 10) {
      res.end();
      clearInterval(timer);
    }
  }, 200);

  req.on("close", () => {
    clearInterval(timer);
  });
});

server.listen(3000);

这个例子只演示 SSE 的最小结构,实际应用还需要处理鉴权、心跳、背压和错误边界。

AI Agent 概念对照

  • Node.js Event Loop 对应请求调度。
  • Stream 对应 LLM Provider 输出流。
  • child_process 对应工具执行和沙箱。
  • SSE 连接对应模型输出到前端的实时通道。

面试追问

1. process.nextTick 和 Promise 微任务谁先执行?

查看深度解析

标准回答: 在 Node.js 常规回调边界,process.nextTick 队列会先于 Promise 微任务队列清空。但顶层脚本与 ES Module、不同 Node 版本和嵌套调度会影响观测上下文,业务正确性不应依赖复杂的竞速顺序。

原理展开: nextTick 是 Node 专有的高优先级延后机制,不属于 libuv 事件循环阶段;递归加入 nextTick 会让 Promise、I/O 和定时器长期得不到机会。

工程示例: API 回调结束后用 nextTick 延后抛错可让调用方先安装监听器,但大批任务切片应使用 setImmediate 或 worker,而不是递归 nextTick。

常见误区: 把 nextTick 说成“下一轮事件循环”,实际上它通常在进入下一阶段前执行。

继续追问: setImmediatesetTimeout(0) 谁先?

回答方向: 在 I/O 回调内 setImmediate 通常先进入 check 阶段;顶层脚本中受计时和轮次影响,没有通用的绝对顺序保证。

2. 什么时候用 worker_threads,什么时候用 child_process

查看深度解析

标准回答: CPU 密集、希望低成本传输或共享内存时选 worker threads;需要运行外部程序、不同 Node 版本、独立崩溃与权限边界时选 child process。二者都不应替代普通异步 I/O。

原理展开: worker 有独立 V8 isolate 但共享进程资源,隔离失败影响面更近;子进程有独立地址空间和退出码,通信与启动成本更高。

工程示例: AST 大批量分析放 worker pool,执行用户选择的 Git 命令放受限 child process,并设置工作目录、环境白名单、超时和输出上限。

常见误区: 把网络请求放 worker 以为会更快,或把 child process 当成天然安全沙箱。

继续追问: 如何选择 worker 数量?

回答方向: 以可用 CPU、任务占用和服务延迟目标压测,不机械等于核心数;保留主线程和系统余量,并限制队列长度。

3. Stream backpressure 是什么?

查看深度解析

标准回答: 背压是消费者跟不上生产者时的流量反馈。Node Writable 的 write() 返回 false 表示内部缓冲达到阈值,生产者应暂停,等待 drain 再继续,避免内存无限增长。

原理展开: highWaterMark 是触发背压的缓冲阈值,不是严格内存上限;pipeline() 能在流之间传播背压并统一处理错误和关闭。

工程示例: 读取大文件并写 HTTP 响应时使用 pipeline;手写循环则在 res.write 返回 false 后暂停读取,收到 drain 恢复。

常见误区: 忽略 write() 返回值,或认为调用 pause() 就自动清理了所有上游资源。

继续追问: SSE 也要处理背压吗?

回答方向: 要。慢客户端会积压响应缓冲,应观察 write 返回值、限制单连接缓存,并在超时或断开时取消上游生成。

4. 一个子进程在 Agent 工具执行中崩溃,如何可靠回传失败?

查看深度解析

标准回答: 为每次工具调用分配 call ID,监听子进程 errorexitclose 和 IPC 结果,使用一次性完成器确保只结算一次;异常退出转换为结构化 ToolError,包含退出码、信号、阶段和截断后的 stderr。

原理展开: spawn 失败和启动后崩溃是不同路径;IPC 消息到达与 close 可能竞速,因此状态机必须幂等,并在结束时清理监听器、超时器和残留进程。

工程示例: 30 秒超时先发送终止信号,宽限后强制结束;无论超时、signal 还是非零 exit,都向 Session Log 追加一个确定的 tool_failed 事件。

常见误区: 只监听 exit,丢失 spawn error;或把完整 stderr、环境变量直接展示给模型和用户,造成泄密。

继续追问: 进程退出前已经产生部分结果怎么办?

回答方向: 协议中区分流式进度与最终提交;未收到带校验的完成消息就不能标记成功,部分结果按工具语义决定丢弃或标注不完整。

5. SSE 客户端断开后,服务端如何清理定时器和连接?

查看深度解析

标准回答: 监听请求或响应的关闭事件,在统一 cleanup 中清理心跳定时器、取消上游 AbortController、移除订阅并结束 reader;cleanup 必须幂等,覆盖正常结束、客户端断开、错误和服务关闭。

原理展开: 仅停止 res.write 不会自动取消模型请求或消息订阅。连接所有权要从 HTTP 层传到底层任务,否则会产生幽灵计算和内存泄漏。

工程示例: 每个连接保存 heartbeatId、subscription disposer 和 controller;req.on('close', cleanup) 后,模型流的 finally 也调用同一 cleanup。

常见误区: 只监听 finish;客户端异常断开时响应未必以正常完成路径结束。

继续追问: 服务进程收到 SIGTERM 怎么办?

回答方向: 停止接受新连接,通知或关闭现有 SSE,给任务有限宽限期,随后取消剩余任务并等待日志落盘后退出。

实践任务

实现一个最小 Node.js SSE 服务,输出模拟 token 流,并处理客户端断开、服务端超时和进程退出。

验收标准

  • 客户端断开后定时器会被清理。
  • 连续输出不会导致内存持续增长。
  • 能解释错误应该在哪里捕获。

关联材料