单线程Node.js如何并发处理请求?

How does single-threaded Node.js handles requests concurrently?

目前正在深入学习Nodejs平台。众所周知,Nodejs 是单线程的,如果执行阻塞操作(例如fs.readFileSync),则需要一个线程等待完成该操作。我决定做一个实验:我创建了一个服务器,它在每次请求时从文件中响应大量数据

const { createServer } = require('http');
const fs = require('fs');

const server = createServer();

server.on('request', (req, res) => {
    let data;
    data =fs.readFileSync('./big.file');
    res.end(data);
});

server.listen(8000);

此外,我启动了 5 个终端以便对服务器进行并行请求。我等着看,当一个请求正在处理时,其他人应该等待第一个请求完成阻塞操作。但是,同时响应了其他 4 个请求。为什么会出现这种行为?

您可能看到的是 res.end() 内部实现的某些异步部分实际发送大量数据,或者您看到所有数据都以非常快速和连续的方式发送,但是客户无法足够快地处理它以实际连续显示它并且因为每个客户都在他们自己单独的进程中,他们 "appear" 显示它同时到达只是因为他们反应太慢而无法显示实际到达顺序.

人们必须使用网络嗅探器来查看其中哪些是实际发生的,或者 运行 一些不同的测试,或者在 res.end() 的实现中放置一些日志记录,或者在客户端的 TCP 堆栈,以确定不同请求中数据包到达的实际顺序。


如果您有一台服务器并且它有一个同步执行的请求处理程序 I/O,那么您将不会同时获得多个请求进程。如果您认为这种情况正在发生,那么您将必须准确记录您是如何测量或得出结论的(这样我们可以帮助您消除误解)因为这不是 node.js 在使用阻塞时的工作方式,同步 I/O 例如fs.readFileSync().

node.js 运行 将您的 JS 设置为单线程,当您使用阻塞、同步 I/O 时,它会阻塞 Javascript 的一个单线程。这就是为什么你永远不应该在服务器中使用同步 I/O,除非在启动代码中只有 运行s 在启动期间。

明确的是 fs.readFileSync('./big.file') 是同步的,因此您的第二个请求在第一个 fs.readFileSync() 完成之前不会开始处理。而且,一遍又一遍地在同一个文件上调用它会非常快(OS 磁盘缓存)。

但是,res.end(data) 是非阻塞的,异步的。 res 是一个流,您正在为流提供一些要处理的数据。它将尽可能多地通过套接字发送出去,但如果它受到 TCP 的流量控制,它将暂停,直到有更多空间可以在套接字上发送。发生多少取决于关于您的计算机的各种事情,它的配置和网络 link 到客户端。

所以,可能发生的是这一系列事件:

  1. 第一个请求到达并执行 fs.readFileSync() 并调用 res.end(data)。这开始向客户端发送数据,但由于 TCP 流量控制,returns 在完成之前。这会将 node.js 发送回其事件循环。

  2. 第二个请求到达并执行 fs.readFileSync() 并调用 res.end(data)。这开始向客户端发送数据,但由于 TCP 流量控制,returns 在完成之前。这会将 node.js 发送回其事件循环。

  3. 此时,事件循环可能开始处理第三个或第四个请求,或者它可能服务更多事件(来自 res.end() 的实现或第一个请求的 writeStream继续发送更多数据。如果它确实为这些事件提供服务,它可能会出现(从客户端的角度来看)不同请求的真正并发)。

此外,客户端可能导致它显示为已排序。每个客户端都在读取不同的缓冲套接字,如果它们都在不同的终端中,那么它们就是多任务的。所以,如果每个客户端的套接字上的数据多于它可以立即读取和显示的数据(可能是这种情况),那么每个客户端都会读取一些,显示一些,再读取一些,再显示一些,等等......如果在您的服务器上发送每个客户端的响应之间的延迟小于在客户端上读取和显示的延迟,那么客户端(每个客户端都在自己独立的进程中)能够同时 运行。


当你使用异步I/O例如fs.readFile(),那么正确编写node.jsJavascript代码可以同时有很多请求"in flight" .它们实际上并不 运行 同时并发,但可以 运行,做一些工作,启动异步操作,然后让位于另一个请求 运行。使用正确编写的异步 I/O,可以从外部世界看到并发处理,尽管它更类似于在请求处理程序等待异步 I/O 请求时共享单个线程结束。但是,您显示的服务器代码不是这种协作的异步 I/O.

也许与您的问题没有直接关系,但我认为这很有用,

您可以使用流而不是将整个文件读入内存,例如:

const { createServer } = require('http');
const fs = require('fs');

const server = createServer();

server.on('request', (req, res) => {
   const readStream = fs.createReadStream('./big.file'); // Here we create the stream.
   readStream.pipe(res); // Here we pipe the readable stream to the res writeable stream.
});

server.listen(8000);

这样做的重点是:

  • 看起来更好看。
  • 您没有将整个文件存储在 RAM 中。

这更好用,因为它是非阻塞的,而且 res 对象已经是一个流,这意味着数据将以块的形式传输。

好的streams = chunked

为什么不从文件中读取块并实时发送它们,而不是读取一个非常大的文件然后将其分成块?

还有为什么在实际生产服务器上真的很重要?

因为每次收到一个请求,你的代码都会把那个大文件添加到 ram 中,添加这个是并发的,所以你希望同时提供多个文件,所以让我们做最高级的数学我糟糕的教育允许:

1 次请求 1gb 文件 = 1gb in ram

2 次请求 1gb 文件 = 2gb in ram

等等

这显然不能很好地扩展对吧?

Streams 允许将该数据与函数的当前状态(在该范围内)分离,因此简单来说它将是(默认 chunk 大小为 16kb):

1 次请求 1gb 文件 = 16kb in ram

2 次请求 1gb 文件 = 32kb in ram

等等

而且,OS 它已经将流传递给节点 (fs),因此它可以端到端地处理流。

希望对您有所帮助:D.

PD:切勿在异步操作(非阻塞)中使用同步操作(阻塞)。