본문 바로가기

이벤트 루프와 process.nextTick() 이해하기

이전에 이벤트루프에 관하여 이렇게 정리한 적이 있다.

이벤트 루프란 멀티 스레드 기반의 브라우저나, Node.js 구동 환경이 단일 스레드인 자바스크립트 엔진의 콜스택과 함께 동작하여 비동기 작업을 처리할 수 있게해주는 장치이다.

<동작 방식>
1. 콜스택이 비워있는지 체크하고, 비었다면 태스크 큐에서 대기 중인 비동기 작업을 큐에서 꺼내 콜스택에 push한다.
2. 태스크 큐에는 setTimeout, HttpRequest, addEventListener과 같은 비동기 함수들이 실행을 대기한다.
3. Promise와 같은 비동기 객체는 마이크로 태스크 큐라는 우선순위가 더 높은 큐에서 대기한다. 따라서 이벤트 루프는 콜스택이 비었으면 마이크로 태스크 큐에서 대기 중인 비동기 함수를 먼저 실행한다.

 

다시 공부하면서 깨달은건데 이 정리는 잘못된 내용도 있고 더 자세히 공부해야 할 점들도 많았다. 잘못 알고 있는 점을 바로 잡고, process.nextTick()에 대한 정확한 이해를 하기 위해 여기에 정리해본다.

 

문서는 Node.js 공식문서 외 기타를 참고하였다.

1. 이벤트 루프는 각 단계(페이즈)가 존재하고 각 페이즈마다 큐가 있다.

먼저 이 문장에서 빠진 세부 사항들이 많았다.

 

"콜스택이 비워있는지 체크하고, 비었다면 태스크 큐에서 대기 중인 비동기 작업을 큐에서 꺼내 콜스택에 push한다."

 

 

각 단계는 실행할 콜백의 FIFO 큐를 가집니다. 각 단계는 자신만의 방법에 제한적이므로 보통 이벤트 루프가 해당 단계에 진입하면 해당 단계에 한정된 작업을 수행하고 큐를 모두 소진하거나 콜백의 최대 개수를 실행할 때까지 해당 단계의 큐에서 콜백을 실행합니다. 큐를 모두 소진하거나 콜백 제한에 이르면 이벤트 루프는 다음 단계로 이동합니다.

2. 이벤트 루프의 작업 흐름

이제 각 단계에 어떤 작업들이 이루어 지는지 정리한다. 역시 이 문장 또한 내가 너무 간략하게 적은 것이었다(ㄱ-)

 

"태스크 큐에는 setTimeout, HttpRequest, addEventListener과 같은 비동기 함수들이 실행을 대기한다."

 

Timers

setTimeout, setInterval과 같은 타이머들이 대기하는 큐이다. 정확히는 태스크 큐에 타이머의 콜백함수를 바로 넣는게 아니라 타이머들을 heap에 오름차순으로 넣고 임계시간이 되면 타이머의 콜백함수를 큐에 넣는다.

* delay 시간이 1초 -> 2초 -> 3초 순으로 heap에 들어가게 된다.

 

이벤트 루프가 Timer 단계에 접어들면, heap의 타이머들이 임계값에 다다랐는지 검사한다.

* now - registeredTime === delay

 

해당 검사를 통과하는 타이머의 콜백을 큐에 넣고 실행한다. 조건을 만족하지 않는 타이머를 만나는 즉시 검사를 종료하고 다음 페이즈로 이동한다. heap에 오름차순으로 정렬되어 있기 때문에 당연히 그 뒤의 타이머들은 조건을 만족하지 않을 것이다.

 

중요한 것은 setTimeout의 delay는 사람이 원하는 실행 시간이 아니라, 제공된 콜백이 일정 시간 후에 실행되어야 하는 기준시간을 말한다.

이말은 즉슨, 사용자의 운영체제나 다른 콜백함수 등 외부 요소에 의해 우리가 지정한 시간이 아니라 약간 지연될 수도 있다는 뜻이다.

참고

 

Pending i/o callbacks

pending queue에 대기 중인 콜백함수들이 있다면 이를 실행한다.

 

Poll

이름처럼 이 단계는 폴링(Polling)하는 단계이다. 이단계에서 입력 데이터를 읽거나, http 응답 콜백 등을 처리할 수 있다. Poll queue가 비어있지 않다면 queue의 콜백함수들을 차례로 실행한다.

 

중요한 것은 poll queue가 비었을 때이다. 비었다고 놀고 있는게 아니라 아래의 단계를 수행한다.

1. 다른 큐들(check, close, pending i/o)이 비었는지 확인하고 비어있지 않는 페이즈로 이동하기 위해 poll 페이즈를 종료한다.

2. 다른 큐들도 비어있다면 다음 태스크가 들어올 때까지 Timer 페이즈의 타이머들의 임계값에 다다랐는지 검사하고, 그 시간에 다다르기까지 대기했다가 Timer페이즈로 이동한다.

* 바로 Timer 단계로 가지 않는 이유는 어차피 가봤자 임계값을 만족하지 못하므로 단계를 한 번 더 돌게 되므로 낭비이기 때문이다.

 

2022.03.06. 추가

 

아래 코드의 setTimeout과 setImmediate 중 무엇이 먼저 실행될까?

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

정답은 순서를 장담할 수 없다.
이벤트 루프가 Timer phase의 큐들을 검사할 때 타이머가 임계값에 다다랐을 수도 있고 아닐수도 있기 때문이다.
Timer의 임계값은 시스템 시간과 사용자가 제공한 시간을 활용하는데 setTImeout이 호출된 순간, 메모리에 이 타이머가 저장되기때문에 성능이나 외부 작업등으로 인해 약간의 지연이 발생할 수 있다.
또한 실행할 때 현재 시간을 변수에 저장하게 되는데 이것은 정확한 시간이라기에는 약간의 노이즈가 있다는 말이다.

따라서 위 코드의 '0'은 사실 '0'이 아니라 약간의 지연이 껴있는 값이 될 것이다.

중요한 것은 setTimeout의 delay는 사람이 원하는 실행 시간이 아니라, 제공된 콜백이 일정 시간 후에 실행되어야 하는 기준시간을 말한다.
이말은 즉슨, 사용자의 운영체제나 다른 콜백함수 등 외부 요소에 의해 우리가 지정한 시간이 아니라 약간 지연될 수도 있다는 뜻이다.

순서를 장담하기 위해서는 아래처럼 pending i/o callback queue에 등록하는 것이다.

fs.readFile('my-file-path.txt', () => {
  setTimeout(() => {
    console.log('setTimeout');
  }, 0);
  setImmediate(() => {
    console.log('setImmediate');
  });
});

위의 순서를 살펴보면
1. pending i/o callback에 readFile 의 콜백을 queue에 enqueue하고, 이벤트 루프가 해당 phase에 다다랐을 때 콜백을 실행한다.

2. Timer 큐에 setTimeout이 등록된다.

3. Check 큐에 setImmediate이 등록된다.

4. 등록이 완료된 후 이벤트 루프는 poll phase에 진입한다.

5. poll phase에서 check queue가 비어있지 않으므로 check phase로 이동하여 setImmediate를 실행한다.

6. 아직 모든 phase의 큐가 비어있지 않으므로(=== Timer queue가 비지 않았으므로) Timer phase로 이동한다.

7. Timer 큐에서 임계값에 다다른 타이머를 실행한다.

 

이 과정으로 setImmediate이 setTimeout보다 먼저 실행됨을 보장할 수 있다.

 

Check

setImmediate()가 실행되는 단계이다.

보통 코드가 실행되었으므로 이벤트 루프는 들어오는 연결, 요청 등을 기다리는 poll 단계에 결국 다다르게 됩니다. 하지만 콜백이 setImmediate()로 스케줄링되었고 poll 단계가 유휴상태가 되었다면 poll 이벤트를 기다리지 않고 check 단계로 넘어가게 됩니다.

 

Close

소켓 close 이벤트 등을 실행하는 단계이다. 이벤트 루프는 Close까지 돌고 나면 다시 TImer 단계로 돌아가거나 더이상 실행할 작업이 없다면 종료된다.

 

3. MicroTaskQueue, NextTickQueue

MicroTaskQueue와 함께 process.nextTick()의 콜백이 대기하는 NextTickQueue또한 함께 살펴보았다.

 

"Promise와 같은 비동기 객체는 마이크로 태스크 큐라는 우선순위가 더 높은 큐에서 대기한다. 따라서 이벤트 루프는 콜스택이 비었으면 마이크로 태스크 큐에서 대기 중인 비동기 함수를 먼저 실행한다."

 

두 큐는 이벤트 루프 페이즈와 관계없이 다음 페이즈로 넘어가기 전에 최대한 빨리 실행해야 할 콜백들을 저장하고 있다.

* 한 페이즈에서 다음 페이즈로 넘어가는 것을 Tick이라고 한다.

 

또한, nextTickQueue가 microTaskQueue보다 높은 우선순위를 가진다.

var i = 0

function foo() {
  i++
  
  console.log('next: ', i)
  
  if (i > 20) {
    return
  } else {
    setTimeout(() => {
      console.log('setTimeout', i)
    }, 0)
    
    process.nextTick(foo) // 재귀 호출
  }
}

foo()

이런 경우 foo가 nextTickQueue에 20번 들어가므로 nextTickQueue의 작업 (console.log('next', i))를 처리하고 Timer 단계의 setTimeout을 실행하게 된다.

해당 단계에서 process.nextTick()을 호출하면 process.nextTick()에 전달한 모든 콜백은 언제나 이벤트 루프를 계속 진행하기 전에 처리될 것입니다. 이 동작 때문에 재귀로 process.nextTick()을 호출하면 이벤트 루프가 poll 단계에 다다르는 것을 막아서 I/O가 "굶주리게" 될 수 있으므로 좋지 않은 상황을 만들 수 있습니다.

 

또한 nextTick은 반드시 한 페이즈를 끝내고 실행되는게 아니라 정확히는 현재 콜백함수의 실행 후에 바로 실행한다.

 

As per node.js documentation, “nextTickQueue will be processed after the current operation completes, regardless of the current phase of the event loop.”
It means, this queue will be executed whenever the boundary between JavaScript and C/C++ is crossed. So it's not like it will be called after the task in the current phase only. Neither it means after the execution of the current callback. It is sometime before the next phase is hit.

 

이 글에서 좋은 예시를 찾았다.

* 원글과 다른 점이 있었다. 아래에 기술함

setImmediate(() => console.log('this is set immediate 1'));
setImmediate(() => console.log('this is set immediate 2'));
setImmediate(() => console.log('this is set immediate 3'));

setTimeout(() => console.log('this is set timeout 1'), 0);
setTimeout(() => {
    console.log('this is set timeout 2');
    process.nextTick(() => console.log('this is process.nextTick added inside setTimeout'));
}, 0);
setTimeout(() => console.log('this is set timeout 3'), 0);
setTimeout(() => console.log('this is set timeout 4'), 0);
setTimeout(() => console.log('this is set timeout 5'), 0);

process.nextTick(() => console.log('this is process.nextTick 1'));
process.nextTick(() => {
    process.nextTick(console.log.bind(console, 'this is the inner next tick inside next tick'));
});
process.nextTick(() => console.log('this is process.nextTick 2'));
process.nextTick(() => console.log('this is process.nextTick 3'));
process.nextTick(() => console.log('this is process.nextTick 4'));

 

실행결과

this is process.nextTick 1
this is process.nextTick 2
this is process.nextTick 3
this is process.nextTick 4
this is the inner next tick inside next tick
this is set timeout 1
this is set timeout 2
this is process.nextTick added inside setTimeout
this is set timeout 3
this is set timeout 4
this is set timeout 5
this is set immediate 1
this is set immediate 2
this is set immediate 3

 

1. nextTick에 있는 태스크들이 nextTickQueue에 들어가고, Timer 페이즈에 접어들기 전에 실행한다.

    1-2. nextTick 내부의 또다른 nextTick이 등록되므로 tick 로그 다음에 출력된다.

2. Timer 페이즈에 등록된 setTimeout을 실행한다.

    2-1. Timer페이즈에 등록된 nextTick을 실행하는데 두번째 setTimeout의 콜백함수가 끝나자마자 실행된다.

3. Check 페이즈의 setImmediate가 실행된다.

 

2022.03.06. 수정

위는 사실 순서를 장담할 수가 없었기 때문에 내 실행 환경에서는 setTimeout이 먼저 실행된 것이다. setImmediate가 먼저 실행되려면 아래처럼 pending i/o callback phase 내부에서 등록하고, poll phase에 다다랐을 때 check phase -> timer phase 순으로 실행하도록 하는 것이다.

const fs = require('fs')

fs.readFile('a.txt', () => {
  setImmediate(() => console.log('this is set immediate 1'));
  setImmediate(() => console.log('this is set immediate 2'));
  setImmediate(() => console.log('this is set immediate 3'));

  setTimeout(() => console.log('this is set timeout 1'), 0);
  setTimeout(() => {
    console.log('this is set timeout 2');
    process.nextTick(() => console.log('this is process.nextTick added inside setTimeout'));
  }, 0);
  setTimeout(() => console.log('this is set timeout 3'), 0);
  setTimeout(() => console.log('this is set timeout 4'), 0);
  setTimeout(() => console.log('this is set timeout 5'), 0);
})

실행 순서

this is set immediate 1
this is set immediate 2
this is set immediate 3
this is set timeout 1
this is set timeout 2
this is process.nextTick added inside setTimeout
this is set timeout 3
this is set timeout 4
this is set timeout 5

 

 

 

참고 문서

- 로우 레벨로 살펴보는 Node.js 이벤트 루프

- [NodeJS] Event-Loop Part 2 : setTimeout() vs setImmediate() vs process.nextTick()

- setImmediate() vs nextTick() vs setTimeout(fn,0) - in depth explanation

'Today I Learn > Nodejs' 카테고리의 다른 글

keepalive 설정  (0) 2021.11.23
잊고 살았던 node.js 라우터 미들웨어 동작방식  (0) 2021.03.04