[220201] Node.js EventLoop

Table of Contents

1 동기

없음. Node.js 공식문서를 보다가 이벤트 루프 관련 내용을 보고 간단히 발번역 해볼까함.

https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

2 The Node.js Event Loop, Timers, and process.nextTick()

2.1 What is the Event Loop?

event loop는 Node.js가 가능한 경우 시스템 커널에 작업을 떠넘겨서(offloading) non-blocking I/O operation을 수행하게 해준다. (Javascript가 single-threaded 임에도)

모던 커널들은 대부분 멀티스레드를 지원하기 때문에, 이들은 백그라운드에서 여러 작업을 처리할 수 있다. 이러한 작업 중 하나가 완료되면 커널은 Node.js에 알려준다. 그러면 Node.js는 적절한 콜백이 poll 큐에 추가되어 종내에는 이 콜백이 실행된다.

2.2 Event Loop Explained

Node.js 가 시작하면

  1. processes the provided input script (or drops into the REPL) which may make async API calls 비동기 API호출을 만들어내는 (제공되는) 입력 스크립트 수행
  2. 타이머 스케줄링
  3. 혹은 process.nextTick() 호출
  4. 이제 이벤트 루프를 프로세스를 시작한다.

아래 다이어그램은 이벤트 루프의 작동 순서를 간략하개 보여준다.

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

각 상자는 이벤트 루프의 페이즈(phase)로 불린다.

각 페이즈에는 실행할 콜백들의 FIFO 큐(대기열)가 있다. 각 단계는 그 나름대로 특별하지만, 일반적으로 이벤트 루프가 주어진 페이즈에 들어갈 때, 해당 페이즈에 특화된 모든 연산들을 수행한 다음, (해당 페이즈의) 큐 안에 콜백들을 수행한다. (모두 소진되거나 최대 수의 콜백이 실행될 때까지) 큐가 소진되거나 콜백 제한에 도달하면 이벤트 루프가 다음 페이즈로 이동한다.

이러한 작업들 중 하나가 더 많은 작업(operation)을 예약할 수 있고(schedule), poll 페이즈에서 처리된 새로운 이벤트가 커널에 의해 대기열(큐)에 있을 수 있으므로, poll 이벤트

이러한 작업들 중 하나가 더 많은 작업을 예약할 수도 있고, 폴링 단계에서 처리된 새 이벤트가 커널에 의해 대기열에 있으므로 폴링 이벤트가 처리되는 동안 poll 이벤트들은 대기열(queue)에 추가될 수 있다. (뭐… 작업 중에도 이벤트는 들어올 수 있다라는 말인듯)

결과적으로 콜백이 오래 실행되면 poll 페이즈가 타이머임계값(timer's threshold)보다 오래 실행될 수 있다. 자세한 내용은 아래 timer섹션과 poll섹션을 참고하자.

Windows과 Unix/Linux 구현체 사이에 약간의 차이(discrepancy)가 있지만 여기서는 중요하지 않다. 실제로 7~8단계가 있지만 우리가 중요하게 여겨야할 (실제로 사용하는) 단계는 위에 있는 단계이다.

2.3 Phases Overview

  • timers : 이 페이즈는 setTimeout() , setInterval() 로 예약된 콜백을 실행한다.
  • pending callbacks : 다음 루프까지 지연된 I/O 콜백을 실행한다.
  • idle, prepare : 내부적으로만 사용됨.
  • poll : 새로운 I/O 이벤트를 조회한다; I/O 관련 콜백 실행 (타이머에 의해 예약된 콜백 및 setImmediate() 을 제외한 거의 모든 것. (node will block here when appropriate, 차단해야하는 경우 차단함. 너무 길어지거나 하면 그만하도록 시킴)
  • check : setImmediate() 콜백들은 여기서 호출됨.
  • close callbacks : 일부 close callbacks 수행 (예 socket.on('close', ...) )

각 이벤트 루프 실행 사이에 Node.js는 비동기 I/O 또는 timers 를 기다리고 있는지 확인하고 아무 것도 없으면 완전히 종료한다.

2.4 Phases in Detail

2.4.1 timers

타이머는 값을 지정한다. 이 임계값은 콜백이 실행되는 임계값이다. (may be executed) 실행되기를 원하는 정확한 시간이 아니다.(not exact time) 타이머 콜백은 지정된 시간이 지나면 최대한 빨리 스케줄을 잡아서 실행되려고한다. 하지만 OS 스케줄링 또는 실행되고 있는 다른 콜백들로 인해 지연될 수 있다.

기술적으로 보면, poll 페이즈가 timers 의 실행을 컨트롤한다.

예를들어, 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) {
	// 아무것도 안함...(?)
    }
});

이벤트 루프가 poll 페이즈에 진입하면, 빈 큐(대기열)을 보게 될 것이다. ( fs.readFile() 가 완료되지 않음) 이제 가장 빠른 타이머 임계값에 도달할 때까지 남은 ms만큼 기다린다. 95ms가 경과 되기를 기다리는 동안 fs.readFile() 파일 읽기가 완료되면 10ms가 걸리는 콜백이 poll 큐에 추가되고 실행된다.

콜백이 완료되면 큐에 더이상 콜백이 없으므로, 이벤트 루프는 가장 빠른(가까운) 타이머의 임계값에 도달하는 것을 확인한다. 그리고는 timers 페이즈로 되돌아가서 타이머의 콜백을 실행한다. 이 예제에서, 예약 중인 타이머와 실행 중인 콜백 사이의 총 지연이 105ms 임을 알 수 있다.

폴링 단계에서 이벤트 루프가 고갈되는 것을 방지하기 위해 libuv (Node.js의 이벤트 루프와 플랫폼의 모든 비동기 동작을 구현하는 C 라이브러리)도 더 많은 이벤트에 대한 폴링을 중지하기 전에 하드 최대값[hard maximum](시스템 종속)을 갖는다.

2.4.2 pending callbacks

이 페이즈는 TCP 에러 유형과 같은 일부 시스템 작업에 대한 콜백을 실행한다. 예를 들어, TCP 소켓이 연결을 시도할 때 ECONNREFUSED 를 받았다면, 일부 *nix 시스템은 에러리포트를 기다리고 싶을 수 있다. 이런 경우 pending callbacks 페이즈 큐에 추가되어 실행된다.

2.4.3 poll

poll 페이즈는 두 개의 주요 함수를 가진다:

  1. I/O를 차단하고, 폴링해야 하는 시간 계산하는 함수 그 다음에
  2. poll 큐에서 이벤트를 처리하는 함수

이벤트 루프가 poll 페이즈에 들어가고 예약 타이머가 없으면 다음 두 가지 중 하나가 발생한다.

  1. poll 큐가 비어있지 않으면, 이벤트 루프는 큐가 소진되거나 시스템 종속적인 하드 최대치에 도달할 때까지 동기적으로 콜백을 실행하는 콜백 큐를 반복한다.
  2. poll 큐가 비어있으면, 다음 두가지 중 하나가 발생한다.
    • setImmediate() 에 의해 스크립트가 예약되어 있다면, 이벤트 루프는 poll 페이즈를 마치고 check 페이즈로 간다.
    • setImmediate() 에 의해 예약된 스크립트가 없으면, 이벤트 루프틑 콜백이 대기열(큐)에 추가될 때까지 기다렸다가 즉시 실행한다.

일단 poll 큐가 비어있게 되면 이벤트 루프는 타이머를 체크할 것이다. 시간 임계값에 도달했는지 확인하기 위해서다.

하나 이상의 타이머가 준비되면 이벤트 루프는 timers 페이즈로 돌아가서 타이머 콜백을 처리한다.

2.4.4 check

이 페이즈는 poll 페이즈가 완료된 직후 콜백을 실행할 수 있다. poll 페이즈가 유휴상태(idle)가 되고 setImmediate() 에 의해 큐에 추가된 스크립트가 있으면, 이벤트 루프는 poll 단계에서 기다리지 않고 check 페이즈로 넘어가서 일을 계속 수행한다.

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

일반적으로, 코드가 실행될 때, 이벤트 루프는 결국 poll 페이즈에 와서 들어오는 커넥션, 요청 등을 기다린다. 그런데 만약에 setImmediate() 로 콜백이 예약되고, poll 페이즈가 할일이 없을 때(IDLE)가 생기면, poll 페이즈에서 poll 이벤트를 기다리는 것을 그만두고 check 페이즈로 넘어간다.

2.4.5 close callbacks

만약 소켓 또는 핸들(handle)이 갑자기 닫힌다면 (e.g. socket.destry() ), 'close' 이벤트가 방출된다.(emitted) 아니면 process.nextTick() 을 통해 방출될 것이다.

2.5 비교하기 SetImmediate() vs setTimeout()

이 둘은 비슷하지만 호출되는 시기에 따라 다른 방식으로 동작한다.

  • setImmediate() : 현재 폴링 단계가 완료되면 스크립트를 실행하도록 설계
  • setTimeout() : 최소 임계값(ms)이 경과한 후 스크립트가 실행되도록 예약한다.

타이머가 실행되는 순서는 컨텍스트에 따라 다르다. 둘 다 기본 모듈 내에서 호출되는 경우 타이밍은 프로세스의 성능에 의해 제한된다. (시스템에서 다른 응용프로그래밍의 영향도 받을 수 있음)

예를 들어, I/O cycle(예. 메인모듈)안에 있지 않은 아래와 같은 스크립트를 실행한다면, 두 타이머가 실행되는 순서는 프로세스의 성능에 의해 제한되기 때문에 비결정적이다.

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

그러나, 두 호출을 I/O cycle 안으로 옮기면, immediate 콜백이 항상 먼저 수행된다.

const fs = require('fs');

fs.readFile(__filename, () => {
    setTimeout(() => {
	console.log('timeout');
    });
    setImmediate(() => {
	console.log('immediate');
    });
});
$ node timeout_vs_immediate.js
immediate
timeout

$ node timeout_vs_immediate.js
immediate
timeout

setImmediatesetTimeout 보다 가지는 이점은, setImmediate() 는 몇개를 갖고 있던 I/O cycle(주기) 안에서 예약되면 타이머보다 항상 먼저 실행된다는 것이다.

2.6 자세히 알기 process.nextTick()

기술적으로보면 process.nextTick() 은 이벤트 루트의 일부가 아니다. 대신 nextTickQueue 는 현재 작업이 끝나면 실행되는 녀석이다. 이것은 현재 이벤트 루프가 어떤 페이즈에 있건 상관없다. 여기에서 작업이란 기본 C/C++ 핸들러에서 전환하여 실행되야하는 javascript를 처리하는 것이다.

다이어그램을 보자, 주어진 페이즈(현재 페이즈)에서 process.nextTick() 이 호출될 때마다, process.nextTick() 으로 들어온 콜백들은 이벤트 루프가 진행되기 전에 해결된다. 이것은 이벤트 루프가 poll 페이즈에 도달하는 것을 막는 상황을 만들 수도 있다. process.nextTick() 을 재귀적으로 호출하여 I/O를 굶주리게(starve) 만드는 것이 그것이다.

왜 이것이 허용되었을까?

그 중 일부는 API가 반드시 비동기식일 필요가 없는 경우에도 항상 비동기식이어야 하는 설계 철학때문이다.

function apiCall(arg, callback) {
    if (typeof arg !== 'string') 
	return process.nextTick(
	    callback,
	    new TypeError('argument should be string')
	);
}

이 경우 arg 를 확인하고 올바르지 않은 경우 콜백에 오류를 전달한다. API는 최근 업데이트되어 process.nextTick() 에 인수를 넣을 수 있도록 하였다. 이것으로 콜백 이후 전달된 이후를 콜백에 인수로 전달하여 함수를 중첩할 필요가 없어졌다.

우리가 하려는 것은 사용자에게 Error를 다시 전달하는 것이다. 그러나 사용자 코드의 나머지 부분이 실행되도록 허용 한 후에만 가능하다.

process.nextTick()apiCall() 이 항상 유저 코드의 나머지가 실행된 이후와 이벤트 루프가 처리를 시작하기 전에 콜백을 실행하도록 한다. 이렇게 하기 위해 JS call stack은 풀어버리고 콜백들을 즉시 실행할 수 있는 것을 허용한다. 이것으로 process.nextTick() 의 재귀호출을 가능하게 만든다. RangeError: Maximum call stack size exceeded from 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;

사용자가 someAsyncApiCall() 를 비동기 시그니처를 정의하여도, 동기적으로 작동한다. 호출이 될 때, someAsyncApiCall() 에 제공된 콜백은 이벤트 루프와 동일한 페이즈에서 호출된다. 이는 someAsyncApiCall() 이 실제로는 비동기적으로 아무것도 수행하지 않기 때문이다. 결과적으로, 콜백은 스크립트가 완료될 때까지 실행되지 못했기 때문에 변수가 없을지라도 bar 의 참조를 시도한다.

process.nextTick() 으로 콜백을 바꾸면, 스크립트는 완료될 때까지 실행되어 '콜백이 호출되기 전에' 모든 변수, 함수 등을 초기화할 수 있다. 이것의 장점은 이벤트 루프로 넘어가지 않도록 허용하지 않는다는 것이다. 이것은 이벤트루프로 넘어가기 전에 에러를 유저에게 알려주고 싶을 때 유용하다. 아래 코드가 process.nextTick() 을 사용한 코드다.

let bar;

function someAsyncApiCall(callback) {
    process.nextTick(callback);
}

someAsyncApiCall(() => {
  console.log('bar', bar); // 1
});

bar = 1;

아래는 또 다른 리얼월드 예시다.

const server = net.createServer(() => {}).listen(8080);

server.on('listening', () => {});

포트만 주어진 경우, 포트는 즉시 반영된다. 하여 'listening' 콜백이 즉시 호출될 수 있다. 문제는 .on('listening') 콜백이 그때까지 설정되지 않았다는 것이다.

이 문제를 해결하기 위해 스크립트가 완료될 때까지 실행될 수 있도록 'listening' 이벤트가 대기열(큐)에 추가된다. nextTick() 을 통해 사용자가 원하는 이벤트 핸들러를 설정할 수 있다.

2.6.1 process.nextTick() vs setImmediate()

두개의 호출이 아주 비슷하다. 이름도 혼란스럽다.

  • process.nextTick() 은 같은 페이즈에서 즉시 발생한다.
  • setImmediate() 이벤트 루프의 다음 Iteration이나 tick 에서 발생한다.

이 두 이름은 서로 뒤바껴야한다. process.nextTick() 이 같은 페이즈에서 즉시 실행되니 setImmediate() 가 되어 마땅하지만 과거의 산물이니 어쩔 수 없다.

하여 문제를 추론하기 쉽게 setImmediate() 만 사용하는 것을 권장한다.

2.6.2process.nextTick() 을 사용하는가

두 가지 주요 이슈가 있다.

  1. 사용자가 오류 처리하도록 허용, 필요하지 않은 리소스 정리, 이벤트 루프가 계속되기 전에 요청을 다시 시도.
  2. 때때로 호출 스택이 해제된(unwind) 후 이벤트 루프가 계속되기 전에 콜백이 실행되도록 허용해야 한다.
const server = net.createServer();
server.on('connection', (conn) => {});

server.listen(8080);
server.on('listening', () => {});

listen() 은 이벤트 루프가 시작하기 전에 실행된다. 하지만 리스닝하는 콜백은 setImmediate() 에 위치된다. hostname이 주어지지 않으면, 포트에 대한 바인딩이 즉시 발생된다. 이벤트 루프가 진행되기 위해, poll 페이즈에 도달해야한다.(hit the poll phase) 즉, listening event 전에 connection event 가 시작될 가능성이 존재한다.

또다른 이벤트는 EventEmitter 를 상속하고 생성자 안에서 이벤트를 호출하고 싶어서 생성한! 함수생성자(function constructor)를 실행할 때 일어난다.

const EventEmitter = require('event');
const util = require('util');

function MyEmitter() {
    EventEmitter.call(this);
    this.emit('event');
}
util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
    console.log('event', () => {
	console.log('an event occurred!');
    });
});

당신은 생성자에서 이벤트를 즉시 방출시킬 수 없다. 왜냐하면 사용자가 해당 이벤트에 콜백을 할당하는 지점까지 스크립트가 처리되지 않았기 때문이다. (왠지 .on('event') 가 먼저 수행되어야 하는데 this.emit('event') 부터 수행된 것이 문제인듯)

하여 생성자 내에거 process.nextTick() 을 사용하면 생성자가 완료된 이후에 이벤트를 방출하도록 설정하는데 사용할 수 있다.

const EventEmitter = require('events');
const util = require('util');

function MyEmitter() {
    EventEmitter.call(this);

    // use nextTick to emit the vent once a handler is assigned
    process.nextTick(() => {
	this.emit('event');
    });
}

util.inherits(MyEmitter, EventEmitter);

const myEmitter = new MyEmitter();
myEmitter.on('event', () => {
    console.log('an event occurred!');
})

3 후기

누군가의 구현 아이디어를 보는 것은 항상 흥미롭다.

Date: 2022-02-01 Tue 00:00

Author: Younghwan Nam

Created: 2024-04-16 Tue 10:05

Emacs 27.2 (Org mode 9.4.4)

Validate