빨간색코딩

nodejs의 내부 동작 원리 (libuv, 이벤트루프, 워커쓰레드, 비동기) 본문

node.js

nodejs의 내부 동작 원리 (libuv, 이벤트루프, 워커쓰레드, 비동기)

빨간색소년 2018. 4. 20. 00:29

참조문서

이벤트루프는 nodejs 의 핵심개념인데, 열심히 파헤쳐보자. 먼저 구글에 널려있는 많은 아키텍처들은 전부 틀렸다고 한다. 이 말은 libuv의 핵심 개발자 Bert Belder 등이 했다고 참조문서에서 말한다. 아무튼 그래서 구글링을 열심히해봐도 공식문서에 부합하는 아키텍쳐 그림이 없었다. nodejs 만든사람이 하나 그려줬으면 좋겠지만.... 일단 직접 그려보았다.

아래는 거짓이라고 말한 사진들이다. 널리 통용되고있어서 구글이미지검색에서도 상위에 있는 것들..


1. 이벤트루프가 왜 중요한가?

이벤트루프는 메인스레드 겸 싱글스레드로서, 비즈니스 로직을 수행한다. 수행도중에 블로킹 IO작업을 만나면 커널 비동기 또는 자신의 워커쓰레드풀에게 넘겨주는 역할까지 한다. 보통 웹어플리케이션에서 쓰니, 웹으로 예를 들자면 request 가 들어오면 라우터태우기, if문분기, 반복문돌며 필터링, 콜백내부로직 등은 이벤트루프가 수행하지만 DB에서 데이터를 읽어오거나(DB드라이버 개발자가 물론 비동기타게 짜야..) 외부 API콜을 하는 것은 커널 비동기 또는 자신의 워커쓰레드가 수행한다. 동시에 많은 요청이 들어온다해도 1개의 이벤트루프에서 처리한다. 따라서 JS로직(if분기, 반복문 등)이 무겁다면 많은 요청을 처리해내기 힘들 것이다. (실제로는 여러개 node인스턴스들이 떠서 상관없지만, 여기선 1개의 node인스턴스로 생각) 따라서 이벤트루프가 블로킹되는 것은 만병의 근원이다. 예를들어 while(true) {} 가 중간에 있으면 nodejs웹서버는 요청조차 받지 못한다. 또한 GC조차 이벤트루프에서 돌고 있으니, 이벤트루프가 바쁘면(= cpu인텐시브 작업) 메모리가 부족해서 뻗을 것이다.

2. libuv는 어떻게 동작하는가?

libuv는 윈도우 커널, 리눅스 커널을 추상화해서 wrapping하고 있다. nodejs는 기본적으로 libuv 위에서 동작하며, node 인스턴스가 뜰 때, libuv에는 워커 쓰레드풀(default 4개)이 생성된다. 위에서 블로킹 작업(api콜, DB Read/Write 등)들이 들어오면 이벤트루프가 uv_io에게 내려준다고 하였다. libuv는 커널단(윈도우의 경우 IOCP, 리눅스는 AIO)에서 어떤 비동기 작업들을 지원해주는지 알고 있기때문에, 그런 종류의 작업들을 받으면, 커널의 비동기함수들을 호출한다. 작업이 완료되면 시스템콜을 libuv에게 던져준다. libuv 내에 있는 이벤트루프에게 콜백으로서 등록된다.

libuv의 워커쓰레드는 커널이 지원안하는 작업들을 수행한다. 대표적인 예로 소켓 작업류는 커널들이 이미 비동기로 지원하지만, 파일시스템쪽 작업은 지원하지 않는데(정확히는 지원하지만 libuv에서 추상화 문제로 안쓴다고 함) 이럴때 libuv의 쓰레드가 쓰인다.

코드레벨에서 확인하기

2-1. 파일시스템 작업이 커널 비동기작업으로 진짜 안 동작해?

  • deps/uv/src/unix/core.c 448라인 : FILE* uv__open_file(const char* path) 에서 fd = uv__open_cloexec(path, O_RDONLY);
  • 963 라인 : int uv__open_cloexec(const char* path, int flags) 에서 fd = open(path, flags | UV__O_CLOEXEC);
  • open() 은 C 라이브러리 함수가 아닌 리눅스 커널단의 함수이다. O_NONBLOCK 옵션을 파라미터로 넣어주지 않았으니 동기 방식으로 동작한다.

2-2. 소켓 관련 작업은 커널 비동기작업으로 진짜 동작해?

  • deps/uv/src/unix/core.c 411라인 : int uv__socket(int domain, int type, int protocol) 에서 sockfd = socket(domain, type | SOCK_NONBLOCK | SOCK_CLOEXEC, protocol);
  • 소켓을 논블로킹으로 얻어오고 있다.

3. 이벤트루프의 내부 동작과정

3-1. 이벤트루프의 phase들

이벤트루프는 몇 개의 phase 들로 구성되어 있다. 각 phase 들은 FIFO 큐를 가지고 있으며, 이 큐에는 특정 이벤트의 콜백들을 넣고, CPU가 할당(=이벤트루프가 해당 phase를 호출할때)될 때 실행한다. 아래는 phase들이다.

  • timers
    • setTimeout()과 setInterval() 과 같은 타이머 콜백들이 처리된다.
    • 코드를 보면 이벤트루프가 uv__run_timers() 호출할때 타이머 콜백들을 받고, 실제 유저로직은 timer_cb인데, 이걸 poll 큐에 등록해버린다.
    • 따라서 타이머 콜백 내부로직들은 poll큐에 먼저 등록된 콜백들이 처리되고 나중에 처리될 수도 있으므로, 파라미터로 지정한 시간에 딱 실행됨을 보장하지 못한다. 즉, 파라미터는 일정 시간 이후에 실행된다는 기준 시간같은 셈이다.
    • ex. setTimeout(?, 100) 은 100ms 이후 언제 실행될지 모름. (poll 큐가 비어있다면 100ms 후 딱 실행되겠지만..)
  • I/O callbacks: 클로즈 콜백, 타이머로 스케줄링된 콜백, setImmediate()를 제외한 거의 모든 콜백들을 집행
    • http, apiCall, DB read 등..
    • 이것 역시 작업완료는 이벤트루프가 I/O callbacks 영역을 호출(uv__run_pending())할 때 체크할 수 있지만, 이후에 콜백이 poll 큐에 등록되므로, 이벤트루프가 poll 영역을 처리할때 콜백 내부로직이 실행된다.
  • idle, prepare: 내부용으로만 사용 (모든 큐가 비어있으면 idle이 되면서 tick frequency가 떨어짐=할일도없으니 이벤트루프가 천천히 돈다고 한다.. 확인안해봄)
  • poll
    • 이벤트루프가 uv__io_poll() 를 호출했을때 poll 큐에 있는 이벤트, 콜백들을 처리
    • 만약 cpu를 할당받았을 때
      • poll 큐가 비어있음 : setImmediate()가 있으면 check로 넘어감. 없으면 이벤트루프가 phase를 돌며 콜백을 무한히 기다림 => 공식문서에 이렇게 써있는데 코드를 보면 당연한 이야기다. poll 이 끝나면 uv__run_check()가 호출된다.
      • poll 큐에 뭐가있음 : 이벤트루프가 큐를 순회하며 처리함.
  • check: setImmediate() 콜백은 여기서 호출되고 집행
  • close callbacks: .on('close', ...) 같은 것들이 여기서 처리됨

에제

fs.readFile(__filename, () => {
    setTimeout(() => {
        console.log('A');
    }, 0);
    setImmediate(() => {
        console.log('B');
    });
});

위 코드를 실행하면 이벤트루프에서는 아래와 같은 순서로 동작한다.

  1. fs.readFile 라는 블로킹작업을 만난 시점에 이벤트루프는 워커쓰레드에게 작업을 넘김
  2. 워크쓰레드가 작업을 완료한 뒤 I/O callbacks 영역의 큐에 콜백을 등록
  3. 이벤트루프가 I/O callbacks 영역을 실행할 때, 콜백을 poll 영역의 큐에 등록
  4. 이벤트루프가 poll 영역을 실행할 때, 큐에 1개가 있으므로 이걸 실행함.
  5. (콜백내부) 2라인에서 setTimeout() 이므로 다시 timers 영역에 넣고 5라인으로 간다.
  6. (콜백내부) 5라인에서 setImmediate() 이므로 check 영역에 넣는다.
  7. 이벤트루프가 poll 큐를 비우고, 다음 실행영역인 check 영역으로 간다. check 영역의 큐에는 들어있는 'B'를 콘솔에 찍는다. check 영역의 큐를 비우고 다시 while문의 시작지점으로 간다.
  8. 이벤트루프가 timers 영역을 호출한다. uv__run_timers()는 setTimeout()의 콜백을 poll큐에 등록한다.
  9. 이벤트루프가 2번째로 poll 영역을 실행한다. 큐에 1개가 있으므로 이걸 실행하고 'A'를 찍는다.
  10. node 프로세스가 반환되고 끝

예제2

setTimeout(() => {
    console.log('A');
}, 0);
setImmediate(() => {
    console.log('B');
});

위 코드를 실행하면 이벤트루프에서는 아래와 같은 순서로 동작한다.

  1. setTimeout() 를 만나면 timers 영역에 넣고 4라인으로 간다.
  2. setImmediate() 를 만나면 check 영역에 넣는다.
  3. 프로세스의 기분과 상태 등에 따라 랜덤하지만, timers 가 이벤트루프의 은총을 받으면 먼저 poll 큐에 등록되고 timers 다음은 poll 이니까 'A'가 찍힐 것이다. 하지만 timers 를 지나쳤을 경우, check 영역이 호출되므로 'B' 가 찍힌다.

3-2. 코드레벨에서 확인

  • nodejs 인스턴스가 뜨면 start 가 호출되는데, V8 컨텍스트 등을 만든다. 이 중에 이벤트루프와 관련된 것은

  • src/node.cc 4477라인 : inline int Start (Isolate* isolate, IsolateData* isolate_data, int argc, const char* const* argv, int exec_argc, const char* const* exec_argv)

  • 4512 라인에서 do-while 문이 있는데 uv_run(env.event_loop(), UV_RUN_DEFAULT); 를 호출

  • deps/uv/src/unix/core.c 348 라인 : int uv_run(uv_loop_t* loop, uv_run_mode mode) 에서 무한 while 문이 핵심

      while (r != 0 && loop->stop_flag == 0) {
          uv__update_time(loop); // loop time 갱신
          uv__run_timers(loop); // timers 이벤트 처리
          ran_pending = uv__run_pending(loop); // IO callbacks 이벤트큐 처리
          uv__run_idle(loop);
          uv__run_prepare(loop);
    
          timeout = 0;
          if ((mode == UV_RUN_ONCE && !ran_pending) || mode == UV_RUN_DEFAULT)
              timeout = uv_backend_timeout(loop);
    
          uv__io_poll(loop, timeout); // poll 이벤트큐 처리
          uv__run_check(loop); // check 이벤트큐 처리
          uv__run_closing_handles(loop); // close 이벤트큐 처리
    
          r = uv__loop_alive(loop); // 이벤트루프 상태체크
          if (mode == UV_RUN_ONCE || mode == UV_RUN_NOWAIT)
              break;
      }
    
  • 대표적으로 몇가지 phase들을 확인해보면 uv__run_pending() 는 자신의 QUEUE* q 를 가지고 있고, 이벤트루프에서 호출했을때 while (!QUEUE_EMPTY(&pq)) 로 처리한다. 처리가 끝난 건 w->cb(loop, w, POLLOUT); 콜백으로 넘겨줌

  • uv__io_poll() 도 마찬가지로 자신의 QUEUE* q; 를 가지고 있었고, while (!QUEUE_EMPTY(&loop->watcher_queue)) 로 처리한다.

4. node.js korea 커뮤니티 이야기

node.js korea 에 해당 내용을 공유하였는데, 여기서 진행했던 QnA도 공유하면 좋을것 같아 추가한다.

Q1. JS 비즈니스로직은 v8엔진에 의해 별개로 진행된다고 생각하고 있었는데, 혹시 어디서 실행되는가?

nodejs는 v8 api, libuv api를 이용하여 코어단(코드 = node/src)을 구현한다. v8과 libuv는 각각 별개로 움직이지 않는다. 위에서 언급했듯이 nodejs는 하나(싱글스레드)의 이벤트루프로만 동작한다. node인스턴스가 생성될때 start 함수에서 do-while문으로 uv_run()이 호출되고 있다. 즉, nodejs에서 동작하는 이벤트루프는 libuv의 구현체이다.

다만, libuv는 js엔진이 아니다. 따라서 libuv 내에 있는 이벤트루프는 파라미터로 넘겨받은 v8::Isolate, v8::Context 를 이용해 js로직을 처리한다.

해당 코드를 간략하게 살펴보면 node.cc 에서 Environment env(isolate_data, context); 와 uv_run(env); 부분이 되겠다.

Q2. IO관련 작업의 콜백은 I/O Callbacks 에서 처리하는 것 아닌가?

아니다. 공식문서에도 '파일 읽기를 끝마치고 완료하고 콜백이 poll 큐에 추가되어 실행' 된다는 문구가 있다. 좀 더 자세히 살펴보자면, I/O callbacks 에서는 io관련된 작업들의 성공, 실패 여부를 기다리며(pending) 판단한다. 성공했을때, 만약 콜백이 없다면(= fs.readFile('파일')) 이 단계에서 바로 끝날 것이고, 콜백이 있다면(= fs.readFile('파일', cb)) 이 콜백을 FD 에 쓰는 역할까지 수행합니다.

이렇게 쓰여진 FD는 써진 콜백들은 poll 단계에서 꺼내서 수행한다. 예를들면, fs.readFile('파일', function() { console.log('A'); }); 에서 console.log('A') 는 poll 단계에서 실행되는 것이다. (이 콜백을 등록하는 것은 I/O callbacks 단계에서 FD에 씀, poll 에서 FD를 읽음, 이것을 epoll방식이라 함)


'node.js' 카테고리의 다른 글

ejs (문법, include, nodejs와 연동)  (0) 2018.06.26
body-parser 모듈 (urlencoded, extended 옵션)  (1) 2018.06.26
V8 inspector을 이용한 디버깅  (0) 2017.08.04
nodejs 메모리 누수  (0) 2017.08.04
mongoose-auto-increment  (0) 2017.08.04
Comments