关于异步与同步代码
不要轻易使用同步代码块,会导致系统卡死
Why is fs.readFileSync() faster than await fsPromises.readFile()?
在调试器中逐步检查每个实现(fs.readFileSync
和 fs.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 可能会更快。
同步代码阻塞
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 服务器,它包含了三个路由,其中两个路由分别使用了同步与异步的文件读取操作。
核心总结:
-
代码结构:
GET /
路由:返回简单的 "Hello Elysia" 字符串。GET /sync
路由:使用fs.readFileSync()
同步读取文件 100000 次。GET /async
路由:使用fs.promises.readFile()
异步读取文件 100000 次。
-
单线程与事件循环的影响:
- Node.js 采用单线程事件循环模型来处理请求,即所有代码都在一个线程中执行。
- 同步操作(
fs.readFileSync()
)会阻塞事件循环,导致主线程在读取文件期间无法处理其他请求。由于 Node.js 只有一个线程来处理请求,这种阻塞行为会导致服务器无法并发处理其他请求,影响整体性能。因此,当访问/sync
路由时,服务器的所有其他请求都会被阻塞,直到 100000 次同步读取完成。 - 异步操作(
fs.promises.readFile()
)是非阻塞的,它通过使用await
将 I/O 操作委托给底层的线程池,从而让事件循环可以继续处理其他请求。这意味着,尽管 100000 次读取操作仍需要时间完成,但服务器仍可以继续响应其他请求,提高并发性能。
-
同步与异步性能的对比:
- 同步版本是单线程模型下的一次性操作,它的优点是代码简单、逻辑直观,并且完成时间可能相对更短(因为它直接占用 CPU 完成任务)。但缺点是它阻塞了事件循环,导致无法处理其他任务和请求,极大降低了服务器的并发处理能力。
- 异步版本在 Node.js 的单线程模型下不会阻塞事件循环,允许事件循环继续处理其他请求,从而提升并发性能。尽管由于频繁返回事件循环导致了一些额外的开销(例如内存管理的开销和等待事件循环回调),但它的优势在于避免了阻塞,使得服务器可以处理多个并发请求。
结论:
- Node.js 是单线程的,这决定了同步 I/O 操作会阻塞事件循环,导致服务器在处理同步任务时无法响应其他请求。
- 同步操作 (
GET /sync
) 会阻塞事件循环,导致服务器性能下降,无法高效处理其他请求。 - 异步操作 (
GET /async
) 不会阻塞事件循环,即使执行 100000 次读取操作,主线程依然可以继续处理其他请求,提升了服务器的并发处理能力。
在单线程环境中,尽量避免使用同步的 I/O 操作,尤其是在处理大量并发请求的服务器应用中,推荐使用异步 I/O,以便更好地利用 Node.js 的事件驱动机制,从而提升性能和用户体验。