이벤트 루프
Node.js의 공식문서 중, 이벤트 루프에 해당하는 부분을 번역/의역한 글입니다.
이벤트 루프란?
노드js가 수시로 시스템 커널에 작업을 던짐으로써, 싱글쓰레드로 작동하면서도 Non-blocking으로 IO작업을 할 수 있도록 해주는 장치
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
* Each box will be referred to as a "phase" of the event loop.
- 각각의 phase는 FIFO queue를 가진다.
- 각 phase별로 특화된 기능을 실행한다.
- phase별로 최대 수행 개수가 정해져 있다.
- 최대 개수만큼 실행하거나, 자신의 큐가 다 비워지게 되면 다음 페이즈로 이동한다.(tick)
- 폴링 단계에 작업이 많아지는 경우, 타이머의 임계 시간보다 길어지는 경우가 발생할 수 있.
Phases Overview
- 타이머 단계**:** setTimeout(), setInterval()에 의해 예정된 콜백을 실행하는 단계
- 대기 콜백 단계: 다음 루프 반복으로 연기된 I/O 콜백을 실행
- 유휴, 준비 단계: 내부적으로만 사용. 실질적으로 코드가 실행되지 않음
- 폴링 단계: 새로운 I/O 이벤트를 검색하고 I/O 관련 콜백을 실행.(타이머로 스케줄링된 콜백, setImmediate() 콜백, 그리고 close 콜백을 제외한 모든 콜백을 실행). 노드는 적정 시간만큼만 폴링을 진행 후, CPU time 분배를 위해 폴링 단계를 차단
- 체크 단계: 여기에서 setImmediate() 콜백이 호출됨
- 종료 콜백 단계: socket.on('close', ...)과 같은 일부 close 콜백을 실행
- nextTickQueue, microTaskQueue: 추가 설명
Timers Phase
타이머는 사용자가 원하는 **‘정확한 콜백 실행 시간’**이 아니라 제공된 콜백이 **‘실행될 수 있는 임계값’**을 지정합니다.
타이머 콜백은 지정된 시간이 경과한 후 예약 가능한 한 빨리 실행되지만 운영 체제 스케줄링이나 다른 콜백의 실행으로 인해 실행이 지연될 수 있습니다.
- 실질적으로 Poll phase가 timer의 실행시간을 결정합니다.
Pending Callbacks Phase
이 단계에서는 TCP 오류 유형과 같은 일부 시스템 작업에 대한 콜백을 실행합니다.
예를 들어, TCP 소켓이 연결을 시도할 때 ECONNREFUSED를 수신하는 경우, 일부 *nix 시스템은 오류를 보고하기 위해 대기하고, 이 오류는 pending callbacks 단계에서 실행되도록 대기열에 추가됩니다.
Poll Phase
폴링 단계에서는 두 가지 기능을 처리합니다.
- 입출력을 위해 차단하고 폴링해야 하는 시간을 계산
- 폴(poll) 큐에서 이벤트 처리
이벤트 루프가 poll phase에 도달하였고, 스케줄된 타이머가 없는 경우 아래의 둘 중 한가지 사건이 발생합니다.
- poll queue가 비어있지 않은 경우, hard-limit에 도달하거나 큐가 빌 때 까지 콜백을 실행
- poll queue가 비어있는 경우, 아래의 두 가지 경우 중 한가지가 추가로 발생
- 스크립트가 setImediate()로 작성된 경우, poll phase를 종료하고 check phase로 이동해서 진행
- setImediate()로 작성되지 않은 경우, 이벤트 루프는 callback이 추가될 때 까지 대기후 즉시 실행
폴링 대기열이 비어 있으면 이벤트 루프는 시간 임계값에 도달한 타이머가 있는지 확인하고, 하나 이상의 타이머가 준비되면 이벤트 루프가 타이머 단계로 다시 래핑되어 해당 타이머의 콜백을 실행합니다.
Check phase
poll phase 직후 콜백을 실행 할 수 있게 해주는 단계입니다.
폴링 단계에서 큐가 비워지고, 스크립트가 setImediate()로 작성된 경우, 이벤트 루프는 check phase에 진입해서 기다리지 않고 callback을 실행합니다
setImmediate()는 실제로 이벤트 루프의 별도 단계에서 실행되는 특수 타이머입니다.
Close callbacks phase
소켓이나 핸들이 갑자기 닫히면(예: socket.destroy()), 이 단계에서 'close' 이벤트가 발생합니다.
다른 경우라면, process.nextTick()을 통해 발생하기도 합니다.
setImediate() vs setTimeout()
setImmediate()는 현재 폴링 단계가 완료되면 스크립트를 실행 setTimeout()은 최소 임계값(ms)이 경과한 후 스크립트가 실행
두 함수를 동시에 실행했을 때, 일반적인 환경에서는 프로세서의 성능, 현재 작업 상황에 따라 결과가 비결정적입니다.
// timeout_vs_immediate.js
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
$ node timeout_vs_immediate.js
timeout
immediate
$ node timeout_vs_immediate.js
immediate
timeout
그러나 IO작업이 있는 경우, setImediate()가 무조건 먼저 실행됩니다.
// timeout_vs_immediate.js
const 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
process.nextTick()
process.nextTick()은 비동기 API의 일부임에도 불구하고 다이어그램에 표시되어있지 않았습니다.
이는 process.nextTick()이 기술적으로 이벤트 루프의 일부가 아니기 때문입니다.
대신 이벤트 루프의 현재 단계에 관계없이 현재 작업이 완료된 후에 nextTickQueue가 처리됩니다. 여기서 연산은 기본 C/C++ 핸들러에서 전환하여 실행해야 하는 자바스크립트를 처리하는 것으로 정의됩니다.
다이어그램을 다시 살펴보면, 특정 페이즈에서 process.nextTick()을 호출할 때마다 이벤트 루프가 계속되기 전에 process.nextTick()에 전달된 모든 콜백이 실행됩니다. 이렇게 하면 재귀적인 process.nextTick() 호출로 인해 I/O가 "고갈"되어 이벤트 루프가 폴링 단계에 도달하지 못하기 때문에 hanging될 위험이 있게됩니다.
위험이 있음에도 이러한 방식으로 구현되어있는 이유
API가 비동기적일 필요가 없는 곳에서도 항상 비동기적이어야 한다는 디자인 철학 때문이라고 합니다.
function apiCall(arg, callback) {
if (typeof arg !== 'string')
return process.nextTick(
callback,
new TypeError('argument should be string')
);
}
위 스니펫은 인수를 검사하고 올바르지 않은 경우 오류를 콜백에 전달합니다.
API가 최근에 업데이트되어 process.nextTick()에 인수를 전달할 수 있게 되었고, 콜백 이후에 전달된 모든 인수를 콜백의 인수로 사용할 수 있으므로, 함수를 중첩할 필요가 없습니다.
우리가 하는 일은 사용자에게 오류를 다시 전달하는 것이지만, 나머지 사용자 코드가 실행되도록 허용한 후에만 오류를 전달하는 것입니다.
process.nextTick()을 사용하면 사용자 코드의 나머지 부분이 실행된 후 이벤트 루프가 진행되도록 허용되기 전에 apiCall()이 항상 콜백을 실행하도록 보장합니다.
이를 위해 JS 호출 스택을 풀고 즉시 제공된 콜백을 실행하도록 허용하여 사용자가 RangeError: Maximum call stack size exceeded from v8에 도달하지 않고도 process.nextTick()을 재귀적으로 호출할 수 있도록 합니다.
이 철학은 잠재적으로 문제가 될 수 있는 상황을 초래할 수 있습니다. 이 스니펫을 예로 들어보겠습니다:
let bar;
// this has an asynchronous signature, but calls callback synchronously
function someAsyncApiCall(callback) {
callback();
}
// the callback is called before `someAsyncApiCall` completes.
someAsyncApiCall(() => {
// since someAsyncApiCall hasn't completed, bar hasn't been assigned any value
console.log('bar', bar); // undefined
});
bar = 1;
사용자는 비동기 서명을 갖도록 일부AsyncApiCall()을 정의하지만 실제로는 동기적으로 작동합니다.
호출될 때, 일부AsyncApiCall()에 제공된 콜백은 이벤트 루프의 동일한 단계에서 호출되는데, 이는 일부AsyncApiCall()이 실제로 비동기적으로 아무 작업도 하지 않기 때문입니다. 결과적으로 콜백은 스크립트가 완료될 때까지 실행되지 않았기 때문에 해당 변수가 아직 범위에 없을지라도 바를 참조하려고 시도합니다.
콜백을 process.nextTick()에 배치하면 스크립트가 완료까지 실행될 수 있으므로 콜백이 호출되기 전에 모든 변수, 함수 등을 초기화할 수 있습니다.
또한 이벤트 루프가 계속되지 않는다는 장점도 있습니다. 이벤트 루프가 계속 진행되기 전에 사용자에게 오류에 대한 경고를 표시하는 것이 유용할 수 있습니다. 다음은 process.nextTick()을 사용한 이전 예제입니다:
let bar;
function someAsyncApiCall(callback) {
**process.nextTick(callback);**
}
someAsyncApiCall(() => {
console.log('bar', bar); // 1
});
bar = 1;
또다른 예제
const server = net.createServer(() => {}).listen(8080);
server.on('listening', () => {});
포트만 전달되면 포트는 즉시 바인딩됩니다. 따라서 'listening' 콜백은 즉시 호출될 수 있습니다. 문제는 그때까지 .on('listening') 콜백이 설정되지 않았을 것이라는 점입니다.
이 문제를 해결하기 위해 'listening' 이벤트는 스크립트가 완료될 때까지 nextTick() 큐에 대기합니다. 이를 통해 사용자가 원하는 이벤트 핸들러를 설정할 수 있습니다.
process.nextTick() vs setImmediate()
사용자 관점에서는 유사한 두 개의 호출이 있지만, 그 이름이 혼란스럽습니다.
- process.nextTick()은 동일한 단계에서 즉시 실행됩니다.
- setImmediate()는 이벤트 루프의 다음 반복 또는 '틱'에서 실행됩니다.
본질적으로 이름을 바꿔야 합니다. process.nextTick()이 setImmediate()보다 더 즉시 실행되지만, 이는 과거의 유물이기 때문에 변경될 가능성이 낮습니다. 이 스위치를 변경하면 npm의 많은 패키지가 손상될 수 있습니다. 매일 더 많은 새로운 모듈이 추가되고 있으며, 이는 우리가 기다릴 때마다 더 많은 잠재적 파손이 발생한다는 것을 의미합니다. 혼란스럽기는 하지만 이름 자체는 변경되지 않습니다.
추론하기 쉽기 때문에 개발자는 모든 경우에 setImmediate()를 사용하는 것이 좋습니다.
process.nextTick()을 사용하는 이유?
크게 두 가지 이유가 있습니다:
사용자가 오류를 처리하고, 불필요한 리소스를 정리하거나, 이벤트 루프가 계속되기 전에 요청을 다시 시도할 수 있도록 하기 위해서입니다.
때로는 호출 스택이 풀린 후 이벤트 루프가 계속되기 전에 콜백이 실행되도록 허용해야 할 때도 있습니다.
한 가지 예는 사용자의 기대치를 맞추는 것입니다. 간단한 예시입니다:
const server = net.createServer();
server.on('connection', (conn) => {});
server.listen(8080);
server.on('listening', () => {});
이벤트 루프가 시작될 때 listen()가 실행되지만 수신 콜백은 setImmediate()에 배치된다고 가정해 보겠습니다. 호스트 이름이 전달되지 않는 한 포트에 대한 바인딩은 즉시 이루어집니다. 이벤트 루프가 진행되려면 폴링 단계에 도달해야 하는데, 이는 연결이 수신되어 수신 이벤트 전에 연결 이벤트가 실행될 가능성이 0이 아닐 수 있음을 의미합니다.
또 다른 예는 생성자 내에서 EventEmitter를 확장하고 이벤트를 방출하는 것입니다:
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {
constructor() {
super();
this.emit('event');
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
사용자가 해당 이벤트에 콜백을 할당할 때까지 스크립트가 처리되지 않았기 때문에 생성자에서 즉시 이벤트를 방출할 수 없습니다. 따라서 생성자 자체 내에서 process.nextTick()을 사용하여 생성자가 완료된 후 예상 결과를 제공하는 이벤트를 발생시키도록 콜백을 설정할 수 있습니다:
const EventEmitter = require('events');
class MyEmitter extends EventEmitter {
constructor() {
super();
// use nextTick to emit the event once a handler is assigned
process.nextTick(() => {
this.emit('event');
});
}
}
const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
console.log('an event occurred!');
});
References
References
The Node.js Event Loop, Timers, and process.nextTick()
Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.
nodejs.org