node.js

关注公众号 jb51net

关闭
首页 > 网络编程 > JavaScript > node.js > nodejs 事件循环

nodejs 快速入门之事件循环

作者:彭加李

这篇文章主要介绍了nodejs 快速入门之事件循环的相关资料,需要的朋友可以参考下

浏览器中的事件循环

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

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
*/

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

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

首先将 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

请看示例:

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):

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

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

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

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

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

高并发和高性能

在 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 浏览器中的事件循环

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

不同点:

虽然两者有不同,但它们有相同的设计目标:高效而可靠的方式处理异步任务(或者说:解决 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 个阶段执行顺序:

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

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

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

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

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

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

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

执行分析:

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

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

根据结果,我们推测: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 模型、事件驱动机制和多线程等技术手段,来支持高并发、高性能的应用程序开发。

到此这篇关于nodejs 快速入门之事件循环的文章就介绍到这了,更多相关nodejs 事件循环内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

您可能感兴趣的文章:
阅读全文