이벤트 루프란 무엇인가요?
이벤트 루프를 사용하면 JavaScript가 단일 스레드라는 사실에도 불구하고 Node.js가 비차단 I/O 작업을 수행할 수 있습니다. 가능한 경우 작업을 운영 체제 커널로 오프로드합니다.
대부분의 최신 시스템 커널은 멀티스레드이므로 커널은 백그라운드에서 수행되는 여러 작업을 처리할 수 있습니다. 작업 중 하나가 완료되면 커널은 Node.js에 알리고 해당 콜백이 폴 큐에 추가되어 결국 실행됩니다. 이 항목에서는 이에 대한 자세한 내용을 나중에 설명합니다.
이벤트 루프
Node.js는 처음에 이벤트 루프를 초기화하고 대상 스크립트를 처리합니다. 스크립트는 비동기 API 호출, 예약된 작업 또는 process.nextTick()을 만든 다음 이벤트 루프를 시작할 수 있습니다.
다음 표는 이벤트 루프의 동작 순서를 간략하게 설명합니다.
참고: 각 상자는 이벤트 루프의 단계를 나타냅니다.
각 단계에는 실행해야 하는 콜백 함수의 선입선출(FIFO) 대기열이 있습니다. 동시에, 각 단계는 특별합니다. 기본적으로 이벤트 루프가 특정 단계에 도달하면 해당 단계에 고유한 작업이 수행된 다음 해당 단계의 대기열에 있는 콜백이 대기열이 비어 있을 때까지 실행됩니다. 실행 제한에 도달했습니다. 이때 이벤트 루프는 다음 단계로 진입하며 순환이 반복됩니다.
이러한 작업은 예약된 작업 작업을 더 많이 생성할 수 있고 폴링 단계에서 처리된 새 이벤트가 커널 큐에 추가되므로 폴링 이벤트가 처리될 때 새 이벤트가 발생합니다. 폴링 이벤트가 추가되었습니다. 따라서 장기 콜백 작업으로 인해 폴링 단계가 타이머 임계값을 초과하게 됩니다. 자세한 내용은 타이머 및 설문 조사 섹션을 참조하세요.
참고: Windows와 Unix/Linux의 구현에 약간의 모순이 있지만 지금 당장의 설명에는 영향을 미치지 않습니다. 가장 중요한 부분이 거기에 있습니다. 실제로는 7~8개의 단계가 있지만 우리가 집중하고 있는 것은 Node.js가 실제로 사용하는 것은 위의 단계입니다.
단계 개요
타이머: 이 단계에서는 setTimeout() 및 setInterval() 계획된 콜백을 실행합니다.
I/O 콜백: 예외가 발생한 거의 모든 닫기 콜백, 타이머 및 setImmediate()에 의해 계획된 콜백을 실행합니다.
idle, prepare ):
폴링: 새로운 I/O 이벤트를 가져옵니다.
확인: setImmediate() 콜백 호출
예: 소켓.on('close', .. );
이벤트 루프가 실행되는 사이 Node.js는 대기 중인 비동기 I/O 또는 타이머가 없으면 지워지고 종료됩니다.
단계 세부 정보
타이머(타이머)
타이밍 함수의 목적은 특정 임계값 이후에 지정된 콜백 함수가 실행되도록 하는 것입니다. 특정 실행 시간이 반드시 정확한 임계값은 아닙니다. 타이머 콜백은 지정된 시간이 지나면 즉시 실행됩니다. 단, 운영체제의 스케줄이나 다른 콜백의 실행으로 인해 콜백 실행이 지연될 수 있습니다.
참고: 기술적으로 폴링 단계는 타이머의 실행 타이밍을 제어합니다.
예를 들어 100ms 후에 작업을 수행하도록 설정한 경우 스크립트는 95ms가 소요되는 파일 읽기 작업을 수행하기 시작합니다.
var fs = require('fs'); function someAsyncOperation (callback) { // Assume this takes 95ms to complete fs.readFile('/path/to/file', callback); } var timeoutScheduled = Date.now(); setTimeout(function () { var delay = Date.now() - timeoutScheduled; console.log(delay + "ms have passed since I was scheduled"); }, 100); // do someAsyncOperation which takes 95 ms to completesomeAsyncOperation(function () { var startCallback = Date.now(); // do something that will take 10ms... while (Date.now() - startCallback < 10) { ; // do nothing } });
event 루프가 폴링 단계에 들어가면 대기열이 비어 있으므로(fs.readFile()이 아직 완료되지 않음) 가장 빠른 타이머가 실행되어야 할 때까지 시간이 계속 흐릅니다. 95ms 후에 fs.readFile()은 파일 읽기를 완료하고 해당 콜백이 폴링 대기열에 추가됩니다. 이 콜백은 10ms 동안 실행되어야 합니다. 이 콜백이 실행되고 대기열에 더 이상 콜백이 없으면 이벤트 루프는 가장 최근에 만료된 타이머를 확인한 다음 타이머 단계로 돌아가 이전 타이머 콜백을 실행합니다.
이 예에서는 타이머 정의와 콜백 실행 사이에 105ms가 경과했습니다.
참고: 폴링 단계가 너무 오래 지속되는 것을 방지하기 위해 libuv는 운영 체제에 따라 폴링의 상한을 설정합니다.
I/O 콜백
이 단계에서는 TCP 오류 등 시스템 작업에 대한 일부 콜백을 수행합니다. 예를 들어, TCP 소켓이 연결을 시도할 때 ECONNREFUSED 오류를 수신하면 일부 *nix 시스템은 이 오류를 보고할 때까지 기다립니다. 이 단계에서는 대기열에 추가됩니다.
Poll
Polling 단계에는 두 가지 주요 기능이 있습니다.
1. 타이머 실행 만료된 스크립트,
2. 폴링 대기열의 이벤트를 처리합니다.
이벤트 루프가 폴링 단계에 진입했지만 타이머를 찾을 수 없는 경우:
폴링 큐가 비어 있지 않으면 이벤트 루프가 콜백 큐를 반복하고 실행됩니다. 대기열이 비어 있거나 상한선(위에서 언급한 것처럼 다른 운영 체제에 따라 설정된 상한선)에 도달할 때까지 동기식으로 콜백합니다.
如果轮询队列是空的:
如果有setImmediate()定义了回调,那么事件循环会终止轮询阶段并进入检查阶段去执行定时器回调;
如果没有setImmediate(),事件回调会等待回调被加入队列并立即执行。
一旦轮询队列空了,事件循环会查找已经到时的定时器。如果找到了,事件循环就回到定时器阶段去执行回调。
检查(check)
这个阶段允许回调函数在轮询阶段完成后立即执行。如果轮询阶段空闲了,并且有回调已经被 setImmediate() 加入队列,事件循环会进入检查阶段而不是在轮询阶段等待。
setImmediate() 是个特殊的定时器,在事件循环中一个单独的阶段运行。它使用libuv的API 来使得回调函数在轮询阶段完成后执行。
基本上,随着代码的执行,事件循环会最终进入到等待状态的轮询阶段,可能是等待一个连接、请求等。然而,如果有一个setImmediate() 设置了一个回调并且轮询阶段空闲了,那么事件循环会进入到检查阶段而不是等待轮询事件。 ---- 这车轱辘话说来说去的
关闭事件的回调(close callbacks)
如果一个 socket 或句柄(handle)被突然关闭(is closed abruptly),例如 socket.destroy(), 'close' 事件会被发出到这个阶段。否则这种事件会通过 process.nextTick() 被发出。
setImmediate() vs setTimeout()
这两个很相似,但调用时机会的不同会导致它们不同的表现。
setImmediate() 被设计成一旦轮询阶段完成就执行回调函数;
setTimeout() 规划了在某个时间值过后执行回调函数;
这两个执行的顺序会因为它们被调用时的上下文而有所不同。如果都是在主模块调用,那么它们会受到进程性能的影响(运行在本机的其他程序会影响它们)。
例如,如果我们在非 I/O 循环中运行下面的脚本(即在主模块中),他俩的顺序是不固定的,因为会受到进程性能的影响:
// timeout_vs_immediate.jssetTimeout(function timeout () { console.log('timeout'); },0); setImmediate(function immediate () { console.log('immediate'); });
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
但是如果把它们放进 I/O 循环中,setImmediate() 的回调总是先执行:
// timeout_vs_immediate.jsvar fs = require('fs') fs.readFile(__filename, () => { setTimeout(() => { console.log('timeout') }, 0) setImmediate(() => { console.log('immediate') }) })
$ node timeout_vs_immediate.js
immediate
timeout
$ node timeout_vs_immediate.js
immediate
timeout
setImmediate() 比 setTimeout() 优势的地方是 setImmediate() 在 I/O 循环中总是先于任何定时器,不管已经定义了多少定时器。
process.nextTick()
理解 process.nextTick()
你可能已经注意到了 process.nextTick() 没有在上面那个表格里出现,虽然它确实是一个异步API。这是因为它技术上不属于事件循环。然而,nextTickQueue 会在当前操作结束后被处理,不管是在事件循环的哪个阶段。
回头看看之前那个表格,你在某个阶段的任何时候调用它,它的所有回调函数都会在事件循环继续进行之前得到处理。有时候这会导致比较糟糕的情况,因为它允许你用递归调用的方式去“阻塞” I/O,这会让事件循环无法进入到轮询阶段。
为什么要允许这样
部分是因为 Node.js 的设计哲学:API 应该总是异步的,即使本不需要是异步。
blablabla,后面几段看的我有点尴尬+晕。既尴尬又晕是觉得这几段说的有点啰嗦,而且举的例子不合适。例子要么是同步的,不是异步的。要么是例子里的写法完全可以避免,比如应该先添加 'connect' 事件监听再进行 .connect() 操作;又或者变量声明最好放在变量使用之前,可以避免变量的提前声明和当时赋值的麻烦。
难道是我没理解里面的秘辛?
process.nextTick() vs setTimeout()
这两个函数有些相似但是名字让人困惑:
process.netxtTick() 在事件循环的当前阶段立即生效;
setImmediate() 生效是在接下来的迭代或者事件循环的下一次tick;
本质上,它们的名字应该互换一下。process.nextTick() 比 setImmediate() 更“立刻”执行,但这是个历史问题没法改变。如果改了,npm上大堆的包就要挂了。
我们推荐开发者在所有情况下都使用 setImmediate() 因为它更显而易见(reason about),另外兼容性也更广,例如浏览器端。
为什么使用 process.nextTick()
有两大原因:
允许用户处理错误,清理不需要的资源,或许在事件循环结束前再次尝试发送请求;
必须让回调函数在调用栈已经清除(unwound)后并且事件循环继续下去之前执行;
下面的两个例子都是类似的,即在 line1 派发事件,却在 line2 才添加监听,因此监听的回调是不可能被执行到的。
于是可以用 process.nextTick() 使得当前调用栈先执行完毕,也即先执行 line2 注册事件监听,然后在 nextTick 派发事件。
const EventEmitter = require('events'); const util = require('util'); function MyEmitter() { EventEmitter.call(this); // use nextTick to emit the event once a handler is assigned process.nextTick(function () { this.emit('event'); }.bind(this)); } util.inherits(MyEmitter, EventEmitter); const myEmitter = new MyEmitter(); myEmitter.on('event', function() { console.log('an event occurred!'); });
翻译总结:
这篇文章写的不太简练,也可能为了有更多的受众吧,我感觉车轱辘话比较多,一个意思要说好几遍。
从编程应用的角度简单来说:
Node.js 中的事件循环大概有七八个阶段,每个阶段都有自己的队列(queue),需要等本阶段的队列处理完成后才进入其他阶段。阶段之间会互相转换,循环顺序并不是完全固定的 ,因为很多阶段是由外部的事件触发的。
其中比较重要的是三个:
定时器阶段 timers:
定时器阶段执行定时器任务(setTimeOut(), setInterval())。
轮询阶段 poll:
轮询阶段由 I/O 事件触发,例如 'connect','data' 等。这是比较重/重要的阶段,因为大部分程序功能就是为了 I/O 数据。
本阶段会处理定时器任务和 poll 队列中的任务,具体逻辑:
如果有 setImmediate(),终止轮询阶段并进入检查阶段去执行;
如果没有 setImmediate(),那么就查看有没有到期的定时器,有的话就回到定时器阶段执行回调函数;
处理到期的定时器任务,然后
处理队列任务,直到队列空了或者达到上限
如果队列任务没了:
检查阶段 check:
当轮询阶段空闲并且已经有 setImmediate() 的时候,会进入检查阶段并执行。
比较次要但也列在表格中的两个:
I/O 阶段:
本阶段处理 I/O 异常错误;
'close'事件回调:
本阶段处理各种 'close' 事件回调;
关于 setTimeout(), setImmediate(), process.nextTick():
setTimeout() 在某个时间值过后尽快执行回调函数;
setImmediate() 一旦轮询阶段完成就执行回调函数;
process.nextTick() 在当前调用栈结束后就立即处理,这时也必然是“事件循环继续进行之前” ;
优先级顺序从高到低: process.nextTick() > setImmediate() > setTimeout()
注:这里只是多数情况下,即轮询阶段(I/O 回调中)。比如之前比较 setImmediate() 和 setTimeout() 的时候就区分了所处阶段/上下文。
另:
关于调用栈,事件循环还可以参考这篇文章:
https://blog.risingstack.com/node-js-at-scale-understanding-node-js-event-loop/
这篇文章里对事件任务区分了大任务(macro task) 、小任务(micro task),每个事件循环只处理一个大任务 ,但会处理完所有小任务。
这一点和前面的文章说的不同。
examples of microtasks:
process.nextTick
promises
Object.observe
examples of macrotasks:
setTimeout
setInterval
setImmediate
I/O