端学习笔记 · 第 124 天:阻塞与非阻塞——同步方法、定时器与主线程排队
- 2026-11-13 06:08:51
前端学习笔记 · 第 124 天:阻塞与非阻塞——同步方法、定时器与主线程排队
Day 124 · 第五阶段 · Node.js 与后端开发
Node 的 JavaScript 只有一条主线程。任何占住主线程的代码,都会让定时器、网络回调与其他请求一起等待。理解「谁在占主线程」,比记住 API 更能解释线上出现的排队现象。
一、问题现场
四个现象:
一个接口用了同步读取,300 毫秒内其他请求全部没有响应。 把一段 CPU 密集循环放进请求处理,所有定时器集体推迟。 以为 setTimeout(fn, 0)会立刻执行,实际排在整段同步代码之后。setInterval的回调耗时超过间隔,节奏越来越乱,间隔不再是设定值。
前两个与主线程被占用有关,后两个与定时器的排队和执行时机有关。
二、阻塞与非阻塞
阻塞指后续 JavaScript 必须等一个非 JavaScript 操作完成才能继续,事件循环在此期间无法运行其他回调。非阻塞指调用立即返回,结果通过回调或 Promise 交付,主线程可以继续处理别的工作。
Node 标准库里的同步方法通常以 Sync 结尾,例如 readFileSync、statSync,它们是最常见的阻塞来源。对应的异步版本 readFile、stat 则是非阻塞的。
import { readFileSync } from "node:fs";const data = readFileSync("notes.txt"); // 读取完成前,主线程停在这里console.log(data.length);同一个操作换成异步版本,主线程在等待期间可以继续执行:
import { readFile } from "node:fs/promises";const data = await readFile("notes.txt"); // 等待交给线程池console.log(data.length);两者输出一致,区别在于等待发生在哪里。同步版本让主线程原地等待,异步版本把等待交给后台线程,结果回来后再排队执行回调。
按 Node 文档的术语,CPU 密集的 JavaScript 不称为阻塞调用,因为它没有等待非 JavaScript 操作。但它同样占满主线程,从其他请求的角度看,效果与阻塞一致。这类代码没有异步版本,只能切分成小段,在段与段之间让出主线程。
同步与异步混用有一个典型陷阱:异步读取还没有完成,同步删除已经执行。
import { readFile, unlinkSync } from "node:fs";readFile("notes.txt", (err, data) => { if (err) throw err; console.log(data.length);});unlinkSync("notes.txt"); // 可能在读取完成之前就删掉了文件unlinkSync 是同步调用,会先于异步读取的回调执行,文件可能在读之前就被删除。正确做法是把删除放进读取的回调里,让两个操作的顺序由回调串起来。
非阻塞的价值体现在吞吐上。一个请求耗时 50 毫秒,其中 45 毫秒在等数据库,异步版本能把这 45 毫秒让给其他请求;同步版本则让整条主线程陪等。
三、定时器基础
setTimeout 在指定延时后执行一次回调,返回一个 Timeout 对象;浏览器里返回的是数字 ID,Node 里是对象,两者都能传给 clearTimeout 取消。
const timer = setTimeout(() => { console.log("100ms 后执行");}, 100);clearTimeout(timer); // 取消尚未触发的回调setTimeout 还可以把参数传给回调,省去一层闭包:
setTimeout((name, count) => { console.log(name, count);}, 100, "任务", 3);延时设为 0 表示「尽快执行」,但仍然要等当前同步代码跑完。实测输出顺序是「同步开始、同步结束、setTimeout(0) 回调」,回调没有插到两行同步日志之间。
setInterval 按固定间隔重复执行,需要用 clearInterval 停止。常见写法是在回调内部判断条件,满足后自己停掉:
const timer = setInterval(() => { if (done) { clearInterval(timer); return; } console.log("tick");}, 100);延时不等于精确时间。实测 setTimeout(100) 实际在 104 毫秒触发,误差来自定时器排队与主线程调度。delay 只是一个最短等待值,不是保证值。
四、执行顺序与偏差
同一延时的多个定时器按注册顺序执行。实测两个 100 毫秒定时器分别打印 A、B,输出为 A B。不同延时的定时器按到期时间执行,50 毫秒的 C 先于 100 毫秒的 D。
主线程忙时,到期的定时器只能排队。实测先注册一个 100 毫秒定时器,再执行 500 毫秒的同步循环,回调在 500 毫秒才触发。到期时间只决定它什么时候进入队列,执行时间由主线程何时空闲决定。
setInterval 不关心上一次回调何时结束。实测间隔 100 毫秒、每次回调耗时 150 毫秒时,三次的间隔为 106、150、150 毫秒:回调一结束,下一次立刻开始,实际节奏被拉长到回调耗时。
需要「上一次结束后再等固定间隔」时,用递归 setTimeout:
function tick() { const end = performance.now() + 150; while (performance.now() < end) { // 本次回调耗时 150ms } setTimeout(tick, 100);}setTimeout(tick, 100);同样的 150 毫秒回调,递归写法实测间隔为 105、250、258 毫秒:回调耗时与间隔相加,两次执行不会挤在一起。
没有回调耗时的时候,setInterval 的偏差也很小。实测间隔 100 毫秒触发五次,间隔为 109、103、102、104、112 毫秒,合计 530 毫秒,略多于理论值 500 毫秒。
五、实战示例:被同步路由拖住的服务器
把阻塞与非阻塞放进一个服务器:/sync 用同步循环占用主线程,/async 用 await 等待定时器,/ping 立即返回。
// server.mjs —— 同步阻塞与异步非阻塞的对比import { createServer } from "node:http";const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));function block(ms) { const end = performance.now() + ms; while (performance.now() < end) { // 占满主线程,不交出控制权 }}const server = createServer(async (req, res) => { const url = new URL(req.url, "http://localhost"); const ms = Number(url.searchParams.get("ms") ?? 300); if (url.pathname === "/sync") { block(ms); res.end(`sync ${ms}`); return; } if (url.pathname === "/async") { await sleep(ms); res.end(`async ${ms}`); return; } res.end("pong");});server.listen(3124, () => console.log("http://localhost:3124"));并发请求的测量方式是:先发一个 300 毫秒的慢请求,20 毫秒后再同时发三个 /ping。
$ node client.mjssync 慢请求 302ms /ping 276 / 275 / 275msasync 慢请求 307ms /ping 2 / 1 / 1ms同步模式下,慢请求占住主线程,三个 /ping 只能等它结束,耗时都在 275 毫秒左右。异步模式下,慢请求的等待交给定时器,/ping 在 1 到 2 毫秒内返回。同一个服务器,只改了一个等待方式,并发请求的响应时间差了两个数量级。
这也解释了「一次请求为什么要排队」:不是网络慢,而是主线程正被前一个同步操作占着。
常见问题
Q1:阻塞与非阻塞、同步与异步有什么区别?
同步与异步描述结果的交付方式:调用是否直接返回结果。阻塞与非阻塞描述主线程是否被占用。Node 的同步方法同时具备「同步」与「阻塞」两个特征,异步方法同时具备「异步」与「非阻塞」两个特征。
Q2:为什么 setTimeout(fn, 0) 不是立刻执行?
定时器的回调要等当前同步代码执行完、主线程空出来才轮到。0 只表示尽快进入队列,不会打断正在执行的函数。
Q3:定时器为什么不准?
delay 是最短等待时间。主线程忙、事件循环在处理其他回调时,到期的定时器只能排队,实际触发时间会晚于设定值。
Q4:setInterval 与递归 setTimeout 怎么选?
回调耗时固定且远小于间隔时用 setInterval;回调耗时变化大、不允许两次执行挨在一起时用递归 setTimeout,它在本次结束后才开始计时。
Q5:同步方法在什么场景下可以用?
启动阶段读取配置、一次性脚本、命令行工具里可以用,此时没有并发请求需要照顾。请求处理路径、服务器主循环里应使用异步版本。
Q6:CPU 密集任务如何处理?
切分成小段并在段之间让出主线程,或者交给工作线程与子进程执行。放在主线程的长时间循环没有任何异步版本,只能占满事件循环。
知识点总结
阻塞:
后续 JavaScript 必须等非 JavaScript 操作完成,事件循环无法继续
识别方式:
标准库中带
Sync后缀的方法通常是阻塞调用非阻塞:
调用立即返回,结果经回调或 Promise 交付,主线程继续工作
术语边界:
CPU 密集的 JavaScript 不称为阻塞调用,但同样占满主线程
混用风险:
异步读取后紧跟同步删除,删除可能先执行,顺序必须由回调串联
定时器返回值:
Node 返回
Timeout对象,clearTimeout与clearInterval用于取消零延时:
回调排在当前同步代码之后,不会打断正在执行的函数
到期不等于执行:
主线程忙时定时器只能排队,实测 100ms 定时器被拖到 500ms
间隔选择:
setInterval不看回调耗时,递归setTimeout能把回调耗时与间隔分开
(本文为学习笔记整理)