Search
🔄

Node.js event loop workflow & lifecycle in low level

0. 시작하기에 앞서

이벤트 루프라는 개념을 접하게된 것은 2021년 초, Python의 FastAPI 프레임워크를 쓰면서 이벤트 루프를 처음 접하게 되었다. 그때 당시, Python 계열에서는 주로 WSGI 기반의 프레임워크 (Django, Flask)가 많이 사용되고 있었다. 그러나 비동기+ 성능 최적화 등의 Needs로 인해 비동기 프레임워크가 점차 떠오르고 있었고, 그때 사내 MSA 프레임워크로 채택되었던게 ASGI 기반의 FastAPI 였다. FastAPI는 starlette 기반의 프레임워크인데, starlette이 Node.js에서 활용되는 이벤트 루프를 기반으로 한 비동기 프레임워크였다.
이후, 사내 플랫폼 서버로 Node.js + Typescript를 사용하게되면서 노드를 본격적으로 사용하게 되었는데, 최근에 노드쪽 밑단 기술들에 관해 서핑을 하던도중 이벤트 루프에 대해 low level까지 상세히 포스팅한 Paul Shan 의  Node.js event loop workflow & lifecycle in low level 내용을 보게 되었다. 평소에 rough하게 알았던 이벤트 루프를 톺아보기에 매우 유익한 내용이었던지라 해당 내용을 공부 + 번역하면서 이벤트 루프에 대해 포스팅을 하려고 한다.

1. libUV

해당 포스팅에 대해 본격적으로 들어가지 전에 Node.js 와 libuv에 대해 간략히 알아보자.

1.1. Node.js

Node.js 구조
Node.js는 기본적으로 싱글 스레드에 논블로킹을 지원한다고 한다. 싱글 스레드임에도 불구하고 I/O 작업이 발생한경우 비동기로 처리할 수 있다는 의미이다. 하나의 스레드는 하나의 실행 흐름을 가지고 있기 때문에 I/O 와 같은 작업은 블로킹이 되어야한다. 이것이 가능한것은 libuv 와 이벤트 루프가 있기 때문이다.

1.2. libUV

libuv는 C++로 작성된 Node.js에서 사용하는 비동기 I/O 라이브러리이다. libUV는 운영체제의 커널을 추상화하여 비동기 I/O를 위한 공통 API를 제공한다. (커널 추상화된 라이브러리를 사용하여 운영체제에 종속적이지 않게 비동기 API 호출 가능)
Node.js가 libUV에게 비동기 요청을 하면 libUV는 비동기 작업 요청을 커널에서 지원하는지에 따라 요청을 다르게 처리한다.
1.
비동기 작업 요청 커널에서 지원 → libUV가 커널에 비동기로 요청 후 응답이 오면 Node.js에 전달
2.
비동기 작업 요청 커널에서 지원 X → libUV 내 워커 스레드가 가진 스레드 풀 사용
libUV는 기본적으로 4개의 스레드를 가진 스레드풀을 생성한다. uv_threadpool 환경 변수를 통해 최대 128개까지 스레드를 늘릴 수도 있다. Node.js는 I/O 등 비동기 작업을 libUV에 위임함으로써 논 블로킹을 지원하고 그 기반에는 이벤트 루프가 있다.
간단 정리 1. Node.js는 싱글 스레드 + 논블로킹을 지원한다. 2. Node.js는 I/O 등 비동기 작업을 libUV에 위임함으로써 논블로킹을 지원한다. 3. libUV는 I/O, 이벤트 처리등을 지원하기 이해 OS 커널을 추상화한 라이브러리이다. 4. libUV는 비동기 I/O 작업을 커널에서 지원하는지에 따라, 커널 또는 스레드 풀을 사용한다. 5. 이벤트 루프는 libUV가 여러 비동기 작업을 관리하기 위한 구현체이다.

2. Why am I writing this?

node.js event loop 를 구글링해보면, 대부분의 결과에서는 이벤트루프의 고수준의 추상화된 형태를 보여줄뿐, 디테일한 내용들을 설명하고 있지 않다.
아래 그림은 node.js event loop 를 구글링했을 때 나온 결과를 캡쳐한 것이다. 대부분의 이미지 검색 결과는 잘못되었거나, 이벤트루프의 고수준의 view만 보여주고 있다. 이러한 종류의 description 때문에 많은 개발자들이 이벤트루프에 대해 잘못된 이해와 오해를 갖고 있다. 아래에서 개발자들이 흔하게 갖고 있는 오해들에 대해 알아보도록 하자.
예시

3. Few common misconceptions (몇가지 흔한 오해들)

3.1. 이벤트 루프는 JS 엔진 내부에 있다.

흔한 오해중 하나는 이벤트 루프가 자바 스크립트 엔진 (v8, spiderMonkey 등)의 일부라는 것이다. 그러나 이벤트 루프는 실제로 자바스크립트 코드를 실행시키기 위해 자바스크립트 엔진을 사용할 뿐이다.

3.2. 이벤트 루프에는 단일 스택/큐가 존재한다.

1.
이벤트 루프에는 스택이 없다.
2.
이벤트루프는 multiple한 큐를 가지고 있는 복잡한 프로세스를 가지고 있다.
대다수의 개발자들은 모든 콜백이 단일 큐에 push 된다고 알고있지만, 그건 실제로 완전히 잘못된 생각이다.

3.3. 이벤트 루프가 별도의 스레드에서 동작한다.

Node.js 이벤트 루프의 잘못된 다이어그램으로 인해, 일부 개발자들은 아래와 같이 이벤트 루프에 2개의 스레드가 존재한다고 생각한다.
1.
자바스크립트 코드를 동작시키는 스레드
2.
이벤트 루프를 동작시키는 스레드
그러나 사실 모든것이 싱글(단일) 스레드에서 동작한다.

3.4. setTimeout에 포함된 일부 비동기 OS API

아주 큰 오해 중 하나는 setTimeout 의 딜레이가 끝났을 때, OS나 커널 등에 의해 setTImeout의 콜백이 큐에 담긴다는 것이다. 그러나 실제로는 OS나 커널과 같은 다른 누군가는 존재하지 않는다.

3.5. setImmediate의 콜백은 큐에서 가장 빨리 이루어진다.

일반적으로 이벤트 루프에는 오직 하나의 큐가 있다는 것이 알려져있다. 따라서 일부 개발자들은 setImmediate() 가 콜백을 작업큐에서 가장 앞쪽에 둔다고 생각한다. 그러나 이것 완전히 잘못된 생각이다. 자바스크립트에 있는 모든 작업큐는 FIFO로 동작한다.
간단 정리 1. 이벤트루프와 JS엔진은 별도이다. 이벤트 루프 내 자바스크립트 코드를 실행시키기 위해 JS 엔진을 사용할 뿐이다. 2. 이벤트루프에는 multiple한 큐가 존재한다. 3. 이벤트루프는 별도의 스레드에 동작하지 않는다. Node.js 싱글 스레드에서 동작한다. 4. setTimeout 의 콜백은 OS나 커널 등의 외부요인과 관련이 없다. 5. 모든 큐는 FIFO 이다. (setImmediate 콜백이 큐에서 가장 빨리 이루어지지 않음)

4. Architecture of the event loop

이벤트 루프의 작업 흐름에 대해 설명하기전에, 이벤트루프의 아키텍처를 이해하는 것이 중요하다. 앞서 말한것처럼, 일반적으로 검색해서 나오는 이벤트루프(돌아가는 wheel과 큐가 존재하는)는 이벤트 루프의 아키텍처를 제대로 설명하지 못한다. 이벤트 루프를 제대로 설명하는 그림은 다음과 같다.
이벤트 루프 아키텍처
그림에 표현된 각각의 네모박스는 특정한 작업을 수행하는 phase를 나타낸다. 각 phase는 큐를 가지고 있다. (이해를 돕기위해 큐라고 설명했지만, 실제 자료구조는 큐가 아닐 수도 있다). idleprepare 를 제외한 나머지 phase 어디서든 자바스크립트 코드가 실행될 수 있다.
nextTickQueue, microTaskQueue 는, 실제로 루프의 일부가 아니다. 해당 큐 내부의 콜백들은 어느 phase에서든 실행될 수 있으며 가장 높은 우선순위로 실행 된다. (정확히는 특정 phase로 이동할때 동작한다)
우리는 이제 이벤트 루프가 실제로는 서로 다른 phase와 서로다른 큐의 조합인 것을 알 수 있다. 이제 각 phase에 대해 알아 보도록 하자.

4.1. Timer phase

Timer phase 는 이벤트 루프를 시작하는 phase이다. 해당 phase 의 큐에는 setTimeout, setInterval 과 같은 타이머들의 콜백이 담긴다.
콜백을 실제로 큐에 넣는것이 아니라, min-heap (최소힙) 에 타이머를 유지하고 있다가 타이머의 만료시간이 지났을 때, 타이머의 콜백을 실행시킨다. (최소힙 자료구조에 타이머를 저장하고 있다가, 타이머의 만료시간을 체크한 후 등록된 콜백을 실행시킨다.)
setTimeout(func, delay)
setInterval(func, delay)

4.2. Pending i/o callback phase

여기 phase 에서는 이벤트 루프의 pending_queue 에 담겨 있는 콜백들을 실행시킨다. 해당 큐에 담겨 있는 콜백들은 직전 동작(직전 루프)에서 푸시된 콜백들이다. 예를 들어, TCP 핸들러에서 파일에 무언가를 쓰고 작업이 끝났다면, 콜백은 해당 큐에 푸시된다. 에러 콜백(에러 핸들러 콜백) 또한 이곳에서 처리된다.

4.3. Idle, Prepare phase

해당 phase의 이름은 idle (실행되고 있지 않음) 이지만, 실제로 이 phase는 매 Tick 마다 동작한다. Prepare 또한 매 폴링이 시작되기 전에 동작한다. 어찌됐던간에, 이 두 개의 phase는 노드의 내부 동작을 위한 phase이다. 따라서 여기서는 더이상 설명하지 않는다.

4.4. Poll phase

아마도 전체 이벤트루프 phase 중 가장 중요한 phase가 poll phase 일것이다. 여기서는 새로운 커넥션 (새로운 소켓 연동 등)과 데이터 (파일 Read, 입력 데이터 읽기 등)을 허용한다.
poll phase 의 작업은 다음과 같이 2가지 파트로 나눌 수 있다.
1.
Queue (예. watcher_queue ) 가 비어있지 않은 경우
큐가 비거나 시스템 max limit에 도달할때까지 큐의 콜백들이 동기적으로 하나씩 수행된다.
2.
Queue (예. watcher_queue ) 가 비어 있는 경우
Node.js는 새로운 커넥션을 기다릴 것이다. 대기하거나 sleep 하는 시간은 다양한 요인에 의해 계산되는데, 해당 부분은 뒤에 기술하도록 하겠다.
새로운 커넥션 (새로운 소켓 연동 등)과 데이터 (파일 Read, 입력 데이터 읽기 등)을 허용한다. 위의 말이 정확히 무슨 일을 하는지 이해가 안될 수 있다. 간단히 말하자면 I/O 관련 콜백이 Poll Phase에서 실행될 수 있다. 예시) - 데이터베이스에 쿼리를 보낸 후 결과가 왔을 때 실행되는 콜백 - HTTP 요청을 보낸 후 응답이 왔을 때 실행되는 콜백 - 파일을 비동기로 다 읽었을 때 실행되는 콜백

4.5. Check phase

poll phase 의 다음 phase는 setImmediate() 콜백만을 위한 check phase 이다. 여기서 우리는 왜 setImmediate 콜백을 위해 별도의 큐가 존재하는지 의문이 들 것이다. 그 이유는, 이벤트 루프의 workflow에서 기술할 poll phase 의 행동때문이다. 우선 지금은 check phasesetImmediate() api 의 콜백을 위한 phase라고만 기억하고 넘어가도록 하자.

4.6. Close callback

close 타입의 콜백 (예. socket.on(‘close’, ()=>{}) ) 이 여기서 핸들링된다. 이는 정리(cleanup) phase와 유사하다고 볼수 있다.

4.7. nextTickQueue & microTaskQueue

nextTickQueue 의 태스크들은 process.nextTick() API를 사용하면서 호출된 콜백들을 가지고 있으며, microTaskQueue 는 Resolved된 프로미스 콜백들을 갖고 있다.
여기 두 큐는 사실상 이벤트 루프의 일부가 아니다. 다시 말해서, libUV 라이브러리에서 개발된 것이 아니라 node.js에 개발된 것이라고 볼 수 있다. 이들은 C/C++ 과 자바스크립트의 경계를 가로지를때마다 가능한한 빠르게 호출된다. 이를 통해 현재 실행 중인 작업들이 끝나자마자 호출 될 수 있다. (현재 동작중인 JS 함수 콜백에서는 필수는 아니다)
간단 정리 1. timer - setTimeout / setInterval 로 예약된 콜백 실행 2. pending I/O callbacks - 이전 이벤트 루프 iteration에서 완료되지 않은 I/O 콜백 실행 3. ide, prepare - 노드 내부 동작을 위해 사용 (중요 X) 4. poll - I/O 작업과 관련된 콜백 실행 5. check - setImmediate 콜백 실행 6. close - close 콜백 실행 7. nextTick / microTaskQueue - 단계 → 단계로 넘어가기전에 콜백을 최대한 빠르게 실행

5. workflow of the event loop

 node my-script.js 라는파일을 콘솔에서 실행시킬 때, 노드(node.js)는 이벤트 루프를 이니셜라이징한 후 이벤트 루프 바깥에서 메인 모듈 my-script.js를 실행한다. 메인 모듈이 실행되고 나면 노드는 이벤트 루프가 살아있는지(alive) 체크한다. (이벤트 루프에서 무언가를 동작시킬 작업있는지 확인)
만약 이벤트 루프에서 동작시킬 작업이 없다면 노드는 process.on('exit, () => {}) 와 같은 exit 콜백을 실행시키고 이벤트 루프를 종료시킨다.
그러나 이벤트 루프가 살아있다면, 노드는 첫번째 phase인 timer phase 부터 루프를 돌린다.
workflow

5.1. Timer Phase workflow

이벤트 루프가 timer phase에 진입하게 되면, 타이머 큐에 실행해야할 작업이 있는지 체크한다. 아주 간단한 과정처럼 들릴지 모르나, 실제로 이벤트 루프는 어떤 작업을 실행해야할지 결정하기 위해 몇가지 과정을 거친다.
timer 스크립트는 실제로 오름차순으로 힙 메모리에 저장되어있다. 그래서 첫번째 타이머부터 꺼내서 now - registeredTime === delta  와 같은 조건을 검사하여 타이머의 콜백을 실행할지 말지를 결정한다.(여기서 delta는 setTimeout(() => {}, 10)에서 10 이라는 값을 의미)
만약 타이머가 해당조건을 만족한다면 타이머의 콜백을 실행하고 그 다음 타이머를 체크한다. 만약 해당 조건을 만족하지 않는 타이머를 만난다면, 나머지 타이머를 체크하는 것을 멈추고 다음 phase로 진입한다. (오름차순으로 정렬되어 있으므로 뒤에 있는 나머지 타이머들을 체크하는것이 의미가 없음)
예를들어, 타이머를 4개를 생성하는 setTimeout을 4번 호출했다고 가정해보자. 해당 타이머들의 threshold(임계값)는 각각 100200300400 이고 t 라는 시점에 등록되어있다.
이벤트 루프가 t+250 시점에 Timer phase에 진입했다고 가정해보자. 먼저 만료시간이 t+100 인 타이머 A를 발견하고 타이머 A의 콜백을 실행시킬 것이다. 그리고 타이머 B를 체크한 후 타이머 B의 만료시간이 지난 것을 확인하여 타이머 B의 콜백도 실행시킨다.
이후, 타이머 C를 체크하지만 타이머의 만료시간은 t+300 이기 때문에 아직 시간이 지나지 않았으므로 해당 phase는 종료된다. 타이머들은 오름차순으로 졍렬되어있기 때문에 그 뒤에 대기중인 타이머 D의 만료시간은 체크할 필요가 없다.
Timer phase는 시스템의 실행 한도를 갖고 있어서, 실행 가능한 타이머가 남아있다하더라도, 만약 max limit에 도달한다면 다음 phase로 넘어가게 된다.

5.2. Pending i/o callback phase workflow

Timer phase 종료 이후 이벤트루프는 i/o Phase로 진입하게 되고, pending_queue 에 있는 이전 작업들이 pending (실행 대기중)인 지 아닌지 체크한다. 만약 pending 상태라면 큐가 비거나, 시스템의 실행 한도에 도달할때까지 하나씩 꺼내 콜백을 실행시킨다. 그 이후 이벤트 루프는 idle hander phase로 진입하게 되고, 내부 동작을 위한 Prepare phase를 거쳐 (아마도 가장 중요할지도 모르는) Poll phase로 이동한다.

5.3. Poll phase workflow

이름에서 유추 가능하듯이, 새로운 요청이나 연결이 들어오고 있는지를 확인하는 phase이다. 경우의 수는 다음과 같이 2가지로 나뉠 수 있다.
1.
Queue (예. watch_queue ) 가 비어있지 않은 경우
큐가 비거나 시스템 max limit에 도달할때까지 큐의 콜백을 하나씩 실행한다.
파일 읽기 응답, 새로운 소켓, HTTP 연결 요청 등을 I/O 관련 스크립트를 실행한다.
2.
Queue (예. watch_queue ) 가 비어 있는 경우
check queue , pending queue , closing callbacks queue , idle hander queue 등에 pending 중인 작업이 있는 경우, 0ms 동안 대기하고 담당 Phase로 이동한다.
만약 위의 큐에 pending 중인 작업이 없는 경우 timer queue 에서 첫번째 타이머를 체크하여 타이머를 실행할 수 있는 시간까지 대기한다. 해당 시간이 되면 timer phase로 이동하여 해당 타이머의 콜백을 실행 시킨다.

5.4. Check Phase workflow

해당 Phase는 setImmediate() API 로부터 호출가능한 콜백들의 큐가 있는 phase 이다. 여기서는 다른 Phase와 마찬가지로 큐가 비거나, max limit에 도달할때까지 순차적으로(동기식으로) 큐에 있는 작업들을 하나씩 실행시킨다.

5.5. Close callback workflow

Check phase의 작업들을 처리 완료한 이후, 이벤트 루프는 close나 destroy 타입의 콜백을 핸들링하는 Close callback phase에 진입한다. close callback 실행이 완료된 이후, 이벤트 루프는 루프가 여전히 살아있는지 (돌아야할 루프가 있는지) 체크한다. 만약 더이상 작업이 없다면, 이벤트 루프는 종료하게 된다.
그러나, 아직 작업들이 남아있다면, 다음 iteration(다음 loop의 Time phase로 이동)으로 이동해서 workflow 대로 로직을 동작시킨다. 위의 Timer phase에서의 예시로 돌아가면, 이제 Timer phase에서는 타이머 C의 만료시간을 체크하는 것부터 시작할 것이다.

5.6. nextTickQueue & microTaskQueue workflow

위의 두가지 큐의 콜백은 언제 실행되는 걸까? 이 두가지 큐는 특정 Phase에서 그다음 Phase로 넘어갈때 최대한 빠르게(ASAP) 실행된다. 다른 Phase들과 다르게, 시스템의 실행 한도가 없다. 따라서 큐가 완전히 빌때까지 콜백을 순차적으로 실행한다. 그리고 nextTickQueue는 microTaskQueue 보다 더 높은 우선순위를 가지고 있다.

5.7. Thread-pool

필자가 자바스크립트 개발자로부터 가장 흔하게 듣는 단어는 스레드 풀이다. 그리고 이와 관련된 가장 흔한 오해는, 노드(Node.js)가 모든 비동기 작업들을 처리하기 위해 사용되는 스레드 풀이 가지고 있다는 것이다. 그러나 스레드 풀은 libUV(비동기 작업을 처리하기 위한 노드의 서드파티 라이브러리)에 존재한다.
필자가 이벤트 루프의 다이어그램에 스레드풀을 따로 표현하지 않은 이유는 스레드풀은 이벤트 루프 매커니즘의 일부가 아니기 때문이다. libUV에 관한 별도의 포스트에서 설명할지도 모르겠다. 아무튼 현재로서는, 필자는 “모든 비동기 작업들이 스레드풀에 의해 처리되지는 않는다” 라고 말하고 싶다. libUV는 환경을 이벤트 드리븐하게 유지하기 위해, 운영체제의 비동기 API를 아주 스마트하게 충분히 사용할 수 있다. 그러나 비동기 API를 지원하지 않는 파일 I/O, DNS Lookup 과 같은 작업들은 스레드풀을 사용한다. 이때 기본값으로 4개의 스레드를 사용한다. uv_threadpool_size 라는 환경변수의 값을 조정하여 최대 128개까지 스레드 풀을 늘릴 수 있다.

6. Reference