前端学习 node 快速入门 系列 —— 事件循环

事件循环

本篇将对以下问题进行讨论:

  • 浏览器有事件循环,node 也有事件循环,两者有什么异同?
  • node 核心特性(事件驱动和非阻塞 I/O )和事件循环有什么关系?
  • node 中的高并发和高性能和事件循环有关系吗?
  • node 不适合什么场景?
  • 有人说 Node 是单线程,有人又说 node 存在多线程,哪个正确?
  • 如果一个请求需要2秒,用 pm2 能将其优化吗?

浏览器中的事件循环

有关事件循环前文已经略作介绍,这里进一步补充。

请在浏览器中运行这段代码:

console.log('1');

setTimeout(() => {
    console.log('2');
    Promise.resolve().then(() => console.log('3'));
    Promise.resolve().then(() => console.log('4'));
}, 100);

setTimeout(() => {
    console.log('5');
    Promise.resolve().then(() => console.log('6'));
}, 150);

Promise.resolve().then(() => console.log('7'));

setTimeout(() => console.log('8'), 200);

console.log('9');

/*
结果:
1
9
7
2
3
4
5
6
8
*/

分析这段代码的事件循环的详细过程之前,有几点需要说一下:

  • 在一次事件循环中,只会执行一个宏任务所有的微任务,而且宏任务和微任务的处理顺序是固定的:每次执行完一个宏任务后,首先会立即处理所有的微任务,然后才会执行下一个宏任务。如果在执行微任务时又产生了新的微任务,那么这些新的微任务也会被添加到队列中,直到全部微任务都执行完成,才会执行宏任务。
  • 宏任务执行期间产生的微任务都会在当前宏任务执行完毕之后立即执行,不会延迟到下一个宏任务或事件循环中执行
  • 当一个宏任务执行的过程中产生了微任务,那么这些微任务会被推入微任务队列中等待处理。而只有当当前宏任务执行结束之后,主线程才会去处理微任务队列中的所有微任务。因此,所有的微任务都会在下一个宏任务执行之前被处理完毕。
  • 在浏览器中,主线程使用轮询方式来实现事件循环机制。在执行完当前的任务之后,如果宏任务队列为空,主线程会等待一段时间,这个时间间隔是由浏览器厂商自行决定的,然后再次查询宏任务队列是否有任务需要执行。
  • setTimeout 是宏任务,比如执行 setTimeout(() => console.log('8'), 200),浏览器会创建一个定时器(200ms),并将回调函数和指定的时间保存在一个任务中。当指定的时间到达时,定时器才会将这个任务推入宏任务队列中等待处理

这段代码大概有四次事件循环,执行过程如下:

  • 第一次事件循环:
首先将 console.log('1') 加入执行栈中,输出 1,然后将其从执行栈中弹出。

第一个 setTimeout 函数被调用时,浏览器会创建一个定时器(100ms),并将回调函数和指定的时间保存在一个任务中。当指定的时间到达时,定时器会将这个任务推入宏任务队列中等待处理

第二个 setTimeout 与第一 setTimeout 类似,等待 150ms 后会被放入宏任务队列中

Promise.resolve().then(() => console.log('7')) 放入微任务队列

第三个 setTimeout 与第一 setTimeout 类似,等待 200ms 后会被放入宏任务队列中

执行 console.log('9')

取出微任务队列中的所有任务,输出 7
  • 第二次事件循环:
执行栈为空,主线程轮询查看宏任务队列(微任务队列刚才已经清空了),此时宏任务队列为空

100ms后,第一个setTimeout 宏任务推入宏任务队列中,取出这个宏任务放入执行栈中

输出 2

执行 `Promise.resolve().then(() => console.log('3'));`、`Promise.resolve().then(() => console.log('4'));`,放入微任务队列

这个宏任务执行完毕之后,主线程会转而执行当前微任务队列中的所有任务,输出 3 和 4
  • 第三次事件循环:
执行栈为空,主线程轮询宏任务队列发现其为空

150ms后,第二个setTimeout 宏任务推入宏任务队列中,取出这个宏任务放入执行栈中

输出 5

执行 `Promise.resolve().then(() => console.log('6'));` 放入微任务队列

这个宏任务执行完毕之后,主线程会转而执行当前微任务队列中的所有任务,输出 6
  • 第四次事件循环:
执行栈为空,主线程轮询宏任务队列发现其为空

200ms后,第三个setTimeout 宏任务推入宏任务队列中,取出这个宏任务放入执行栈中

输出 8

宏任务优先级

宏任务之间其实存在优先级。比如 click > requestAnimationFrame > setTimeout

  • 用户交互相关的任务具有最高的优先级。在用户交互(例如点击)后,会将与该事件相关的任务添加到宏任务队列中并标记为紧急,从而使它们具有比其他任务更高的优先级。这确保了与用户直接交互相关的操作具有更快的响应时间。
  • requestAnimationFrame函数,这个函数也有较高的优先级,因为它需要在下一次屏幕刷新之前进行处理以提供平滑的动画效果
  • setTimeout 或 setInterval 添加的回调函数。通常情况下,先添加到队列中的回调函数会优先得到处理。它们只能保证至少在指定的时间后才开始执行

请看示例:

function log(message) {
    const now = new Date();
    console.log(`[${now.getSeconds()}:${now.getMilliseconds()}] ${message}`);
}

setTimeout(() => {
    log('setTimeout callback');
}, 0);

requestAnimationFrame(() => {
    log('requestAnimationFrame callback');
});

document.addEventListener('click', () => {
    log('click event');
});

// 手动触发 click 事件
const event = new Event('click');
document.dispatchEvent(event);

/*
[46:280] click event
[46:299] setTimeout callback
[5:646] requestAnimationFrame callback
*/

无论测试多少次,click 总是最先输出。但是 requestAnimationFrame 就不一定先 setTimeout 输出,因为 requestAnimationFrame 有自己的节奏,只要不影响平滑的动画效果,即使在 setTimeout 后面也可能。

核心特性

Node.js 核心的特性是事件驱动(Event-driven)和非阻塞 I/O(Non-blocking I/O):

  • 事件驱动 - nodejs 中的异步操作基于事件,也就是说,当某个操作完成时,Node.js 会发出一个事件来通知你,然后你就可以通过注册事件的方式来执行回调函数。
  • 非阻塞 I/O - nodejs 执行一个 I/O 操作时,它不会像传统的同步阻塞 I/O 一样等待操作完成,而是会在操作的同时继续处理其他请求。这种方式可以避免 I/O 导致的阻塞,提高系统的吞吐量和响应能力。

Tip:两个特性有关系,但不是一个概念。比如可以说:基于事件驱动的非阻塞 I/O

Node.js 中的事件驱动和非阻塞 I/O 是基于事件循环实现的。

在 node 中,事件循环是一个持续不断的循环过程,不断地从事件队列中取出事件并处理,直到事件队列为空。具体来说,当 Node.js 遇到一个需要异步处理的 I/O 操作时,它不会等待操作完成后再执行下一步操作,而是将该操作放到事件队列中,并继续执行下一步。当操作完成后,Node.js 会将相应的回调函数也放到事件队列中,等待事件循环来处理。这样一来,Node.js 就可以同时处理多个请求,而且不会因为某一个操作的阻塞而影响整个应用程序的性能。

除了 I/O 操作之外,事件循环还可以用于处理定时器HTTP 请求数据库访问等各种类型的事件

Tip: 事件队列不仅包含宏任务队列微任务队列,还有维护着几个其他的队列,这些队列通过事件循环机制来实现异步非阻塞。其他队列有:

  • check 队列。check 队列用于存放 setImmediate() 的回调函数
  • I/O 观察器队列(watcher queue)
  • 关闭事件队列(close queue)

高并发和高性能

在 Node.js 中,高并发指的是系统能够处理高并发请求的能力。不会因为一个请求的处理而阻塞其他请求的执行,系统能够同时处理众多请求。高性能通常指的是它在处理大量并发请求时表现出的优异性能。

事件循环是 Node.js 实现高并发和高性能的核心机制之一。通过将计算密集型任务和 I/O 任务分离并采用异步执行,Node.js 能够充分利用 CPU 和内存资源,从而实现高性能和高并发。

没有事件循环,Node.js 就无法实现异步 I/O 和非阻塞式编程模型。在传统的阻塞式 I/O 模型中,一个 I/O 操作会一直等待数据返回,导致应用程序被阻塞,无法进行其他操作。而通过事件循环机制,Node.js 实现了异步 I/O,当一个 I/O 操作被触发后,Node.js 将其放入事件循环队列中,然后立即执行下一个任务,不必等待当前的 I/O 操作结束。当 I/O 操作完成时,Node.js 会将相应的回调函数添加到事件队列中等待执行。

node 中的事件循环

vs 浏览器中的事件循环

相同点:单个主线程、单个执行栈、有宏任务队列和微任务队列

不同点:

  • 实现不同。Node.js 是一款服务端运行时,而浏览器则用于页面和交互等,场景不同,所以实现方式不同。Node.js 中的事件循环机制是通过 libuv 库来实现,因为它具有跨平台性、高效性、多功能性(除了事件循环机制外,libuv 还提供了很多其他的系统功能和服务,能够满足 Node.js 在服务器端编程上的需要)等。
  • 一次事件循环不同。浏览器中的一次事件循环包括一个宏任务和相关所有微任务。在 node 中,一次事件循环包含6个阶段(下文会详细介绍)

虽然两者有不同,但它们有相同的设计目标:高效而可靠的方式处理异步任务(或者说:解决 JavaScript 异步编程问题)。

原理

一次事件循环包含以下 6 个阶段:

+--------------------------+
|                          |
|   timers                 | 计时器阶段:处理 setTimeout() 和 setInterval() 定时器的回调函数。
|                          |
+--------------------------+
|                          |
|   pending callbacks      | 待定回调阶段:用于处理系统级别的错误信息,例如 TCP 错误或者 DNS 解析异常。
|                          |
+--------------------------+
|                          |
|   idle, prepare          | 仅在内部使用,可以忽略不计。
|                          |
+--------------------------+
|                          |
|   poll                   | 轮询阶段:等待 I/O 事件(如网络请求或文件 I/O 等)的发生,然后执行对应的回调函数,并且会处理定时器相关的回调函数。
|                          |          如果没有任何 I/O 事件发生,此阶段可能会使事件循环阻塞。
+--------------------------+
|                          |
|   check                  | 检查阶段:处理 setImmediate() 的回调函数。check 的回调优先级比 setTimeout 高,比微任务要低
|                          |
+--------------------------+
|                          |
|   close callbacks        | 关闭回调阶段:处理一些关闭的回调函数,比如 socket.on('close')。
|                          |
+--------------------------+

这 6 个阶段执行顺序:

  1. 事件循环首先会进入 timers 阶段,执行所有超时时间到达的定时器相关的回调函数。
  2. 当 Node.js 执行完 timers 阶段后,就会进入到 pending callbacks 阶段。在这个阶段, Node.js 会执行一些系统级别的回调函数,这些回调函数一般都是由 Node.js 的内部模块触发的,而不是由 JavaScript 代码直接触发的。
  3. 然后进入 poll 阶段,等待 I/O 事件的发生,处理相关的回调函数。如果在此阶段确定没有任何 I/O 事件需要处理,那么事件循环会等待一定的时间,以防止 CPU 空转,这个时间会由系统自动设置或者手动在代码中指定。如果有定时器在此阶段需要处理,那么事件循环会回到 timers 阶段继续执行相应的回调函数。
  4. 接着进入 check 阶段,处理 setImmediate() 注册的回调函数。setImmediate() 的优先级比 timers 阶段要高。当事件循环进入 check 阶段时,如果发现事件队列中存在 setImmediate() 的回调函数,则会立即执行该回调函数而不是继续等待 timers 阶段的到来。
  5. 最后进入 close callbacks 阶段,处理一些关闭的回调函数。

事件循环的每个阶段都有对应的宏任务队列微任务队列。当一个阶段中的所有宏任务都执行完之后,事件循环会进入下一个阶段。在该阶段结束时,如果存在微任务,事件循环将会在开始下一个阶段之前执行所有的微任务。这样一来,无论在何时添加微任务,都能确保先执行所有的微任务,避免了某些任务的并发问题。如果我们在某个阶段中添加了多个微任务,那么它们会在该阶段结束时依次执行,直到所有微任务都被处理完成,才会进入下一个阶段的宏任务队列。

一次事件循环周期以清空6个阶段的宏任务队列和微任务队列来结束。

一次事件循环周期内,每个阶段是否可以执行多次。例如此时在 poll 阶段,这时 timers 阶段任务队列中有了回调函数,由于 timers 的优先级高于 poll,所以又回到 timers 阶段,执行完该阶段的宏任务和微任务后,在回到 poll 阶段。

总之,这 6 个阶段构成了 Node.js 的事件循环机制,确保了所有被注册的回调函数都能得到及时、准确的执行

Tip:当调用 setTimeout 方法时,如果超时时间还没到,则生成的定时器宏任务也不会立刻放入宏任务队列中,而是会被放入计时器队列中。计时器队列和延迟队列类似,都是由定时器宏任务组成的小根堆结构,每个定时器宏任务也对应着其到期时间以及对应的回调函数。当超时时间到达后,Node.js 会将该定时器宏任务从计时器队列中取出并放入宏任务队列中,等待事件循环去执行。

尽管事件循环的机制比较明确,但由于各种因素的影响,具体的执行顺序仍然难以精确预测。其顺序取决于当前事件队列中各个回调函数的执行情况、耗时以及系统各种资源的利用情况等多种因素。每次事件循环的顺序都不一定相同:

  • 例如,在事件循环的 poll 阶段中,如果存在大量耗时较长的 I/O 回调函数,则事件循环可能会在 poll 阶段中花费较长的时间。此时,即使定时器的超时时间到达了,事件循环也不会立即进入 timers 阶段,而是要先处理 poll 阶段中还未完成的任务。

Tip: setTimeout 在node 中最小是 1ms,在浏览器中是4ms

示例

console.log("start");

setTimeout(() => {
  console.log("first timeout callback");
}, 1);

setImmediate(() => {
  console.log("immediate callback");
});

process.nextTick(() => {
  console.log("next tick callback");
});

console.log("end");

运行10次node 输出如下:

start
end
next tick callback
first timeout callback
immediate callback

执行分析:

  • 先执行同步代码,输出 startend
  • setTimeout和setImmediate属于宏任务
  • process.nextTick 是微任务,输出 next tick callback

现在的难点是 setImmediate 和 setTimeout 的回调哪个先执行!

:在某些特殊情况下,timers 阶段和 check 阶段的任务可能会交错执行。这通常发生在以下两种情况下:

  • 当 timers 阶段中存在长时间运行的回调函数时(如一个耗时很长的 for 循环),会导致该阶段阻塞,影响事件循环的正常执行。在这种情况下,如果 check 阶段中有一些较短的回调函数需要执行,Node.js 可能会在 timers 阶段中间中断执行,并立即进入 check 阶段处理已经准备好的回调函数,然后再返回 timers 阶段继续执行剩余的回调函数。
  • 当注册了 setImmediate() 和 setTimeout() 回调函数并且它们被分别安排到不同的事件循环周期中执行时,这时候 setImmediate() 的回调函数可能会在 timers 阶段的回调函数之前被执行。这是因为 check 阶段的任务队列优先级比 timers 阶段的任务队列要高,所以在下一个循环周期的 check 阶段中,setImmediate() 的回调函数会被优先处理。

根据结果,我们推测:setImmediate 和 setTimeout 都进入了下一个循环周期,先执行 timers 阶段,在执行 check 阶段的回调。

Tip: 尽管 setImmediate 被称为 "immediate",但它并不保证会立刻执行。在 Node.js 的事件循环中,setImmediate() 的回调函数会被加入到 check 阶段的任务队列中,等到轮到 check 阶段时才会执行。

CPU 密集型场景

Node.js 不适合CPU 密集型场景。比如大量数学计算,可能会阻塞 Node.js 主线程。

比如一个 1 到 10亿求和的请求:

const http = require('http');

http.createServer((req, res) => {
  console.log('start');
  let sum = 0;
  for (let i = 1; i <= 1000000000; i++) {
    sum += i;
  }
  console.log('end');

  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(sum.toString());
}).listen(3000);

console.log('server running at http://localhost:3000/');

通过curl 检测访问 http://localhost:3000/ 的时间,分别是 1.754s1.072s2.821s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0     15      0 --:--:--  0:00:01 --:--:--    15500000000067109000

real    0m1.754s
user    0m0.000s
sys     0m0.078s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0     20      0 --:--:-- --:--:-- --:--:--    21500000000067109000

real    0m1.072s
user    0m0.015s
sys     0m0.093s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    18    0    18    0     0      6      0 --:--:--  0:00:02 --:--:--     6500000000067109000

real    0m2.821s
user    0m0.031s
sys     0m0.077s

接着用node 内置的 cluster 模块将计算工作分配到4个子进程中,访问速度大幅度提升。

const http = require('http');
const cluster = require('cluster');

if (cluster.isMaster) {
  // 计算工作分配到4个子进程中
  const numCPUs = require('os').cpus().length;
  const range = 1000000000;
  const rangePerCore = Math.ceil(range / numCPUs);
  let endIndex = 0;
  let sum = 0;

  for (let i = 0; i < numCPUs; i++) {
    const worker = cluster.fork();
    worker.on('message', function({ endIndex, result }) {
      sum += result;
      if (endIndex === range) {
        console.log(sum);
        // 启动 Web 服务器,在主进程中处理请求
        http.createServer((req, res) => {
          res.statusCode = 200;
          res.setHeader('Content-Type', 'text/plain');
          res.end(`The sum is ${sum}\n`);
        }).listen(3000, () => {
          console.log(`Server running at http://localhost:3000/`);
        });
      }
    });
    worker.send({ startIndex: endIndex + 1, endIndex: endIndex + rangePerCore });
    endIndex += rangePerCore;
  }
} else {
  process.on('message', function({ startIndex, endIndex }) {
    let sum = 0;
    for (let i = startIndex; i <= endIndex; i++) {
      sum += i;
    }
    process.send({ endIndex, result: sum });
  });
}

访问时长分别是:0.230s0.216s0.205s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2354      0 --:--:-- --:--:-- --:--:--  4285The sum is 500000000098792260


real    0m0.230s
user    0m0.000s
sys     0m0.109s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2212      0 --:--:-- --:--:-- --:--:--  3750The sum is 500000000098792260


real    0m0.216s
user    0m0.000s
sys     0m0.078s

Administrator@ MINGW64 /e/ (master)
$ time curl http://localhost:3000/
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100    30  100    30    0     0   2545      0 --:--:-- --:--:-- --:--:--  6000The sum is 500000000098792260


real    0m0.205s
user    0m0.000s
sys     0m0.078s

其他

pm2 的一个局限性

假如一个请求得花费2秒(1 到 10亿之和),使用 pm2 也不能减小请求时间。

pm2能做的是:比如一个 node 应用单核(1个cpu内核)可以支持一千个并发请求,现在并发四千个请求,由于超出能力,请求响应会变慢。现在通过 Pm2 在四核服务器中启动4个node应用,之前还存在负载均衡,这样就可以支持四千个并发请求。

Tip:pm2的介绍请看这里

单线程

Node.js 是单线程的,这意味着所有事件循环(Event Loop)和 I/O 操作都在一个主线程中运行。所以说,Node.js 中只存在一个事件循环和一个执行上下文栈。

不过,Node.js 的实现并不简单粗暴。它通过使用非阻塞 I/O、异步编程以及事件驱动机制,让单线程可以支持高并发处理大量的 I/O 操作。Node.js 底层采用的是 libuv 库来实现异步 I/O 模型,该库在底层会使用 libev 和 libeio 等多种事件驱动框架来实现对底层 I/O 系统调用的封装,从而让单线程可以同时处理多个 I/O 任务,避免了线程切换的开销,提高了应用程序的性能。

此外,在 Node.js 版本 10.5.0 之后,Node.js 引入了 worker_threads 模块,支持通过创建子线程的方式来实现多线程。worker_threads 模块提供了一套 API,使得开发者可以方便地创建和管理多个子线程,并利用多线程来加速处理计算密集型任务等场景。

总之,Node.js 是单线程的,但同时也通过采用异步 I/O 模型、事件驱动机制和多线程等技术手段,来支持高并发、高性能的应用程序开发。

posted @ 2023-05-22 20:05  彭加李  阅读(322)  评论(0编辑  收藏  举报