编程进阶网 编程进阶网
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接

杨充

专注编程 · 终身学习者
首页
  • 计算机原理
  • 操作系统
  • 网络协议
  • 数据库原理
  • 面向对象
  • 设计原则
  • 设计模式
  • 系统架构
  • 性能优化
  • 编程原理
  • 方案设计
  • 稳定可靠
  • 工程运维
  • 基础认知
  • 线性结构
  • 树与哈希
  • 工业级实现
  • 算法思想
  • 实战与综合
  • 算法题考核
  • C语言入门
  • C综合案例
  • C专栏博客
  • C标准集库
  • C++入门教程
  • C++综合案例
  • C++专栏博客
  • C++开发技巧
  • Java入门教程
  • Java综合案例
  • Java专栏博客
  • Go入门教程
  • Go综合案例
  • Go专栏博客
  • Go开发技巧
  • JavaScript入门
  • JavaScript高级
  • Android库解读
  • Android专栏
  • Android智能硬件
  • iOS ObjC入门
  • iOS Swift入门
  • iOS入门精通
  • Web之Html手册
  • Web之TypeScript
  • Web之Vue高级进阶
  • Linux之QML入门
  • Linux之QT核心库
  • Linux实践开发
  • Python教程
  • Shell&Bash教程
  • 工具脚本
  • 自动化脚本
  • 质量保障
  • 产品思考
  • 软实力
  • 开发流程
  • Git应用
  • 技术模版
  • 技术规范
  • Markdown
  • Mermaid
  • 开源协议
  • JSON工具
  • 文本工具
  • 图片处理
  • 文档转化
  • 代码压缩
  • 关于我
  • 自我精进
  • 职场管理
  • 职场面试
  • 心情杂货
  • 友情链接
  • README
  • C语言入门精通

  • Cpp入门到精通

  • Java入门精通

  • Go入门到精通

  • JavaScript入门

    • 基础入门

    • 综合案例

    • 专栏博客

      • README
      • 引擎解析编译执行
      • 隐藏类与回收机制
      • 类型隐式转换精算
      • 作用域链闭包原理
      • 函数绑定规则组合
      • 原型链语法糖本质
      • 代理与元编程协议
      • 事件循环承诺机制
      • 工作线程并发调度
      • 页面渲染像素原理
      • 网络接口存储架构
      • 服务端运行时编程
        • 1. 案例与疑问引入
          • 1.1 一个静默 OOM
          • 1.2 顺藤摸到根因
          • 1.3 我们要回答什么
        • 2. 架构全景概览
          • 2.1 Node.js
          • 2.2 单线程 JS
          • 2.3 为什么不是"浏览
        • 3. libuv 事件
          • 3.1 timers
          • 3.2 每阶段的职责与回
          • 3.3 poll 阶段的
          • 3.4 浏览器 vs N
        • 4. nextTick
          • 4.1 nextTick
          • 4.2 Promise.
          • 4.3 nextTick
          • 4.4 完整优先级图谱
        • 5. Stream流
          • 5.1 Readable
          • 5.2 Writable
          • 5.3 Duplex
          • 5.4 objectMo
        • 6. 背压机制详解
          • 6.1 write()
          • 6.2 drain 事件
          • 6.3 pipe()
          • 6.4 pipeline
        • 7. Buffer本质
          • 7.1 Uint8Arr
          • 7.2 Buffer.a
          • 7.3 与浏览器 Typ
        • 8. Cluster
          • 8.1 Cluster
          • 8.2 Worker
          • 8.3 child_pr
        • 9. 同构代码设计与双
          • 9.1 Node 18
          • 9.2 SSR 的双端渲
          • 9.3 最常见的 5 个
        • 10. 综合案例串讲
          • 10.1 案例真相揭晓
          • 10.2 用 Stream
          • 10.3 设计哲学回扣
          • 10.4 速查表
      • 模块系统双轨操作
      • 现代工程链三件套
      • 设计模式函数哲学
      • 跨端架构终局总结
  • CodeX
  • JavaScript入门
  • 专栏博客
杨充
2026-06-11
目录

服务端运行时编程

# 12.Node.js 运行时与流式编程

📍 上接第 11 篇《Web API 网络与存储架构》。浏览器端的数据流已打通。本文进入 JS 的另一主场——Node.js 服务端。libuv 的六阶事件循环和浏览器有什么不同?Stream 背压怎么破?Buffer 和 Uint8Array 到底什么关系?

# 目录介绍

  • 1. 案例与疑问引入
    • 1.1 一个静默 OOM
    • 1.2 顺藤摸到根因
    • 1.3 我们要回答什么
  • 2. 架构全景概览
    • 2.1 Node.js
    • 2.2 单线程 JS
    • 2.3 为什么不是"浏览
  • 3. libuv 事件
    • 3.1 timers
    • 3.2 每阶段的职责与回
    • 3.3 poll 阶段的
    • 3.4 浏览器 vs N
  • 4. nextTick
    • 4.1 nextTick
    • 4.2 Promise.
    • 4.3 nextTick
    • 4.4 完整优先级图谱
  • 5. Stream流
    • 5.1 Readable
    • 5.2 Writable
    • 5.3 Duplex
    • 5.4 objectMo
  • 6. 背压机制详解
    • 6.1 write()
    • 6.2 drain 事件
    • 6.3 pipe()
    • 6.4 pipeline
  • 7. Buffer本质
    • 7.1 Uint8Arr
    • 7.2 Buffer.a
    • 7.3 与浏览器 Typ
  • 8. Cluster
    • 8.1 Cluster
    • 8.2 Worker
    • 8.3 child_pr
  • 9. 同构代码设计与双
    • 9.1 Node 18
    • 9.2 SSR 的双端渲
    • 9.3 最常见的 5 个
  • 10. 综合案例串讲
    • 10.1 案例真相揭晓
    • 10.2 用 Stream
    • 10.3 设计哲学回扣
    • 10.4 速查表

# 1. 案例与疑问引入

# 1.1 一个静默 OOM

先看一段在生产环境跑了大半年的代码——一个日志转发服务,负责把上游服务的日志实时写入文件:

// log_forwarder.js —— 日志转发器
const fs = require('fs');
const net = require('net');

const writer = fs.createWriteStream('./forwarded.log', { flags: 'a' });
const server = net.createServer(socket => {
  socket.on('data', chunk => {
    writer.write(chunk);  // ← 直接写,不检查返回值
  });
});
server.listen(9999, () => console.log('Forwarder on :9999'));
1
2
3
4
5
6
7
8
9
10
11

现象:

  • 日常流量(几十 MB/h):稳定运行 6 个月
  • 双十一当天(上游瞬时 20 GB/h):进程 RSS 从 80 MB 飙到 1.8 GB,被 OOM Killer 杀掉
  • 日志文件里没有任何错误——不是代码显式抛了异常,是系统直接 SIGKILL

直觉怀疑:是不是 chunk 太大、内存里积了太多引用?加了 --max-old-space-size=512——一样崩,只是晚崩了 30 秒。

# 1.2 顺藤摸到根因

带着这条线往下挖:

  • 假设 1:是不是 socket.on('data') 触发了 GC 压力?——用 --trace-gc 看,minor GC 确实密集,但每次回收量正常,不是根因。
  • 假设 2:是不是 fs.WriteStream 自己缓冲了太多数据?——在 writer.write() 后打印 writer.writableLength,发现它从 0 一路涨到 1.2 GB,从来没降过。
  • 假设 3:为什么缓冲区只涨不降?——因为 write() 返回 false 时我们还在继续写。WriteStream 内部的缓冲区超过了 highWaterMark,但代码没停手——数据像洪水一样灌进去。
  • 假设 4:那为什么日常流量没问题?——因为日常流量下 write() 几乎总是返回 true,缓冲区来及写磁盘——"刚好够用"掩盖了"设计缺陷"。

这不是 fs 模块的 bug——这是**没有处理背压(backpressure)**的坑。

# 1.3 我们要回答什么

这一段事故里至少藏着 6 个原理点:

① libuv 事件循环:fs.write 的回调什么时候被执行?          → 第 3 章
② nextTick 优先级:为什么"递归 nextTick"能饿死整个循环?    → 第 4 章
③ Stream 背压:write()→false→drain 这条链怎么来的?        → 第 5~6 章
④ Buffer 本质:WriteStream 内部缓冲区到底是什么数据结构?   → 第 7 章
⑤ 多进程 / 多线程:Cluster 怎么分摊这个服务的负载?         → 第 8 章
⑥ 同构代码:如果你把这条逻辑写进 SSR 组件,两边有什么差异?  → 第 9 章
1
2
3
4
5
6

本篇路线:

架构总图(第 2 章)
   ↓
libuv 事件循环(第 3 章)──→ 解开"回调什么时候执行"
   ↓
nextTick vs Promise(第 4 章)──→ 解开"优先级怎么排的"
   ↓
Stream 四类型 + 背压(第 5~6 章)──→ 解开"write() 为什么返回 false"
   ↓
Buffer 本质(第 7 章)──→ 解开"Uint8Array 子类 + 内存池"
   ↓
Cluster / Worker Threads(第 8 章)──→ 解开"怎么利用多核"
   ↓
同构代码(第 9 章)──→ 解开"同一份代码怎么跑两边"
   ↓
综合案例(第 10 章)──→ 案例彻底剖开
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

📌 本篇定位:上接浏览器 API 篇、下接模块系统篇。读完本篇后,你能画出"从 JS 代码到 OS 系统调用"的完整数据流路线图——每一个 setTimeout、每一次 write()、每一块 Buffer,都知道它在哪、怎么活、怎么死。

# 2. 架构全景概览

# 2.1 Node.js

┌──────────────────────────────────────────────────────────┐
│                    Node.js 全景架构                         │
├──────────────────────────────────────────────────────────┤
│  ┌────────────────────────────────────────────────────┐  │
│  │                  JS 层(用户代码)                    │  │
│  │     fs.readFile / http.createServer / crypto.xxx    │  │
│  └──────────────────────┬─────────────────────────────┘  │
│                         │ Node.js C++ Binding (node_file.cc│
│                         │ / node_http_parser.cc / ...)     │
│  ┌──────────────────────▼─────────────────────────────┐  │
│  │  ┌─────────────┐  ┌──────────────┐                 │  │
│  │  │  V8 (JS 引擎) │  │ libuv (事件循环)│               │  │
│  │  │  + Ignition   │  │  + 线程池       │               │  │
│  │  │  + TurboFan   │  │  + epoll/kqueue │              │  │
│  │  │  + Orinoco GC │  │  + TCP/UDP/FS  │               │  │
│  │  └─────────────┘  └──────────────┘                 │  │
│  └────────────────────────────────────────────────────┘  │
│  ┌────────────────────────────────────────────────────┐  │
│  │    OpenSSL (TLS)  │  zlib  │  http-parser          │  │
│  │    c-ares (DNS)   │  nghttp2                       │  │
│  └────────────────────────────────────────────────────┘  │
│                         │                                 │
│  ┌──────────────────────▼─────────────────────────────┐  │
│  │         操作系统(Linux/macOS/Windows)               │  │
│  │    epoll / kqueue / IOCP  │  文件系统  │  网络栈      │  │
│  └────────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

每一层职责:

层级 组件 职责
JS 层 用户代码 / npm 包 业务逻辑
Binding 层 node_file.cc 等 C++ 胶水 把 JS 调用翻译成 C++ API
引擎层 V8 编译 + 执行 JS(Ignition 解释器 → TurboFan JIT)
事件循环层 libuv 跨平台异步 I/O 抽象(epoll/kqueue/IOCP 统一封装)
系统库层 OpenSSL / zlib 等 专项功能(TLS、压缩、HTTP 解析)
OS 层 内核 最终的系统调用

# 2.2 单线程 JS

疑惑:Node.js 说自己是"单线程",那一个耗时 5 秒的 fs.readFile 怎么没把整个服务卡住?

论证:

JS 代码确实在一个线程上执行(V8 的主线程)。但 fs.readFile 这类操作的实际 I/O 工作不是在主线程上完成的——libuv 把任务投递到了独立的 C++ 线程池(默认 4 个线程,可通过 UV_THREADPOOL_SIZE 调整)。

JS 主线程(V8)                  libuv 线程池(C++)
┌────────────────┐            ┌─────────────────────┐
│ fs.readFile()  │  ──投递──→ │ 线程 1: 读文件       │
│   ↓(不阻塞)   │            │ 线程 2: 空闲          │
│ 继续执行下一行  │            │ 线程 3: 压缩          │
│   ...          │            │ 线程 4: DNS 解析     │
│   ...          │            └──────────┬──────────┘
│                │  ←──回调──            │
│ onRead(err,    │  文件读完后回到这里      │
│   data) { ... }│                       │
└────────────────┘
1
2
3
4
5
6
7
8
9
10
11

关键事实:

  • 线程池只处理文件 I/O、DNS 解析、加密/压缩等 CPU 密集的同步操作
  • 网络 I/O(TCP/UDP)不走线程池——它们用 OS 原生的非阻塞 API(epoll/kqueue/IOCP),在主线程上直接轮询
  • 这就解释了为什么"Node.js 能支撑上万并发连接"——网络 I/O 没有线程切换开销,一个线程轮询所有 socket

结论:单线程指的是"JS 执行层面"单线程——异步 I/O 本身是多线程的。这种模型把"编写代码的简单性"(不用想锁、竞态)和"执行效率"(真正的异步 I/O)结合到一起。

# 2.3 为什么不是"浏览

疑惑:很多开发者以为 Node 的事件循环是"去掉了渲染步骤的浏览器事件循环"。真的吗?

论证:

浏览器事件循环(HTML Spec §8.1.4):

┌─────────────────────────────────────────┐
│  1. 取一个 macroTask(setTimeout / 事件)│
│  2. 执行它                                │
│  3. 清空所有 microTask(Promise.then)    │
│  4. 渲染(如需要)                        │
│  5. 回到 1                                │
└─────────────────────────────────────────┘
1
2
3
4
5
6
7

Node.js event loop(libuv 的 uv_run):

┌──────────────────────────────────────────────────────┐
│  1. timers        →  执行到期的 setTimeout/setInterval│
│  2. pending       →  执行上轮延迟的 I/O 回调          │
│  3. idle/prepare  →  libuv 内部(用户代码不接触)     │
│  4. poll          →  等待并执行 I/O 回调(核心阶段)   │
│  5. check         →  执行 setImmediate 回调           │
│  6. close         →  执行 close 事件的回调            │
│  7. 回到 1(如果还有活跃句柄)                        │
└──────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9

本质差异:

维度 浏览器 Node.js
规范约束 HTML 规范(§8.6 定时器要求嵌套≥5层夹持到 4ms) libuv 源码,不受 HTML 约束
阶段划分 只有一个 macroTask → microTask → render 循环 六个独立阶段,每阶段处理不同异步源
nextTick 不存在 在阶段之间执行,优先级比 Promise 高
setImmediate 不存在 固定在第 5 阶段(check)执行
渲染管线 有(requestAnimationFrame) 无
关闭回调 无单独阶段 有专门的 close 阶段

结论:两者不是"删减版 vs 完整版"的关系——它们的设计目标不同。浏览器的循环围绕渲染帧设计(有了 rAF、Layout、Paint),Node 的循环围绕I/O 完成设计(有了 poll、check)。setTimeout(fn, 0) 在两边的延迟差异不是 bug,是两个调度器在各自的设计约束下做出的行为。

# 3. libuv 事件

# 3.1 timers

┌──────────────────────────────────────────────────────────────────┐
│                    libuv 事件循环六阶段(单轮)                      │
├──────────────────────────────────────────────────────────────────┤
│                                                                  │
│   启动(或上一轮结束)                                              │
│        │                                                         │
│        ▼                                                         │
│   ┌──────────┐                                                    │
│   │ 1.timers │  遍历最小堆,执行所有到期的 setTimeout/setInterval    │
│   └────┬─────┘                                                    │
│        │   ↕  nextTick 队列在每阶段之间被清空(见第 4 章)            │
│        ▼                                                         │
│   ┌──────────┐                                                    │
│   │ 2.pending│  执行上一轮 poll 阶段收集到的"延迟 I/O 回调"         │
│   │          │  (主要是系统级错误回调,如 TCP ECONNREFUSED)        │
│   └────┬─────┘                                                    │
│        │                                                         │
│        ▼                                                         │
│   ┌──────────┐                                                    │
│   │ 3.idle/  │  libuv 内部使用,用户代码不可见                      │
│   │  prepare │  各平台 epoll/kqueue 事件轮询前的准备工作             │
│   └────┬─────┘                                                    │
│        │                                                         │
│        ▼                                                         │
│   ┌───────────────────────────────────────┐                      │
│   │           4. poll(核心阶段)            │                      │
│   │                                       │                      │
│   │  ┌─ 有 I/O 事件就绪? ──→ 执行回调      │                      │
│   │  ├─ 无 I/O,但有待处理 timer?           │                      │
│   │  │    ──→ 超时返回(进入 check)         │                      │
│   │  ├─ 无 I/O,无 timer,有 setImmediate?  │                      │
│   │  │    ──→ 立即返回(进入 check)         │                      │
│   │  └─ 什么都没有? ──→ 阻塞等待(主线程休眠)│                      │
│   └──────────────────┬────────────────────┘                      │
│        │                                                         │
│        ▼                                                         │
│   ┌──────────┐                                                    │
│   │ 5.check  │  执行所有 setImmediate(fn) 注册的回调                │
│   └────┬─────┘                                                    │
│        │                                                         │
│        ▼                                                         │
│   ┌──────────┐                                                    │
│   │ 6.close  │  执行 socket.on('close') / stream.destroy() 回调    │
│   └────┬─────┘                                                    │
│        │                                                         │
│        ▼                                                         │
│   ┌──────────────────────────────┐                                │
│   │ 还有活跃句柄/请求?            │                                │
│   │  YES → 回到 1.timers          │                                │
│   │  NO  → 进程退出                │                                │
│   └──────────────────────────────┘                                │
└──────────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

# 3.2 每阶段的职责与回

阶段 执行什么 对应 API 回调来源
timers 到期的定时器回调 setTimeout / setInterval 内层最小堆(binary heap),按到期时间排序
pending 上轮 poll 延迟的系统错误回调 TCP ECONNREFUSED、ECONNRESET 等 操作系统返回错误码时入队
poll I/O 完成回调(核心) fs.readFile / http.get / net.connect epoll/kqueue 返回的 ready events
check setImmediate 回调 setImmediate(fn) uv_check_t handle
close 关闭事件回调 socket.on('close') / stream.destroy() uv_close() 触发的 close callback

关键细节——timer 的精度问题:

// 你以为的:100ms 准时触发
setTimeout(fn, 100);

// 实际的:>= 100ms,且与 poll 阶段有关
// libuv 在 timers 阶段检查的是"now >= timer.deadline"
// 如果 poll 阶段卡了很久(在处理大量 I/O 回调),timer 的实际延迟 = 设定值 + poll 耗时
1
2
3
4
5
6

# 3.3 poll 阶段的

疑惑:poll 阶段一直有 I/O 事件来,会不会永远到不了 check 阶段,导致 setImmediate 永远不执行?

论证:

libuv 在 poll 阶段内部有一个 "回调上限"保护:

// libuv/src/unix/core.c —— uv__io_poll() 简化版
int uv__io_poll(uv_loop_t* loop, int timeout) {
    // ...
    for (;;) {
        // epoll_wait / kevent 等待 I/O 事件(带 timeout)
        nevents = epoll_pwait(loop->backend_fd, events, maxevents, timeout, ...);

        for (i = 0; i < nevents; i++) {
            // 执行 I/O 回调
            w->cb(loop, w, events[i].events);
        }

        // ⚠️ 关键:每轮执行完后检查是否有 setImmediate 在等着
        if (uv__has_active_handles(loop, UV_CHECK)) {
            // 有 check 阶段任务 → 不再等,退出 poll 回到主循环
            break;
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

保护机制:

  • 如果有 setImmediate 在 check 等着,poll 不会"贪"——执行若干轮 I/O 回调后主动退出
  • 这防止了"永远在 poll 里转圈,check 饿死"的情况
  • 如果 poll 阶段真的没有 I/O 事件可执行——epoll_wait 会阻塞等待(timeout 由最近的 timer 到期时间决定),此时主线程空闲

结论:timer 和 I/O 回调可能在 poll 被打断,但 check(setImmediate)永远不会被饿死——libuv 在 poll 的内层循环里主动检查并退出。真正会被饿死的是那些"一直有 I/O 涌入时的 timer"——但 timer 在下一轮开头的 timers 阶段,而 poll 最多超时退出。

# 3.4 浏览器 vs N

疑惑:setTimeout(fn, 0) 在 Chrome 里实际延迟可能到 4.7ms,在 Node 里却可以 1.2ms——这个差异从哪来?

论证:

这是 HTML 规范 §8.6 的硬性要求(浏览器必须遵守):

HTML Standard §8.6 Timers:
  "If nesting level is greater than 5, and timeout is less than 4ms,
   then set timeout to 4ms."
1
2
3

Chrome 的 Blink 渲染引擎对这一条的实现:

// Chromium 源码简化版:third_party/blink/renderer/core/frame/dom_timer.cc
static const int kMaxTimerNestingLevel = 5;
static const base::TimeDelta kMinimumInterval = base::Milliseconds(4);
1
2
3

而 libuv 的 timer 实现没有这个约束——它直接读 uv_timer_t->timeout 和 uv_now(loop) 比较,到期的就执行,不设最小间隔。

┌─────────────────── 4ms 夹持触发条件 ───────────────────┐
│                                                         │
│  浏览器(Chrome):                                      │
│    setTimeout(fn, 0)                                     │
│    第 1~5 次:延迟 ~1ms(无夹持)                         │
│    第 6 次起:嵌套层级 > 5 → 强制抬高到 4ms               │
│    → 实际延迟 ~4.7ms(4ms + 调度开销)                    │
│                                                         │
│  Node.js(libuv):                                      │
│    setTimeout(fn, 0)                                     │
│    任何嵌套层级:libuv 按实际到期判断                      │
│    → 实际延迟 ~1.2ms(只受事件循环遍历开销影响)           │
│                                                         │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

结论:浏览器的 4ms 夹持是为了防止嵌套定时器锁死渲染(给了渲染管线一个"最小窗口")。Node 没有渲染管线,所以不需要这个约束——libuv 的 timer 精度只受 poll 阶段带 I/O 回调的耗时影响。

# 4. nextTick

# 4.1 nextTick

疑惑:为什么 process.nextTick(fn) 比 Promise.resolve().then(fn) 先执行?

论证:

先看代码输出顺序:

// test_priority.js
const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => console.log('1. timeout'), 0);
  setImmediate(() => console.log('2. immediate'));

  process.nextTick(() => console.log('3. nextTick'));
  Promise.resolve().then(() => console.log('4. promise'));
});

// 输出:
// 3. nextTick
// 4. promise
// 2. immediate  (或 1. timeout —— 取决于 poll 之后先到 check 还是 timer)
// 1. timeout   (或 2. immediate)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

nextTick 的执行时机不是"比 Promise 快",而是"执行位置不同":

┌──────────────────────────────────────────────┐
│  poll 阶段执行完一个 I/O 回调后                │
│                    │                         │
│                    ▼                         │
│  ┌─────────────────────────────────────┐     │
│  │  清空 nextTick 队列(process.nextTick)│     │
│  └─────────────────┬───────────────────┘     │
│                    ▼                         │
│  ┌─────────────────────────────────────┐     │
│  │  清空 microTask 队列(Promise.then)   │     │
│  └─────────────────┬───────────────────┘     │
│                    ▼                         │
│        进入下一阶段(check / close / timers)   │
└──────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Node 的 nextTick 队列在每个阶段之间被清空,而 Promise 微任务在同一阶段内的每个回调之后被清空。

# 4.2 Promise.

更精确的时序:

setTimeout(() => {
  console.log('1. timeout callback start');

  process.nextTick(() => console.log('2. nextTick'));

  Promise.resolve()
    .then(() => {
      console.log('3. promise 1');
      process.nextTick(() => console.log('4. nextTick in promise'));
    });

  console.log('5. timeout callback end');
}, 0);

// 输出:
// 1. timeout callback start
// 5. timeout callback end
// 2. nextTick           ← 阶段之间:所有 nextTick 先跑
// 3. promise 1
// 4. nextTick in promise ← Promise 里的 nextTick 也要等当前阶段结束!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

规则总结:

队列 执行时机 是否会饿死事件循环
nextTick 每阶段之间(即 poll→check、check→close 等过渡时) 会(如果递归注册新 nextTick)
Promise.then 当前回调执行完后立刻清空 通常不会(V8 有限制)

# 4.3 nextTick

// ⚠️ 危险:递归 nextTick 会饿死事件循环
function starve() {
  process.nextTick(starve);  // 每次 nextTick 都在下一阶段前插队
}
starve();

setTimeout(() => console.log('never runs'), 0);  // ← 永远不会执行

// 原因:nextTick 在每阶段之间清空 → 如果递归注册新 nextTick
// → nextTick 队列永远不为空 → 永远到不了下一阶段
1
2
3
4
5
6
7
8
9
10

Node 的防护:Node 对 process.nextTick 有递归深度上限(process.maxTickDepth,默认 1000)。达到上限后,Node 会强制把新的 nextTick 推迟到下一轮事件循环——防止彻底的饿死。但这个上限只是"软上限",不能完全依赖。

为什么要有 nextTick? 它的设计初衷是提供一种"比 Promise 更早"的异步机制——用于需要在当前操作完成后、任何其他异步操作之前执行的回调(比如:在触发 error 事件前确保 error listener 已注册)。

# 4.4 完整优先级图谱

┌────────────────────────────────────────────────────────────┐
│               Node.js 异步回调执行优先级                      │
├────────────────────────────────────────────────────────────┤
│                                                            │
│   同步代码(最高优先级,无悬念)                               │
│       │                                                    │
│       ▼                                                    │
│   process.nextTick() 队列                                   │
│   在每个阶段之间被清空                                        │
│       │                                                    │
│       ▼                                                    │
│   Promise.then / async/await / queueMicrotask()            │
│   在同一阶段内的回调执行完后清空                                │
│       │                                                    │
│       ▼                                                    │
│   下一阶段(按照 libuv 六阶段顺序)                            │
│   timers → pending → poll → check → close                  │
│       │                                                    │
│       ├── 如果 poll 中有 I/O 就绪                            │
│       │   └→ 执行 I/O 回调 → 回到上述 nextTick/Promise 清空   │
│       │                                                    │
│       ├── 如果 poll 中超时(最近 timer 到期)                  │
│       │   └→ 回到 timers 阶段                                │
│       │                                                    │
│       └── 如果有 setImmediate 在 check 等着                  │
│           └→ 进入 check 阶段                                 │
│                                                            │
└────────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

一句话记忆:同步 > nextTick > Promise > 各阶段回调,其中 setTimeout vs setImmediate 谁先跑取决于你从哪个阶段出发(非 I/O 回调里 setTimeout 可能先跑;I/O 回调里 setImmediate 必定先跑)。

# 5. Stream流

# 5.1 Readable

Stream 不是新技术——它来自 Unix 哲学的"管道"思想。Node 把这套抽象成四个类。

┌─────────────┐      ┌─────────────┐      ┌─────────────┐
│  Readable   │ pipe │  Transform  │ pipe │  Writable   │
│  (可读流)    │ ───→ │  (转换流)    │ ───→ │  (可写流)    │
└─────────────┘      └─────────────┘      └─────────────┘
      ↑                                        │
      │          ┌─────────────┐               │
      └──────────│   Duplex    │───────────────┘
      可同时读/写 │  (双工流)    │ 可同时读/写
                 └─────────────┘
1
2
3
4
5
6
7
8
9

疑惑:Readable 有两种模式——流动模式和暂停模式。默认哪个?什么时候切换?

论证:

const { Readable } = require('stream');
const fs = require('fs');

// -- 暂停模式(默认创建时就是暂停模式)--
const rs1 = fs.createReadStream('large.log', { highWaterMark: 64 * 1024 });
rs1.on('readable', () => {
  let chunk;
  while ((chunk = rs1.read()) !== null) {
    // 手动拉取——你自己控制节奏
    console.log('Paused mode:', chunk.length);
  }
});

// -- 流动模式(一旦监听了 'data' 事件就自动切换)--
const rs2 = fs.createReadStream('large.log');
rs2.on('data', chunk => {
  // 数据自动推送——你控制不了节奏
  console.log('Flowing mode:', chunk.length);
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

模式切换规则:

暂停模式                           流动模式
  │                                  │
  ├── .resume() ───→                 │
  ├── .on('data') ──→               │
  ├── .pipe(writable) ──→           │
  │                                  │
  │                  ←── .pause() ───┤
  │                  ←── .unpipe() ──┤
1
2
3
4
5
6
7
8

内部实现:两种模式对应 readable._readableState.flowing 这个布尔值。流动模式下,read() 被自动调用并把数据 push 给 data 事件;暂停模式下,你不调用 read() 就永远阻塞。

结论:暂停模式让你控制读取速度(相当于给下游装了阀门);流动模式让数据自动到(但容易失控)。生产代码优先暂停模式(或直接用 pipe()/pipeline() 让框架帮你控速)。

# 5.2 Writable

const { Writable } = require('stream');

const ws = new Writable({
  highWaterMark: 16 * 1024,  // 16 KB 水位线
  write(chunk, encoding, callback) {
    // 模拟慢速写入:每次 10ms
    setTimeout(() => {
      console.log('Wrote:', chunk.length, 'buffered:', ws.writableLength);
      callback();
    }, 10);
  }
});

// 快速写入 100 个 1KB chunk → 共 100KB → 远超高水位线
for (let i = 0; i < 100; i++) {
  const ok = ws.write(Buffer.alloc(1024));
  if (!ok) console.log(`Write #${i}: backpressure!`);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

内部缓冲区的生命周期:

ws.write(chunk)
   │
   ├── writableLength &lt; highWaterMark ?
   │   YES → chunk 直接交给 _write(),返回 true
   │   NO  → chunk 进入内部缓冲队列,返回 false
   │
   ▼
_write(chunk, enc, cb) 被调用(异步)
   │
   ▼
cb() 被调用 → 缓冲区被消费一部分
   │
   ├── writableLength &lt; highWaterMark ?
   │   YES → 触发 'drain' 事件
   │   NO  → 继续等待更多 cb() 被调用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 5.3 Duplex

类型 读 写 内部实现 典型例子
Readable ✅ ❌ _read() fs.createReadStream / process.stdin
Writable ❌ ✅ _write() fs.createWriteStream / process.stdout
Duplex ✅ ✅ _read() + _write() net.Socket / tls.TLSSocket
Transform ✅ ✅ _transform() + _flush() zlib.createGzip() / crypto.createCipher()

Duplex vs Transform 的关键区别:

  • Duplex 的读和写是独立的——你写进去的 chunk 和读出来的 chunk 可以完全不同(比如 net.Socket:写的是你发的数据,读的是对方发的数据)
  • Transform 的读和写是因果相关的——写进去的 chunk 经过 _transform() 变成读出来的 chunk(比如 gzip 压缩:写入原始数据,读出压缩数据)

# 5.4 objectMo

疑惑:Stream 默认传的是 Buffer/string,怎么传 JS 对象?

论证:

const { Transform } = require('stream');

// 默认模式:传输 Buffer/string
const byteTransform = new Transform({
  transform(chunk, encoding, cb) {
    // chunk 是 Buffer
    cb(null, chunk.toString().toUpperCase());
  }
});

// objectMode:传输任意 JS 对象
const objectParser = new Transform({
  readableObjectMode: true,   // 读出的是 JS 对象
  writableObjectMode: true,    // 写入的是 JS 对象
  highWaterMark: 16,           // 对象模式下:16 个对象(不是 16 字节!)
  transform(obj, encoding, cb) {
    // obj 是真正的 JS 对象,不需要 toString()
    cb(null, { ...obj, processed: true });
  }
});

objectParser.write({ id: 1, data: 'hello' });
objectParser.write({ id: 2, data: 'world' });

objectParser.on('data', obj => {
  console.log(obj); // { id: 1, data: 'hello', processed: true }
});
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

objectMode 关键差异:

维度 字节模式(默认) objectMode
chunk 类型 Buffer / string / Uint8Array 任意 JS 值(除 null)
highWaterMark 单位 字节数(如 16384 = 16 KB) 对象个数(如 16 = 放 16 个对象就触发背压)
read(n) 的 n 含义 读取 n 字节 读取 n 个对象

结论:objectMode 在消息队列、JSON 解析/序列化管道、ETL 数据管道中大量使用。但要注意背压单位变成了对象个数——一个大对象就可能撑爆内存,需要额外关注。

# 6. 背压机制详解

# 6.1 write()

回到第 1 章的日志转发器崩溃——根因就在 write() 的返回值被忽视了。

const fs = require('fs');
const writer = fs.createWriteStream('./log');

// ❌ 错误:不检查返回值
function writeUnsafe(data) {
  writer.write(data);
}

// ✅ 正确:尊重背压信号
function writeSafe(data) {
  if (!writer.write(data)) {
    // → false:内部缓冲区满了,下游(磁盘)处理不过来
    console.log('⚠️ Backpressure: buffer at', writer.writableLength);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

write() 返回值的语义:

write(chunk) → true
  └→ chunk 被 accept,直接交给 _write() → 内存不增长(或微增)

write(chunk) → false
  └→ chunk 被放入内部缓冲队列(writableBuffer)
     └→ 队列大小 = writableLength(可读属性)
        └→ 队列增长速度 > 消费者速度 → 内存持续增长 → OOM
1
2
3
4
5
6
7

内部缓冲区是什么:WriteStream 内部维护一个链表——每个节点是一块待写入的 Buffer。writableLength 是链表中所有 chunk 的字节数之和。这个链表没有上限——你可以往里塞任意多数据(直到进程内存炸了)。

# 6.2 drain 事件

疑惑:收到 false 之后怎么办?什么时候可以继续写?

论证:

function writeWithBackpressure(data, writer, cb) {
  if (!writer.write(data)) {
    // 暂停写入,等待下游排空
    writer.once('drain', () => {
      console.log('✅ Drain: buffer emptied, can write again');
      cb();
    });
  } else {
    cb();
  }
}
1
2
3
4
5
6
7
8
9
10
11

drain 事件的触发时机:

write(chunk) → false                    write(chunk) → false
    │                                       │
    ▼                                       ▼
┌─────────┐   ┌─────────┐   ┌─────────┐ ┌─────────┐
│ chunk 1 │ → │ chunk 2 │ → │ chunk 3 │→│ chunk 4 │  ← writableLength > highWaterMark
└─────────┘   └─────────┘   └─────────┘ └─────────┘
    │                                       │
    ▼ _write() 逐个消费                      │
┌─────────┐   ┌─────────┐                  │
│ ✅ done │   │ ✅ done │                  │
└─────────┘   └─────────┘                  │
                  │                         │
                  ▼                         ▼
          writableLength &lt; highWaterMark → 触发 'drain' 事件
1
2
3
4
5
6
7
8
9
10
11
12
13
14

不处理 drain 的后果(就是第 1 章事故的根因):

// 上游速度 20 GB/h(~5.5 MB/s)
// 磁盘写入速度 ~200 MB/s(SSD)→ 看起来够快
// 但 SSD 会遇到"写放大"——随机写比顺序写慢 10 倍
// 写放大时有效速度降到 ~20 MB/s
// 5.5 MB/s(入)> 20 MB/s(出)? 不,这次够了
// 但再来一个峰值 → 50 MB/s(入)> 20 MB/s(出)
// → 每秒积压 30 MB → 1 分钟积压 1.8 GB → OOM
1
2
3
4
5
6
7

结论:背压不是理论上的极端情况——它是任何一个上下游速度失配的系统中都会发生的事。"日常够用"掩盖的设计缺陷,在峰值流量下会原形毕露。

# 6.3 pipe()

// pipe() 源码简化版(核心逻辑):
Readable.prototype.pipe = function(dest) {
  // 1. 进入流动模式
  this.on('data', chunk => {
    // 2. 写入下游
    const ok = dest.write(chunk);

    // 3. write() 返回 false → 暂停上游
    if (!ok) {
      this.pause();
      // 4. 等下游排空 → 恢复上游
      dest.once('drain', () => this.resume());
    }
  });

  // 5. 上游结束 → 关闭下游
  this.on('end', () => dest.end());
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

pipe() 自动做了五件事:

  1. 把上游切换到流动模式(.on('data'))
  2. 每次写入检查 write() 返回值
  3. 返回 false 时暂停上游(.pause())
  4. 下游排空后恢复上游(.resume())
  5. 上游结束时关闭下游(.end())

为什么还是要懂原理:pipe() 处理了背压,但不处理错误传播——上游出错,下游不会自动被销毁。

# 6.4 pipeline

const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');

// ❌ pipe():错误不会自动传播
fs.createReadStream('bad_file')    // 文件不存在 → error
  .pipe(zlib.createGzip())         // ← 不会收到错误通知!继续等着(泄漏)
  .pipe(fs.createWriteStream('out.gz'));

// ✅ pipeline():任一环节出错,整个管道被销毁
pipeline(
  fs.createReadStream('bad_file'),
  zlib.createGzip(),
  fs.createWriteStream('out.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline failed:', err.message);
      // 此时三个 stream 都已被自动 destroy()
    }
  }
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

对比表:

pipe() pipeline()
背压处理 ✅ 自动 ✅ 自动
错误传播 ❌ 需手动绑定每个 stream 的 error 事件 ✅ 自动传播、自动销毁所有 stream
回调完成信号 ❌ 需手动监听 finish/end/close ✅ callback(err)
Node 版本 所有版本 v10+
推不推荐 ❌ 不推荐(遗留 API) ✅ 始终用 pipeline()

结论:pipeline() 是 pipe() 的完整替代——它不仅自动处理背压,还自动传播错误、自动销毁管道中所有 stream。在 Node 10+ 中,永远用 pipeline() 而不是 pipe()。

# 7. Buffer本质

# 7.1 Uint8Arr

疑惑:为什么 Node 有 Buffer 这个独立类型?ES6 不是已经有 Uint8Array 了吗?

论证:

// Buffer 确实是 Uint8Array 的子类
console.log(Buffer.prototype instanceof Uint8Array);  // true
console.log(Buffer.alloc(4) instanceof Uint8Array);   // true

// 但它有自己的内存分配策略
const b1 = Buffer.alloc(8);        // 0 填充,安全
const b2 = Buffer.allocUnsafe(8);  // 不填充,快(可能含旧内存数据)
const b3 = Buffer.from('hello');   // 从字符串/数组创建
1
2
3
4
5
6
7
8

内存分配策略——slab 预分配:

┌─────────────────────────────────────────────────────┐
│           Node.js Buffer 内存池(slab allocation)    │
├─────────────────────────────────────────────────────┤
│                                                     │
│  小 Buffer (&lt; 4KB / Buffer.poolSize):                │
│  ┌────────────────────────────────────────────┐    │
│  │           8 KB slab 内存池                   │    │
│  │  ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐      │    │
│  │  │ 64B  │ │ 256B │ │ 1KB  │ │ 512B │ ... │    │
│  │  └──────┘ └──────┘ └──────┘ └──────┘      │    │
│  │  从 slab 切分 → 减少 GC 压力                │    │
│  └────────────────────────────────────────────┘    │
│                                                     │
│  大 Buffer (>= 4KB):                                 │
│  ┌────────────────────────────────────────────┐    │
│  │  直接走堆分配(malloc / new Uint8Array)     │    │
│  │  不在 slab 池中管理                          │    │
│  └────────────────────────────────────────────┘    │
│                                                     │
└─────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

关键参数:Buffer.poolSize(默认 8192 = 8 KB),小 Buffer 从这个池中分配。这个池是在第一次使用 Buffer.alloc() 或 Buffer.from() 时懒加载创建的。

Buffer.allocUnsafe(size) 的实现(极简版):

// Node 源码简化:lib/buffer.js
Buffer.allocUnsafe = function allocUnsafe(size) {
  if (size <= Buffer.poolSize >>> 1) {
    // 小 Buffer:从 slab 内存池里切一块
    return pool.allocate(size);
  }
  // 大 Buffer:直接创建新的 Uint8Array
  return new FastBuffer(size);
};
1
2
3
4
5
6
7
8
9

为什么要 slab 预分配:Node.js 内部大量使用短生命周期的 Buffer(每次网络请求读一块数据、每次文件操作读一块)。如果每个 Buffer 都走一次 malloc—free,会产生大量堆碎片和 GC 压力。slab 池让 Buffer 分配变成"从已分配的 8 KB 区里标记一块已用"——O(1) 分配,且释放后不需要立刻还给 OS。

# 7.2 Buffer.a

疑惑:allocUnsafe 比 alloc 快多少?值得冒风险吗?

论证:

const { performance } = require('perf_hooks');

// 测试 1:小 Buffer(1KB)分配 10 万次
let start = performance.now();
for (let i = 0; i < 100000; i++) Buffer.alloc(1024);
console.log('alloc (safe):', performance.now() - start, 'ms');

start = performance.now();
for (let i = 0; i < 100000; i++) Buffer.allocUnsafe(1024);
console.log('allocUnsafe (fast):', performance.now() - start, 'ms');

// 典型结果:
// alloc (safe):      ~45 ms
// allocUnsafe (fast): ~8 ms    → 约 5.6x 快
1
2
3
4
5
6
7
8
9
10
11
12
13
14

allocUnsafe 的风险:返回的 Buffer 没有清零——内存中可能有上一次被释放的数据残留:

const risky = Buffer.allocUnsafe(16);
console.log(risky.toString('hex'));
// 输出:可能是 "deadbeef0000..." 也可能是前一个请求的 JWT Token 或密码
1
2
3

安全矩阵:

方法 清零 速度 风险 适用场景
Buffer.alloc(n) ✅ 全部写 0 慢 无 默认选择
Buffer.allocUnsafe(n) ❌ 快(~5x) 可能暴露旧内存数据 你立刻用新数据完全覆盖它
Buffer.allocUnsafeSlow(n) ❌ 快 同 allocUnsafe,但不走 slab 池 需要 Buffer 不被池管理(如传给 C++ addon)

什么时候可以用 allocUnsafe:

// ✅ 安全:立刻被新数据完全覆盖
const buf = Buffer.allocUnsafe(1024);
fs.readSync(fd, buf, 0, 1024);  // 整块被文件数据覆盖
socket.read(buf);                 // 整块被网络数据覆盖

// ❌ 不安全:只部分覆盖
const buf = Buffer.allocUnsafe(1024);
fs.readSync(fd, buf, 0, 512);   // 只覆盖前 512 字节,后 512 还是旧数据
buf.toString();                   // ⚠️ 后 512 字节泄露了之前的敏感数据!
1
2
3
4
5
6
7
8
9

结论:永远默认用 Buffer.alloc()。allocUnsafe 只在性能关键热路径上,且你100% 确定会立刻用新数据完全覆盖整个 Buffer的情况下使用。这是一个"用安全性换性能"的 tradeoff——大部分场景不值得。

# 7.3 与浏览器 Typ

疑惑:ES6 的 Uint8Array 做二进制操作已经够用了,Node 为什么还要维护一个独立的 Buffer 类?

论证:

这是历史与功能叠加的结果。2009 年 Node.js 诞生时,JavaScript 没有 TypedArray——处理二进制数据的唯一方式是用字符串(又慢又不安全)。Ryan Dahl 写了 Buffer 类塞进 Node。2015 年 ES6 引入 TypedArray 后,Node 把 Buffer 改成了 Uint8Array 的子类——但保留了额外的专有 API。

Buffer 独有的能力:

// 1. 处理字节序(几乎只在网络协议/文件格式中需要)
const buf = Buffer.from([0x00, 0x00, 0x01, 0x02]);
buf.readUInt32BE(0);   // → 258(大端序)     Uint8Array 没有
buf.readUInt32LE(0);   // → 33619968(小端序)  Uint8Array 没有

// 2. slice 共享内存(不拷贝)——Uint8Array.slice 是拷贝
const buf1 = Buffer.alloc(1024);
const buf2 = buf1.slice(0, 512);  // buf2 和 buf1 共享底层 ArrayBuffer
buf1[0] = 0xFF;
console.log(buf2[0]);             // 0xFF —— 修改相互可见

// 3. 编码转换(Node 专有)
Buffer.from('你好', 'utf8');
Buffer.from('48656c6c6f', 'hex');
Buffer.from('SGVsbG8=', 'base64');
// Uint8Array 需要走 TextEncoder/TextDecoder

// 4. 零拷贝从 Buffer 到 string(Buffer.toString 比 TextDecoder 快)
const str = buf.toString('utf8');  // 内部优化,绕过 TextDecoder 的开销
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

对比矩阵:

能力 Buffer (Node) Uint8Array (浏览器 + Node)
继承关系 Uint8Array 的子类 独立(TypedArray 子类)
内存池 slab 池 (≤4KB 走池) 无(每次 new 都独立分配)
slice() 共享内存(零拷贝) 拷贝(ES6 规范要求)
字节序读写 ✅ readUInt16BE/LE 等 30+ 方法 ❌ 需手动位移
编码转换 ✅ Buffer.from(str, 'utf8') / buf.toString('hex') ✅ TextEncoder/TextDecoder(浏览器 API)
跨平台安全 仅 Node ✅ Node + 浏览器
零填充 alloc 填充 0 / allocUnsafe 不填 部分环境填充 0,部分不保证

什么时候用哪个:

  • 纯 Node.js 后端:Buffer 是首选——它有更丰富的 API、更快的字符串转换、内存池
  • 同构代码(SSR/SSG):用 Uint8Array / TextEncoder / TextDecoder——它们在浏览器和 Node 都能跑
  • npm 包:如果不确定下游是浏览器还是 Node,用 Uint8Array;如果只面向 Node,用 Buffer

结论:Buffer 是 Node.js 的"遗产优势"——它在 TypedArray 诞生之前就已存在,积累了大量专门为服务端二进制处理优化的 API。如今它成了 Uint8Array 的子类,但保留了自己的特色(内存池、slice 共享、字节序读写)。在同构代码里用标准 API;在纯 Node 代码里用 Buffer 能更快。

# 8. Cluster

# 8.1 Cluster

疑惑:Node 是单线程的,要怎么利用服务器的 16 核 CPU?

论证:Cluster 模块不是让你写并发代码——它是把同一个进程复制 N 份,每份跑在独立核心上。

const cluster = require('cluster');
const http = require('http');
const numCPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`Master ${process.pid} is running`);

  // Fork workers
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  // 自动重启:某个 worker 崩了 → 立刻 fork 一个新的
  cluster.on('exit', (worker, code, signal) => {
    console.log(`Worker ${worker.process.pid} died (${signal || code}). Restarting...`);
    cluster.fork();
  });

} else {
  // 每个 Worker 都运行同一个 HTTP 服务器
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end(`Hello from worker ${process.pid}\n`);
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

端口共享的底层原理:

┌──────────────────────────────────────────────────┐
│           Cluster 端口共享机制(简化)               │
├──────────────────────────────────────────────────┤
│                                                  │
│  Master (PID 1000)                               │
│  ┌────────────────────────────────────┐          │
│  │  net.createServer() → listen(8000) │          │
│  │  拿到 socket fd = 42               │          │
│  └──────────────┬─────────────────────┘          │
│                 │                                 │
│      ┌──────────┼──────────┐                     │
│      │          │          │                     │
│      ▼          ▼          ▼                     │
│  Worker 1    Worker 2    Worker 3                │
│  (PID 1001)  (PID 1002)  (PID 1003)              │
│  ┌────────┐ ┌────────┐ ┌────────┐                │
│  │ fd:42  │ │ fd:42  │ │ fd:42  │ ← 同一个 fd    │
│  │ accept │ │ accept │ │ accept │   内核负责分发   │
│  └────────┘ └────────┘ └────────┘                │
│                                                  │
│  用户请求 → 内核 TCP 栈 → 负载均衡分给 Worker       │
└──────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

关键细节:

  • Master 进程负责 listen(),拿到 socket 的文件描述符
  • Worker 进程通过 IPC 拿到同一个 fd
  • 多个 Worker 对同一个 fd 调用 accept()——内核负责把连接分发到不同的 Worker(不是 Node 自己做的)
  • 分发策略是操作系统决定的:Linux 采用**"惊群 → 只有一个 Worker 拿到连接"(SO_REUSEPORT 之前的默认行为)或内核级轮转分发**(SO_REUSEPORT)

平滑重启(zero-downtime restart):

// PM2 的 reload 简化原理:
// 1. Master 收到 SIGHUP 信号
// 2. fork 新 Worker(带着新代码)
// 3. 新 Worker 启动完成 → 发 'listening' 消息给 Master
// 4. Master 发 'shutdown' 给老 Worker → 老 Worker 不再 accept 新连接
// 5. 老 Worker 处理完现有连接 → 优雅退出
// 这期间:新请求由新 Worker 处理,老请求在旧 Worker 中完成 → 零停机
1
2
3
4
5
6
7

# 8.2 Worker

疑惑:Worker Threads 和 child_process 都能"开新的",到底选哪个?

论证——核心差异是内存模型:

child_process(进程)              Worker Threads(线程)
┌─────────────────┐              ┌─────────────────────────┐
│  进程 A          │              │  进程                     │
│  ┌─────────────┐│              │  ┌────────┬────────┐    │
│  │ 独立内存空间  ││              │  │ 主线程  │Worker  │    │
│  │ 堆/栈/代码    ││              │  │ 堆/栈   │线程 1  │    │
│  └─────────────┘│              │  │        │ 堆/栈  │    │
│        │ IPC    │              │  └────────┴────────┘    │
│  ┌─────▼───────┐│              │  ┌──────────────────┐   │
│  │  进程 B      ││              │  │ SharedArrayBuffer│   │
│  │  ┌─────────┐││              │  │  ┌────┬────┬───┐│   │
│  │  │独立内存  │││              │  │  │data│data│... ││   │
│  │  │堆/栈/代码│││              │  │  └────┴────┴───┘│   │
│  │  └─────────┘││              │  │  共享内存区域     │   │
│  └─────────────┘│              │  └──────────────────┘   │
└─────────────────┘              └─────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
维度 Worker Threads child_process (fork)
内存模型 共享 SharedArrayBuffer 独立(IPC 序列化通信)
启动开销 轻(~2 MB 虚拟空间) 重(~30 MB + 独立 V8 实例)
通信方式 postMessage / SharedArrayBuffer / MessageChannel IPC(pipe / socket)/ 序列化
大数据传输 零拷贝(Transferable objects / SharedArrayBuffer) 必须序列化(JSON/clone)
隔离性 低(共享内存有竞态风险) 高(完全独立,一个崩不影响另一个)
沙箱安全 低(同进程,可访问 process) 高(独立进程,可用 --experimental-permission)
适用场景 CPU 密集计算(图像处理、加密、压缩) 启动子程序、沙箱执行不受信任代码

选型决策树:

需要"多跑一核"?
   │
   ├─ 任务类型是 CPU 密集(大量计算)?
   │   ├─ 输入/输出数据大(>100 MB)?
   │   │   └→ Worker Threads + SharedArrayBuffer
   │   │      (避免序列化开销)
   │   └─ 输入/输出数据小?
   │       ├→ Worker Threads(轻量,快速)
   │
   ├─ 需要沙箱隔离(不可信代码)?
   │   └→ child_process(独立进程)
   │
   └─ 需要利用多核处理 HTTP 请求?
       └→ Cluster(进程级,与 PM2 兼容)
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 8.3 child_pr

exec spawn fork
缓冲输出 ✅ 全缓冲在内存(默认最大 1MB→maxBuffer) ❌ 流式输出(stdout/stderr 是可读流) ❌ 流式输出
默认 shell /bin/sh 无(直接 execve) 无(直接 execve)
IPC 通道 ❌ ❌ ✅(内置 process.send)
用途 简单一次性命令(ls -la) 长时间进程、大数据输出 Node 子进程(需 IPC)
风险 大输出会 OOM(全缓冲在内存) 安全 安全
const { exec, spawn, fork } = require('child_process');

// ❌ exec:数据量大时危险——整个 stdout 被缓冲在内存
exec('cat 10gb_log.txt', (err, stdout, stderr) => {
  // stdout 是 10 GB 的字符串 → 💥 OOM
});

// ✅ spawn:流式处理——内存恒定
const child = spawn('cat', ['10gb_log.txt']);
child.stdout.on('data', chunk => {
  console.log(`Got ${chunk.length} bytes`);
  // 每块 64KB,随到随处理——不会 OOM
});

// ✅ fork:Node 子进程 + 内置 IPC
const worker = fork('./worker.js');
worker.on('message', msg => console.log('From worker:', msg));
worker.send({ task: 'compute', data: [1, 2, 3] });
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

# 9. 同构代码设计与双

# 9.1 Node 18

疑惑:一段包含 fetch() 的代码,怎么在浏览器和 Node 都能跑?

论证:Node.js 从 18 版本起逐步支持浏览器标准 API——不再需要 node-fetch 等 polyfill。这不是把浏览器的实现搬过来,而是在 Node 内部用 C++ 实现(底层走 undici/libuv)与浏览器行为保持一致的 API。

// 以下代码在浏览器和 Node 18+ 都能运行(无需 import/polyfill)
const response = await fetch('https://api.github.com/repos/nodejs/node');
const data = await response.json();
console.log(data.stargazers_count);

// 在 Node 18 之前:需要 import 'node-fetch' 且 API 有细微差异
// 在 Node 18+ 之后:直接用 globalThis.fetch
1
2
3
4
5
6
7

Node.js Web API 对齐表:

API Node 版本 浏览器支持 底层实现
fetch() 18+ (稳定) / 17.5 (实验) ✅ undici (HTTP/1.1 客户端) + libuv
URL / URLSearchParams 10+ ✅ whatwg-url (C++ 绑定)
AbortController / AbortSignal 15+ ✅ C++ 内建
crypto.subtle (Web Crypto API) 15+ ✅ OpenSSL 绑定
Blob 16+ ✅ C++ 内建
File 20+ ✅ Blob 子类
performance (Performance API) 8.5+ ✅ libuv uv_hrtime()
TextEncoder / TextDecoder 11+ ✅ V8 内建 (ICU)
EventTarget 14.5+ ✅ C++ 内建
structuredClone 17+ ✅ V8 内建
ReadableStream / WritableStream 18+ ✅ Web Streams API (底层可兼容 Node Stream)

双端统一的代码:

// ✅ 以下代码在任何现代运行时都能跑(浏览器 + Node 18+ + Deno + Bun)
async function fetchUser(id) {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 5000);

  try {
    const res = await fetch(`/api/users/${id}`, {
      signal: controller.signal,
    });
    return await res.json();
  } finally {
    clearTimeout(timeout);
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# 9.2 SSR 的双端渲

一个 React/Vue 组件在 SSR 中经历的双端生命周期:

┌─────────────────── 服务端(Node.js)───────────────────┐
│                                                         │
│  1. 解析路由 → 匹配组件                                   │
│  2. 执行组件的 getServerSideProps / asyncData            │
│     ├── 在 Node 端发起 fetch/数据库查询                    │
│     └── 拿到数据,塞进组件 props                           │
│  3. 执行组件的 render():                                  │
│     ReactDOMServer.renderToString(&lt;App />)               │
│     └── 注:window/document 不存在 → 跳过 useEffect       │
│  4. 输出:HTML 字符串 + &lt;script>脱水数据&lt;/script>          │
│                                                         │
└──────────────────────┬──────────────────────────────────┘
                       │ HTTP 响应(HTML 字符串)
                       ▼
┌─────────────────── 客户端(浏览器)──────────────────────┐
│                                                         │
│  5. 浏览器接收 HTML → 立即渲染首屏(白屏时间 &lt; 100ms)      │
│  6. 加载 JS bundle                                       │
│  7. ReactDOM.hydrate(&lt;App />, container):               │
│     ├── 复用已有的 DOM 节点(不重新创建)                    │
│     ├── 绑定事件监听器(onClick / onChange)               │
│     └── 执行 useEffect / componentDidMount               │
│  8. 此时:window/document 存在 → 访问 DOM API 的代码执行    │
│                                                         │
└─────────────────────────────────────────────────────────┘
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

关键挑战——条件导入隔离:

// ❌ 这段代码会在 Node 端崩掉(没有 document)
const isMobile = document.documentElement.clientWidth < 768;

// ✅ 方案 1:延迟到客户端执行(useEffect)
import { useEffect, useState } from 'react';
function Component() {
  const [isMobile, setIsMobile] = useState(false);
  useEffect(() => {
    setIsMobile(window.innerWidth < 768);  // 只在浏览器执行
  }, []);
  return <div>{isMobile ? 'Mobile' : 'Desktop'}</div>;
}

// ✅ 方案 2:动态 import(Next.js 的 'use client' 指令)
// ✅ 方案 3:框架的条件导入(Nuxt 的 <ClientOnly> 包裹)
// ✅ 方案 4:在 getServerSideProps 中通过 User-Agent 判断设备类型
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

# 9.3 最常见的 5 个

差异点 浏览器 Node.js 如何兼容
全局对象 window global globalThis(Node 12+ / 所有现代浏览器)
模块系统 ESM 原生(<script type="module">) CJS 默认(require)+ ESM(.mjs / "type": "module") 同构包双导出(main + module/exports)
setTimeout 4ms ✅ 嵌套≥5 层夹持 ❌ 无夹持 不依赖精度,用 setImmediate 语义时用 setImmediate(Node)或 queueMicrotask(双端)
__dirname / __filename ❌ ✅ ESM 下用 import.meta.url(Node 12+)
Buffer ❌(有 Uint8Array) ✅(Uint8Array 子类) 同构代码用 Uint8Array / TextEncoder / TextDecoder

globalThis 是双端的统一入口:

// 不需要判断环境
const root = globalThis;

// Node: globalThis === global
// 浏览器: globalThis === window
// Web Worker: globalThis === self
1
2
3
4
5
6

# 10. 综合案例串讲

# 10.1 案例真相揭晓

回到第 1 章的日志转发器崩溃——六个疑问逐条作答:

疑问 答案
① libuv 事件循环:fs.write 的回调什么时候执行? 第 3 章:写入完成后,回调在 poll 阶段被执行(线程池完成 I/O → 通知 libuv → poll 轮询到 → 执行回调)
② nextTick 优先级:为什么"递归 nextTick"能饿死整个循环? 第 4 章:nextTick 在每阶段之间执行——递归注册意味着每次"阶段之间"都有新任务,形成"永远到不了下一阶段"的闭环
③ Stream 背压:write()→false→drain 这条链怎么来的? 第 5~6 章:write() 把 chunk 放入 WriteStream 内部链表,链表总大小超过 highWaterMark 时返回 false;链表消费到低于高水位线时触发 drain
④ Buffer 本质:WriteStream 内部缓冲区是什么? 第 7 章:是 Buffer 对象的链表。每个从 slab 池或堆分配。未被消费的 chunk 都在这个链表里——链表只增不减 = 内存只涨不降 = OOM 的前奏
⑤ 多进程:Cluster 怎么分摊这个服务的负载? 第 8 章:Master 监听端口,Worker 共享 socket fd,内核负责把新连接分发给 Worker
⑥ 同构:如果你把这条逻辑写进 SSR 组件? 第 9 章:fs.createWriteStream 在浏览器端不存在——需要在双端各自提供接口的"适配层"

修复方案(按代价从小到大):

方案 A:手动处理背压(最小改动)

// 改一行 → 检查返回值 + 暂停 socket
socket.on('data', chunk => {
  if (!writer.write(chunk)) {
    socket.pause();                        // ← 暂停上游
    writer.once('drain', () => socket.resume()); // ← 排空后恢复
  }
});
1
2
3
4
5
6
7

代价:代码多两行;收益:内存从 1.8 GB 降到 ~50 MB。

方案 B:用 pipeline() 取代手写(推荐)

const { pipeline } = require('stream');

server.on('connection', socket => {
  pipeline(socket, writer, err => {
    if (err) console.error('Pipeline failed:', err);
  });
});
1
2
3
4
5
6
7

代价:彻底改掉手动 on('data');收益:背压 + 错误传播 + 自动销毁全部由框架负责。

方案 C:加 Cluster 分摊流量(规模化后)

const cluster = require('cluster');
if (cluster.isMaster) {
  for (let i = 0; i < require('os').cpus().length; i++) cluster.fork();
} else {
  // 原来的 server + pipeline 逻辑
}
1
2
3
4
5
6

代价:需要管理多进程的生命周期;收益:4 个 Worker 各扛 1/4 流量 → 每个 Worker 写入压力降到 1/4 → 背压触发概率降为 1/4。

生产建议:方案 B(pipeline)是基础——先正确地"流",再考虑方案 C(Cluster)分摊。

# 10.2 用 Stream

const { pipeline } = require('stream');
const fs = require('fs');
const zlib = require('zlib');
const { Transform } = require('stream');

// 自定义 Transform:只保留 ERROR 和 FATAL 行,同时统计各级别数量
const filterAndCount = new Transform({
  transform(chunk, enc, cb) {
    const lines = chunk.toString().split('\n');
    const errors = [];

    for (const line of lines) {
      if (line.includes('ERROR')) { errors.push(line); this.errorCount++; }
      else if (line.includes('FATAL')) { errors.push(line); this.fatalCount++; }
      else if (line.includes('WARN')) this.warnCount++;
      else if (line.includes('INFO')) this.infoCount++;
    }

    if (errors.length > 0) cb(null, errors.join('\n') + '\n');
    else cb(); // 跳过大段 INFO——减少输出体积
  },
  // 初始化计数器
  errorCount: 0, fatalCount: 0, warnCount: 0, infoCount: 0
});

// 管道:1GB server.log → 过滤 → 压缩 → 写入
pipeline(
  fs.createReadStream('server.log', { highWaterMark: 64 * 1024 }),
  filterAndCount,
  zlib.createGzip(),
  fs.createWriteStream('errors_only.log.gz'),
  (err) => {
    if (err) {
      console.error('Pipeline failed:', err);
    } else {
      console.log('Done! Stats:',
        `ERROR=${filterAndCount.errorCount}`,
        `FATAL=${filterAndCount.fatalCount}`,
        `WARN=${filterAndCount.warnCount}`,
        `INFO=${filterAndCount.infoCount}`
      );
    }
  }
);

// 这套管道处理 1GB 文件,内存占用 < 100 MB
// 因为数据是一块一块"流"过去的——任何时候内存中只保存 ~64KB(highWaterMark)
// 对比:fs.readFile('server.log') 会把整个 1GB 读进内存 → 1GB RSS 起步
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48

内存轨迹对比:

        fs.readFile (1GB)              pipeline(流式处理)
RSS     │                              RSS
1.2 GB  │       ╱──────                │
1.0 GB  │     ╱                        │
800 MB  │   ╱                          │
600 MB  │ ╱                            │
400 MB  │╱                             │  ───────────── ~80 MB ──────────
200 MB  │                              │ ╱                                ╲
  0     └────────────── t              └────────────────────────────────── t
        读入       处理      写入         随时都在"吃→处理→吐",内存恒定
1
2
3
4
5
6
7
8
9
10

# 10.3 设计哲学回扣

哲学一·「libuv 的事件循环是对 OS 的适配层」

浏览器的事件循环被 HTML 规范约束(渲染管线、4ms 夹持、每帧 16.67ms 预算)。Node.js 的事件循环由 libuv 驱动——它直接面对 OS 的 I/O 模型:Linux 的 epoll、macOS 的 kqueue、Windows 的 IOCP。同一个 setTimeout——浏览器和 Node 跑在不同的调度器上,不同的约束之下。 这不是"谁更好"的问题,而是:浏览器的循环必须为"一帧一帧画给用户看"服务;Node 的循环必须为"一个线程服务 10000 个 socket"服务。调度器的设计,由应用场景决定。

哲学二·「背压是流式架构的诚实信号」

write()→false 不是错误——它是下游在说"我处理不过来"。不处理这个信号 = 无视物理限制(内存不是无限的,磁盘不是无限快的)。流式编程的核心不是"怎么传数据",而是"怎么在传数据的每一步都对下游保持诚实"。 pipeline() 把这个诚实从"开发者手动管理"变成了"框架自动保证"——每一块数据进入管道时,下游都有明确的"我吃完了"信号,上游才继续投喂。这本质上是一个分布式反压算法——只是发生在一个进程内。

哲学三·「同构不是共享代码——是共享契约」

SSR 组件在 Node 和浏览器两端运行——不是因为代码一样(document 在 Node 端不存在,Buffer 在浏览器端不存在),而是因为它们遵循同一个渲染契约:Node 端产出的 HTML 字符串结构和浏览器端 hydrate 的 DOM 结构一一对应。同构的本质不是"代码能同时跑",而是"两个运行时对同一个输入的输出收敛到同一个结果"。 这个契约是现代前端框架(React/Next.js, Vue/Nuxt)的根基——它让"首屏在服务端渲染"和"后续交互在客户端运行"共享同一套组件逻辑。

哲学四·「Buffer 是历史的资产,不是历史的包袱」

Buffer 诞生于一个 TypedArray 不存在的年代。当 ES6 引入 Uint8Array 后,Node 没有废弃 Buffer——而是让它成为 Uint8Array 的子类,同时保留了自己的特色(内存池、slice 共享、字节序读写)。这是一种务实的取舍:向下兼容所有已有 npm 包的 Buffer API,同时向上对齐 ECMAScript 标准。 在同构代码里用标准 API;在纯 Node 代码里用 Buffer 更快——两种选择都对,取决于你是否需要"跑在浏览器里"这个约束。

# 10.4 速查表

libuv 六阶段:

阶段 回调类型 典型 API 饿死风险
timers 定时器到期 setTimeout / setInterval 如果 poll 一直繁忙,timer 延迟增加
pending 上轮延迟 I/O 错误 TCP ECONNREFUSED / ECONNRESET 低(通常极少回调)
poll I/O 完成(核心) fs.readFile / http.get / net.connect 主动退出保护(有 setImmediate 在等)
check setImmediate 回调 setImmediate(fn) 不会饿死(poll 主动退出保证)
close 关闭事件 socket.on('close') / stream.destroy() 低(只在新 close 时触发)

Stream 四类型:

类型 读 写 内部方法 典型场景
Readable ✅ ❌ _read(size) fs.createReadStream / 网络下载
Writable ❌ ✅ _write(chunk, enc, cb) fs.createWriteStream / HTTP 响应
Duplex ✅ ✅ _read + _write(独立) net.Socket / tls.TLSSocket
Transform ✅ ✅ _transform(chunk, enc, cb) + _flush(cb) zlib.createGzip() / 过滤器 / 加密

Buffer 安全分配:

方法 清零 内存池 速度 安全性
Buffer.alloc(n) ✅(零填充) ✅(≤4KB 走 slab 池) 慢 安全(默认选择)
Buffer.allocUnsafe(n) ❌ ✅(≤4KB 走 slab 池) 快 ~5x ⚠️ 可能含旧内存数据
Buffer.allocUnsafeSlow(n) ❌ ❌(强制堆分配) 快 ⚠️ 仅供 C++ addon 场景
Buffer.from(string) N/A(已有内容) ✅(拷贝进 slab) 中 ✅ 安全
Buffer.from(arrayBuffer) N/A(零拷贝共享) ❌ 极快 ✅ 安全(共享底层内存)

同构双端差异速查:

差异点 浏览器 Node.js 统一方式
全局对象 window global globalThis
模块系统 ESM 原生 CJS + ESM 双导出(main + module/exports)
4ms 夹持 ✅ ❌ 不依赖精确延迟
__dirname ❌ ✅ ESM 下用 import.meta.url
Buffer ❌ ✅ 同构代码用 Uint8Array

60 秒诊断命令清单:

# 看事件循环活跃句柄(哪些东西让进程不退)
node --trace-event-categories node.perf --trace-event-file-pattern 'trace.json' server.js

# 看 Stream 背压状态(process.stdout / stderr 的缓冲)
node -e "console.log(process.stdout.writableLength)"

# 看 libuv 线程池并发度
UV_THREADPOOL_SIZE=8 node server.js

# 看 Buffer 内存池大小(默认 8KB)
node -e "console.log(Buffer.poolSize)"

# 看进程 RSS 趋势
watch -n 1 "ps -o pid,rss,comm -p \$(pgrep -f node)"

# 强制 GC(排查内存泄漏时的快照)
node --expose-gc -e "global.gc(); console.log(process.memoryUsage())"
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

背压调试模板:

// 监控任意 Writable Stream 的缓冲状态
function monitorBackpressure(name, writable) {
  const interval = setInterval(() => {
    const len = writable.writableLength;
    if (len > 1024 * 1024) { // > 1MB
      console.warn(`⚠️ ${name}: writableLength=${(len/1024/1024).toFixed(1)}MB`);
    }
  }, 1000);
  writable.on('close', () => clearInterval(interval));
}

const writer = fs.createWriteStream('./log');
monitorBackpressure('log-writer', writer);
1
2
3
4
5
6
7
8
9
10
11
12
13

下一步:浏览器和 Node 都跑通了。但一个产品不是单文件——模块系统怎么组织?进入 13.模块系统双轨互操作。

📌 本文与 C++ 专栏的关系:C++ 专栏第 01 篇讲的是"进程地址空间——数据住在哪一段",本文讲的是"运行时——数据怎么流动"。读完后你会看到:Node.js 的 Buffer(JavaScript 层的 Uint8Array 子类)和 C++ 的堆/mmap(操作系统层的虚拟内存段)——它们之间隔了一个 V8 的堆、一层 libuv 的 slab 池、一次系统调用的边界。理解"数据从 JS 到 OS"的完整路径,是成为全栈工程师的核心能力。

上次更新: 2026/06/16, 12:36:20
网络接口存储架构
模块系统双轨操作

← 网络接口存储架构 模块系统双轨操作→

最近更新
01
信号崩溃快速排查
06-15
02
CoreDump破案
06-15
03
perf火焰图实战
06-15
更多文章>
Theme by Vdoing | Copyright © 2019-2026 杨充 | MIT License | 桂ICP备2024034950号 | 桂公网安备45142202000030
  • 跟随系统
  • 浅色模式
  • 深色模式
  • 阅读模式