
Node.js 동작 원리 (libuv, 이벤트 루프)
흔히 Node.js는 단일 스레드 기반의 논블로킹 비동기 방식으로 동작한다고 합니다. 풀어서 설명하자면, 하나의 흐름에서 각 작업이 완료될 때까지 대기하지 않고(논블로킹) 다음 작업을 처리하며 처리된 결과를 먼저 반환해준다는 것입니다.
여기서 의문이 있습니다. 단일 스레드 특성상 한 번에 하나의 작업만 순차적으로 처리할 수 있는데 Node.js는 어떻게 비동기적으로 작업을 수행할 수 있다는 걸까요?
핵심은 Node.js에 포함된 비동기 I/O 라이브러리 libuv와 이벤트 루프에 있습니다.
Node.js 아키텍처
Node.js의 아키텍처는 아래와 같이 구성됩니다.
-
Node.js API
개발자가 Node.js에서 사용할 수 있는 내장 모듈입니다. (fs, http, stream, path 등)
-
Bindings
자바스크립트 코드와 C++로 작성된 로우레벨 API 사이를 연결해 주는 역할을 합니다.
JS에서
fs.readFile()
을 호출하면 실제로 내부에서는 C++로 구현된 libuv의uv_fs_read()
등을 호출하게 됩니다. 이 징검다리 역할을 해주는 것이 바로 바인딩입니다. -
C/C++ addons
성능이 중요한 경우, 직접 C/C++로 작성된 모듈을 Node.js로 불러와 사용할 수 있습니다.
-
V8 Engine
Google에서 만든 JavaScript 실행 엔진으로 자바스크립트 코드를 컴파일하고 실행하는 역할을 합니다.
-
libuv
Node.js의 비동기 I/O 처리와 이벤트 루프를 담당하는 핵심 라이브러리입니다.
-
c-areas(DNS)
DNS를 처리하기 위한 라이브러리 입니다.
-
http_parser, zlib, etc.
Node.js에서 사용하는 내장 C 라이브러리들로 V8이나 자바스크립트가 직접 처리하기 어려운 로우레벨 작업을 수행합니다.
여기서 핵심 요소인 libuv 라이브러리에 대해 잠깐 살펴보겠습니다.
libuv
libuv는 Node.js에서 논블로킹 비동기 I/O 작업을 가능하게 해주는 C 기반의 라이브러리입니다. 주로 이벤트 루프를 관리하고 자체 스레드 풀(thread pool)을 구성해 시간이 오래 걸리는 작업을 백그라운드에서 병렬로 처리하는 역할을 합니다.
덕분에 Node.js는 단일 스레드로 동작하면서도 파일 I/O와 같은 무거운 작업은 libuv(스레드 풀)에 위임하여 처리할 수 있습니다. 메인 스레드는 작업이 끝날 때까지 대기하지 않고 다음 작업을 계속 처리하므로 여러 작업을 동시에 처리하는 것처럼 빠르게 동작할 수 있으며 개발자 역시 복잡한 멀티스레드 로직을 신경 쓰지 않고 간단하고 직관적인 방식으로 코드를 작성할 수 있습니다.
위 그림은 libuv를 구성하는 다이어그램입니다.
-
Network I/O
TCP, UDP 등 네트워크 관련 작업은 대부분 OS에서 논블로킹 시스템 콜을 지원하기 때문에 libuv는 이를 OS에 위임합니다.
-
File I/O, DNS Ops, User Code
해당 작업들은 일반적으로 CPU를 많이 사용하거나 OS에서 논블로킹을 지원하지 않습니다. libuv는 자체 스레드 풀에서 작업을 처리합니다.
-
uv__io_t
libuv에서 I/O 작업을 등록하고 추적하는 구조체입니다.
어떤 리소스를 추적할지 정하고 해당 이벤트가 발생했을 때 어떤 콜백을 실행할지 관리합니다.
-
epoll, kqueue, event ports, IOCP
운영체제별 비동기 I/O 시스템 콜입니다. libuv는 OS를 감지하여 자동으로 해당 시스템 콜을 사용합니다.
-
Thread Pool*
백그라운드에서 동기 작업을 실행하고 완료되면 콜백을 이벤트 루프에 전달하여 실행합니다.
libuv는 모든 I/O 작업에 대해 스레드 풀을 사용하는 것은 아니고 운영체제의 비동기 처리 지원 여부에 따라 처리 방법을 다르게 결정합니다.
- OS에서 논블로킹 I/O를 지원 O
→ libuv는 OS에 직접 I/O 요청을 맡기고 완료되면 이벤트 루프가 콜백을 처리합니다. - OS에서 논블로킹 I/O를 지원 X
→ libuv는 자체적으로 만든 스레드 풀(worker thread pool)을 사용해 작업을 처리합니다.
아래 간단한 예시로 확인해 보겠습니다.
1. 동기 작업
sync
접미사가 붙은 node.js 메서드는 항상 메인 스레드에서 동기로 실행됩니다.
const fs = require('fs');
const startTime = Date.now();
for (let i = 1; i <= 5; i++) {
fs.readFileSync(`test${i}.txt`, 'utf-8');
console.log(`#${i} test${i}.txt done: ${Date.now() - startTime}ms`);
}
2. libuv 스레드 풀 사용
libuv가 기본으로 생성하는 스레드 풀 크기는 4입니다. 환경 변수 UV_THREADPOOL_SIZE
로 최대 128 크기만큼 늘릴 수 있습니다.
pbkdf2
는 대표적인 비동기 함수입니다. 스레드 풀 크기를 5로 설정한 후 실행하면 5개의 연산이 동시에 병렬로 처리되는 것을 확인할 수 있습니다.
process.env.UV_THREADPOOL_SIZE = 5;
const crypto = require('crypto');
const startTime = Date.now();
for (let i = 1; i <= 10; i++) {
crypto.pbkdf2('pw', 'salt', 1000000, 64, 'sha512', () => {
console.log(`#${i} done: ${Date.now() - startTime}ms`);
});
}
3. OS 비동기 작업
OS에 위임하여 처리할 때는 OS 내 자체 스레드에서 작업이 수행됩니다. UV_THREADPOOL_SIZE
와 관계없이 모든 작업이 동시에 처리되는 것을 확인할 수 있습니다.
process.env.UV_THREADPOOL_SIZE = 3;
const fs = require('fs');
const startTime = Date.now();
for (let i = 1; i <= 6; i++) {
const stream = fs.createReadStream(`test${i}.txt`, { encoding: 'utf-8' });
stream.on('data', () => {});
stream.on('end', () => {
console.log(`#${i} test${i}.txt done: ${Date.now() - startTime}ms`);
});
}
Event Loop
이벤트 루프는 6단계로 이루어져 있으며 단계(phase)마다 큐를 가지고 있습니다.
node main.js
명령어로 애플리케이션을 콘솔에서 실행시키면 Node.js는 이벤트 루프를 생성한 후 메인 모듈인 main.js
를 실행합니다.
이후 이벤트 루프 내에서 실행할 작업이 있는지 확인하고 단계별로 큐에서 콜백을 꺼내 작업을 수행합니다.
큐가 모두 비어 수행할 작업이 없다면 루프를 빠져나가고 프로세스를 종료합니다.
자바스크립트 실행은 idle, prepare 단계를 제외한 어느 단계에서나 실행될 수 있습니다.
nextTickQueue
와 microTaskQueue
는 이벤트 루프의 구성 요소는 아니지만 해당 큐에 들어있는 작업들은 루프의 다음 단계에 진입하기 전에 실행되므로 가장 우선순위가 높습니다. (nextTickQueue > microTaskQueue)
Timer Phase
이벤트 루프의 첫 진입 단계이며, 해당 단계의 큐에서는 setTimeout
이나 setInterval
과 같은 타이머 콜백들을 실행합니다.
모든 타이머가 생성되자마자 큐에 들어가는 것은 아니고 별도로 최소 힙(min heap)에 저장하고 있다가 이미 실행할 시간이 지난 타이머들만 들어갑니다.
- 최소 힙은 완전 이진 트리 형태의 자료구조로 항상 작은 값이 루트 노드에 위치합니다.
(부모 노드 값 ≤ 자식 노드 값) - 항상 최솟값을 O(1) 시간 내 확인할 수 있습니다.
등록된 타이머들은 최소 힙으로 구성되어 있으므로 실행할 시간이 가장 적게 남은 타이머가 루트가 될 것입니다.
이벤트 루프가 timer phase에 진입하면 최솟값부터 차례로 꺼내어 now - registeredTime ≥ delay
를 만족하는 타이머들을 큐에 등록합니다.
- now: 이벤트 루프가 timer phase에 진입한 시점의 시간
- registeredTime: 타이머가 등록된 시점의 시간
- delay: 타이머에 저장된 delay(
setTimeout(() ⇒ {}, delay)
에서의 delay)
delay
값이 각각 100, 200, 300, 400인 타이머 4개를 특정 시간 t
에 등록하고 timer phase에 진입했을 때의 시간을 t+250
이라고 가정해봅시다.
먼저 A부터 힙에서 꺼내 이미 실행할 시간이 지난 타이머인지 조건을 확인합니다.
A는 타이머 실행 시점이 t+100
이므로 콜백이 실행됩니다.
B도 마찬가지로 조건이 성립되어 실행됩니다.
반면 C는 실행 시점이 t+300
이므로 조건을 만족하지 않습니다.
이 경우 페이즈는 바로 종료되고 다음 단계로 넘어가게 됩니다.
C 이후부터는 C보다 항상 실행 시간이 긴 타이머들만 존재하므로 조건을 더 이상 검사하지 않습니다.
핵심은, setTimeout(fn, delay)
의 콜백이 정확하게 delay만큼의 시간이 지난 직후에 실행되는 것이 아니라 delay 이상이 지난 후 가능한 빠른 시점에 실행된다는 것입니다.
타이머를 실행하는 도중 시간이 흐르면 타이머가 계속 실행되는가?
→ 그렇지 않습니다. 현재 시각(now)은 timer phase 진입 시점에 한 번만 갱신됩니다. 이후 타이머를 실행하는 도중 시간이 흘러도 해당 시점을 기준으로 만료된 타이머들만 처리합니다.
timer phase는 uv_run()
→ uv__update_time()
→ uv__run_timers()
순으로 호출됩니다.
// src/unix/core.c
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
...
while (r != 0 && loop->stop_flag == 0) {
...
uv__update_time(loop); // 현재 시각 업데이트
uv__run_timers(loop); // 등록된 타이머 중 실행할 타이머 확인 후 실행
...
}
}
// src/unix/internal.h
UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}
// src/timer.c
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
struct uv__queue* queue_node;
struct uv__queue ready_queue;
uv__queue_init(&ready_queue);
for (;;) {
heap_node = heap_min(timer_heap(loop)); // 최소 힙 루트 가져오기
if (heap_node == NULL)
break;
handle = container_of(heap_node, uv_timer_t, node.heap);
if (handle->timeout > loop->time) // 실행 시간이 지난 타이머가 아니면 종료
break;
uv_timer_stop(handle); // 타이머 제거
uv__queue_insert_tail(&ready_queue, &handle->node.queue); // 준비 큐 삽입
}
while (!uv__queue_empty(&ready_queue)) {
queue_node = uv__queue_head(&ready_queue);
uv__queue_remove(queue_node); // 준비 큐에서 제거
uv__queue_init(queue_node);
handle = container_of(queue_node, uv_timer_t, node.queue);
uv_timer_again(handle); // 반복 타이머 재등록
handle->timer_cb(handle); // 콜백 실행
}
}
위 코드를 살펴보면 루프 사이클 시작 시점에 uv__update_time()
를 한 번만 호출하여 현재 시각을 갱신하는 것을 확인할 수 있습니다.
uv__run_timers()
내에서는 해당 시점을 기준으로 만료된 타이머들을 처리합니다.
타이머 A의 만료 시점이 100ms, 타이머 B의 만료 시점이 101ms, timer phase 진입 시간이 100ms라고 가정한다면, 타이머 A를 처리하면서 도중에 101ms가 되어도 현재 시각은 갱신되지 않기 때문에 B는 실행되지 않습니다.
실행되어야 하는 타이머들의 콜백이 무한히 실행되는 것은 아니고 시스템 실행 한도(hard limit)에 도달하면 다음 단계로 넘어갑니다.
pending callbacks phase
pending phase는 이전 루프에서 실행되지 못하고 대기하던 시스템 I/O 콜백들을 실행하는 단계입니다.
해당 콜백들이 담긴 큐를 pending_queue
라고 하며, 큐가 비거나 시스템 실행 한도에 도달할 때까지 대기 중인 콜백들을 실행합니다.
static void uv__run_pending(uv_loop_t* loop) {
struct uv__queue* q;
struct uv__queue pq;
uv__io_t* w;
uv__queue_move(&loop->pending_queue, &pq);
while (!uv__queue_empty(&pq)) {
q = uv__queue_head(&pq);
uv__queue_remove(q);
uv__queue_init(q);
w = uv__queue_data(q, uv__io_t, pending_queue); // 콜백 정보 가져오기
w->cb(loop, w, POLLOUT); // 콜백 함수 호출
}
}
idle, prepare phase
idle과 prepare 단계는 Node.js의 내부적인 관리를 위해 존재합니다. idle phase는 매 틱(Tick)마다, prepare phase는 매 폴링(Polling) 직전에 실행됩니다.
이벤트 루프의 한 사이클(timers → pending → idle/prepare → poll → check → close)을 의미합니다. 문맥에 따라 phase 간 이동을 뜻하기도 합니다.(e.g. nextTick)
poll phase
이벤트 루프 내에서 가장 중요한 단계인 poll phase는 새로운 I/O 이벤트를 가져와 관련 콜백들을 실행합니다.
해당 콜백들이 담긴 큐는 watcher_queue
이며 파일 읽기, 네트워크 요청, DNS 조회 등의 작업 등이 해당됩니다.
watcher_queue
에 수행할 콜백들이 존재한다면 큐가 비거나 시스템 실행 한도에 도달할 때까지 모든 콜백을 동기적으로 실행합니다.
다른 phase들과 다른 점이 하나 있는데, 큐가 비어 있다면 곧바로 다음 단계로 이동하는 것이 아니라 조건에 따라 잠시 대기할지 다음 단계로 넘어갈지 확인한다는 것입니다.
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
...
while (r != 0 && loop->stop_flag == 0) {
...
timeout = 0;
if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
timeout = uv__backend_timeout(loop);
uv__metrics_inc_loop_count(loop);
uv__io_poll(loop, timeout);
...
}
}
uv__backend_timeout
는 timeout
값을 계산하여 반환하는 함수입니다.
여기서 timeout
이 0보다 크다면 새 I/O 작업이 들어올 때까지 해당 시간만큼 blocking 상태로 대기합니다.
timer phase로 바로 이동하지 않는 이유는 이동한다고 하더라도 아직 타이머를 수행할 수 있는 시간이 되지 않았기 때문에 어차피 이벤트 루프를 한 번 더 돌아야 하기 때문입니다.
timeout
이 0이면 대기하지 않고 바로 반환(이벤트가 없으면 다음 단계로 이동)하고 -1이라면 이벤트가 있을 때까지 무기한 대기합니다.
// src/unix/core.c
static int uv__backend_timeout(const uv_loop_t* loop) {
if (loop->stop_flag == 0 &&
(uv__has_active_handles(loop) || uv__has_active_reqs(loop)) &&
uv__queue_empty(&loop->pending_queue) &&
uv__queue_empty(&loop->idle_handles) &&
(loop->flags & UV_LOOP_REAP_CHILDREN) == 0 &&
loop->closing_handles == NULL)
return uv__next_timeout(loop); // 타이머를 기준으로 timeout 계산
return 0; // 위 경우에 해당하지 않는 경우 루프를 다음 phase로 넘기기 위해 non-blocking로 반환
}
// src/timer.c
int uv__next_timeout(const uv_loop_t* loop) {
const struct heap_node* heap_node;
const uv_timer_t* handle;
uint64_t diff;
heap_node = heap_min(timer_heap(loop));
if (heap_node == NULL)
return -1;
handle = container_of(heap_node, uv_timer_t, node.heap);
if (handle->timeout <= loop->time)
return 0; // 실행 시각이 이미 지났거나 현재 시각과 같으면 non-blocking
diff = handle->timeout - loop->time;
if (diff > INT_MAX)
diff = INT_MAX;
return (int) diff; // 타이머 실행 시각까지 남은 시간 반환
}
check phase
check phase는 setImmediate()
로 등록된 콜백 함수들을 실행하는 단계입니다.
해당 단계 역시 큐가 비거나 시스템 실행 한도에 도달할 때까지 콜백을 실행합니다.
setTimeout(), setImmediate() 비교
setTimeout과 setImmediate는 I/O 사이클 내부에서 호출되는지 여부에 따라 실행 순서가 달라질 수 있습니다.
예시로 아래와 같이 메인 스크립트 상에서 두 함수를 실행했을 때는 실행 결과가 매번 달라지게 됩니다.
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
setTimeout
은 timer phase, setImmediate
는 check phase에서 실행됩니다.
각 phase에 진입하는 순서는 Node.js 내부 실행 흐름과 OS 스케줄링 타이밍에 따라 달라지므로 실행 순서가 보장되지 않는 것입니다.
여러 번 실행시켜 보면 매번 다른 결과가 나오는 것을 확인할 수 있습니다.
I/O 사이클 내부에서 두 함수를 호출하면 어떨까요?
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => console.log('timeout'), 0);
setImmediate(() => console.log('immediate'));
});
이 경우 설정된 타이머 수와 관계없이 항상 setImmediate
가 setTimeout
보다 먼저 실행됩니다.
setImmediate
는 I/O 작업이 처리되는 poll phase 다음 순서인 check phase에 등록되므로 I/O 콜백 이후에 실행되는 것이 보장되기 때문입니다.
여러 번 실행해도 아래와 같이 항상 setImmediate
콜백이 먼저 실행됩니다.
- I/O 작업 완료 직후 콜백을 실행하고 싶다면
setImmediate()
를 사용합니다. - 일정 시간 동안 실행을 지연시켜야 할 때는
setTimeout()
을 사용합니다.
close callbacks phase
socket.on(’close’, () ⇒ {})
과 같은 close나 socket.destroy()
와 같은 destory 이벤트 타입의 콜백이 처리되는 단계입니다.
해당 페이즈가 완료되면 이벤트 루프는 다음 루프에서 처리할 작업이 있는지 확인하고 다시 루프를 시작하거나 종료합니다.
nextTickQueue, microTaskQueue
nextTickQueue
와 microTaskQueue
는 libuv 라이브러리에 포함되어 있는 것이 아니라 Node.js에 포함되어 있기 때문에 이벤트 루프의 일부는 아닙니다.
두 큐는 각각 process.nextTick()
의 콜백, resolve된 Promise의 콜백을 가지고 있으며 각 콜백 실행 직후에 실행됩니다.
시스템 실행 한도의 영향을 받지 않고 큐가 모두 비워질 때까지 처리됩니다.
setTimeout(() => console.log('timeout1'));
setTimeout(() => {
console.log('timeout2 start');
process.nextTick(() => console.log('nextTick'));
Promise.resolve().then(() => console.log('microtask'));
console.log('timeout2 end');
}, 0);
setTimeout(() => console.log('timeout3'));
timeout2의 콜백이 모두 실행된 후 process.nextTick()
과 Promise.then()
이 실행되는 것을 확인할 수 있습니다.
nextTick과 microTask의 순서를 변경해도 nextTick의 우선순위가 높기 때문에 출력 결과는 동일합니다.
좀 더 복잡한 예시를 살펴보겠습니다.
const fs = require('fs');
setImmediate(() => console.log('immediate1'));
setTimeout(() => console.log('timer1'));
Promise.resolve().then(() => console.log('microtask'));
fs.readFile(__filename, 'utf-8', () => {
setTimeout(() => console.log('timer2'));
process.nextTick(() => console.log('nextTick2'));
setImmediate(() => console.log('immediate2'));
console.log('read file');
});
process.nextTick(() => console.log('nextTick1'));
console.log('End');
출력 순서는 다음과 같습니다.
- [Sync]
메인 동기 코드 “End”가 실행됩니다. - [NextTick, MicroTask]
우선순위가 높은 nextTick 콜백(”nextTick1”)이 먼저 실행되고 다음으로 promise 콜백(”microtask”)이 실행됩니다. - [Timer Phase]
첫 번째 타이머 “timer1”이 실행됩니다. - [Check Phase]
readFile 작업이 완료되지 않았기 때문에 check phase로 넘어가 “immediate1”가 먼저 실행됩니다. - [Poll Phase]
- readFile 내 콜백 “read file”이 실행됩니다.
process.nextTick
,setImmediate
순서로 실행됩니다.
- [Timer Phase]
두 번째 타이머 “timer2”가 실행됩니다.
References
Design overview - libuv documentation
Node.js github
NodeJS runtime environment: Libuv Library (Event loop, Thread pool)
Comprehensive Guide to Libuv with Node.js Examples
Libuv Thread Pool vs OS Threads in Node.js
Node.js — The Node.js Event Loop
[번역] 로우 레벨로 살펴보는 Node.js 이벤트 루프
[stackoverflow] Does the nodejs (libuv) event loop execute all the callbacks in one phase (queue)…