Node.js의 이벤트 루프는 libuv 라이브러리에 의해 관리된다.
libuv 를 학습하면서, 솔직히 정말 이해가 되지 않았다. 구체적으로 이벤트 루프가 무슨 동작을 하는 건지, microtask queue는 무엇인지, 이벤트 루프가 어떻게 논블로킹 I/O를 처리할 수 있는지 등 추상적으로만 이해할 뿐 어느 누구에게 제대로 설명하기가 쉽지 않았다.
그래서 node.js 공식 문서부터 여러 포스팅, git에 올라와있는 node.js, libuv 라이브러리 코드를 며칠간 파헤쳐보았다.
libuv란?
Node.js의 이벤트 루프는 libuv라는 비동기 I/O 라이브러리에 의해 구현된다.
libuv는 C++로 작성된 라이브러리로, Node.js가 단일 스레드로 동작하면서도 비동기적으로 I/O 작업을 처리할 수 있게 하는 핵심 엔진이다.
libuv와 커널의 관계
libuv는 단순한 라이브러리가 아니라, 운영체제의 커널을 추상화한 Wrapping 라이브러리라는 점에서 특별하다.
운영체제는 비동기 처리를 위한 여러 API를 제공한다. 예를 들어, 리눅스의 epoll, macOS의 kqueue, 윈도우의 IOCP 같은 것들이다.
libuv는 이러한 운영체제별 비동기 API를 추상화해, Node.js가 운영체제의 종류에 상관없이 동일한 방식으로 비동기 작업을 요청할 수 있도록 만든다.
쉽게 말해, libuv는 운영체제의 비동기 API가 무엇인지 이미 알고 있고, 이를 활용해 파일 읽기, 네트워크 작업 같은 논블로킹 작업을 수행한다.
그래서 libuv는 운영체제의 비동기 API를 활용해 논블로킹 작업을 처리하거나, 커널이 이를 지원하지 않는 경우 자체적으로 스레드 풀을 활용해 작업을 처리한다.
즉, 아래와 같은 두 가지 경우가 있다.
1. 커널이 비동기 작업을 지원하는 경우
만약 커널에서 해당 비동기 작업을 지원할 경우, libuv는 이를 커널에 위임한다.
이 경우 libuv의 uv_io_t가 사용된다. uv_io_t는 libuv의 내부 데이터 구조로, 커널의 비동기 작업을 추적하고, 작업 완료 시 이벤트 루프에서 이를 감지하고 처리하도록 도와준다.
2. 커널이 비동기 작업을 지원하지 않는 경우
만약 커널에서 해당 비동기 작업을 지원하지 않을 경우, libuv의 스레드 풀이 직접 작업을 처리한다.
조금 더 구체적으로는 아래의 두 가지 주요 상황에서 발생한다.
운영체제가 비동기 API를 지원하지 않는 작업
예를 들어, 유닉스 계열 운영체제에서는 대부분의 파일 시스템 작업(예: read(), write())이 블로킹 호출로 제공된다. 이 경우 libuv는 스레드 풀을 활용해 이러한 블로킹 작업을 처리하고, 완료된 작업을 이벤트 루프에 전달한다.
즉, 스레드 풀이 내부적으로 운영체제의 블로킹 API를 호출하지만, 이를 논블로킹처럼 동작하도록 처리하는 것이다.
CPU 집약적인 작업
해시 함수, 압축, 암호화 같은 CPU 연산 작업은 운영체제의 비동기 API로 처리할 수 없다. 이때도 libuv는 스레드 풀을 사용해 이러한 작업을 처리한다.
libuv의 스레드 풀 구조
공식 문서에 따르면, libuv는 기본적으로 4개의 스레드를 가지는 스레드 풀을 생성한다. 그리고 UV_THREADPOOL_SIZE라는 환경 변수를 통해 최대 1024개의 스레드 생성이 가능하다.
이벤트 루프란?
이제 libuv의 핵심 구성 요소인 이벤트 루프에 대해 알아보자.
이벤트 루프는 각 요청을 특성에 맞게 커널이나 Thread Pool에 위임하고, 실행 대기 중인 callback을 Event Queue에 모았다가 Main Thread에 의해 실행될 수 있도록 call stack으로 옮기는 역할을 한다.
이벤트 루프에 대한 오해
이벤트 루프를 공부하면서, 인터넷에 떠돌아다니는 이벤트 루프을 묘사한 그림에 많은 혼란을 겪었다. 실제로 대부분의 이미지는 이벤트 루프의 실제 동작을 표현하기엔 무리가 있다.
Node.js의 핵심 개발자 중 한명인 Bert Belder의 강연이다. 위 영상의 1분 15초 부터 인터넷 상에 돌아다니는 많은 이벤트 루프 참고 그림은 잘못된 그림이라는 것을 지적한다.
이벤트 루프의 페이즈
공식 문서와 코드를 참고해 그린 이벤트 루프는 위와 같다.
페이즈
이벤트 루프는 6개의 페이즈로 구성되어있다. 위 그림에서 박스가 특정 작업을 수행하기 위한 페이즈이다.
각 페이즈는 자신들이 관심있어 하는 작업들만 관리한다.
예를 들어 Timer Phase는 이름 그대로 타이머에 관한 비동기 작업들을 관리하고 Pending Callbacks는 이전 단계에서 완료되지 않은 I/O작업 콜백을 실행한다.
페이즈 전환 순서
페이즈 전환 순서는 그림에 그린 것처럼
Timer Phase → Pending Callbacks Phase → Idle, Prepare Phase → Poll Phase → Check Phase → Close Callbacks Phase
로 진행된다.
여기서 한 페이즈에서 다음 페이즈로 넘어가는 것을 `Tick`이라고 한다.
nextTickQueue, microTaskQueue
위 그림 중, 점선으로 구성된 nextTickQueue와 microTaskQueue는 이벤트 루프를 구성하는 요소는 아니다. 하지만 Node.js의 비동기 관리 작업을 도와주며, 이벤트 루프의 동작 방식에 영향을 미친다.
nextTickQueue
Node.js에서 process.nextTick으로 등록된 콜백을 저장하는 큐이다. 모든 이벤트 루프 페이즈보다 우선적으로 실행된다. 즉, 현재 페이즈에서 처리 중이던 작업이 완료된 직후 실행된다.
microTaskQueue
Promise의 .then 또는 MutationObserver와 같은 미시 작업(microtask)을 저장하는 큐이다. nextTickQueue 다음으로 실행되며, 현재 이벤트 루프 페이즈가 끝나기 전에 실행된다.
실행 순서를 간단하게 정리해보면 아래와 같다.
1. 현재 이벤트 루프 페이즈에서 할 일을 처리한다.
2. nextTickQueue의 작업을 처리한다.
3. microTaskQueue의 작업을 처리한다.
4. 다음 이벤트 루프 페이즈로 이동한다.
페이즈에 대한 구체적인 설명은 다음 포스팅에서 다룰 예정이다.
페이즈의 큐와 시스템 실행 한도
이벤트 루프의 각 페이즈는 자신만의 큐를 하나씩 가지고 있다. 이 큐에는 해당 페이즈에서 처리할 작업들이 순서대로 담긴다.
예를 들어, Timer Phase에는 setTimeout과 setInterval의 콜백이, Check Phase에는 setImmediate의 콜백이 각각의 큐에 들어가고, 이벤트 루프는 이를 하나씩 꺼내어 실행한다.
그런데 여기서 중요한 점이 있다. 이벤트 루프가 단순히 큐의 작업을 모두 처리하고 다음 페이즈로 넘어가는 것은 아니다. 큐에서 작업을 실행하는 동안 새로운 작업이 추가될 수도 있기 때문이다.
만약 새로운 작업이 계속해서 추가된다면?
새로운 작업이 계속해서 추가된다면 어떻게 될까? 만약 이벤트 루프가 끝없이 하나의 페이즈에 머물며 큐의 작업만 처리한다면, 다른 페이즈로 넘어가지 못하고 특정 작업에 갇혀버릴 위험이 있다.
이를 방지하기 위해 시스템 실행 한도라는 개념이 존재한다.
시스템 실행 한도란, 각 페이즈에서 특정 조건에 따라 일정 시간이 경과하거나, 처리할 수 있는 작업량이 초과했을 때 강제로 다음 페이즈로 넘어가게 하는 제한이다.
예를 들어, 아래와 같은 코드가 있다고 가정해보자.
setTimeout(() => {
console.log('Callback 1');
setTimeout(() => {
console.log('Callback 2');
setTimeout(() => {
console.log('Callback 3');
setTimeout(() => {
console.log('Callback 4');
...
}, 1000);
}, 1000);
}, 1000);
}, 1000);
위 코드를 이벤트 루프에서 동작하는 방식으로 나타내보면 아래와 같을 것이다.
1. Timer Phase에서 setTimeout 콜백 하나가 실행된다.
2. 콜백을 실행하는 도중 새로운 setTimeout 작업이 추가된다.
3. 이 새로운 작업이 다시 Timer Phase의 큐로 들어가면서 이벤트 루프가 계속해서 Timer Phase에 머무른다.
이처럼 특정 페이즈가 작업 추가로 인해 끝없이 실행되는 것을 방지하기 위해 시스템 실행 한도가 작동한다. 이를 통해 이벤트 루프는 적절한 시점에 다음 페이즈로 넘어가면서 다른 작업도 처리할 수 있게 된다.
결론적으로, 시스템 실행 한도는 이벤트 루프가 공정하게 각 페이즈를 순회하도록 만들어주는 안전 장치라고 볼 수 있다.
NodeJS 실행 시 이벤트 루프의 흐름
NodeJS에서 어플리케이션을 실행하면 어떤 흐름으로 이벤트 루프가 실행될까?
main.js 라는 어플리케이션을 실행한다고 가정했을 때, 전체 흐름은 아래와 같다.
1. 이벤트 루프를 생성한다.
2. 이벤트 루프에 진입하기 전, main.js를 처음부터 끝까지 실행한다.
3. 이벤트 루프가 살아있는지 확인한다.(남아있는 작업이 있는지 확인)
4. 이벤트 루프가 살아있다면 이벤트 루프로 진입하고, 남은 작업이 없다면 이벤트 루프가 종료된다.
NodeJS 소스 코드로 살펴보기
이제 nodejs의 소스 코드를 보면서 다시 그림을 살펴보자.
node main.js
NodeMainInstance::Run
https://github.com/nodejs/node/blob/main/src/node_main_instance.cc#L104
void NodeMainInstance::Run(ExitCode* exit_code, Environment* env) {
if (*exit_code == ExitCode::kNoFailure) {
if (!sea::MaybeLoadSingleExecutableApplication(env)) {
LoadEnvironment(env, StartExecutionCallback{});
}
*exit_code =
//이벤트 루프
SpinEventLoopInternal(env).FromMaybe(ExitCode::kGenericUserError);
}
#if defined(LEAK_SANITIZER)
__lsan_do_leak_check();
#endif
}
node main.js 명령을 통해 NodeJS가 구동되면, NodeMainInstance::Run 함수가 호출된다. 그리고 SpinEventLoopInternal 함수가 호출되는 것을 알 수 있다.
SpinEventLoopInternal
https://github.com/nodejs/node/blob/main/src/api/embed_helpers.cc#L22C1-L91C1
Maybe<ExitCode> SpinEventLoopInternal(Environment* env) {
...
do {
if (env->is_stopping()) break;
//이벤트 루프 생성
uv_run(env->event_loop(), UV_RUN_DEFAULT);
if (env->is_stopping()) break;
platform->DrainTasks(isolate);
...
//이벤트 루프가 살아있는지 확인!
more = uv_loop_alive(env->event_loop());
}
while (more == true && !env->is_stopping());
env->performance_state()->Mark(
node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_EXIT);
}
...
}
그리고 SpinEventLoop 함수에서는 uv_run 호출을 통해 이벤트 루프를 생성하고, uv_loop_alive 함수를 통해 이벤트 루프가 살아있는지 체크한다.
uv_run()
https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c#L425
int uv_run(uv_loop_t* loop, uv_run_mode mode) {
int timeout;
int r;
int can_sleep;
r = uv__loop_alive(loop);
if (!r)
uv__update_time(loop);
if (mode == UV_RUN_DEFAULT && r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop); // 1. timer phase
}
while (r != 0 && loop->stop_flag == 0) {
uv__run_pending(loop); // 2. pending phase
uv__run_idle(loop); // 3. idle phase
uv__run_prepare(loop); // 4. prepare phase
uv__io_poll(loop, timeout); // 5. poll phase
...
uv__run_check(loop); // 6. check phase
uv__run_closing_handles(loop);
...
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
uv_run 함수는 앞서 소개한 것과 같다. while 문 안에서 6개의 페이즈가 순차적으로 실행되는 것을 확인할 수 있다.
다음 포스팅에서 이벤트 루프의 각 페이즈에 대해 살펴볼 것이다.
참고자료
[Node.js의 이벤트 루프와 비동기] https://akasai.space/node-js/about_node_js_2/
[libuv 공식 문서] https://docs.libuv.org/en/latest/index.html
[NodeJs 공식 문서] https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
[libuv 소스 코드] https://github.com/libuv/libuv/blob/v1.x/src/unix/core.c#L425
[로우 레벨로 살펴보는 Node.js 이벤트 루프] https://evan-moon.github.io/2019/08/01/nodejs-event-loop-workflow/
[Node.js 이벤트 루프(Event Loop) 샅샅이 분석하기] https://www.korecmblog.com/blog/node-js-event-loop
[Tasks, microtasks, queues and schedules]
https://jakearchibald.com/2015/tasks-microtasks-queues-and-schedules/
[node - 기본 동작 원리와 이벤트 루프, 브라우저를 벗어난 js 실행!]
https://velog.io/@qlgks1/node-%EA%B8%B0%EB%B3%B8-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC
[What you should know to really understand the Node.js Event Loop]
https://medium.com/the-node-js-collection/what-you-should-know-to-really-understand-the-node-js-event-loop-and-its-metrics-c4907b19da4c
[Morning Keynote- Everything You Need to Know About Node.js Event Loop - Bert Belder, IBM]
https://www.youtube.com/watch?v=PNa9OMajw9w
'JavaScript > JS를 파헤쳐보자' 카테고리의 다른 글
C10K와 node.js의 비동기 처리 (0) | 2024.08.22 |
---|---|
[JS] 자바스크립트의 비동기 처리 (0) | 2024.07.30 |
Node.js는 완전한 싱글 스레드일까? (0) | 2024.07.26 |
Call Stack과 클로저함수 (0) | 2024.07.24 |
자바스크립트는 컴파일러 언어일까 인터프리터 언어일까? (1) | 2024.07.20 |