노드 비동기 및 이벤트 루프에 대한 깊은 이해

Node는 원래 고성능 웹 서버를 구축하기 위해 탄생한 것으로 자바스크립트 서버 런타임으로 이벤트 구동, 비동기 I/O, 단일 쓰레드의 특징을 가지고 있습니다. 이벤트 루프에 기반한 비동기 프로그래밍 모델은 Node가 높은 동시성을 처리할 수 있도록 하여 서버의 성능을 크게 향상시킴과 동시에 JavaScript의 단일 스레드 기능이 유지되기 때문에 Node에서 다음과 같은 문제를 처리할 필요가 없습니다. 다중 스레딩에서 상태 동기화 및 교착 상태로, 또한 스레드 컨텍스트 전환으로 인한 성능 오버헤드가 없습니다. 이러한 특성을 바탕으로 Node는 고성능과 높은 동시성의 고유한 장점을 가지고 있으며 이를 기반으로 다양한 고속 확장형 네트워크 응용 플랫폼을 구축할 수 있습니다.

이 기사에서는 노드 비동기 및 이벤트 루프의 기본 구현 및 실행 메커니즘을 자세히 살펴보고 도움이 되기를 바랍니다.

왜 비동기식입니까?

Node가 핵심 프로그래밍 모델로 비동기를 사용하는 이유는 무엇입니까?

앞서 언급했듯이 Node는 원래 고성능 웹 서버를 구축하기 위해 탄생했으며 비즈니스 시나리오에서 완료해야 할 관련 없는 여러 작업 세트가 있다고 가정할 때 최신 주류 솔루션은 다음과 같습니다.

  • 단일 스레드 직렬 실행.

  • 멀티스레딩은 병렬로 수행됩니다.

싱글 쓰레드 직렬 실행은 동기식 프로그래밍 모델로 프로그래머가 생각하는 순서대로 생각하는 방식에 더 가깝지만 더 편리한 코드를 작성하기 쉽지만 동기식으로 I/O를 실행하기 때문에 동시에 단일 요청으로 인해 서버가 느리게 응답하므로 높은 동시성 응용 프로그램 시나리오에서는 적용할 수 없으며 I/O를 차단하고 있기 때문에 CPU는 항상 I/O가 완료될 때까지 기다리며 할 수 없습니다. 다른 일을 하여 CPU의 처리 능력이 부족합니다.

다중 스레드 프로그래밍 모델은 또한 프로그래밍의 상태 동기화 및 교착 상태와 같은 문제로 인해 개발자에게 골칫거리가 될 것입니다. 멀티 스레딩은 멀티 코어 CPU에서 CPU 사용률을 효과적으로 향상시킬 수 있습니다.

단일 스레드 직렬 실행 및 다중 스레드 병렬 실행의 프로그래밍 모델은 고유한 장점이 있지만 성능 및 개발 난이도 측면에서 단점도 있습니다.

또한 클라이언트 요청에 응답하는 속도부터 시작하여 클라이언트가 동시에 두 자원을 획득한 경우 동기 모드의 응답 속도는 두 자원의 응답 속도를 합한 값이 됩니다. 비동기 모드는 가장 큰 두 모드 사이에 있으며 성능 이점은 동기화에 비해 매우 분명합니다. 애플리케이션의 복잡성이 증가함에 따라 시나리오는 n개의 요청에 동시에 응답하는 방식으로 발전할 것이며 동기화보다 비동기식의 이점이 강조될 것입니다.

요약하자면 Node는 단일 스레드를 사용하여 다중 스레드 교착 상태 및 상태 동기화와 같은 문제를 방지하고 비동기 I/O를 사용하여 단일 스레드를 차단하지 않도록 하여 CPU를 더 잘 활용하도록 합니다. 이것이 Node가 핵심 프로그래밍 모델로 비동기를 사용하는 이유입니다.

또한 단일 스레드가 멀티 코어 CPU를 활용할 수 없다는 단점을 보완하기 위해 Node는 브라우저에서 Web Worker와 유사한 하위 프로세스를 제공하여 작업자 프로세스를 통해 CPU를 효율적으로 활용할 수 있습니다.

비동기를 달성하는 방법?

비동기를 사용하는 이유에 대해 이야기한 후 비동기를 구현하는 방법은 무엇입니까?

우리가 일반적으로 부르는 비동기 작업에는 두 가지 유형이 있습니다. 하나는 파일 I/O 및 네트워크 I/O와 같은 I/O와 관련된 작업이고 다른 하나는 I/O와 같이 I/O와 관련 이 setTimeOut없는 작업입니다. setInterval우리가 말하는 비동기가 I/O, 즉 비동기 I/O와 관련된 작업을 의미한다는 것은 분명합니다.

비동기식 I/O의 제안은 I/O 호출이 후속 프로그램의 실행을 차단하지 않을 것으로 예상하고 I/O 완료를 위한 원래 대기 시간이 실행에 필요한 나머지 비즈니스에 할당된다는 것입니다. 이렇게 하려면 비차단 I/O를 사용해야 합니다.

I/O를 차단한다는 것은 CPU가 I/O 호출을 시작한 후 항상 차단하고 I/O가 완료될 때까지 기다리는 것을 의미합니다. 블로킹 I/O를 알면 논 블로킹 I/O를 이해하기 쉽습니다. CPU는 블로킹 대기 대신 I/O 호출을 시작한 후 즉시 반환됩니다. I/O가 완료되기 전에 CPU는 다른 트랜잭션을 처리할 수 있습니다. 분명히, 차단 I/O와 비교하여 비 차단 I/O는 성능 향상 이상입니다.

따라서 non-blocking I/O가 사용되기 때문에 CPU는 I/O 호출을 시작한 후 즉시 반환할 수 있습니다. 그렇다면 I/O가 완료되었음을 어떻게 알 수 있을까요? 정답은 투표입니다.

I/O 호출의 상태를 적시에 얻기 위해 CPU는 I/O 완료 여부를 확인하기 위해 I/O 작업을 반복적으로 호출합니다. 투표.

분명히 폴링은 CPU가 상태 판단을 반복적으로 수행하도록 하여 CPU 자원을 낭비하게 합니다. 또한, 폴링 간격을 제어하기 어렵고 간격이 너무 길면 I/O 작업 완료가 적시에 응답하지 않아 애플리케이션의 응답 속도가 간접적으로 저하되고 간격이 너무 짧으면 CPU는 필연적으로 폴링 시간을 소비하게 되며, 시간이 더 오래 걸리고 CPU 자원의 활용도가 떨어집니다.

因此,轮询虽然满足了非阻塞 I/O 不会阻塞后续程序的执行的要求,但是对于应用程序而言,它仍然只能算是一种同步,因为应用程序仍然需要等待 I/O 完全返回,依旧花费了很多时间来等待。

我们所期望的完美的异步 I/O,应该是应用程序发起非阻塞调用,无须通过轮询的方式不断查询 I/O 调用的状态,而是可以直接处理下一个任务,在 I/O 完成后通过信号量或回调将数据传递给应用程序即可。

如何实现这种异步 I/O 呢?答案是线程池。

虽然本文一直提到,Node 是单线程执行的,但此处的单线程是指 JavaScript 代码是执行在单线程上的,对于 I/O 操作这类与主业务逻辑无关的部分,通过运行在其他线程的方式实现,并不会影响或阻塞主线程的运行,反而可以提高主线程的执行效率,实现异步 I/O。

通过线程池,让主线程仅进行 I/O 的调用,让其他多个线程进行阻塞 I/O 或者非阻塞 I/O 加轮询技术完成数据获取,再通过线程之间的通信将 I/O 得到的数据进行传递,这就轻松实现了异步 I/O:

image-20220703233325903.png

主线程进行 I/O 调用,而线程池进行 I/O 操作,完成数据的获取,然后通过线程之间的通信将数据传递给主线程,即可完成一次 I/O 的调用,主线程再利用回调函数,将数据暴露给用户,用户再利用这些数据来完成业务逻辑层面的操作,这就是 Node 中一次完整的异步 I/O 流程。而对于用户来说,不必在意底层这些繁琐的实现细节,只需要调用 Node 封装好的异步 API,并传入处理业务逻辑的回调函数即可,如下所示:

const fs = require("fs");

fs.readFile('example.js', (data) => {
  // 进行业务逻辑的处理
});

Node 的异步底层实现机制在不同平台下有所不同:Windows 下主要通过 IOCP 来向系统内核发送 I/O 调用和从内核获取已完成的 I/O 操作,配以事件循环,以此完成异步 I/O 的过程;Linux 下通过 epoll 实现这个过程;FreeBSD下通过 kqueue 实现,Solaris 下通过 Event ports 实现。线程池在 Windows 下由内核(IOCP)直接提供,*nix 系列则由 libuv 自行实现。

由于 Windows 平台和 *nix 平台的差异,Node 提供了 libuv 作为抽象封装层,使得所有平台兼容性的判断都由这一层来完成,保证上层的 Node 与下层的自定义线程池及 IOCP 之间各自独立。Node 在编译期间会判断平台条件,选择性编译 unix 目录或是 win 目录下的源文件到目标程序中:

image.png

以上就是 Node 对异步的实现。

(线程池的大小可以通过环境变量 UV_THREADPOOL_SIZE 设置,默认值为 4,用户可结合实际情况来调整这个值的大小。)

那么问题来了,在得到线程池传递过来的数据后,主线程是如何、何时调用回调函数的呢?答案是事件循环。

基于事件循环的异步编程模型

既然使用回调函数来进行对 I/O 数据的处理,就必然涉及到何时、如何调用回调函数的问题。在实际开发中,往往会涉及到多个、多类异步 I/O 调用的场景,如何合理安排这些异步 I/O 回调的调用,确保异步回调的有序进行是一个难题,而且,除了异步 I/O 之外,还存在定时器这类非 I/O 的异步调用,这类 API 实时性强,优先级相应地更高,如何实现不同优先级回调地调度呢?

因此,必须存在一个调度机制,对不同优先级、不同类型的异步任务进行协调,确保这些任务在主线程上有条不紊地运行。与浏览器一样,Node 选择了事件循环来承担这项重任。

Node 根据任务的种类和优先级将它们分为七类:Timers、Pending、Idle、Prepare、Poll、Check、Close。对于每类任务,都存在一个先进先出的任务队列来存放任务及其回调(Timers 是用小顶堆存放)。基于这七个类型,Node 将事件循环的执行分为如下七个阶段:

timers

这个阶段的执行优先级是最高的。

事件循环在这个阶段会检查存放定时器的数据结构(最小堆),对其中的定时器进行遍历,逐个比较当前时间和过期时间,判断该定时器是否过期,如果过期的话,就将该定时器的回调函数取出并执行。

pending

该阶段会执行网络、IO 等异常时的回调。一些 *nix 上报的错误,在这个阶段会得到处理。另外,一些应该在上轮循环的 poll 阶段执行的 I/O 回调会被推迟到这个阶段执行。

idle、prepare

这两个阶段仅在事件循环内部使用。

poll

检索新的 I/O 事件;执行与 I/O 相关的回调(除了关闭回调、定时器调度的回调和 之外几乎所有回调setImmediate());节点会在适当的时候阻塞在这里。

poll,即轮询阶段是事件循环最重要的阶段,网络 I/O、文件 I/O 的回调都主要在这个阶段被处理。该阶段有两个主要功能:

  1. 计算该阶段应该阻塞和轮询 I/O 的时间。

  2. 处理 I/O 队列中的回调。

当事件循环进入 poll 阶段并且没有设置定时器时:

  • 如果轮询队列不为空,则事件循环将遍历该队列,同步地执行它们,直到队列为空或达到可执行的最大数量。

  • 如果轮询队列为空,则会发生另外两种情况之一:

    • 如果有 setImmediate() 回调需要执行,则立即结束 poll 阶段,并进入 check 阶段以执行回调。

    • 如果没有 setImmediate() 回调需要执行,事件循环将停留在该阶段以等待回调被添加到队列中,然后立即执行它们。在超时时间到达前,事件循环会一直停留等待。之所以选择停留在这里是因为 Node 主要是处理 IO 的,这样可以更及时地响应 IO。

一旦轮询队列为空,事件循环将检查已达到时间阈值的定时器。如果有一个或多个定时器达到时间阈值,事件循环将回到 timers 阶段以执行这些定时器的回调。

check

该阶段会依次执行 setImmediate() 的回调。

close

该阶段会执行一些关闭资源的回调,如 socket.on('close', ...)。该阶段晚点执行也影响不大,优先级最低。

当 Node 进程启动时,它会初始化事件循环,执行用户的输入代码,进行相应异步 API 的调用、计时器的调度等等,然后开始进入事件循环:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

事件循环的每一轮循环(通常被称为 tick),会按照如上给定的优先级顺序进入七个阶段的执行,每个阶段会执行一定数量的队列中的回调,之所以只执行一定数量而不全部执行完,是为了防止当前阶段执行时间过长,避免下一个阶段得不到执行。

OK,以上就是事件循环的基本执行流程。现在让我们来看另外一个问题。

对于以下这个场景:

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

当服务成功绑定到 8000 端口,即 listen() 成功调用时,此时 listening 事件的回调还没有绑定,因此端口成功绑定后,我们所传入的 listening 事件的回调并不会执行。

再思考另外一个问题,我们在开发中可能会有一些需求,如处理错误、清理不需要的资源等等优先级不是那么高的任务,如果以同步的方式执行这些逻辑,就会影响当前任务的执行效率;如果以异步的方式,比如以回调的形式传入 setImmediate() 又无法保证它们的执行时机,实时性不高。那么要如何处理这些逻辑呢?

基于这几个问题,Node 参考了浏览器,也实现了一套微任务的机制。在 Node 中,除了调用 new Promise().then() 所传入的回调函数会被封装成微任务外,process.nextTick() 的回调也会被封装成微任务,并且后者的执行优先级比前者高。

有了微任务后,事件循环的执行流程又是怎么样的呢?换句话说,微任务的执行时机在什么时候?

  • 在 node 11 及 11 之后的版本,一旦执行完一个阶段里的一个任务就立刻执行微任务队列,清空该队列。

  • 在 node11 之前执行完一个阶段后才开始执行微任务。

因此,有了微任务后,事件循环的每一轮循环,会先执行 timers 阶段的一个任务,然后按照先后顺序清空 process.nextTick()new Promise().then() 的微任务队列,接着继续执行 timers 阶段的下一个任务或者下一个阶段,即 pending 阶段的一个任务,按照这样的顺序以此类推。

利用 process.nextTick(),Node 就可以解决上面的端口绑定问题:在 listen() 方法内部,listening 事件的发出会被封装成回调传入 process.nextTick() 中,如下伪代码所示:

function listen() {
    // 进行监听端口的操作
    ...
    // 将 `listening` 事件的发出封装成回调传入 `process.nextTick()` 中
    process.nextTick(() => {
        emit('listening');
    });
};

在当前代码执行完毕后便会开始执行微任务,从而发出 listening 事件,触发该事件回调的调用。

一些注意事项

由于异步本身的不可预知性和复杂性,在使用 Node 提供的异步 API 的过程中,尽管我们已经掌握了事件循环的执行原理,但是仍可能会有一些不符合直觉或预期的现象产生。

比如定时器(setTimeoutsetImmediate)的执行顺序会因为调用它们的上下文而有所不同。如果两者都是从顶层上下文中调用的,那么它们的执行时间取决于进程或机器的性能。

我们来看以下这个例子:

setTimeout(() => {
  console.log('timeout');
}, 0);

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

以上代码的执行结果是什么呢?按照我们刚才对事件循环的描述,你可能会有这样的答案:由于 timers 阶段会比 check 阶段先执行,因此 setTimeout() 的回调会先执行,然后再执行 setImmediate() 的回调。

实际上,这段代码的输出结果是不确定的,可能先输出 timeout,也可能先输出 immediate。这是因为这两个定时器都是在全局上下文中调用的,当事件循环开始运行并执行到 timers 阶段时,当前时间可能大于 1 ms,也可能不足 1 ms,具体取决于机器的执行性能,因此 setTimeout() 在第一个 timers 阶段是否会被执行实际上是不确定的,因此才会出现不同的输出结果。

(当 delaysetTimeout 的第二个参数)的值大于 2147483647 或小于 1 时, delay 会被设置为 1。)

我们接着看下面这段代码:

const fs = require('fs');

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

可以看到,在这段代码中两个定时器都被封装成回调函数传入 readFile 中,很明显当该回调被调用时当前时间肯定大于 1 ms 了,所以 setTimeout 的回调会比 setImmediate 的回调先得到调用,因此打印结果为:timeout immediate

以上是在使用 Node 时需要注意的与定时器相关的事项。除此之外,还需注意 process.nextTick()new Promise().then() 还有 setImmediate() 的执行顺序,由于这部分比较简单,前面已经提到过,就不再赘述了。

总结

文章开篇从为什么要异步、如何实现异步两个角度出发,较详细地阐述了 Node 事件循环的实现原理,并提到一些需要注意的相关事项,希望对你有所帮助。

如果觉得这篇文章写的不错的话,就请给我点个赞吧!

参考资料

추천

출처juejin.im/post/7121719645710581767