Search
🔄

Node.js event loop workflow & lifecycle in low level (2)

Tags
Node.js
Post
Last edited time
2023/07/12 11:57
2 more properties
포스팅 1편 링크

1. Workflow with examples

앞서 우리는 이벤트 루프의 아키텍처와 워크플로우에 대해 알아보았다. 이벤트 루프의 동기식 Semi 무한 루프 (synchronous semi infinite while loop)가 어떻게 자바스크립트를 비동기스럽게 동작시키는지 알 수 있었다. 이벤트루프는 오직 하나의 작업을 수행하지만, 그 어떠한 것도(hardly) blocking 하지 않는다. 이제 예제를 통해 이벤트 루프가 어떻게 동작하는지 살펴보자.

1.1. Snippet 1 - basic understanding

setTimeout(() => { console.log('setTimeout'); }, 0); setImmediate(() => { console.log('setImmediate'); });
JavaScript
복사
위 코드의 결과를 예측해보면 setTimeout 이 먼저 출력될 것이라고 생각하겠지만 100% 그렇지는 않다. 그이유는 메인 모듈이 실행되고난 이후, timer phase에 들어갔을 때, 타이머가 만료되었을 수도 있고 아닐수도 있기 때문이다.
왜 그렇냐고? 타이머 스크립트는 시스템 시간과 사용자가 제공하는 delta (delay) 기반으로 등록되기 때문이다. setTimeout 이 호출되고, 타이머 스크립트가 메모리에 저장되는 순간에는, 사용자의 컴퓨터 성능이나 노드 외 다른 작업으로 인해 약간의 딜레이가 발생할 수 있기 때문이다.
또한, 노드는 timer phase에 진입하기전에 now 라는 변수를 선언하는데, now 변수는 현재시간으로 간주된다. 그러므로, 100% 정확한 계산이 아닐 수도 있고, 그렇기때문에 setTimeout 이 무조건 먼저 출력될 것이라고 확신할 수 없다.
그러나 만약 해당 코드를 I/O 사이클 내부로 이동시킨다면, setTimeout 보다 setImmediate 가 먼저 실행되는 것이 보장된다.
fs.readFile('my-file-path.txt', () => { // 1. i/o 작업 수행 콜백 실행 setTimeout(() => { // 3. timer phase에서 콜백 실행 console.log('setTimeout'); }, 0); setImmediate(() => { // 2. check phase에서 setImmediate 콜백 실행 console.log('setImmediate'); }); });
JavaScript
복사

1.2. Snippet 2 - understanding timers better

var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setImmediate(foo); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();
JavaScript
복사
위의 예시는 매우 간단하다. 함수 foosetImmediate 에 의해 1000번 재귀호출된다. 해당 코드는 필자의 맥북 프로(+node 8.9.1)에서는 6~8ms가 소요된다. 이제 위 예시에서 setImmediate(foo)setTimeout(foo, 0) 로 바꿔보자.
var i = 0; var start = new Date(); function foo () { i++; if (i < 1000) { setTimeout(foo, 0); } else { var end = new Date(); console.log("Execution time: ", (end - start)); } } foo();
JavaScript
복사
해당 함수를 필자의 컴퓨터에서 실행시키면 대략 1400+ms 가 소요된다.
코드를 변경한다하더라도, I/O 이벤트가 전혀 없기때문에 폴링하는데 걸리는 시간이 0이기때문에 소요 시간이 거의 같아야 한다. 왜 이렇게 차이가 많이 나는걸까?
그 이유는 바로 시간을 비교하고 편차를 계산하는것이 CPU를 많이 쓰는 작업이기 때문에 더 많은 시간이 소요된다. 타이머 스크립트를 등록하는 것 또한 시간이 소요된다. 매 timer phase 마다 타이머 만료시간이 지났는지, 콜백이 실행되어야하는 지 확인하는데, 이 작업을 1000번 하기때문에 시간이 많이 걸릴 수 밖에 없다. 그러나 setImmediate 는 timer phase와 같은 과정이 필요 없기때문에 실행속도가 빠르다.

1.3. Snippet 3 - understanding nextTick() & timer execution

var i = 0; function foo(){ i++; if(i>20){ return; } console.log("foo"); setTimeout(()=>{ console.log("setTimeout"); },0); process.nextTick(foo); } setTimeout(foo, 2);
JavaScript
복사
위 함수의 실행시키면, foo 들이 먼저 출력될 것이고, 그다음에 setTimeout 들이 출력될 것이다. 코드를 실행시키면 setTimeout 의 delta 값인 2ms가 지나고나면 foo 함수가 실행되어 첫번째 foo 가 출력되고, nextTickQueue에서 다시 foo 함수가 호출된다. nextTickQueue 의 콜백들은 큐가 빌때까지 순차적으로 (동기식으로) 처리한다. 따라서 nextTickQueue의 모든 콜백이 실행된 다음에 timer phase에서 setTimeout 콜백이 처리된다.
아래와 같이 코드를 조금 수정하고 다시 살펴보자.
var i = 0; function foo(){ i++; if(i>20){ return; } console.log("foo", i); setTimeout(()=>{ console.log("setTimeout", i); },0); process.nextTick(foo); } setTimeout(foo, 2); setTimeout(()=>{ console.log("Other setTimeout"); }, 2);
JavaScript
복사
Other setTimeout 를 출력하는 setTimeout 이 하나 추가되었다. 100% 보장되는것은 아니나, 한개의 foo 가 먼저 출력되고, Other setTimeout 가 출력될 가능성이 다분히 높다. 최소 힙 내부에 있는 동일한 delta 값을 가진 타이머들은 어떠한 형태로 그룹화가 되어 있고, nextTickQueue는 진행중인 콜백 그룹의 실행이 끝난 이후에 실행되기 때문이다.
출력 결과 예시
nextTickQueue는 phase → phase로 넘어갈 때 실행됨 현재 timer phase에는 2개의 setTimeout이 같은 delta 값을 가진 콜백들이 존재. 따라서 queue에 있는 2개의 콜백을 처리한 후 nextTimeQueue의 콜백이 실행된다.

2. 다른 예시들

2.1. 예제 1

// Execute setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); // Output 1 timeout immediate // Output 2 immediate timeout
JavaScript
복사
실행 전 프로세스
1.
setTimeout() 의 콜백이 timer queue에 등록
2.
setImmediate() 의 콜백이 check queue에 등록
3.
이벤트 루프 실행
기대 상황
1.
timer phase에서 delta (만료 시간)이 지났는지 확인
2.
0ms가 지났으므로 콜백이 실행 (timeout 출력)
3.
check phase에서 콜백이 실행 (immediate 출력)
실제 결과
timeout이 먼저 실행될 수도 있고, immediate가 먼저 실행될 수도 있음
setTimeout의 delta 0 값은 실제로 1ms 이다 (아래 3.2 참고)
1ms 전에 timer phase 도달 시 → setImmediate 먼저 실행
1ms 이후 timer phase 도달 시 → setTimeout 먼저 실행

2.2. 예제 2

// Execute const fs = require('fs'); fs.readFile('a.js', (result) => { setTimeout(() => { console.log('timeout'); }, 0); setImmediate(() => { console.log('immediate'); }); }); // Output immediate timeout
JavaScript
복사
예제 1과 비슷하나, setTimeout과 setImmediate 이 File I/O의 콜백으로 들어가 있다. 실행결과는 다음과 같다.
1.
poll phase에서 fs.readFile 함수가 호출된다. (libUV 가 커널에 비동기로 처리 요청)
2.
커널이 비동기 처리를 완료하면 콜백이 실행된다.
a.
setTimeout의 콜백이 timer queue에 등록된다.
b.
setImmediate 콜백이 check queue에 등록된다.
3.
현재 poll phase에 있으므로 그다음 순서인 check phase로 이동하여 setImmediate 콜백이 먼저 실행된다
4.
그이후 timer phase로 이동하여 setTimeout 콜백이 실행된다.
같은 I/O 사이클에서는 setImmdiate()가 항상먼저 실행된다는 것을 알 수 있다. 공식문서에도 아래와 같이 설명한다.
The main advantage to using setImmediate() over setTimeout() is setImmediate() will always be executed before any timers if scheduled within an I/O cycle, independently of how many timers are present.

2.3. 예제 3

// Execute setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate')); process.nextTick(() => { setTimeout(() => console.log('timeout2'), 0); setImmediate(() => { process.nextTick(() => console.log('next tick2')); console.log('immediate2'); }); console.log('next tick'); }); // Output nexttick; timeout; timeout2; immediate; immediate2; next tick2;
JavaScript
복사
1.
setTimeout, setImmediate가 각각 timer queue, check queue에 등록된다. 이때 timer의 delta는 실제로 0이 아니라 1로 설정된다.
setTimeout(() => console.log('timeout'), 0); setImmediate(() => console.log('immediate'));
JavaScript
복사
2.
process.nextTick()의 콜백이 nextTickQueue에 등록된다. 그리고 nextTickQueue 특성 상 제일 먼저 실행된다.
3.
process.nextTick()의 콜백인 setTimeout, setImmediate이 각각 timer queue, check queue에 등록된다.
setTimeout(() => console.log('timeout2'), 0); setImmediate(() => { process.nextTick(() => console.log('next tick2')); console.log('immediate2'); });
JavaScript
복사
4.
process.nextTick()의 콜백의 마지막인 next tick 가 제일 먼저 출력된다.
5.
timer phase로 넘어가 timer의 delta를 비교하여 시간이 지났음을 확인하여, timout, timeout2가 출력된다.
6.
check phase로 이동하여, check queue에 있는 콜백을 실행한다. 가장먼저 immediate 가 출력된다.
7.
두번째 콜백인 setImmediate 를 실행하면서 해당 함수의 콜백을 실행하고, nextTickQueue에 첫번째 코드의 콜백을 등록한다.
process.nextTick(() => console.log('next tick2')); console.log('immediate2');
JavaScript
복사
8.
이때 아직까지 check phase에 있으므로 immediate2 이 출력된다.
9.
이후 마지막으로 nextTickQueue의 콜백을 실행하여 next tick2 이 출력된다.

3. Few common questions (몇 가지 일반적인 질문들)

3.1. 그래서 자바스크립트는 정확히 어디에서 실행되나요?

지금까지 이벤트루프가 별도의 스레드에서 루프로 돌면서 실행되며 콜백들을 큐에 넣고 꺼내서 실행되는 것을 살펴보았다. 이 포스트를 읽고나면, “그래서 자바스크립트는 정확히 어디에서 실행되는지” 헷갈릴 수 도 있다.
앞서 말했듯이, Node.js에는 오직 하나의 스레드를 가지고 자바스크립트를 실행하며, 이때 V8 엔진과 이벤트 루프를 사용한다. 실행 자체는 완벽히 동기적이며, 자바스크립트 실행이 완료되지 않으면 이벤트 루프 또한 진행되지 않는다.

3.2. setTimeout(fn, 0)가 있는데도 setImmediate가 왜 필요한가요?

우선, setTimeout(fn, 0) 은 정확하게는 0이 아니며 사실 1이다. 타이머의 delta 값을 1ms보다 적게 또는 2147483647ms 보다 크게 설정하면 delta 값이 자동으로 1로 설정된다. 그렇기 때문에 delta 값이 0으로 해놓으면 자동으로 1로 설정된다.
이와 반대로 setImmdiate 의 경우 타이머의 delta값을 비교하는 등의 과정이 필요하지 않기때문에 빠르다. 또한 setImmdiate 는 poll phaase 바로 다음에 실행되기 때문에, setImmdiate 콜백은 새로운 요청 직후 바로 실행된다고 볼 수 있다.

3.3. 왜 setImmediate는 Immediate 라고 부르는 거죠?

setImmediate와 process.nextTick  는 둘다 네이밍이 잘못 되었다고 생각한다. setImmediate 은 실제로 오직 Tick/루프 중 오직 1번만 관리되고, nextTick 은 매 Tick 마다 최대한 빠르게 호출되도록 구현되어 있다. 이러한 관점에 setImmediatenextTick 이라는 네이밍을 가지고, nextTicksetImmediate 라는 네이밍을 가지는게 적절할 것이다.

3.4. 자바스크립트는 Block 될 수 있나요?

앞서 언급한것과 같이, nextTickQueue 에는 콜백 실행을 위한 어떠한 limit 조건이 없다. 그래서 process.nextTick() 이 recursive하게 호출된다면, 다른 phases의 큐에 실행가능한 콜백이 있던 없던간에 여러분의 프로그램은 절대 그 작업에서 빠져나오지 못할 것이다.

3.5. exit callback phase에서 setTimeout을 호출하면 어떻게 되나요?

타이머 자체가 실행될 지도 모르겠지만, setTimeout 콜백은 실제로 절대 호출되지 않을 것이다. Node.js가 exit 콜백에 있다는 말은, 이미 이벤트 루프로에서 빠져나왔다는 것을 의미한다. 따라서 콜백은 실행되지 않는다.

4. Few short takeaways (간단 요약)

이벤트루프는 어떠한 job stack을 가지고 있지 않다.
이벤트 루프가 별도의 스레드에서 실행되고, 어떤 큐에서 꺼내서 자바스크립트를 실행하는것이 아니라, 자바스크립트 실행 자체가 이벤트 루프에서 되는 것이다.
setImmediate 는 job queue의 가장 앞에서 실행되는 것이 아니라, 이를 위한 별도의 phase와 queue가 있고 거기서 처리된다.
setImmediate 실행은 실제로는 다음 phase 혹은 다음 루프에서 실행되고, nextTick 이 실제로는 거의 즉시 실행된다.
nextTickQueue 을 잘못 사용(재귀 호출 등)하면 Node.js 자체가 블로킹이 될 수 있으니 조심하도록 하자.

5. Reference