[Node.js] Node.js Event Loop

hellollolo·2024년 4월 27일

Event Loop란?

Javascript는 기본적으로 싱글 스레드로 동작한다. 그래서 Nodejs는 Event Loop를 사용해 작업의 일부를 외부 시스템에 양도하여 논-블록킹 IO를 수행할 수 있도록 한다.

Node.js가 시작되면 이벤트 루프를 초기화하고, 제공된 입력 스크립트를 처리하여 비동기 API 호출을 하거나 타이머를 예약하거나 process.nextTick()을 호출한 다음 이벤트 루프를 처리하기 시작합니다.

Event Loop의 단계

Event Loop는 여러 단계 구성되어 있으며 그림으로 나타내면 아래와 같다.

  • 주의: Window와 Unix/Linux 간에 미묘한 차이가 있을 수 있으나 크게 중요하지 않다. 실제로 중요한 Nodejs가 사용하는 단계들은 위 사진과 같다.

단계 별 설명

timers

이 단계는 setTimeout() and setInterval()가 실행되는 단계입니다.

pending callbacks

다음번 루프로 연기된 I/O callback들을 실행하는 단계입니다.

idle, preare

내부적으로만 사용된다.

poll

새 I/O 이벤트를 검색하고, I/O 관련 콜백을 실행합니다. 닫기 콜백, 타이머로 예약된 콜백, setImmediate()를 제외한 거의 모든 콜백이 실행됩니다. 이 단계에서 Node는 필요한 경우 block을 실행합니다.

check

setImmediate() callback이 여기서 실행된다.

close callbacks

종료 callback들이 실행된다. 예) socket.on('close', ...)

Timers

이 단계에서는 사용자가 원하는 정확한 시간이 아니라 제공된 콜백이 실행될 수 있는 임계값을 지정합니다. 타이머 콜백은 지정된 시간이 경과한 후 가능한 한 빨리 실행되지만 운영 체제 예약이나 다른 콜백의 실행으로 인해 지연될 수 있습니다.

const fs = require('node:fs');

function someAsyncOperation(callback) {
  // Assume this takes 95ms to complete
  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);

// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

Pending Callbacks

이 단계는 TCP 에러와 같은 시스템 동작에 관한 콜백들을 실행합니다. 예를 들어, TCP 소켓이 연결을 실행할 때 ECONNREFUSED를 받은 경우, 몇몇 운영체제들은 에러를 보고하기 위해 기다립니다. 이는 pending callbacks 단계에서 실행되기 위해 queue에 적재됩니다.

Polling

이 단계는 크게 2가지 동작을 합니다.

  1. I/O를 차단하고 폴링해야 하는 기간을 계산한 다음
  2. Poll 큐에 있는 이벤트들을 실행한다.

이벤트 루프가 poll 단계에 진입후, 타이머가 예약되어있지 않으면 다음 중 하나를 실행한다.

poll queue가 비어있지 않은 경우, 이벤트 루프는 큐를 모두 소모하거나 시스템 한계치에 도달하기 전까지 queue의 callback들을 동기적으로 반복해서 실행합니다.

poll queue가 비어있는 경우, 다음 중 하나를 실행합니다.

만약 setImmediate가 예약되어 있는 경우, 이벤트 루프는 poll 단계를 종료하고 check 단계로 이동해 예약된 스크립트를 실행합니다.

poll queue가 비어있는 경우, 이벤트 루프는 시간 임계치에 도달할 때 까지 타이머를 확인합니다. 타이머가 준비된 경우 이벤트 루프를 timer 단계로 돌아가 해당 타이머의 callback들을 실행합니다.

Check

socket이나 handle이 갑자기 닫히면(예: socket.destroy()), 이 단계에서 'close' 이벤트가 발생하게 됩니다. 그렇지 않으면 process.nextTick()을 통해 발생합니다.

setImmediate() vs setTimeout()

setImmediate()와 setTimeout()는 비슷하지만 다소 다르게 동작한다.

setImmediate()는 현재 poll phase가 완료되면 실행되도록 설계되었다.
setTimeout()는 최소 임계점을 지난 이후에 실행되도록 설계되었다.

흥미로운 점은 둘의 실행순서는 어플리케이션의 상태에 따라 달라진다. 만약 두 함수가 I/O cycle안에 있지 않은 스크립트에서 실행된 경우 순서는 보장되지 않는다.

// timeout_vs_immediate.js
setTimeout(() => {
  console.log('timeout');
}, 0);
setImmediate(() => {
  console.log('immediate');
});

그러나 I/O cycle 안으로 이동시키면 항상 setImmediate 함수가 먼저 실행된다.

// timeout_vs_immediate.js
const fs = require('node:fs');
fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout');
  }, 0);
  setImmediate(() => {
    console.log('immediate');
  });
});

setTimeout()보다 setImmediate()를 사용할 때의 가장 큰 장점은 I/O 사이클 내에서 예약된 경우 타이머 수에 관계없이 항상 타이머보다 먼저 실행된다는 점이다.

setImmediate을 0으로 설정한 경우

setTimeout의 시간을 0으로 설정한 경우 이벤트 루프의 순서상 setImmediate보다 setImmediate가 먼저 실행된 다고 생각할 수 있다.

하지만 Nodejs 문서에 따르면 setTimeout의 시간을 0으로 설정한 경우 사실은 0이 들어가지 않고 1이 들어간다.

따라서 setTimeout이 실행되는 "Time Phase"에 도달했을 때 1ms가 지나지 않은 경우 함수가 실행되지 않고 다음 단계로 넘어가는 것이다.

process.nextTick()

process.nextTick()의 이해

process.nextTick은 비동기 API임에도 불구하고 위 다이어그램에서 보이지 않는다. 엄밀히 말해서, process.nextTick()은 이벤트 루프가 아니기 때문이다. 대신에, 현재 어떤 이벤트 루프의 단계에 있건 상관없이 현재 연산이 끝난 뒤에 nextTickqueue는 실행될 것이다.

process.nextTick()을 언제 호출하던 모든 콜백은 이벤트 루프가 진행되기 전에 실행될 것이다. 이는 반복적인 process.nextTick의 호출이 이벤트 루프를 막을 수 있기 때문에 안 좋은 상황을 초래할 수 있다.

왜 이런것이 가능할까?

왜 이런 기능이 Node.js에 있을까. 그 이유 중 하나는 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 호출 스택이 풀린 다음 제공된 콜백을 즉시 실행할 수 있도록 허용하여 범위 오류에 도달하지 않고도 process.nextTick()을 재귀적으로 호출할 수 있도록 합니다: v8에서 최대 호출 스택 크기를 초과했습니다.

아래 예제를 보자

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;

process.nextTick() vs setImmediate()

사용자 입장에서는 비슷하지만 이름이 헷갈리는 두 가지 호출이 있습니다.

process.nextTick(): 같은 단계에서 즉시 실행된다.
setImmediate(): 이벤트 루프의 다음 반복 혹은 'tick'에서 실행된다.

두 함수는 이름과 쓰임새가 정반대이다. process.nextTick()이 setImmediate() 함수보다 먼저 실행된다. 원래는 둘이 뒤바뀌어야 하지만 이는 레거시이기 때문에 바뀔 가능성이 낮다. 둘을 바꾸게 되면 많은 npm이 망가질 것이다. 결과적으로 둘의 이름은 바뀌지 않을 것이다.

이러한 이유로 인해 Nodejs 공식문서에서는 추론하기 쉽기 때문에 가능하면 모든 경우에 setImmediate를 사용하기를 권장하고 있다.

precess.nexttick()은 왜 사용할까?

크게 2가지 이유가 있다.

  1. 사용자가 오류를 처리하고 불필요한 리소스를 정리하거나 이벤트 루프가 계속되기 전에 요청을 다시 시도할 수 있도록 한다.

  2. 때로는 호출 스택이 풀린 후 이벤트 루프가 계속되기 전에 콜백이 실행되도록 허용해야 할 때도 있다.

예제 코드를 보자

const EventEmitter = require('node: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('node: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!');
});

setImmediate()의 이해

setImmediate()는 이벤트 루프의 다음 반복 때 실행된다.

그렇다면 setImmediate()는 setTimeout(() => {}, 0), process.nextTick() 그리고 Promise.then()과 어떻게 다를까?

process.nextTick()은 이벤트 루프의 현재 연산이 끝난 후 현재 반복에서 실행된다. 이는 항상 setTimeout과 setImmediate 보다 먼저 실행됨을 의미한다.

process.nextTick의 callback은 rocess.nextTick queue에 추가가 된다. Promise.then()의 callback은 promises microtask queue에 추가가 된다. setTimeout, setImmediate의 콜백은 macrotask queue에 추가된다.

이벤트 루프는 process.nextTick queue를 먼저 실행하고 promises microtask queue 그리고 macrotask queue 순으로 실행한다.

아래 예제를 보자

const baz = () => console.log("baz");
const foo = () => console.log("foo");
const zoo = () => console.log("zoo");

const start = () => {
  console.log("start");
  setImmediate(baz);
  new Promise((resolve, reject) => {
    resolve("bar");
  }).then((resolve) => {
    console.log(resolve);
    process.nextTick(zoo);
  });
  process.nextTick(foo);
};

start();

참고 문헌

https://www.korecmblog.com/blog/node-js-event-loop
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick#understanding-processnexttick

0개의 댓글