文章

Node.js 入门:单线程的服务器,凭什么扛住上万连接

先跑一段代码(浏览器控制台和 Node 里都行):

1
2
3
4
console.log("A");
setTimeout(() => console.log("C"), 0);
Promise.resolve().then(() => console.log("B"));
console.log("D");

setTimeout 的第二个参数是 0 毫秒–零延迟,”马上”的意思。很多人预测输出是 A C B D 或 A B C D。实际输出是:

1
2
3
4
A
D
B
C

两处意外:延迟 0 毫秒的回调不但没有插队,反而排在了所有同步代码之后;而更晚登记的 Promise 回调,居然跑在它前面。零毫秒不等于马上,这背后是整个 Node.js 的命门。

再看一个更大的谜题:Node.js 的服务器是单线程的–同一时刻只做一件事。可正是它,撑着同一个 WebSocket 上万人的聊天室、每秒几千次的 API 请求。一个排队都要排死的单线程,凭什么?

这篇文章就沿着这条谜题往下爬。

L1:先让它跑起来–三行代码起一个服务器

你在第一层,目标是把 Node 装好、跑起来,对它”是什么”有个不跑偏的认识。

先纠正一个常见误解:Node.js 不是一门语言,也不是一个框架。它是一个 JavaScript 运行时(runtime,就是”让代码真正跑起来的那套环境”)–把浏览器里的 JS 引擎 V8 挖出来,再配上一套操作系统能力的胶水层(读写文件、开网络端口、起进程)。JavaScript 负责算,Node 负责让它摸到你的硬盘和网卡。

跑起来只要两步:

1
2
3
4
5
# 1. 从 nodejs.org 装好 Node(自带 npm,包管理器,装别人写好的轮子用)
# 2. 新建 hello.js,写入一行:
console.log("Hello from Node");
# 然后执行:
node hello.js

输出 Hello from Node。没有编译,没有构建,存盘即跑。

接着是本文的主角–一个能用的 HTTP 服务器,真的只有几行:

1
2
3
4
5
6
7
8
9
// server.js
const http = require("http");

http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
  res.end("你好,Node");
}).listen(8080);

console.log("服务器已启动:http://localhost:8080");

node server.js,浏览器打开 http://localhost:8080,你会看到”你好,Node”。一个 require(导入模块)、一个回调(callback,”你先拿着这段代码,事情办完了替我执行”的托付),服务器就活了。

到这里你已经会用了。但这三行代码解释不了一件事:浏览器每刷新一次,服务器就收到一个请求;你现在开着 50 个标签页一起刷新,这个单线程的服务器不仅没排队卡死,反而每个响应都是秒回。它在等第 1 个请求的网络数据时,拿什么去处理第 2 个?往下爬一层。

L2:懂原理–事件循环,一张登记表加一个永不停歇的循环

你在第二层了,目标是搞清楚”底下发生了什么”。

先请一位服务员出场。想象一家只有一个服务员的餐厅:如果他接待方式是”站在这桌旁边,等客人看完菜单、点完菜、吃完、结完账,再去下一桌”(每次只服务一桌),那这家店基本开不下去。真实的服务员是另一种做法:把菜单放下就走,去招呼下一桌;谁举手了再过去处理。等待是客人的事,服务员只处理”有事”的桌。

Node 就是这个服务员。”一个服务员”= 单线程;”谁举手了再过去” = 事件循环(event loop)。它的工作方式可以压缩成一张登记表加一个死循环:

  1. 登记:碰到”要等”的操作(读文件、等网络、设定时器),Node 不站在原地等,而是把”办完之后干什么”写进登记表,然后立刻继续执行下一行代码;
  2. 盯表:后台的机制(定时器到了、网卡数据到了、文件读完了)把对应的回调移入待办队列;
  3. 叫号:主线程一空下来,就从这个队列里取出回调,执行它;执行完再取下一个,循环往复,进程不退出。

现在回头解释开头的谜题。setTimeout(fn, 0) 的意思是”把 fn 登记到定时器表里,最早 0 毫秒后可执行”——注意是”最早”,它必须等主线程把眼前的活(console.log("D"))全部干完,循环走到定时器阶段才轮到它。所以 C 排在 D 后面。

那 B 为什么插到 C 前面?因为队列其实分优先级:微任务(microtask,Promise.then 这类,”办完手头这一件事就立刻处理”的插队通道)的优先级高于宏任务(macrotask,setTimeout、IO 回调这类,要等本轮循环走到对应阶段)。每个回调执行完,循环都会先清空微任务队列,再去做下一件宏任务。于是顺序定格为 A D B C。

还有一个被藏起来的事实,值得单独说:“单线程”指的是你的 JavaScript 代码只有一个线程在跑,但 Node 进程里并非只有一条线程。读磁盘、解析 DNS 这类操作系统也没有”做完叫你”机制的活,是由一个默认 4 条线程的线程池(libuv 提供,Node 底层那套 C 库)默默干掉的。你的代码是单线程的,脏活累活不在你的线程上。

动手验证一下。把下面这段存成 loop.js,先在心里预测输出顺序,再运行:

1
2
3
4
setTimeout(() => console.log("1 定时器"), 0);
Promise.resolve().then(() => console.log("2 微任务"));
setImmediate(() => console.log("3 immediate"));
console.log("4 同步");

前两个的答案是确定的:

1
2
4 同步
2 微任务

同步代码最先、微任务插队到所有宏任务之前,这部分跟上面的分析一致。但”1”和”3”谁先?多跑几次,你会发现顺序会变。这不是玄学:setTimeout(fn, 0) 其实会被 Node 悄悄钳成 1 毫秒(浏览器也这么干,防止被零延迟定时器刷爆),而循环头一次走到”定时器阶段”时这 1 毫秒过没过去,取决于进程启动那些琐碎开销–差之毫厘,”1”和”3”就对调。顺带记住一个确定的规则:如果这段代码写在某个 IO 回调里面,setImmediate 永远先于定时器执行。零毫秒不是零毫秒、主模块里顺序还会掷硬币–这就是很多”Node 面试题”的出处。看穿它们,你就拿到了读一切 Node 异步代码的钥匙。

到这里,”怎么工作”已经清楚了。但一个更根本的问题还悬着:这设计未免太绕了吧?给每个请求开一个线程,各等各的,各跑各的,不是更符合直觉吗?Apache 服务器不就这么干了二十年?要回答它,得爬到第三层。

L3:想得透–为什么非得是事件循环

你在第三层,最后一层。这一层不谈”怎么用”也不谈”怎么跑”,谈”为什么非这样不可”。

把时间拨回 2009 年。当年的主流服务器 Apache 用的是”一请求一线程(或进程)”模型:来一个连接,开一条线程去伺候。这在线程几十条时没什么问题,但随着互联网用户暴涨,一个尖锐的问题浮出水面–C10K 问题:一台服务器怎么同时扛住一万个连接?

线程模型在万级连接面前撞了两堵墙:

  • 内存墙:每条线程都要预留 MB 级的栈空间(Linux 默认 8MB,哪怕实际只用一小角),一万个连接光栈就要几十 GB 虚拟内存,还没干正事先把机器吃穿了;
  • 切换墙:操作系统在几万条线程之间来回调度,光是”记住每条线程干到哪了、换下一条”的上下文切换,就能把 CPU 的相当一部分时间耗在纯管理开销上。

而最冤枉的是:这些线程大部分时间根本没在干活,只是在等–等网卡、等磁盘。几万条线程,95% 的时间在睡觉,却照付内存和调度的成本。2009 年,Ryan Dahl 把这个观察推到极端:既然瓶颈是”等”,那就别让线程去等。他把 V8 和事件驱动库 libuv 拼在一起,Node.js 诞生了。一万个连接在事件循环模型下意味着什么?一万条登记表记录–每条不过是一个回调加几百字节,一个服务员扫一眼举手名单的功夫。

所以本质段来了。去掉所有术语之后,Node.js 到底是什么?

Node.js 把”等待”本身变成了廉价的纸面记录:你不用派一个人站在那里等,只要留一张”事情办完之后干什么”的字条。一个永不停歇的循环替所有人盯梢,谁的事到了就叫谁。

服务员不站在桌边等,菜单上夹一张”叫我就来”的便签,就腾出了接待整条街的吞吐量。衡量一个东西是不是”本质”,标准是读者能不能不借用任何术语把同事讲懂–你可以试试只用”字条”和”叫号”讲一遍。

当然,这笔交易不是没有代价,而且代价正好砍在它的软肋上:只要有一桌客人把服务员拉住不放,全店瘫痪。事件循环的前提是每段代码都”快进快出”,一旦你的 JS 里出现一个重计算–

1
2
3
4
5
6
7
// block.js -- 跑之前想清楚,它会卡死 3 秒
setInterval(() => console.log("心跳"), 500);

setTimeout(() => console.log("3 秒到了"), 3000);

const start = Date.now();
while (Date.now() - start < 3000) {} // 同步死等 3 秒

运行它,预期行为:头 3 秒内一个”心跳”都打不出来,3 秒一到,”心跳”和”3 秒到了”挤在一起喷出来。因为那条 while 循环霸占了唯一的主线程,登记表上的字条堆成山也没人叫号。这在”一请求一线程”模型里最多拖慢一个请求,在 Node 里是全站冻结。同样是这段代码,你也顺手验证了 L2 的结论:定时器到点≠回调执行,还得等主线程腾出手。

一句话总结这一层的设计权衡:线程模型用内存和切换成本买”隔离”,事件循环用”全员自律”换”容量”。IO 密集(等得多、算得少)的活儿,Node 赢得漂亮;CPU 密集(算得多)的活儿,一条 while 就能撕毁契约。后来 Node 官方也补了 worker_threads(真线程,用来跑重计算,跑完把结果交还主线程),算是给这道软肋打上了补丁。

收尾:一张地图

什么时候选 Node,什么时候别选:

你的活儿长什么样该不该用 Node原因
大量等待、少量计算(API 网关、聊天室、实时推送、爬虫)很合适事件循环把”等”的成本压到近乎为零
单次计算就要跑几百毫秒以上(视频转码、密码破解、大数据运算)别用主线程一段重计算冻结全站;要用也得 worker_threads
本来就跑在浏览器里的 JS 团队想写后端很合适前后端同一种语言,JSON.parse 用到飞起

两个自测问题,能答上来说明真懂了:

  1. setTimeout(fn, 0) 为什么不是”马上执行”?(答案在 L2 的登记表与定时器阶段。)
  2. Node 明明有线程池,为什么还叫”单线程”?(答案在 L2 末段:谁的代码是单线程的。)

下一站按顺序走会更顺:先用 async/await 把回调写法升级(你会发现它只是给微任务排队换了身衣服,回头看 L2 的输出顺序会”原来如此”);然后是 Stream(流–让大文件像水管一样边流边处理,而不是憋成一整桶);再上手 Express 或 Fastify 写真正的 API。到那时再回头看这张登记表,你会看见它无处不在。

1
2
3
{
  "下一站": "async/await -> Stream -> Express"
}
本文由作者按照 CC BY 4.0 进行授权