[NODEJS] Nodejs 내부구조에 대해(2)
- 시험기간 들어가기 전에 작성했던 글이었는데, 팀프로젝트 여러개와 겹치다보니 이제서야 마무리한다.
- 웹서버가 수많은 요청을 처리하는 것이 그냥 그렇구나라고 알고있었는데, NodeJS를 조금이나마 깊게 공부하면서 웹서버들이 어떻게 수많은 요청을 처리할 수 있는지 알 수 있는 것 같다.
workflow
- Node로 스크립트를 실행(node script.js)
- V8엔진의 Ignition을 이용해 사용자의 script를 bytecode로 변환
- TurboFan으로 최적화
- V8 runtime에서 bytecode를 수행, 그리고 Node runtime이 I/O작업, 연산을 수행한다.
- 이때 발생하는 I/O, 비동기 작업은 NodeJS runtime이 libuv를 사용해 작업을 offload한다.
- libuv 공식문서에 보면, libuv가 운영체제의 이벤트 수집, 모니터링을 하기때문에 사용자는 이벤트가 발생할때 호출할 콜백을 등록하기만 하면된다.
- libuv는 해당 작업이 종료되면 이벤트 큐에 등록한다.
- Node runtime의 수행이 종료되면 이때부터 event loop가 활성화된다.
- console.log가 어느위치에 있든 setTimeout보다 일찍 수행되는 이유가 이 이유.
- setTimeout, setInterval등의 time 관련한 작업은 I/O작업처럼 운영체제와 밀접한 관련이 있기에 libuv에서 관리하도록하는 것이다.
- 그리고 libuv에 콜백함수는 직접 전달되는게 아니라 비동기작업의 파라미터로 전달된다.
- 그리고 이 콜백함수들은 libuv에서 실행되는 것이 아니며, 해당 콜백함수는 Node runtime에서 V8로 execute한다.
- promise, async-await, process.nexttick이 담기는
microtask queue는 libuv의 이벤트루프 구성요소가 아니다.
- 아래 설명할 timer phase, check phase, poll phase 등의 각 event loop phase에는 각자 콜백이 담길 자료구조가 구현되어있으며, 이를 추상화해서
macrotask queue
라고 불린다. - 각 phase는 특정 시간 동안, 그리고 모든 콜백이 수행될때까지 각 phase에서 대기하고있으며, 각 phase가 끝날때마다 node는 microtask queue의 콜백을 처리하도록 되어있다.
-
아래 설명하는 이벤트루프는 흔히 알고있는 node의 이벤트루프가 아닌, 더 row-level에 있는 이벤트루프이다.
libuv/src/unix/core.c
의 384line에 이벤트루프가 구현되어있다.
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);
while (r != 0 && loop->stop_flag == 0) {
uv__update_time(loop);
uv__run_timers(loop);
can_sleep =
QUEUE_EMPTY(&loop->pending_queue) && 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__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 && !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);
if (mode == UV_RUN_ONCE) {
/* UV_RUN_ONCE implies forward progress: at least one callback must have
* been invoked when it returns. uv__io_poll() can return without doing
* I/O (meaning: no callbacks) when its timeout expires - which means we
* have pending timers that satisfy the forward progress constraint.
*
* UV_RUN_NOWAIT makes no guarantees about progress so it's omitted from
* the check.
*/
uv__update_time(loop);
uv__run_timers(loop);
}
r = uv__loop_alive(loop);
if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
break;
}
/* The if statement lets gcc compile it to a conditional store. Avoids
* dirtying a cache line.
*/
if (loop->stop_flag != 0)
loop->stop_flag = 0;
return r;
}
Timer phase
설명
setTimeout, setInterval
함수를 통해 실행되는 코드는 Timer phase heap에 들어간다.- 필요한 시간 순서대로 큐에 오름차순으로 저장된다.
큐에 등록된 시간 - 지난 시간
을 계산해 실행할지 말지 결정한다.- 큐의 콜백들을 실행해나가며, 시간이 더 필요한 작업을 확인하면 (오름차순으로 저장되어있으므로)뒤의 작업은 확인하지 않고 다음 phase로 이동한다.
const test1 = () => {
console.log("test1");
}
const test2 = () => {
console.log("test2");
}
const test3 = () => {
console.log("test3");
}
const test4 = () => {
console.log("test4");
}
setTimeout(test1, 100);
setTimeout(test2, 200);
setTimeout(test3, 300);
setTimeout(test4, 400);
- 위의 코드에서 이벤트루프의 timer phase가 250에 시작됐다고 생각해보자.
test1, test2
는 이미 시간이 지났음을 확인했으므로 실행하며,test3
는 실행하지 않고 다음 phase로 넘어간다.test4
는 위에서 언급했듯 당연히 시간이 더 필요할 것이므로 확인하지 않는다.-
timer phase의 작업은 시스템에 의존적이며, 시스템에서 설정된 timer phase에서의 최대 수행횟수에 도달하면 실행되어야할 timer의 작업이 있더라도 수행하지 않고 다음 phase로 넘어간다.
- ⭐️timer phase는 정확한 시간에 해당 함수는 실행시키는 것이 아닌
최소시간 이후에 해당 함수가 실행됨을 보장한다.
- ⭐️프로그래머가 원하는 최소시간에 바로 실행시킬 수 있으면 좋겠지만, event loop가 loop하는 시간이 OS의 영향을 많이 받기 때문에 지연된다.
detailed
const fs = require('fs');
function someAsyncOperation(callback) {
// 시간이 걸리는 I/O작업을 한 후, 콜백함수를 실행한다.
// 95ms가 걸린다고 가정하자
fs.readFile('/path/to/file', callback);
}
//현재 시간 계산
const timeoutScheduled = Date.now();
//1. 100ms이후 지난 시간을 계산해 console
setTimeout(() => {
const delay = Date.now() - timeoutScheduled;
console.log(`${delay}ms have passed since I was scheduled`);
}, 100);
// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();
// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});
/*
result : 102ms have passed since I was scheduled
*/
- event loop가 poll phase에 들어간다.
- 아직 fs.readFile이 완료되지 않았으므로, 가장 빠른 timer(setTimeout의 100ms)까지 기다린다.
- 95ms에서 fs.readFile이 종료되고, 10ms가 소요되는 익명 callback함수가 poll queue에 넣어진 다음, 수행된다.
- 위 callback함수가 종료되면 더이상 poll queue에는 아무 callback도 없으므로, eventloop는
timer phase
로 다시 이동해 가장 이른 시간에 수행될 timer callback(setTimeout)을 수행한다.
timer phase 구현체
- uv__run_timers가 timer phase에 해당하는 함수인데, callback이 아닌 timer를 가진다고 배웠었고, 실제로 heap으로 구현된 것을 볼 수 있다.
- libuv/src/timer.c 163에 구현되어있다.
void uv__run_timers(uv_loop_t* loop) {
struct heap_node* heap_node;
uv_timer_t* handle;
for (;;) {
heap_node = heap_min(timer_heap(loop));
if (heap_node == NULL)
break;
handle = container_of(heap_node, uv_timer_t, heap_node);
if (handle->timeout > loop->time)
break;
uv_timer_stop(handle);
uv_timer_again(handle);
handle->timer_cb(handle);
}
}
- uv_t_loop 구조체에 timer field에 time관련 필드가 있는데, 공식문서에 나와있듯, 해당 phase의 시간이 지나면 중간에 break로 loop를 탈출하는 것을 알 수 있다.
- I/O callbacks phase는 pending, idle, prepare로 구성되어있다.
Pending i/o phase
fs.readFile, http.request
와 같은 I/O 관련 작업이 끝나길 기다리는 큐- timer phase이후, I/O 작업이 끝나고 실행되어야하는(보류중인) 콜백이 있는지 확인한다.
-
보류중인 콜백들은 큐가 비거나 시스템 최대 한도까지 차례로 실행된다.
- 이후
idle handler phase, prepare phase
로 넘어간 다음,Poll phase
로 넘어간다.- idle handler phase, prepare phase는 내부적으로만 사용된다는 것을 기억하자.
pending phase 구현체
static void uv__run_pending(uv_loop_t* loop) {
QUEUE* q;
QUEUE pq;
uv__io_t* w;
QUEUE_MOVE(&loop->pending_queue, &pq);
while (!QUEUE_EMPTY(&pq)) {
q = QUEUE_HEAD(&pq);
QUEUE_REMOVE(q);
QUEUE_INIT(q);
w = QUEUE_DATA(q, uv__io_t, pending_queue);
w->cb(loop, w, POLLOUT);
}
}
- Queue의 각 원소에 대해 함수는 Queue에서 해당 원소 제거하고 해당 항목에 대해 cb 필드에서 지정한 콜백 함수를 호출한다.
- 콜백 함수는 루프
uv__io_t
및 이벤트가 발신(POLOUT) 데이터용임을 나타내는 플래그를 통과한다.
idle, prepare phase 구현체
- idle, prepare, check phase는 구현체가 똑같다.
uv__run_idle(loop);
uv__run_prepare(loop);
uv__run_check(loop);
//##name에 idle, prepare가 들어간다.
void uv__run_##name(uv_loop_t* loop) { \
uv_##name##_t* h; \
QUEUE queue; \
QUEUE* q; \
QUEUE_MOVE(&loop->name##_handles, &queue); \
while (!QUEUE_EMPTY(&queue)) { \
q = QUEUE_HEAD(&queue); \
h = QUEUE_DATA(q, uv_##name##_t, queue); \
QUEUE_REMOVE(q); \
QUEUE_INSERT_TAIL(&loop->name##_handles, q); \
h->name##_cb(h); \
} \
}
Poll phase
- 감시하는 phase이다.
- 새로운 request나 새로운 connection을 감시한다.
- event loop가 poll phase에 들어가면 watcher_queue에 있는 스크립트를 수행한다.
- 일정시간 대기하면서 감시하다가 이벤트루프가 poll phase에서 고갈되지 않도록 다음단계로 넘어간다.
- node를 웹서버로 사용할 수 있게해주는 phase이다. 이 단계에서 node가 계속 connection을 감시하고있기때문에 클라이언트에게 빠르게 응답이 가능하다.
- poll phase의 우선순위는 일단 poll queue에 있는 콜백이다. 이걸 먼저수행한다.
- poll queue가 비어있다면 setImmediate로 스케쥴링된 코드가 수행될때 check phase로 이동해 수행한다.
- setImmediate로 스케쥴링된 코드가 아니라면 알맞는 event queue에 등록한 후 poll phase에서 대기한다.
- poll phase에 있는 상태에서 만료된 timer가 있는지 감시하고 있다면 timer phase에서 수행한다.
5.uv_io_poll
uv_update_time called
// 대기중이다가
// timer가 만료되면!
uv_update_time called
6.uv_check
7.uv_close
uv_update_time called
3.uv_run timers called
uv_update_time called
uv_update_time called
timeoutFnc
Check phase
- idle, prepare와 구현체가 같다. 가변인자 매크로이다.
- poll phase에서 대기중이다가 setImmediate로 스케쥴링된 콜백이 들어오면 바로 check phase로 이동한다.
Close callback
static void uv__run_closing_handles(uv_loop_t* loop) {
uv_handle_t* p;
uv_handle_t* q;
p = loop->closing_handles;
loop->closing_handles = NULL;
while (p) {
q = p->next_closing;
uv__finish_close(p);
p = q;
}
}
댓글남기기