들어가며
가장 간단한 예시부터 시작해 보겠습니다:
console.log('script start');
setTimeout(function() {
console.log('setTimeout');
}, 0);
Promise.resolve().then(function() {
console.log('promise1');
}).then(function() {
console.log('promise2');
});
console.log('script end');
log 출력 순서는 다음과 같습니다:
script start
script end
promise1
promise2
setTimeout
왜 그럴까요?
macrotask
우선 매크로태스크(Macrotask)라고 부르며, 많은 문맥에서 단순히 태스크(Task)라고도 불립니다. 예를 들어:
-
setTimeout -
setInterval -
setImmediate -
requestAnimationFrame -
I/O
-
UI rendering
가장 흔한 지연 호출 및 간격 호출, Node 환경의 즉시 호출, 고빈도 RAF, 그리고 I/O 작업과 UI 렌더링 등이 있습니다. 이들은 모두 macrotask이며, 이벤트 루프의 주요 작업은 macrotask queue를 한 바퀴씩 확인하며 이 태스크들을 처리하는 것입니다.
예를 들어:
setImmediate(() => {
console.log('#1');
});
setImmediate(() => {
console.log('#2');
});
setImmediate(() => {
console.log('#3');
setImmediate(() => {
console.log('#4');
});
});
다음번에 immediate macrotask queue를 확인할 때 바깥쪽의 콜백 함수 3개가 차례대로 실행되고, 그 다음번이 되어서야 안쪽의 콜백 함수가 실행됩니다. 따라서 macrotask의 규칙은 다음 버스를 기다리는 것입니다(다음 회차의 이벤트 루프, 혹은 현재 이벤트 루프에서 아직 발생하지 않은 특정 단계).
microtask
마이크로태스크(Microtask)이며 잡(Job)이라고도 합니다. 예를 들어:
-
process.nextTick -
Promise callback
-
Object.observe -
MutationObserver
nextTick과 Promise는 자주 쓰이며, Object.observe는 폐기된 API인 네이티브 옵저버 구현체입니다. MutationObserver는 꽤 오래전부터 있었던 것으로 DOM 변경을 감시하는 데 사용됩니다.
일반적으로 이 콜백 함수들은 특정 조건에서 microtask queue에 추가되며, 현재 macrotask 큐의 플러시(flush)가 끝난 후 해당 큐를 확인하여 모두 플러시합니다(큐 안의 모든 microtask를 처리함).
P.S. 예외적인 상황은 일부 브라우저 버전의 Promise 콜백이 반드시 microtask queue를 거치지 않을 수도 있다는 점입니다. Promises/A+ 명세에서 이 점을 명확히 요구하지 않았기 때문입니다(둘 다 괜찮다고 함).
예를 들어:
setImmediate(() => {
console.log('immediate');
});
Promise.resolve(1).then(x => {
console.log(x);
return x + 1;
}).then(x => {
console.log(x);
return x + 1;
}).then(x => console.log(x));
다음 microtask queue 확인 시 Promise 콜백이 하나뿐임을 발견하고 즉시 실행합니다. 다시 확인하니 또 하나가 나와서 계속 실행하고, 또 확인하니 또 하나가 새로 생겨서 이어서 실행합니다. 다시 확인했을 때 더 이상 없으면 이벤트 루프를 계속하여 immediate macrotask queue를 확인하고, 이때서야 setImmediate 콜백을 실행합니다. 따라서 microtask의 규칙은 현재 버스의 맨 뒤에 매달리는 것이며, 즉석에서 만들어진 것도 바로 처리할 수 있다는 것입니다(현재 macrotask 큐의 플러시가 끝날 때 바로 실행되며 다음 버스를 기다릴 필요가 없습니다. 또한 microtask queue 플러시 과정에서 생성된 동일한 유형의 microtask도 즉시 처리되므로 블로킹이 허용됩니다).
Event Loop
JS의 선천적인 비동기 특성은 이벤트 루프(Event Loop)를 통해 완성된다는 것을 알고 있습니다. 예를 들어:
const afterOneSecond = console.log.bind(console, '1s later');
setTimeout(afterOneSecond, 1000);
구체적인 실행 과정은 대략 다음과 같습니다:
-
JS 스레드 시작, 이벤트 루프 생성
-
script가 호출 스택(Call Stack)에 추가됨
-
첫 번째 줄 실행으로 Function 생성
-
두 번째 줄 실행, (Event Table에 의해)
1000ms 후afterOneSecond콜백을 처리하도록 기록 -
script가 스택에서 제거되고 호출 스택이 비워짐
-
이벤트 루프가 잠시 공회전함(macrotask queue가 비어 있어 할 일이 없음)
-
1초 남짓 지나 타이머가 만료되고,
afterOneSecond콜백이 macrotask queue에 삽입됨 -
다음 회차 이벤트 루프에서 macrotask queue가 비어 있지 않음을 확인하고, FIFO 방식으로
afterOneSecond콜백을 꺼내 호출 스택에 추가함 -
afterOneSecond실행, 로그에1s later출력 -
afterOneSecond가 스택에서 제거되고 호출 스택이 다시 비워짐 -
더 이상 발생할 일이 없으므로 이벤트 루프 종료
여기서부터 흥미로워집니다. 예를 들어 이벤트 루프가 종료되는 시점에 대해 한 가지 흔한 오해는 다음과 같습니다:
JS 코드 실행은 모두 이벤트 루프 내에 있다
이는 당연히 모호한 표현입니다. 실제로 호출 스택이 빌 때까지 이벤트 루프는 존재감을 드러내지 않으며(태스크 큐 확인), 더 이상 발생할 일이 없음을 확인했을 때 이벤트 루프를 종료합니다. 예를 들어:
// 위 예시를 ./setTimeout.js 파일에 저장
$ node ./setTimeout.js
1s later
./setTimeout.js를 실행하는 Node 프로세스는 약 1초 동안 생존했다가 이벤트 루프의 종료와 함께 정상적으로 exit 되었습니다. 반면 서버 프로그램은 특정 포트의 요청을 계속 리스닝하므로 이벤트 루프가 끝날 수 없고, 따라서 Node 프로세스도 계속 유지됩니다.
P.S. 각 JS 스레드는 자신만의 이벤트 루프를 가지므로 Web Worker도 독립적인 이벤트 루프를 가집니다.
P.S. Event Table은 이벤트 루프와 함께 사용되는 데이터 구조로, 콜백 트리거 조건과 콜백 함수의 매핑 관계를 기록합니다:
Every time you call a setTimeout function or you do some async operation?—?it is added to the Event Table. This is a data structure which knows that a certain function should be triggered after a certain event.
역할
그렇다면 이벤트 루프의 존재 의의는 무엇일까요? 이게 없으면 안 될까요?
비동기 특성을 지원하기 위해서입니다. JS가 브라우저 환경에서 사용된 지난 세월을 생각해보면, UI 상호작용이나 네트워크 요청은 상대적으로 느립니다. JS는 메인 스레드에서 실행되어 렌더링을 방해하므로, 만약 이러한 느린 동작들이 동기적으로 블로킹된다면 사용자 경험은 매우 나쁠 것입니다. 예를 들어:
document.body.addEventListener('click', () => alert(+new Date));
const xhr = new XMLHttpRequest();
// Sync xhr
xhr.open('GET', 'http://www.ayqy.net', false);
xhr.send(null);
console.log(xhr.responseText);
send()를 실행하는 약 3초 동안 페이지는 완전히 무반응 상태가 되며, 이 기간에 클릭하여 발생한 alert 창은 macrotask 큐에 삽입되었다가 요청 응답이 돌아온 후에야 하나씩 팝업됩니다.
이벤트 루프가 없다면 이 3초 동안 상호작용이 전혀 불가능하며 alert 창도 미래의 어느 시점에 뜨지 않을 것입니다. 따라서 이벤트 루프는 비동기 특성을 가져와 느린 동작이 렌더링을 방해하는 문제에 대응합니다.
P.S. 실제로 DOM 이벤트 콜백은 모두 macrotask이며, 역시 이벤트 루프에 의존합니다.
Call Stack
JS의 단일 스레드 환경은 한 시점에 한 가지 일만 할 수 있음을 의미하므로 (하나의 JS 스레드 내에서) 호출 스택은 하나뿐입니다. 예를 들어:
function mult(a, b) { return a * b; }
function double(a) { return mult(a, 2); }
+ function main() {
return double(12);
}();
실행 과정 중 호출 스택의 변화는 다음과 같습니다:
// push script
// push main
// push double
// push mult
// pop mult
// pop double
// pop main
// pop script
주의할 점은, 호출 스택이 비어 있을 때만 이벤트 루프가 작동할 기회를 얻는다는 것입니다. 예를 들어:
function onClick() {
console.log('click');
setTimeout(console.log.bind(console, 'timeout'), 0);
// Wait 10ms
let now = Date.now();
while (Date.now() - now < 10) {}
}
document.body.addEventListener('click', onClick);
document.body.firstElementChild.addEventListener('click', onClick);
document.body.firstElementChild.click();
위 예시의 출력 결과는 다음과 같습니다:
click
click
timeout
timeout
첫 번째 click 출력 후 즉시 timeout이 출력되지 않는 이유는 이때 호출 스택이 비어 있지 않기 때문입니다(스택에는 자식 요소의 onClick 하나가 있음). 이벤트 루프는 macrotask 큐에 만료된 타이머 콜백이 있더라도 확인하지 않습니다. 구체적으로는 이벤트 버블링으로 인해 body의 onClick이 트리거되었기 때문에, 일련의 동기적 버블링이 끝날 때까지 자식 요소의 onClick은 스택에서 나갈 수 없습니다.
P.S. 따라서 이 시나리오의 흥미로운 점은 이벤트 버블링이 가져오는 "암시적 함수 호출"에 있습니다.
6개의 작업 큐
NodeJS에는 *4개의 macrotask 큐(명확한 처리 순서가 있음)*가 있습니다:
-
만료된 타이머/인터벌 큐:
setTimeout,setInterval -
IO 이벤트 큐: 파일 읽기/쓰기, 네트워크 요청 등의 콜백
-
Immediate 큐:
setImmediate -
Close 핸들러 큐: 소켓의 close 이벤트 콜백 등
이벤트 루프는 만료된 타이머부터 확인을 시작하여, 순서대로 각 큐에서 대기 중인 모든 콜백을 처리합니다.
또한, *2개의 microtask 큐(이 역시 명확한 처리 순서가 있음)*가 있습니다:
-
Next tick 큐:
process.nextTick -
Micro task 큐: Promise 콜백 등
nextTick 마이크로태스크 큐는 다른 마이크로태스크 큐보다 우선순위가 높으므로, nextTick이 비어야만 Promise와 같은 다른 것들을 처리합니다.
Next tick queue has even higher priority over the Other Micro tasks queue.
nextTick과 setImmediate
전자는 microtask이고 후자는 macrotask입니다. 이는 과도하게 연속적인 nextTick 호출이 이벤트 루프를 차단하고 나아가 I/O를 차단할 수 있음을 의미합니다. 따라서 필요한 경우가 아니라면 nextTick을 남용하지 마세요:
It is suggested you use setImmediate() over process.nextTick(). setImmediate() likely does what you are hoping for (a more efficient setTimeout(..., 0)), and runs after this tick's I/O. process.nextTick() does not actually run in the "next" tick anymore and will block I/O as if it were a synchronous operation.
또한, 두 가지의 주요 차이점은 nextTick은 이번 버스 꼬리에 매달려 실행되고, setImmediate는 다음 버스를 기다려야 한다는 것입니다:
- process.nextTick() fires immediately on the same phase
- setImmediate() fires on the following iteration or 'tick' of the event loop
P.S. setImmediate에 대한 설명은 아주 엄격하지는 않습니다. 다음 immediate 단계에 도달하면 실행될 수 있으며, 반드시 다음 회차의 이벤트 루프일 필요는 없습니다(현재 어느 단계에 있는지에 따라 다름).
P.S. 이름만 봐서는 immediate가 더 가까운 것 같지만, 실제로는 nextTick이 가장 가까운 미래입니다. 역사적인 이유로 바꿀 수 없게 되었습니다.
주의할 점은, nextTick이 존재하는 이유는 더 세밀한 태스크를 제공하여 이벤트 루프 각 단계 사이의 틈새에서 실행될 수 있도록 하기 위함입니다. 예를 들어 급한 정리 작업, 에러 처리/재시도와 같이 실제 수요가 있는 시나리오가 있습니다. 자세한 내용은 Why use process.nextTick()?을 참고하시기 바라며 여기서는 생략합니다.
setTimeout과 setImmediate
setTimeout(function() {
console.log('setTimeout')
}, 0);
setImmediate(function() {
console.log('setImmediate')
});
timer-IO-immediate-close의 macrotask 처리 순서에 따르면 로그 순서는 다음과 같을 것으로 추측됩니다:
setTimeout
setImmediate
하지만 실제 상황은 다음과 같습니다:
// 1st
setImmediate
setTimeout
// 2nd
setImmediate
setTimeout
// 3rd
setImmediate
setTimeout
// 4th
setTimeout
setImmediate
// 5th
setImmediate
setTimeout
// 6th
setTimeout
setImmediate
출력 순서가 일정하지 않은 것은 경쟁 관계 때문이 아니라, setTimeout 0의 0이 엄격한 의미의 "즉시"가 아니기 때문입니다. 즉, 0ms 타이머라고 해서 반드시 콜백 함수가 태스크 큐에 즉시 삽입되는 것은 아닙니다. 따라서 setTimeout 0이 바로 다음 회차의 이벤트 루프를 타지 못할 수 있으며, 이때 상식 밖의 출력이 발생합니다.
그렇다면 어떤 경우에 두 가지의 순서를 확정할 수 있을까요?
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0);
setImmediate(() => {
console.log('immediate')
})
});
IO 큐 처리 중에 새로운 timer task와 immediate task가 생성되면, 순서에 따라 다음에 immediate 큐를 처리하게 되므로 항상 'immediate'가 먼저 출력되며 순서가 뒤섞이지 않습니다.
그렇다면 순서를 반대로 유지할 방법이 있을까요?
네, 있습니다. 다음과 같이 하면 됩니다:
setTimeout(function() {
console.log('setTimeout')
}, 0);
//! wait timer to be expired
var now = Date.now();
while (Date.now() - now < 2) {
//...
}
setImmediate(function() {
console.log('setImmediate')
});
위 예시는 안정적으로 setTimeout을 먼저 출력합니다. 중간의 2ms 블로킹은 타이머가 만료되기를 기다리는 것이며, 이를 통해 이벤트 루프가 시작되기 전에 타이머가 만료되어 콜백이 처리 대기 큐에 삽입되도록 보장합니다.
P.S. 여기서 왜 2ms를 사용하는지에 대해서는, setTimeout 0이 setTimeout 1ms로 변환된다고 알려져 있기 때문입니다. 그래서 아주 조금 더 기다리는 것이며, 자세한 내용은 Understanding Non-deterministic order of execution of setTimeout vs setImmediate in node.js event-loop의 uvlib 소스 분석을 참고하세요.
P.S. 만약 2ms가 부족하다면 조금 더 기다리면 됩니다. 어쨌든 핵심은 타이머가 만료되기를 기다리는 것입니다. 그래야만 이벤트 루프가 다음 회차까지 기다리지 않고 첫눈에 setTimeout 0의 콜백을 발견할 수 있기 때문입니다.
IO starvation
microtask 메커니즘은 IO starvation 문제를 가져왔습니다. 무한히 긴 microtask 큐는 이벤트 루프를 차단할 수 있습니다. 이 문제를 피하기 위해 NodeJS 초기 버전(v0.12)에서는 1000의 깊이 제한(process.maxTickDepth)을 두었으나 나중에 제거되었습니다.
process.maxTickDepth has been removed, allowing process.nextTick to starve I/O indefinitely. This is due to adding setImmediate in 0.10.
P.S. 자세한 내용은 https://github.com/nodejs/node/wiki/API-changes-between-v0.10-and-v0.12#process를 참고하세요.
예를 들어:
const fs = require('fs');
function addNextTickRecurs(count) {
let self = this;
if (self.id === undefined) {
self.id = 0;
}
if (self.id === count) return;
process.nextTick(() => {
console.log(`process.nextTick call ${++self.id}`);
addNextTickRecurs.call(self, count);
});
}
addNextTickRecurs(Infinity);
setTimeout(console.log.bind(console, 'omg! setTimeout was called'), 10);
setImmediate(console.log.bind(console, 'omg! setImmediate also was called'));
fs.readFile(__filename, () => {
console.log('omg! file read complete callback was called!');
});
console.log('started');
omg! xxx는 절대 출력되지 않습니다. 동기 코드가 모두 실행된 후 호출 스택이 비워지면, 이벤트 루프가 태스크 큐를 확인하여 nextTick 마이크로태스크 큐가 비어 있지 않음을 발견합니다. 해당 마이크로태스크를 꺼내 호출 스택에서 실행하면 또 하나가 삽입되는 과정이 끊임없이 반복되기 때문입니다.
주의할 점은, 현재 이벤트 루프가 어느 단계에 있든 상관없이 즉시 nextTick 큐를 확인한다는 것입니다:
the nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.
(The Node.js Event Loop, Timers, and process.nextTick()에서 인용)
Event Loop Counter
이벤트 루프의 횟수를 어떻게 셀까요?
이렇게 해보면 어떨까요:
const LoopCounter = {
counter: 0,
active: true,
start() {
setImmediate(this.countLoop.bind(this));
},
stop() {
this.active = false;
},
get() {
return this.counter;
},
countLoop() {
this.counter++;
if (this.active) setImmediate(this.countLoop.bind(this));
}
};
// test
LoopCounter.start();
let now = Date.now();
let intervals = 0;
let MAX_COUNT = 10;
let handle = setInterval(() => {
console.log(LoopCounter.get());
if (++intervals >= MAX_COUNT) {
clearInterval(handle);
LoopCounter.stop();
}
}, 10);
setImmediate를 시계로 사용하는 이유는 4가지 macrotask 중에서 오직 setImmediate만이 다음 회차 이벤트 루프에서 즉시 처리됨을 보장할 수 있기 때문입니다.
이 카운터는 어디에 쓸까요?
이벤트 루프를 추적하는 데 사용할 수 있습니다. 예를 들어 동일한 이벤트 루프 내에 있는지 확인하거나, 앞서 논의한 setTimeout 0과 setImmediate의 순서 문제를 카운터를 통해 추가로 검증할 수 있습니다. 결과는 다음과 같습니다:
// 1st
setImmediate 1
setTimeout 1
// 2nd
setTimeout 0
setImmediate 1
1 1은 타이머가 바로 다음의 첫 번째 이벤트 루프를 타지 못하고 두 번째 루프에서 실행되었음을 의미하며, 0 1은 첫 번째 이벤트 루프가 시작되기 전에 타이머가 이미 만료되었음(성공적으로 루프를 탔음)을 의미합니다.
참고 자료
-
Difference between microtask and macrotask within an event loop context
-
Philip Roberts: Help, I’m stuck in an event-loop.: 이벤트 루프와 호출 스택, 태스크 큐에 관한 영상
-
loupe: JS 실행 과정 시각화 도구
-
Timers, Immediates and Process.nextTick— NodeJS Event Loop Part 2: 마지막 문제가 매우 흥미롭습니다. 답은 댓글에 있으며, 이 시리즈 5편 모두 훌륭할 것 같습니다.
아직 댓글이 없습니다