Node.js - (2) 이벤트 루프 톺아보기

adam2·2020년 10월 30일
0

Node.js

목록 보기
2/2
post-thumbnail

앞선 포스팅에서 이벤트루프는 Node가 IO작업을 비동기적으로 처리할 수 있도록 도와주고, 여러개의 phase로 구성되어있다고 했다.

phase 종류

timers

setTimeout(), setInterval() 같은 타이머 콜백이 처리된다.
타이머 콜백 내부로직들은 poll큐에 먼저 등록된 콜백들이 처리되고 나중에 처리될 수도 있으므로, 파라미터로 지정한 시간에 딱 실행됨을 보장하지 못한다.

예를 들어 100ms 이후에 실행되는 타임아웃을 걸고, 파일 읽기는 95ms가 걸린다고 해본다.

const fs = require("fs");

function someAsyncOperation(callback) {
  // 파일 리드 작업은 95ms가 소요된다
  fs.readFile("/path/to/file", callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

someAsyncOperation(() => {
  const startCallback = Date.now();

  // 10ms가 소요된다
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

이벤트 루프가 poll phase에 진입했을 readFile()이 아직 완료되지 않았기 때문에 때 큐는 비어있다. 그래서 이벤트 루프는 수 ms동안 가장 빠른 타이머의 임계값(100ms)에 도달할 때 까지 기다린다. 95ms후 readFile()은 파일 읽기를 끝내고 완료하는데 10ms가 걸리는 readFile()의 콜백은 poll 큐에 추가되고 실행된다. 콜백 실행이 끝나면 poll큐에는 더이상 남은 콜백함수가 없기 때문에 이벤트 루프는 가장 빠른 타이머의 임계값에 도달했음을 확인 한 후, timers phase로 돌아가 타이머의 콜백을 실행하게 된다.

여기서 setTimeout의 콜백이 최소 95+10=105ms 이후에 실행된 것을 알 수 있다. setTimout에 설정한 대호 정확히 100ms 이후에 콜백이 실행되는 것이 아니다.

pending Callbacks

다음 루프 반복으로 연기된 I/O 완료 결과가 큐에 담긴다. I/O 작업이 완료되면 다음번 루프에 이 단계에 들어와있게 되고, I/O 작업 블록내의 콜백함수들을 poll 단계의 큐로 넘겨준다. 또한 TCP 오류 같은 시스템 작업의 콜백을 실행한다.

idle, prepare

내부용으로만 사용

poll

I/O와 연관된 콜백(클로즈 콜백, 타이머로 스케줄링된 콜백, setImmediate()를 제외한 거의 모든 콜백)을 실행한다.
poll 큐에 쌓인 콜백함수들을 한도가 넘지 않을때까지 모두 동기적으로 실행. 만약 한도가 넘거나, 더이상 실행할 콜백함수가 없을때는 별도의 규칙을 따라, 다음 단계로 넘어가거나 대기한다.

poll phase는 두가지의 메인 기능이 있다.

  1. IO를 위해 block하고 poll해야하는 시간을 계산하고
  2. poll 큐에 이벤트를 생성한다.

이벤트 루프가 poll phase에 들어갔는데 스케줄된 timer가 없으면 다음 두가지 중 하나가 발생한다.

  • 만약 poll 큐가 차있는 경우
    • 이벤트 루프는 큐를 돌면서 콜백을 동기적으로 실행한다. 큐를 모두 비우거나 실행 제한 횟수까지 돌릴 때 까지.
  • poll 큐가 비어있는 경우
    • 스크립트가 setImmediate()에 의해 예약되어있는 경우
      • 이벤트 루프는 poll phase를 나와 check phase로 진입해 명령을 실행
    • setImmediate() 명령이 없는 경우
      • 이벤트 루프는 poll 큐에 콜백이 추가되기를 기다렸다가 바로 콜백을 실행한다.

check

setImmediate() 콜백은 여기서 호출되고 집행된다.

poll phase가 완료된 후에 check phase에 있는 콜백을 즉시 실행한다. 만약 poll phase가 비어있고 스크립트가 setImmediate()로 check 큐에 추가된 경우 이벤트 루프는 check phase에서 작업을 진행한다.

setImmediate()는 이벤트 루프에서 timers가 아닌 별도의 check phase에서 실행되는 특별한 타이머이다. poll phase가 완료된 후 실행할 콜백을 예약하는 libuv api를 사용한다.

보통 코드가 실행되면 이벤트 루프는 poll phase에 도달하게 된다. 하지만 콜백이 setImmediate()로 예약되어 있고 poll 큐가 비어있으면, poll큐를 기다리는 대신 poll phase는 끝나고 check phase가 진행된다.

close callbacks

on('close', ...) 같은 것들이 여기서 처리됨

setImmediate() vs setTimeout()

setImmediate()setTimeout()는 비슷하지만 호출시기에 따라서 다른 방식으로 작동한다.

  • setImmediate(): 현재 poll phase가 완료되면 실행된다
  • setTimeout(): 설정된 시간이 경과한 후에 스크립트가 실행되도록 예약한다.

만약 두 함수 모두 메인 모두에서 호출되었다면, 타이밍은 프로세스 성능에 의해 결정된다. 예를들어 만약 다음과 같은 스크립트를 실행한다면 두 타이머의 실행 순서는 딱 정해지지 않는다.

// 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처리를 하는 함수가 내부에 있다면 immediate 콜백이 항상 먼저 실행된다.

// 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

동작 과정

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log("A");
  }, 0);
  setImmediate(() => {
    console.log("B");
  });
});

위 코드를 실행하면 이벤트루프에서는 아래와 같은 순서로 동작한다.
1. fs.readFile 라는 블로킹작업을 만난 시점에 이벤트루프는 워커쓰레드에게 작업을 넘김
2. 워크쓰레드가 작업을 완료한 뒤 I/O callbacks 영역의 큐에 콜백을 등록
3. 이벤트루프가 I/O callbacks 영역을 실행할 때, 콜백을 poll 영역의 큐에 등록
4. 이벤트루프가 poll 영역을 실행할 때, 큐에 1개가 있으므로 이걸 실행함.
5. (콜백내부) 2라인에서 setTimeout() 이므로 다시 timers 영역에 넣고 5라인으로 간다.
6. (콜백내부) 5라인에서 setImmediate() 이므로 check 영역에 넣는다.
7. 이벤트루프가 poll 큐를 비우고, 다음 실행영역인 check 영역으로 간다. check 영역의 큐에는 들어있는 'B'를 콘솔에 찍는다. check 영역의 큐를 비우고 다시 while문의 시작지점으로 간다.
8. 이벤트루프가 timers 영역을 호출한다. uv__run_timers()는 setTimeout()의 콜백을 poll큐에 등록한다.
9. 이벤트루프가 2번째로 poll 영역을 실행한다. 큐에 1개가 있으므로 이걸 실행하고 'A'를 찍는다.
10. node 프로세스가 반환되고 끝

process.nextTick()

process.nextTick()은 비동기 api임에도 맨위의 다이어그램에 포함되어있지 않다. 그 이유는 process.nextTick()이벤트 루프의 일부가 아니기 때문이다.

nextTickQueue라는 큐는 이벤트 루프의 phase에 상관 없이 현재 작업이 완료된 후에 처리가 된다.

어떤 phase에서든 process.nextTick()을 호출하게 되면 process.nextTick()에 전달된 모든 콜백이 이벤트 루프가 계속 진행되기 전에 실행된다.


참고

0개의 댓글