10MB JSON 实测:异步读取仍会阻塞解析
作者比较 Node.js 同步读取、异步读取后解析和工作线程解析:前两种均在解析阶段阻塞事件循环约 70—90 毫秒。工作线程保持了主线程响应,但单次解析总耗时增加约 100—200 毫秒。
AI 深度解读
把文件读取改成 await,并不能让 JSON.parse 也变成异步。作者用同一份约 10MB 的 JSON 文件比较了三条处理路径:同步读取并解析、异步读取后解析、交给 Worker 读取并解析。前两条都会卡住 Node 主线程,第三条能保住响应能力,却增加了总耗时。
这组实验值得看的地方,是它把「任务多久完成」和「期间还能不能处理别的事」分开测量了。对 Node 服务来说,这两个指标经常决定着不同的技术选择。
为什么异步读取救不了 JSON.parse
测试文件包含 40,000 条商品数据,每条都有 id、SKU、标题、价格、标签、库存标记和 metadata。整个数组经 JSON.stringify 序列化后,文件大小为 10,403,316 字节,作者记作 10.4MB。
三条路径的区别在于,哪些工作留在主线程:
fs.readFileSync读取文件,再调用JSON.parse,两步都同步执行。fs.promises.readFile异步读取文件,拿到结果后仍由主线程执行JSON.parse。- 通过
worker_threads创建Worker,在工作线程内完成读取和解析,再向主线程返回行数。
第二条路径容易让人误判。文件读取期间,主线程确实可以处理其他任务;但读取完成后,把字符串转换成 JavaScript 对象,仍是一段需要连续占用 CPU 的同步工作。await 只能等待前面的异步操作,不能改变后面函数的执行方式。
异步读取解决的是等待磁盘时的阻塞,无法消除 JSON.parse 占用主线程的时间。
作者用一个每 1ms 调度一次的 setInterval 观察响应能力。如果主线程正在执行同步解析,定时器回调就没有机会运行。这比只看解析函数花了多久,更接近服务端真正关心的问题:解析期间,其他请求有没有机会被处理。
Worker 保住了响应,为什么完成得更慢
每条路径各跑三次,同步读取路径中,单独的 JSON.parse 耗时分别为 79.42ms、84.83ms、93.13ms。三次解析窗口内,定时器回调计数都是 0。
异步读取路径在等待 I/O 时能执行 17 到 23 次回调,但进入解析后同样停住。三次解析耗时为 71.33ms、83.9ms、73.97ms,与同步路径处于相近范围。
所以,不能因为磁盘读取已经异步化,就认为整个数据加载过程都不会阻塞。这个文件的解析成本已经足以主导任务耗时,换一种读取方式没有解决主要矛盾。
Worker 路径中,主线程定时器分别执行了 168、207、259 次。这支持作者最重要的观察:解析搬走以后,主线程仍有机会持续运行其他任务。
代价也很明确。Worker 内部测得的解析时间分别为 109.18ms、160.36ms、143.5ms,反而比主线程上的同步解析更慢。从主线程开始创建 Worker,到收到结果的端到端耗时,则分别达到 205.66ms、258.38ms、319.76ms。
按作者的概括,整个任务约慢了两到三倍,多付出了约 100 到 200ms。
这里采用的是每次任务新建一个 Worker 的方式,需要创建线程和新的 V8 isolate,也就是相对独立的 JavaScript 执行环境。任务完成前,还要经历文件路径传入和结果消息返回。对于一次性解析,这些固定开销很难摊薄。
还有一个容易遗漏的边界:Worker 只返回了解析后的行数。
如果实际业务需要把整个商品对象数组交回主线程,通信和数据交接的成本就不再等同于返回一个数字。原文没有测试这种情况,因此这组端到端耗时不能直接当成「主线程拿到完整解析结果」的成本。
应该先决定保护谁,再决定用不用 Worker
如果程序只在启动时读一次配置或数据文件,尚未接收请求,也没有其他任务依赖主线程响应,那么同步处理可以是合理选择。在作者这次测试里,它完成得更快,也省去了线程管理。
不过,「启动时」不自动等于「没有其他工作」。假如进程已经开放健康检查,或者正在执行其他初始化任务,同步解析仍可能影响这些任务。关键是解析发生的那个时间窗口里,究竟有没有事情需要及时执行。
对于已经处理并发请求的 Node 服务,取舍会不同。单个任务多花一些时间,可能换来其他请求继续被调度。桌面应用中承担界面职责的线程,以及同时处理定时任务的进程,也有类似需求。
Worker 的价值,是让主线程保持可用;任务本身是否更快,需要另算。
如果需要反复解析文件,作者提出可以复用长期运行的 Worker pool,把启动成本分摊到多个任务。但他们没有测试线程池,因此不能用本文数据推断复用后的每次开销,更不能直接宣称吞吐量会提高多少。
文件大小同样影响判断。作者称,同一机器上的 1MB 文件只需几毫秒;对 50MB、100MB 文件,则预期同步阻塞可能进入数百毫秒甚至更久。后两者没有给出具体测量结果。对象形状和嵌套深度也会改变解析成本,不能把 10MB 的数据简单按比例放大。
我的判断
我认同作者把响应能力与完成时间分开讨论。这比一句「大 JSON 用 Worker」更有用,也比只强调异步读取更准确。
但这是一组范围有限的实验,不足以给出通用阈值。
首先,原文没有交代 Node 版本、硬件配置、操作系统,也没有提供完整测试代码。每条路径只有三次结果,能说明这次运行中的明显差异,无法据此判断不同环境下的稳定分布。
其次,每次重新从磁盘读取文件,不代表绕过了操作系统文件缓存。作者强调没有复用内存中的文件副本,但没有说明如何控制缓存,因此「不是预热后的最佳情况」还不能被严格确认。
定时器指标也需要收紧解释。1ms 是请求的调度间隔,并不保证回调严格每毫秒执行一次。168、207、259 次回调说明主线程持续获得了执行机会;但仅凭总次数,不能证明作者所说的「零丢失」或全程没有明显间隙。后者需要回调时间戳、最大间隔等证据。
此外,总耗时与 Worker 内部解析时间之间的差值,不能全部归为启动成本;它还包含其他未单独拆出的工作。作者观察到了额外开销,但没有完整分解来源。
与流式解析相比,Worker 也只是另一种取舍:前者尝试改变数据消费方式,后者把现有同步工作搬到其他线程。原文没有测试流式方案,也没有比较内存占用。对于能够逐条处理的数据导入任务,不能据此认定 Worker 就是最佳选择。
能拿来做什么
- 检查 Node 项目里读取大文件的调用链,尤其是
fs.promises.readFile后紧接JSON.parse的位置。把读取与解析分别计时,确认真正占用主线程的是哪一步。 - 对处理商品数据或批量导入的服务,同时测任务总耗时和其他请求的延迟。是否值得增加 Worker 开销,应由并发任务受到的影响决定。
- 如果准备使用 Worker,按业务真实需要返回数据。只返回行数、返回汇总结果、返回完整对象数组,是不同的测量场景,应分别验证。
- 对反复执行的解析任务,比较每次新建 Worker 与复用 Worker pool 的表现;对一次性的启动加载,则先确认服务是否已经需要响应请求,再决定是否接受同步阻塞。