업데이트:

태그: ,

카테고리:

  • 시험기간 들어가기 전에 작성했던 글이었는데, 팀프로젝트 여러개와 겹치다보니 이제서야 마무리한다.
  • 웹서버가 수많은 요청을 처리하는 것이 그냥 그렇구나라고 알고있었는데, NodeJS를 조금이나마 깊게 공부하면서 웹서버들이 어떻게 수많은 요청을 처리할 수 있는지 알 수 있는 것 같다.

workflow

스크린샷 2022-11-04 오전 11 49 24

  1. Node로 스크립트를 실행(node script.js)
  2. V8엔진의 Ignition을 이용해 사용자의 script를 bytecode로 변환
  3. TurboFan으로 최적화
  4. V8 runtime에서 bytecode를 수행, 그리고 Node runtime이 I/O작업, 연산을 수행한다.
  5. 이때 발생하는 I/O, 비동기 작업은 NodeJS runtime이 libuv를 사용해 작업을 offload한다.
    • libuv 공식문서에 보면, libuv가 운영체제의 이벤트 수집, 모니터링을 하기때문에 사용자는 이벤트가 발생할때 호출할 콜백을 등록하기만 하면된다.
    • libuv는 해당 작업이 종료되면 이벤트 큐에 등록한다.
  6. 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
 */
  1. event loop가 poll phase에 들어간다.
  2. 아직 fs.readFile이 완료되지 않았으므로, 가장 빠른 timer(setTimeout의 100ms)까지 기다린다.
  3. 95ms에서 fs.readFile이 종료되고, 10ms가 소요되는 익명 callback함수가 poll queue에 넣어진 다음, 수행된다.
  4. 위 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;
  }
}

댓글남기기