티스토리 뷰

반응형

[※ 주의 ※] 아래를 이해하지 않고 이 글을 볼 경우, 이해가 되지 않는 부분이 있을 수 있습니다.

 

1. 2023.01.08 - [Javascript] - [JS / ECMAScript] 비동기 처리를 조금 더 효율적으로 해보자.

2. 2023.01.26 - [모두보기] - 동시성과 이벤트 처리 문제에 대한 탐구. - 0. GIL vs Proactor/Reactor

 


JavaScript는 기본적으로 싱글 쓰레드 언어이다. 즉, 쓰레드를 여러 개 열 수 없고 동시에 여러 개의 일을 처리하려면 새로운 런타임을 생성해야한다는 암시를 가진다. 그러나, Node.js는 I/O 작업이나 커널에 접근해야 하는 작업이 생긴다면 이를 비동기로 처리가 가능하다. 생각해보면 JS는 싱글 쓰레드인데 어떻게 서버로 괜찮은 성능이 나오는지 궁금하지 않는가? 어떻게 이게 가능할까? 

 

Event loop에 대하여 자세히 알아보자.

 

1. 어떠한 경우든 JS는 "절대" 쓰레드를 가지지 않는다.

제목을 이해하기 위해서는 Node.js의 구조를 알아야 하고, worker에 대한 개념을 탐구하여야 한다. Node.js 프로세스가 새로 생성된다는 것은, 다음 의미를 함축하고 있다.

 

  • 하나의 프로세스이다.
  • 하나의 쓰레드이다.
  • 하나의 Event loop를 가진다.
  • 하나의 JS 엔진 인스턴스를 가진다.
  • 하나의 Node.js 인스턴스를 가진다.

 

Node.js는 하나의 프로세스, 싱글 쓰레드이고 컴퓨터는 하나의 JS 엔진 인스턴스를 생성하여 JS 코드를 실행하고, Node.js 인스턴스를 생성하여 Node.js 코드를 실행한다.

여기서 가장 중요한 부분은 하나의 event loop을 생성한다는 부분이다. 하나의 코드는 하나의 event loop를 통해 한 번만 실행되며, 코드는 여러 개의 쓰레드로 돌지 않는다. 처음부터 싱글 쓰레드로 설계되었기도 하고, 브라우저 단에서는 굳이 쓰레드를 여러 개 열어 동작할 이유가 없기 때문이다. 그러나, 이러한 경우 경우 CPU 연산이 많이 필요한 작업을 할 경우, blocking 동작으로 인하여 다른 handler 동작에 영향을 줄 수 있다.

 

그래서, Node.js와 브라우저는 CPU 연산이 많은 blocking 작업을 외부의 쓰레드에 도움을 받기로 하였고, 이들을 worker, worker thread라고 칭한다. 이들은 고유한 자신만의 런타임, event loop를 가지며, 각자만의 JS 엔진 인스턴스를 가진다. 우리가 실행한 코드는 main thread에서 실행되며, 사용자가 worker thead에 비동기 task를 던져주면 thread pool 안의 worker thread가 비동기적으로 처리하여서 main thread에 작업 결과를 반환한다. 자세한 건 밑의 단원에서 알아보자.

 

2. Node.js Architecture

사실, Node.js Event loop 관련 여러 잘못된 정보 혹은 누락된 정보가 너무 많이 돌아다닌다. 가령, 다음과 같은 사진들이던가... 이러한 사진들을 힐난하는 건 아니지만, 그래도 오해를 불러일으키기 좋은 사진들이라 생각한다.

 

직접 Node.js의 Architecture를 다시 그리면 다음과 같이 그릴 수 있다. 이외에도 crypto, zlib과 같은 여러 컴포넌트들이 존재하지만 중요한 부분만 표시하였다. 

 

Node.js의 추상화된 Architecture

JS는 이벤트 처리시 reactor 패턴을 활용한다. Event Queue에 알맞은 이벤트를 쌓은 후, handler에 queue에서 이벤트를 가져가 이를 처리하는 방식을 사용한다. Node.js도 비슷하다. 하나의 스레드로 여러 비동기작업을 non-blocking하게 처리를 한다. 그리고, 그 기저에는 event loop가 활용된다. 

Node.js의 구조는 위와 같이 생겼다. Node.js는 C++로 작성되었고, Node.js 안에는 여러 개의 부품들이 담겨있다. 먼저, 크롬으로 인하여 알려진 엔진 V8이 담겨있고, C / C++로 작성된 코드와 JS간의 인터페이스과 같은 여러 인터페이스를 제공해주는 Node.js Bindings(Node API), 그리고 비동기 I/O 처리를 도와주는 libuv 라이브러리, 하나의 main event loop, 그리고 worker thread가 존재한다. Main event loop 안에는 event queue와 여러 개의 queue가 담겨있다.

 

2-1) JS code & V8 engine

Node.js에서는 사용자가 작성한 코드를 V8 JS엔진을 활용하여 컴파일한다. V8 엔진은 현재 크롬 브라우저, Node.js의 핵심 JS 엔진이며, 이번 글은 V8이 주제가 아니기에 V8 엔진의 low-level 부분은 탐구하지 않겠다. 다만, V8 엔진은 우리가 아는 call stack, memory heap와 관련된 곳이며, 바이트코드 생성 등 JS 코드 컴파일 관련 부분을 수행한다.

 

2-2) libuv

libuv의 추상화된 구조

이 중 우리가 유심히 지켜볼만한 곳은 libuv이다. 다시 말하지만, libuv는 비동기 I/O 처리를 도와주는 라이브러리이자 인터페이스이다. 즉 여러 종류의 I/O 를 도와주는 라이브러리이고, 쉽게 말해 I/O 관련 커널 함수들을 추상화해놓은 라이브러리라 생각하면 된다. 구체적으로 파일 시스템에 접근하거나, epoll, kqueue, IOCP에 접근하거나, TCP/UDP와 같은 네트워크 관련 I/O, IPC, DNS 접근, signal handling과 같은 작업 모두 libuv를 통해 쉽게 할 수 있다.

Node.js는 비동기 I/O 관련 항목들은 libuv를 통해 작업한다. libuv를 활용할 수 있으면 최대한 libuv의 도움을 받아 libuv가 대신 비동기 작업을 진행한다. libuv는 해당 작업의 종류를 확인한 다음, 커널이 지원하는 비동기 I/O 작업일 경우 커널에 해당 작업을 커널이 하도록 요청한다.

만약 도착한 작업이 file system 관련 작업이거나, DNS 관련 요청이거나, 사용자가 직접 쓰레드 풀에 작업하도록 요청하거나, CPU-bound 코드(ex. crypto)이면 libuv는 대신 내부 쓰레드 풀에 해당 작업을 해달라 요청한다. 이들은 non-pollable하거나, event loop의 동작을 막을 수 있는 가능성이 있는 작업들이기 때문이다. 해당 작업이 마무리 될 경우 쓰레드 풀에서 작업이 되었다면 작동한 쓰레드가 libuv에게 콜백을 통해 작업을 완료했음을 알리고, libuv는 Node.js에게 알린다. Asynchronous 하고 Non-blocking 한 방법을 활용했으며, event loop의 동작과도 잘 맞아 떨어지는 방법이다.

 

쓰레드 풀은 uv_io라는 이름으로 제공되며, 기본적으로 4개의 worker thread를 가지고 있다. 다만, Node.js를 처음 시작할 때 최대 1024개까지 생성이 가능해진다. (최신 버젼 기준 1024개까지 생성이 가능하고, 1.30.0 버젼 이하일 경우 128개까지 생성이 가능하다.).이 쓰레드 풀은 모든 event loop에 거쳐 공유되는 전역 worker이며, 만약 특정 함수가 쓰레드 풀을 활용하면 메모리를 최대한으로 사용하여 쓰레드를 한도까지 최대한 많이 생성하여 성능을 극대화한다.

 

쓰레드 풀에 요청할 때 uv_queue_work 함수 API를 활용해 event loop에 대응되는 쓰레드 풀에 request를 보낼 수 있으며 , 이후 worker thread가 queue에서 작업을 꺼내 request type과 작업의 내용을 보내고, 이후 콜백을 통해 해당 작업을 한 worker한테 결과를 받을 수 있다. 

 

2-3) The Event Loop

libuv의 Event Loop Diagram

싱글 스레드를 채택한 언어는 하나의 스레드 안에 여러 이벤트에 대한 핸들링을 해야하기에, 여러 이벤트 handling을 round robin 방법으로 처리한다. 즉, 여러 개의 handler queue를 만들고, 해당 차례일 때 handler queue를 하나씩 처리한다.

 

먼저, Node.js를 실행하면 Node.js 어플리케이션은 초기화를 진행하고, 모듈을 불러오고 이벤트 콜백 핸들러를 등록하면서 main event loop을 생성한다. 이후 JS 코드가 올라가면 V8 엔진에서 해당 JS 코드를 실행하고, 비동기 I/O 처리 코드 혹은 콜백 관련 비동기 요청을 만나면 뒷 단의 libuv 안에 event loop에 등록한다. 같은 방법으로 모든 JS 코드를 끝까지 실행시킨다. 다시 말해 일단 모든 JS 코드를 본 이후 event loop를 처리한다는 의미이다.

 

let a = 1;
let b = 2;
let c = a+b;

 

만약 위 코드 같이 아무 이벤트도 event loop안에 포함되지 않는다면, Node.js는 event loop을 없애 그대로 런타임을 종료한다.

 

const fs = require('fs');

function someAsyncOperation(callback) {
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
  const delay = Date.now() - timeoutScheduled;

  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);

setImmediate(() => {
  const startCallback = Date.now();

  // do something that will take 10ms...
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

 

만약 HTTP 요청을 하거나, setTimeout과 같은 timer 관련 함수를 호출하거나, process.nextTick()과 같은 함수들이 실행되었다면, event loop안의 queue안에 카테고리에 맞게 안에 등록이 된 다음, event loop가 시작된다.

Node.js의 Event Loop 실행 순서

Event Loop 안에는 5종류의 queue가 존재한다. 또한, 각 queue는 자신의 차례가 되었을 경우에 실행되며, 순서는 위 사진과 같은 순서를 따른다. 구체적으로 timer queue -> pending callbacks queue -> (idle, prepare) -> poll queue -> check queue -> close callbacks queue -> timer queue ... 와 같이 보통 무한히 돌아가는 round robin 형식을 채택한다. 또한, 다음 queue로 넘어가는 것을 하나의 Tick이 흘렀다고 표현한다.

 

다만, OS 별로 구현되어 있는 방법이 미묘하게 살짝 다르고, 여기에 작성되지 않은 다른 단계들이 있지만 중요하지 않기에 Node.js 측도 무시하라고 한다. 실제로는 7~8단계를 거친다고 한다.

 

int uv_run(uv_loop_t* loop, uv_run_mode mode) {
  int timeout;
  int r;
  int can_sleep;

  r = uv__loop_alive(loop); // check if loop is alive
  if (!r) uv__update_time(loop); // if loop is alive, update time

  while (r != 0 && loop->stop_flag == 0) { // infinite loop until event loop stops
    uv__update_time(loop); // update timers
    uv__run_timers(loop); // timer phase

    can_sleep = QUEUE_EMPTY(&loop->pending_queue) && QUEUE_EMPTY(&loop->idle_handles);

    uv__run_pending(loop); // Pending Callbacks phase
    uv__run_idle(loop); // Idle Phase
    uv__run_prepare(loop); // Prepare Phase

    timeout = 0;
    if ((mode == UV_RUN_ONCE && can_sleep) || mode == UV_RUN_DEFAULT)
      timeout = uv__backend_timeout(loop);

    // ...

    uv__io_poll(loop, timeout); // Poll phase

    for (r = 0; r < 8 && !QUEUE_EMPTY(&loop->pending_queue); r++)
      uv__run_pending(loop); // Do immediate callbacks to prevent event loop starvation.

    uv__metrics_update_idle_time(loop); // ahandle IO poll related edge cases.

    uv__run_check(loop); // Check Phase
    uv__run_closing_handles(loop); // Close Callbacks phase.

    if (mode == UV_RUN_ONCE) { // if loop runs once, handle timers
      uv__update_time(loop);
      uv__run_timers(loop);
    }

    r = uv__loop_alive(loop); // check loop is alive
    if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
      break;
  }

  if (loop->stop_flag != 0) // if stop_flag is set, unset flag, making GCC cache happy.
    loop->stop_flag = 0;

  return r;
}

 

실제 event loop을 굴리는 함수인 uv_run 함수 코드를 살펴보면, 공식 문서대로의 phase 순서대로 함수 순서가 정해져 있고, 코드를 round robin 형태로 처리하는 것을 살펴볼 수 있다. 또한 중간에 poll phase에 작은 pending callback phase가 존재하는 것을 살펴볼 수 있고, 빠른 시간 내로 처리할 수 있는 콜백 함수들만 처리한다.

 

Node.js는 각 queue를 처리할 때 FIFO(First in, First Out) 방식을 사용한다. 먼저 등록된 작업이 먼저 처리된다. Queue 안에 있는 모든 작업이 처리되거나, 일정 threshold가 끝나거나, queue에 작업이 너무 많이 쌓여 있다면 다음 queue로 넘어간다.

 

2-3-1) Timer Phase

 

Timer phase는 Node.js에서 첫 번째 phase로, 시간 관련 함수들의 콜백을 실행할 지를 처리하거나 낮은 확률로 콜백을 처리하는 phase이다. Node.js에서 제공하는 시간 함수는 setTimeout, setInterval과 같은 작업들이며, 해당 작업들에 대한 콜백을 poll queue에 넣을 지 event loop이 확인한다. 만약 이례적인 상황이 발생한다면 poll phase에서 처리하지 않고 직접 timer phase에서 처리한다.

libuv는 uv_timer 관련 API 및 여러 type들을 제공하며, 실제 uv_run 함수에서 timer phase와 관련된 함수는 uv__update_time과 uv__run_timers이다.

uv__update_time는 시간 단위를 변한하는 코드로, 시간 단위를 ms 단위로 통일시켜주는 역할을 한다.

실제 uv__update_time은 다음 코드로 구현되어 있다.

UV_UNUSED(static void uv__update_time(uv_loop_t* loop)) {
  /* Use a fast time source if available.  We only need millisecond precision.
   */
  loop->time = uv__hrtime(UV_CLOCK_FAST) / 1000000;
}

 

uv__hrtime에는 Unix인지 Windows인지에 따라 다르게 구현되어 있는 OS-depenedent 함수이다.

 

두 번째 함수는 uv__run_timers로, 다음과 같은 코드로 이루어져 있다.

void uv__run_timers(uv_loop_t* loop) {
  struct heap_node* heap_node;
  uv_timer_t* handle;

  for (;;) { // infinite loop
    heap_node = heap_min(timer_heap(loop)); // get timer from min_heap
    if (heap_node == NULL) // if no timer in heap, break the loop
      break;

    handle = container_of(heap_node, uv_timer_t, heap_node);
    if (handle->timeout > loop->time) // if timer exceeds threshold, break the loop.
      break;

    uv_timer_stop(handle); // remove the timer from heap.
    uv_timer_again(handle); // handle repeat case.
    handle->timer_cb(handle); // register callback to poll phase
  }
}

흥미로운 부분은 heap_node를 꺼내오는 부분으로, timer 관련으로 등록된 함수들은 min-heap 자료구조로 저장되어 있는 것을 확인할 수 있다. min-heap 구조는 O(logN) 시간복잡도를 가지기에, 빠른 실행을 보장하기 위한 자료구조라 할 수 있다.

 

timer_heap안에서 등록된 timer을 꺼낼 때, 해당 delay가 가장 작은 timer를 가장 꺼낸다. 가령 setTimeout(cb, 100)이라는 함수를 호출함에 따라 timer에 넣는다면, 세 가지 변수에 의해 해당 콜백을 동작할 지 안 할지 결정한다.

 

  1. currentTime (현재 시간)
  2. registeredTime (등록된 시간)
  3. delay(지연 시간), 여기서는 100ms이다.

이때, heap_node 안의 struct를 통해 registeredTime과 delay정보를 얻고, 현재 시간 current Time을 비교하여 현재 콜백을 등록할 수 있는지 if 문을 통해 작성한다. 즉, if문을 다음과 같은 쉽게 알아볼 수 있게 바꾸어 쓸 수 있다.

 

if (registeredTime + delay > currentTime) {
  break;
}

 

즉, registeredTime + delay가 currentTime보다 작으면 해당 timer를 poll phase에 콜백으로 등록할 수 있다는 의미이다. 이 경우 timer를 업데이트 하며, Timer phase에 머무르게 된다. 다만, Node.js와 OS에 따른 hard limit으로 인하여 다음 phase로 넘어갈 수 있고, 정확한 실행 시간은 보장할 수 없다. setTimeout의 delay를 100으로 설정하여도, 100ms 뒤에 실행된다는 보장을 할 수 없는 일. 보장 할 수 있는 것은 적어도 100ms 뒤에 실행할 수 있다는 것이다.

 

2-3-2) Pending Callbacks Phase 

 Pending callbacks phase는 이전 event loop iteration에서 처리하지 못한 I/O 콜백들을 수행한다. 만약 OS-dependent hard limit로 인하여 처리하지 못한 콜백들을 처리하는 곳이다. 실제 libuv의 코드에선 uv__run_pending으로 구현되어 있으며 주석을 달면 다음과 같다.

static void uv__run_pending(uv_loop_t* loop) {
  QUEUE* q;
  QUEUE pq;
  uv__io_t* w;

  QUEUE_MOVE(&loop->pending_queue, &pq); // move pending callbacks to queue pq.

  while (!QUEUE_EMPTY(&pq)) { // while queue is not empty
    q = QUEUE_HEAD(&pq); // get head item of queue.
    QUEUE_REMOVE(q); // remove item from queue
    QUEUE_INIT(q); // cleanup relations from queue.
    w = QUEUE_DATA(q, uv__io_t, pending_queue); // pass item to worker thread queue
    w->cb(loop, w, POLLOUT); // register callback
  }
}

loop->pending_queue에서 이전에 처리못한 작업들을 가져오며, 이를 임시로 Queue 구조 pq에 담고 pq가 빌때까지 하나씩 worker thread에 던져준다.

 

2-3-3) Idle & Prepare Phase

 

Idle & Prepare phase는 아무런 JS 코드를 실행하지 않는다. Node.js도 내부적으로만 사용한다. 다만, idle이라는 이름답지않게 idle callback과 prepare callback을 실행한다. 쉽게 말해 시스템에게 idle 상태를 피드백한다고 할 수 있다. 이 phase는 또한 process.nextTick과 같은 함수에 영향을 받는다.

 

2-3-4) Poll Phase

poll phase의 여러 가지 구현

Poll phase는 poll queue에 담긴 작업들을 처리하고, I/O blocking, polling 관련 작업들을 처리한다. 이벤트 루프가 poll phase에 들어온다면, I/O 관련 입력들을 다룬다. uv__io_poll이라는 함수를 활용하며, OS에 따라 같은 이름의 여러 종류의 API를 구현해놓았다.

공통적으로 watcher_queue 이름을 가진 queue 자료구조를 사용하여 I/O 관련 콜백들을 모두 저장한다. 이 phase에 진입할 때다른 phase queue에 저장되는 timer 관련 함수, close 관련 함수를 제외하면 모두 poll queue에 저장되며, 대부분의 시간이 여기에 할당되고 hard limit도 다른 phase에 비해 널널하다.

이 경우 I/O를 사용하기에 OS에서 제공하는 소켓을 사용한다. 각 소켓에 대한 정보와 연결된 file descriptor를 가진 watcher가 watcher_queue에 저장되어 있다. 이후 fd의 return 값을 통해 파일 정보에 관한 signal을 보내고, event loop는 watcher를 통해 각 fd 배열마다 for문으로 순회하여 fd의 결과 및 signal watcher에 따라 알맞게 핸들링을 한다. 

 

Node.js에서의 poll phase는 다른 phase과 다르게, 단순하게 동작하지 않는다. 몇 가지에 따라 분기점이 결정되어, 해당 phase에서 대기할 지 다음 phase로 넘어갈지 결정한다. 이때 대기시간은 timeout이라는 변수에 담기며, 여러 경우에 따라 timeout의 값을 직접 변경한다. 정말 많은 분기점으로 결정한다. (300줄 부터 Linux에서 사용하는, epoll과의 통신은 다음github 링크와 같다.)

간단히 요약하면, 다음과 같다.

 

  • 이벤트 루프 종료 시 다음 phase로 이동
  • close callbacks phase, pending callbacks phase queue에 작업이 있다면 다음 phase로 이동
  • Timer phase에 즉시 실행할 수 있는 타이머가 있다면 바로 다음 phase로 이동.
  • Timer phase에 n초 이후 실행 가능 타이머가 있다면 n초 후 다음 phase로 이동.

timeout 이름에 의하여 오해할 수 있는 부분은 대기 시간이 아닌, I/O block 시간이다. 싱글쓰레드 환경이기에 이벤트를 처리하는 동안 I/O 요청을 막아야 하며, fd를 처리한다. linux의 epoll의 경우 timeout 값에 따라 반환값이 달라진다.

 

  • timeout = 0일 경우 완료된 이벤트의 수를 반환한다.
  • timeout > 0일 경우 완료된 I/O 요청없다면 요청이 생길때까지 block하고, I/O 요청이 완료되거나 모든 I/O 요청이 완료되지 않고 timeout이 지나면 완료된 이벤트의 수를 반환한다.
  • timeout < 0일 경우 I/O 요청이 완료될 때까지 대기한다. epoll의 경우 max_safe_time의 값은 1789569이고, 최대 30분이다.

이외에도 OS, file descriptor에 따라 구현 방법이 다르며, 세부 구현은 각 함수에 따라 확인해야한다.

 

2-3-5) Check Phase

 

Check phase는 setImmediate 전용 queue이다. setImmediate의 경우 poll queue가 비어있고 setImmediate 콜백을 실행한다.

Node.js 공식 문서에서 process.nextTick과 setImmediate을 활용한 콜백을 비교하는데, 두 차이는 다음과 같다.

 

  • process.nextTick의 경우 즉시 실행된다.
  • setImmediate는 여러 틱에 거쳐 Check Phase에 돌아오면 실행한다.

 

uv__run_check라는 함수를 통해 구현되어 있다. libuv 안에는 uv__run_check 함수가 따로 생성되어 있지 않고, loop-watcher.c라는 파일에서 생성해서 사용한다.

 

2-3-6) Close Callbacks Phase

 

Close Callbacks Phase는 close 관련 작업을 처리하는 phase이다. 예를 들어 소켓 close나 handle close 관련 작업들이다. 실제 코드는 다음과 같다.

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;
  }
}

하나의 linked list 형태로 이루어져있다고 추정할 수 있고, head부터 순회하며 close를 uv__finish_close 함수로 처리하는 것을 할 수 있다. uv__finish_close 함수는 다음과 같다.

static void uv__finish_close(uv_handle_t* handle) {
  uv_signal_t* sh;

  assert(handle->flags & UV_HANDLE_CLOSING);
  assert(!(handle->flags & UV_HANDLE_CLOSED));
  handle->flags |= UV_HANDLE_CLOSED;

  switch (handle->type) {
    case UV_PREPARE:
    case UV_CHECK:
    case UV_IDLE:
    case UV_ASYNC:
    case UV_TIMER:
    case UV_PROCESS:
    case UV_FS_EVENT:
    case UV_FS_POLL:
    case UV_POLL:
      break;

    case UV_SIGNAL:
      sh = (uv_signal_t*) handle;
      if (sh->caught_signals > sh->dispatched_signals) {
        handle->flags ^= UV_HANDLE_CLOSED;
        uv__make_close_pending(handle);  /* Back into the queue. */
        return;
      }
      break;

    case UV_NAMED_PIPE:
    case UV_TCP:
    case UV_TTY:
      uv__stream_destroy((uv_stream_t*)handle);
      break;

    case UV_UDP:
      uv__udp_finish_close((uv_udp_t*)handle);
      break;

    default:
      assert(0);
      break;
  }

  uv__handle_unref(handle);
  QUEUE_REMOVE(&handle->handle_queue);

  if (handle->close_cb) {
    handle->close_cb(handle);
  }
}

위에 코드를 살펴보면 case에 따라 close 관련 적절하게 처리하는 것을 볼 수 있다. 또한, 마지막에 close 콜백이 있다면 이에 관하여 처리하는 것을 볼 수 있다.

 

Event loop는 위와 같은 phase를 반복하며 작업을 계속 처리하고 있다.


2-4) MicroTaskQueue, NextTickQueue

 

Node.js는 위의 queue 이외에서 event loop가 아닌 다른 message queue들을 가지고 있고, 두 개의 message queue인 MicroTaskQueue, NextTickQueue가 존재한다. 두 queue는 libuv에 포함되어 있지 않고, MicroTaskQueue는 resolved된 프로미스, NextTickQueue는 process.nextTick의 콜백을 가지고 있다. nextTickQueue와 microTaskQueue는 event loop의 phase와 상관 없이 수행 중인 작업이 끝나자마자 call stack로 넣는다. Node.js v11.0.0 이전 버전에는 phase 변화가 생길 때 nextTickQueue, microTaskQueue를 확인하였다.

 

두 queue는 OS hard limit의 제한을 받지 않기에 이 queue가 비워질때까지 순차적으로 처리하며, nextTickQueue 안의 작업들이 더 우선순위가 높이 처리된다.

 

V8 엔진에서 microTaskQueue에 담긴 작업들을 실행하는 함수인 RunMicrotasks는 다음과 같다.

 

int MicrotaskQueue::RunMicrotasks(Isolate* isolate) {

  if (!size()) {
    OnCompleted(isolate);
    return 0;
  }
  
  intptr_t base_count = finished_microtask_count_;
  HandleScope handle_scope(isolate);
  MaybeHandle<Object> maybe_exception;
  MaybeHandle<Object> maybe_result;
  int processed_microtask_count;
  {
    SetIsRunningMicrotasks scope(&is_running_microtasks_);
    v8::Isolate::SuppressMicrotaskExecutionScope suppress(
        reinterpret_cast<v8::Isolate*>(isolate));
    HandleScopeImplementer::EnteredContextRewindScope rewind_scope(
        isolate->handle_scope_implementer());
    TRACE_EVENT_BEGIN0("v8.execute", "RunMicrotasks");
    TRACE_EVENT_CALL_STATS_SCOPED(isolate, "v8", "V8.RunMicrotasks");
    maybe_result = Execution::TryRunMicrotasks(isolate, this, &maybe_exception);
    processed_microtask_count =
        static_cast<int>(finished_microtask_count_ - base_count);
    TRACE_EVENT_END1("v8.execute", "RunMicrotasks", "microtask_count",
                     processed_microtask_count);
  }
  // If execution is terminating, clean up and propagate that to TryCatch scope.
  if (maybe_result.is_null() && maybe_exception.is_null()) {
    delete[] ring_buffer_;
    ring_buffer_ = nullptr;
    capacity_ = 0;
    size_ = 0;
    start_ = 0;
    isolate->SetTerminationOnExternalTryCatch();
    OnCompleted(isolate);
    return -1;
  }
  DCHECK_EQ(0, size());
  OnCompleted(isolate);
  return processed_microtask_count;
}

코드 가운데 Execution::TryRunMicrotasks를 통해 실제 작업을 수행하여, 이후에 작업한 microtask를 반환하는 것을 확인할 수 있다.

 

3. 대표적인 오해들

 

Node.js의 Event loop에 대한 잘못된 정보가 널리 퍼져있고, 오해하기 쉬운 부분들이 많다보니 따로 정리하였다. 오개념 잡기 스타트.

 

1) Event loop는 libuv만의 특별한 기능이다. & 모든 event loop는 같은 꼴을 띤다. & Event loop는 JS 엔진 내부에 존재한다.

Event loop는 싱글쓰레드 언어와 궁합이 잘 맞는, 하나의 프로그래밍 패러다임이며 구현 방법일 뿐이다. V8 엔진, libuv 모두 event loop가 구현이 되어있다. V8 엔진의 경우 event loop를 사용하며, 이는 브라우저 내에서의 비동기 I/O 처리 스케쥴링때 사용한다. Node.js의 경우 libuv가 제공하는 event loop을 사용할 뿐이다. 어떻게 event loop를 구현했는지는 차이가 존재한다. 당장 V8 엔진의 event loop와 libuv의 event loop에서 phase 종류에도 차이가 존재한다. Webkit 엔진의 event loop와도 차이가 있다. 헷갈리지 말자.


2) Event loop는 JS의 코드와 별도로 작동하는, 새로운 쓰레드이다.

JS는 어떠한 경우든 싱글쓰레드이며, 단일 쓰레드에서 event loop가 작동한다. 세상에 싱글쓰레드이면서 멀티쓰레드인 프로그래밍 언어는 없다. 멀티쓰레드인 것처럼 착시현상을 일으키는 것이다. 이 착시현상이 가능케 하는 방법이 event loop이다.

다음과 같은 코드를 Node.js에서 실행하면 어떤 결과가 나오는지 관찰을 하여보아라.

setTimeout(() => {
  console.log("runs in separate thread?");
 }, 1000);
 
 while (true) {}

만약 event loop가 별개의 thread에서 작동한다면 console.log가 작동이 되겠지만, 실제로는 무한루프에 의하여 아무것도 출력이 되지 않는 것을 확인할 수 있다.

쓰레드가 여러 개 라는 것은 JS가 아닌, Node.js 내부에 프레임워크와 라이브러리, 컴포넌트의 이야기이다. 용어와 맥락을 정확히 파악하자.


3) Timer는 OS API를  활용한다. & setTimeout, setInterval은 정해진 delay에 실행 / 반복실행된다.

Timer들은 min heap 자료구조로 libuv 안에서 관리된다. 따라서 setTimeout, setInterval은 정확한 시간에 작동하지 않는다. 이에 대한 원리와 설명은 "Timer Phase" 부분을 읽길 바란다.


4) 모든 비동기 I/O 작업은 Thread pool에서 처리한다.

Node.js는 비동기 I/O 관련 항목들은 libuv를 통해 작업한다. libuv를 활용할 수 있으면 최대한 libuv의 도움을 받아 libuv가 대신 비동기 작업을 진행한다. 만약 도착한 작업이 file system 관련 작업이거나, DNS 관련 요청이거나, 사용자가 직접 쓰레드 풀에 작업하도록 요청하거나, CPU-intensive 코드이면 libuv는 대신 내부 쓰레드 풀에 해당 작업을 해달라 요청한다. 쉽게 말해 blocking intensive(I/O-intensive, CPU-intensive) 작업은 event loop을 막을 가능성이 크기에 쓰레드 풀에 넘기는 것이다.


5) libuv의 Thread Pool은 Node.js의 worker_threads 모듈이다. & Node.js의 worker_threads모듈로 쓰레드를 추가하면 libuv의 thread pool이 증가하는 것이다. & Node.js에는 비동기 작업을 관리하는 별도의 쓰레드 풀이 있다.

둘은 별개이다. libuv의 thread pool은 libuv 내부에서 사용하는 쓰레드이며, Node.js가 기본으로 제공하는 worker_threads 모듈을 활용한 멀티쓰레딩은 Node.js 런타임을 새로 만드는 것이다.  다음 그림을 통해 어떤 차이가 있는지 이해하여보자. worker_threads 모듈로 새로운 스레드를 만드는 것은 런타임을 여러 개 생성하고, 각자만의 V8 엔진과 libuv를 가진다.

Worker Threads

다만, Node.js에서 칭하는 쓰레드 풀은 libuv 내부의 쓰레드 풀이다. 헷갈리지 말자.


6) Event loop는 자신만의 call stack, memory heap을 가진다.

Call stack과 memory heap은 V8 엔진에 존재하며, event loop는 그 어떤 것도 가지고 있지 않고 스케쥴러에 가깝다. 다만 스케쥴링 정책이 round robin으로 고정되어있다. 


7) Event loop는 스택, 큐와 같은 자료구조이다.

Event loop 내부에는 여러 개의 메시지 큐를 사용하지만, 하나의 자료 구조가 아닌 phase의 집합과 동작이라고 생각하는 편이 맞다.


8) Event loop로 인하여 실행되는 콜백 큐는 FIFO이므로, 실행 순서를 보장할 수 있다.

실행 순서는 큐의 순서에 맞추어 FIFO로 실행되지만, 언제 큐에 등록되느냐에 따라 실행 순서가 보장되지 않는다. 비동기 처리 방식이므로, 언제 큐에 등록되는지는 시스템과 같은 여러 변수들에 의해 달라질 수 있다.


9) Event loop안에 microTaskQueue, nextTickQueue가 존재한다.

둘의 개념은 V8엔진으로부터 왔고, event loop와는 상관이 없다. 다만, event loop의 작업을 한 번 끝내고(tick 포함), 이 들을 처리한다.

 

 

반응형
댓글
Total
Today
Yesterday
공지사항
최근에 올라온 글
최근에 달린 댓글
링크
«   2024/05   »
1 2 3 4
5 6 7 8 9 10 11
12 13 14 15 16 17 18
19 20 21 22 23 24 25
26 27 28 29 30 31
글 보관함