关于异步与同步代码

不要轻易使用同步代码块,会导致系统卡死

fs.promises.readFile is 40% slower than fs.readFile

Why is fs.readFileSync() faster than await fsPromises.readFile()?

在调试器中逐步检查每个实现(fs.readFileSyncfs.promises.readFile)之后,我可以确认同步版本(fs.readFileSync)一次性读取整个文件(即文件的完整大小)。而 fs.promises.readFile() 则是每次读取 16,384 字节,在一个循环中等待每次读取完成(即使用 await)。这意味着在读取整个文件之前,fs.promises.readFile() 会多次返回事件循环。这不仅给其他任务运行的机会,而且在循环的每次迭代中返回事件循环还会产生额外的开销。此外,还存在内存管理的开销,因为 fs.promises.readFile() 会分配一系列的 Buffer 对象,然后在最后将它们合并起来,而 fs.readFileSync() 在一开始就分配一个较大的 Buffer 对象,并直接将整个文件读入到这个 Buffer 中。

所以,从纯粹的完成时间角度来看,同步版本(允许独占整个 CPU)是更快的(在多用户服务器中,从 CPU 使用的效率角度来看,这样做的效率会显著降低,因为它在读取期间阻塞了事件循环,无法执行其他任务)。异步版本则是以较小的块来读取,可能是为了避免过多地阻塞事件循环,从而使其他任务可以在 fs.promises.readFile() 执行时有效地交错运行。

对于我之前参与的一个项目,我自己写了一个简单的异步版本的 readFile(),可以一次性读取整个文件,并且明显比内置的实现更快。那个项目中我并不关心事件循环的阻塞问题,所以没有深入研究这是否会带来什么影响。

此外,fs.readFile() 使用 524,288 字节的较大块来读取文件(比 fs.promises.readFile() 的块更大),并且没有使用 await,只是使用了普通的回调。显然它的实现比 Promise 的实现更为高效。我不清楚为什么他们在实现 fs.promises.readFile() 时会使用更慢的方法。目前看来,将 fs.readFile() 包装为一个 Promise 可能会更快。

同步代码阻塞

ts
1import { Elysia } from "elysia";
2import * as fs from "node:fs";
3import { dirname, join } from 'node:path';
4import { fileURLToPath } from 'node:url';
5
6const __filename = fileURLToPath(import.meta.url);
7const __dirname = dirname(__filename);
8
9const app = new Elysia()
10    .get("/", () => "Hello Elysia")
11    .get('/sync',()=>{
12        for (let i = 0; i < 100000; i++) {
13            fs.readFileSync(join(__dirname, 'hello'))
14        }
15        return 'Sync Read'
16    })
17    .get('/async', async () => {
18        for (let i = 0; i < 100000; i++) {
19            await fs.promises.readFile(join(__dirname, 'hello'))
20        }
21        return 'Async Read'
22    })
23    .listen(3000);
24
25console.log(
26  `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`
27);

这段代码是使用 Elysia 框架编写的一个 HTTP 服务器,它包含了三个路由,其中两个路由分别使用了同步与异步的文件读取操作。

核心总结:

  1. 代码结构

    • GET / 路由:返回简单的 "Hello Elysia" 字符串。
    • GET /sync 路由:使用 fs.readFileSync() 同步读取文件 100000 次。
    • GET /async 路由:使用 fs.promises.readFile() 异步读取文件 100000 次。
  2. 单线程与事件循环的影响

    • Node.js 采用单线程事件循环模型来处理请求,即所有代码都在一个线程中执行。
    • 同步操作(fs.readFileSync())会阻塞事件循环,导致主线程在读取文件期间无法处理其他请求。由于 Node.js 只有一个线程来处理请求,这种阻塞行为会导致服务器无法并发处理其他请求,影响整体性能。因此,当访问 /sync 路由时,服务器的所有其他请求都会被阻塞,直到 100000 次同步读取完成。
    • 异步操作(fs.promises.readFile())是非阻塞的,它通过使用 await 将 I/O 操作委托给底层的线程池,从而让事件循环可以继续处理其他请求。这意味着,尽管 100000 次读取操作仍需要时间完成,但服务器仍可以继续响应其他请求,提高并发性能。
  3. 同步与异步性能的对比

    • 同步版本是单线程模型下的一次性操作,它的优点是代码简单、逻辑直观,并且完成时间可能相对更短(因为它直接占用 CPU 完成任务)。但缺点是它阻塞了事件循环,导致无法处理其他任务和请求,极大降低了服务器的并发处理能力。
    • 异步版本在 Node.js 的单线程模型下不会阻塞事件循环,允许事件循环继续处理其他请求,从而提升并发性能。尽管由于频繁返回事件循环导致了一些额外的开销(例如内存管理的开销和等待事件循环回调),但它的优势在于避免了阻塞,使得服务器可以处理多个并发请求。

结论:

  • Node.js 是单线程的,这决定了同步 I/O 操作会阻塞事件循环,导致服务器在处理同步任务时无法响应其他请求。
  • 同步操作 (GET /sync) 会阻塞事件循环,导致服务器性能下降,无法高效处理其他请求。
  • 异步操作 (GET /async) 不会阻塞事件循环,即使执行 100000 次读取操作,主线程依然可以继续处理其他请求,提升了服务器的并发处理能力。

在单线程环境中,尽量避免使用同步的 I/O 操作,尤其是在处理大量并发请求的服务器应用中,推荐使用异步 I/O,以便更好地利用 Node.js 的事件驱动机制,从而提升性能和用户体验。