NodeJS의 주요 특징은 다음과 같다.
- 싱글 스레드
- Event Driven
- Non-blocking I/O
NodeJS는 싱글 스레드 기반으로 작동하는데 어떻게 Non-blocking이 가능한 것일까?
1️⃣ NodeJS의 구조
NodeJS의 공식문서에 따라 NodeJS의 구조를 그림으로 나타내면 다음과 같다.
NodeJS는 JS엔진(V8)과 함께 다양한 기능들이 함께 존재하는 것을 볼 수 있다.
여기서 싱글 스레드 기반의 NodeJS에서 비동기 처리가 가능하게 해주는 역할을 담당하는 것이 libuv이다.
JS엔진(V8)은 구글에 의해 개발된 JS엔진으로 메모리 할당, 콜 스택 실행, GC 등의 기능을 수행한다.
libuv는 비동기 I/O를 지원하는 C언어 기반의 라이브러리로 커널의 비동기 API로 할 수 없는 작업을 비동기화 하기 위한 별도의 스레드 풀을 가지고 있으며, 이벤트 루프, 이벤트 큐를 관리한다.
결론적으로 NodeJS는 싱글 스레드 기반으로 작동하지만 비동기 처리가 가능한 이유는 libuv가 있기 때문이라고 할 수 있다.
2️⃣ libuv
libuv는 비동기 라이브러리로 네트워크, 파일 I/O등 비동기 처리를 지원한다.
스레드 풀을 가지고 있으며 별도의 지정이 없다면 4개를 기본으로 갖고 있다.
(스레드 갯수를 지정할 수도 있다)
네트워크, 소켓 작업은 시스템 API를 사용하고, 파일은 스레드 풀에 있는 스레드를 이용해 비동기 처리를 한다.
libuv는 이벤트 루프와 이벤트 큐를 가지며 요청을 판별하여 적절히 처리하는 역할을 한다.
우선 요청이 들어오면 이벤트 루프가 해당 요청을 판별한다.
커널 비동기 I/O의 지원을 받을 수 있는 요청이면 커널의 도움을 받아 해당 요청을 처리한 후 이벤트 큐에 콜백을 등록한다.
만약, 파일, 네트워크와 같은 blocking I/O는 스레드 풀에서 작업을 수행한 후 이벤트 큐에 콜백을 등록한다.
이벤트 루프는 주기적으로 콜 스택이 비었는지 확인하고 이벤트 큐에 대기중인 콜백이 있다면 콜백들을 콜 스택으로 이동시켜 메인 스레드에서 실행되도록 한다.
위의 내용을 그림으로 나타내면 아래와 같다.
3️⃣ libuv가 있는데 NodeJS는 왜 싱글 스레드?
libuv는 스레드 풀을 가지고 있으며 작업들을 스레드에 위임할 수 있다.
NodeJS는 싱글 스레드로 작동하지만 libuv가 스레드를 가지고 있다면 싱글 스레드라고 할 수 있는가라는 의문이 들 수 있다.
아래의 예시를 보자.
setTimeout(() => console.log('timeout'));
while (true) {};
만약, 비동기 처리 함수인 setTimeout
이 별도의 스레드로 동작한다면 위에서 while
문이 있더라도 timeout
이 출력되어야 한다.
하지만, 직접 실행해본다면 무한 루프에 빠져서 벗어날 수 없는 것을 확인할 수 있다.
NodeJS는 하나의 스레드(메인 스레드)만 갖고 있으며 콜 스택을 이용해 FILO 방식으로 코드를 실행한다.
위의 예시에서 전역 컨텍스트가 생성되고, setTimeout
의 콜백이 이벤트 큐에 들어가게 될 것이다.
하지만, 이벤트 루프는 콜 스택이 비어있을 때 이벤트 큐에서 콜백을 꺼내 콜 스택에 넣은 후 실행시키며 위의 예시에서 while
문의 실행 컨텍스트가 제거되지 않으므로 콜 스택은 비지 않게된다.
따라서, setTimeout
의 콜백함수는 while
문의 blocking 때문에 실행되지 않는다.
4️⃣ 이벤트 루프
이벤트 루프의 작동 순서는 아래 그림과 같다.
실제로 위의 과정으로 진행되는지 확인하기 위해 NodeJS의 소스코드를 확인해보면 아래와 같은 내용을 확인할 수 있다.
(node/deps/uv/src/unix/core.c 432번째 줄)
while (r != 0 && loop->stop_flag == 0) {
can_sleep =
uv__queue_empty(&loop->pending_queue) &&
uv__queue_empty(&loop->idle_handles);
uv__run_pending(loop);
uv__run_idle(loop);
uv__run_prepare(loop);
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);
/* Process immediate callbacks (e.g. write_cb) a small fixed number of
* times to avoid loop starvation.*/
for (r = 0; r < 8 && !uv__queue_empty(&loop->pending_queue); r++)
uv__run_pending(loop);
/* Run one final update on the provider_idle_time in case uv__io_poll
* returned because the timeout expired, but no events were received. This
* call will be ignored if the provider_entry_time was either never set (if
* the timeout == 0) or was already updated b/c an event was received.
*/
uv__metrics_update_idle_time(loop);
uv__run_check(loop);
uv__run_closing_handles(loop);
uv__update_time(loop);
uv__run_timers(loop);
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
(자세한 내용은 아래 GItHub를 참고하면 된다. NodeJS 20.5.0)
nodejs/node: Node.js JavaScript runtime :sparkles::turtle::rocket::sparkles: (github.com)
NodeJS에 포함된 라이브러리들은 deps 디렉토리에 있으며 libuv의 경우 deps/uv에 존재한다.
NodeJS에서 libuv의 핵심 동작은 core.c
에 존재한다.
위의 코드를 보면 while
문을 이용해 그림의 각 단계를 반복하고 있음을 확인할 수 있다.
그림의 각 단계를 다르게 표현하면 아래 그림과 같다.
각 단계는 아래와 같은 기능을 한다.
- Timer
setInterval
,setTimeout
과 같은 타이머의 콜백을 처리한다.
- Pending
- pending_queue에 있는 콜백을 실행한다.
- pending_queue에는 이전 루프에서 완료된 콜백 또는 Error 콜백이 쌓인다.
- Idle, prepare
- Poll을 준비하기 위한 단계
- Poll
- 대기중인 콜백을 콜 스택으로 가장 많이 올려보내는 단계
- watch_queue가 비어있지 않다면 주어진 시간동안 queue가 모두 소진될 때 까지 모든 콜백을 콜 스택으로 보내 실행한다.
- Check
setImmediate
만을 위한 단계setImmediate
를 사용하여 수행한 콜백만 이벤트 큐에 쌓이고 콜 스택으로 보냄
- Close
- close 타입의 콜백을 관리하는 단계
여기서 중요한 점은 각 단계마다 이벤트 큐를 가지고 있다는 것이다.
하나의 이벤트 큐를 통해 여섯 단계를 모두 관리하는 것이 아니라 각 단계는 각각 자신의 이벤트 큐를 갖고 있다.
5️⃣ NodeJS 소스코드
위 내용들에 대해 NodeJS의 소스코드를 보면 아래와 같다.
(node/src/node_main_instance.cc 110번째 줄)
void NodeMainInstance::Run(ExitCode* exit_code, Environment* env) {
if (*exit_code == ExitCode::kNoFailure) {
bool runs_sea_code = false;
#ifndef DISABLE_SINGLE_EXECUTABLE_APPLICATION
if (sea::IsSingleExecutable()) {
sea::SeaResource sea = sea::FindSingleExecutableResource();
if (!sea.use_snapshot()) {
runs_sea_code = true;
std::string_view code = sea.main_code_or_snapshot;
LoadEnvironment(env, code);
}
}
#endif
// Either there is already a snapshot main function from SEA, or it's not
// a SEA at all.
if (!runs_sea_code) {
LoadEnvironment(env, StartExecutionCallback{});
}
*exit_code =
SpinEventLoopInternal(env).FromMaybe(ExitCode::kGenericUserError);
}
#if defined(LEAK_SANITIZER)
__lsan_do_leak_check();
#endif
}
SpinEventLoopInternal
함수가 보이고 해당 함수가 정의된 부분을 살펴보면 아래와 같다.
(node/src/api/embed_helper.cc 22번째 줄)
Maybe<ExitCode> SpinEventLoopInternal(Environment* env) {
...
if (env->is_stopping()) return Nothing<ExitCode>();
env->set_trace_sync_io(env->options()->trace_sync_io);
{
bool more;
env->performance_state()->Mark(
node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_START);
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());
if (more && !env->is_stopping()) continue;
if (EmitProcessBeforeExit(env).IsNothing())
break;
{
HandleScope handle_scope(isolate);
if (env->RunSnapshotSerializeCallback().IsEmpty()) {
break;
}
}
// Emit `beforeExit` if the loop became alive either after emitting
// event, or after running some callbacks.
more = uv_loop_alive(env->event_loop());
} while (more == true && !env->is_stopping());
env->performance_state()->Mark(
node::performance::NODE_PERFORMANCE_MILESTONE_LOOP_EXIT);
}
if (env->is_stopping()) return Nothing<ExitCode>();
...
}
이후 uv_run
의 선언부를 따라가면 먼저 살펴봤던 while
문을 확인할 수 있다.
결론적으로 NodeJS가 실행되면 (Run
) 이벤트 루프를 돌리고 (SpinEventLoopInternal
) 6단계의 이벤트 루프가 반복하는 것을 확인할 수 있다(uv_run
).
uv_run
의 마지막 부분에서 r = uv__loop_alive(loop);
를 통해 loop가 유효한지 확인하고 유효하다면 loop를 계속 실행할 것이다.
프로그램의 전체적인 흐름을 살펴보면 아래 그림과 같다.
💡 결론
NodeJS는 메인 스레드 하나로 작동하는 싱글 스레드 방식이며 하나의 콜 스택을 갖는다.
하지만, JS엔진과 함께 포함된 libuv를 통해 비동기 처리를 할 수 있다.
libuv는 비동기 처리를 위한 이벤트 루프와 스레드 풀을 갖는다.
이벤트 루프는 6단계로 이루어져 있으며 각 단계는 각각 이벤트 큐를 갖는다.
[참고자료]
https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick
https://github.com/nodejs/node
https://docs.libuv.org/en/v1.x/
https://medium.com/zigbang/nodejs-event-loop%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0-16e9290f2b30
https://www.voidcanvas.com/nodejs-event-loop/
https://chathuranga94.medium.com/nodejs-architecture-concurrency-model-f71da5f53d1d
'개발 > JavaScript' 카테고리의 다른 글
[JavaScript] 이벤트 버블링과 캡처링 (0) | 2023.09.03 |
---|---|
[JavaScript] addEventListener과 onclick의 차이 (0) | 2023.09.03 |
[JavaScript] 싱글 스레드와 비동기 (0) | 2023.07.23 |
[JavaScript] 함수 (0) | 2023.02.02 |
[JavaScript] 원시 값과 객체 (0) | 2023.01.21 |