[Javascript/Typescript] Node.js는 어떻게 싱글 스레드로 논블로킹 비동기 작업을 지원하는걸까?

Hocaron·2022년 4월 26일
0

Javascript/Typescript

목록 보기
3/4

흔히 Node.js를 싱글 스레드 논 블로킹이라고 한다. Node.js는 하나의 스레드로 동작하지만 I/O 작업이 발생한 경우 이를 비동기적으로 처리할 수 있다. 분명 하나의 스레드는 하나의 실행 흐름만을 가지고 있고 파일 읽기와 같이 기다려야 하는 작업을 실행하면 그 작업이 끝나기 전에는 아무것도 할 수 없어야만 한다. 그러나 Node.js는 하나의 스레드만으로 여러 비동기 작업들을 블로킹 없이 수행할 수 있고 그 기반에는 이벤트 루프가 존재한다.

libuv

  • C++로 작성된, Node.js가 사용하는 비동기 I/O 라이브러리다. 이는 사실 운영체제의 커널을 추상화한 Wrapping 라이브러리로 커널이 어떤 비동기 API를 지원하는지 알고있다.


다시 말해 우리가 libuv 에게 파일 읽기와 같은 비동기 작업을 요청하면 libuv는 이 작업을 커널이 지원하는지 확인한다. 만약 지원한다면 libuv가 대신 커널에게 비동기적으로 요청했다가 응답이 오면 그 응답을 우리에게 전달해준다. 만약 요청한 작업을 커널이 지원하지 않는다면 어떻게 할까? 바로 자신만의 워커 스레드가 담긴 스레드 풀을 사용한다.

작동과정

  1. libuv는 운영체제의 커널을 추상화해서 비동기 API를 지원한다.
  2. libuv는 커널이 어떤 비동기 API를 지원하고 있는지 알고 있다.
  3. 만약 커널이 지원하는 비동기 작업을 libuv에게 요청하면 libuv는 대신 커널에게 이 작업을 비동기적으로 요청해준다.
  4. 만약 커널이 지원하지 않는 비동기 작업을 libuv에게 요청하면 livuv는 내부에 가지고있는 스레드 풀에게 이 작업을 요청해준다.

그래서 libuv가 뭔데?

지금까지 libuv라는 비동기 I/O 라이브러리가 존재하고 Node.js가 이를 내부적으로 이용한다는 사실을 살펴봤다. 그렇다면 도대체 libuv와 이벤트 루프는 어떠한 관계가 있고 그래서 Node.js는 어떻게 싱글 스레드로 논블로킹 비동기 작업을 지원하는걸까?

Node.js는 I/O 작업을 자신의 메인 스레드가 아닌 다른 스레드에 위임함으로써 싱글 스레드로 논 블로킹 I/O를 지원한다. 다르게 말하면 Node.js는 I/O 작업을 libuv에게 위임함으로써 논 블로킹 I/O를 지원하고 그 기반에는 이벤트 루프가 있다.

이벤트 루프


이벤트 루프는 Node.js가 여러 비동기 작업을 관리하기 위한 구현체다. console.log("Hello World")와 같은 동기 작업이 아니라file.readFile('test.txt', callback)과 같은 비동기 작업들을 모아서 관리하고 순서대로 실행할 수 있게 해주는 도구이며 위와 같이 구성되어있다.

이벤트 루프와 libuv의 관계는?
libuv는 비동기 입출력, 이벤트 관련 기능을 지원하기 위해 OS 커널을 추상화한 라이브러리라고 생각할 수 있다.
그래서 이벤트 루프는 특정 라이브러리라기보다는 비동기 처리를 하는 방법 중 하나이고 libuv가 이를 구현했다고 생각하면 된다.

이벤트 루프의 페이즈

  • Timer Phase
  • Pending Callbacks Phase
  • Idle, Prepare Phase
  • Poll Phase
  • Check Phase
  • Close Callbacks Phase

1
2
3
4

한눈에 정리

  • 이벤트 루프는 Node.js가 비동기 작업을 관리하기 위한 구현체다.
  • 이벤트 루프는 총 6개의 페이즈로 구성되어 있으며 한 페이즈에서 다음 페이즈로 넘어가는 것을 틱이라고 한다.
  • 각 페이즈는 자신만의 큐를 관리한다.
  • Node.js는 순서대로 페이즈를 방문하면서 큐에 쌓인 작업을 하나씩 실행한다.
  • 페이즈의 큐에 담긴 작업을 모두 실행하거나 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다.
  • 이벤트 루프가 살아있는 한 Node.js는 이벤트 루프를 반복한다.
  • 쌓인 작업을 처리하던 중 이전 페이즈에서 실행했던 작업의 콜백이나 커널이 스케줄링한 새로운 작업이 큐에 추가될 수 있다.
  • Node.js가 큐에 계속 추가되는 작업을 처리하느라 다음 페이즈로 넘어가지 못할 수 있다. 단, 페이즈는 시스템의 실행 한도의 영향을 받으므로 Node.js가 한 페이즈에 영원히 갇히는 일은 없다.


1. Node.js는 코드를 실행하기 전에 우선 이벤트 루프를 생성한다.
2. Node.js는 이벤트 루프 바깥에서 코드를 처음부터 끝까지 실행한다.
3. 이벤트 루프가 살아있는지 확인하고 진입하거나 Exit Callbacks을 실행하고 프로그램을 종료한다.
4. 이벤트 루프에 진입하면 페이즈를 차례대로 돌면서 실행할 수 있는 작업을 실행한다.
5. 매 반복마다 이벤트 루프가 살아있는지 확인하고 죽었다면 Exit Callbacks을 실행하고 프로그램을 종료한다.

이벤트 루프의 여러 페이즈

Timer Phase

Timer Phase는 말 그대로 setTimeout이나 setInterval과 같은 함수가 만들어 내는 타이머들을 다룬다. 엄밀하게 말하면 Timer Phase가 관리하는 큐에 콜백을 직접 담지는 않는다.
Timer Phase는 setTimeout이 호출되었을 때 타이머의 콜백을 큐에 저장하지 않는다. 그대신 콜백을 언제 실행할 지에 정보가 담긴 타이머를 Timer Phase가 관리하는 min-heap에 넣는다. 만약 Poll Phase에서 setTimeout를 3번 호출했다면 Timer Phase의 min-heap에 3개의 타이머가 저장되어있다. 그리고 타이머를 실행할 준비가 되면(시간이 되면) 타이머가 가리키고 있는 콜백을 호출한다.
setTimeout(fn, 1)을 호출한다 해도 Node.js는 정확히 1ms 뒤에 콜백이 실행됨을 보장하지 않는다. Timer Phase에 진입하는데 1초가 걸린다면 타이머의 콜백을 실행하는 데는 1ms가 아니라 1초 이상이 걸리게 된다.

정리

  • Timer Phase는 min-heap을 이용해서 타이머를 관리한다. 이 덕분에 실행 시간이 가장 이른 타이머를 효율적으로 찾을 수 있다.
  • Timer Phase는 setTimeout(fn, 1000)을 호출했다고 하더라도 정확하게 1s가 지난 후에 fn이 호출됨을 보장하지 않는다. 1s가 흐르기 전에 실행되지 않는 것을 보장한다. 다르게 말하면 1초 이상의 시간이 흘렀을 때 fn이 실행됨을 보장한다.
  • 큐에 있는 모든 작업을 실행하거나 시스템의 실행 한도에 다다르면 다음 페이즈인 Pending Callbacks Phase로 넘어간다

Pending Callbacks Phase

ending_queue에 담기는 콜백들을 관리한다. 이 큐에 담기는 콜백들은 이전 이벤트 루프 반복에서 수행되지 못했던 I/O 콜백들이다.

정리

  • 에러 핸들러 콜백 또한 pending_queue로 들어오게 된다.
  • 시스템의 실행 한도 제한에 의해 큐에 쌓인 모든 작업을 실행하지 못하고 다음 페이즈로 넘어갈 수도 있다.

Idle, Prepare Phase

이 페이즈들은 Node.js의 내부적인 관리를 위한 페이즈로 자바스크립트를 실행하지 않는다. 공식 문서에서도 별다른 설명이 없고 코드의 직접적인 실행에 영향을 미치지 않는다.

Poll Phase

새로운 I/O 이벤트를 다루며 watcher_queue의 콜백들을 실행한다. watcher_queue에는 I/O에 대한 거의 모든 콜백들이 담긴다. 쉽게 말하면 setTimeout, setImmediate, close 콜백 등을 제외한 모든 콜백이 여기서 실행된다고 생각하면 된다. 예를 들면 아래와 같은 콜백들이 실행된다.

  • 데이터베이스에 쿼리를 보낸 후 결과가 왔을 때 실행되는 콜백
  • HTTP 요청을 보낸 후 응답이 왔을 때 실행되는 콜백
  • 파일을 비동기로 읽고 다 읽었을 때 실행되는 콜백

Poll Phase Blocking

Node.js가 Poll Phase에 진입했을 때 기다리고 있는 I/O 요청이 없거나, 아직 응답이 오지 않았다면 어떻게 할까? 그동안 살펴본 Timer Phase, Pending Callbacks Phase에서는 큐에 실행할 수 있는 작업이 없다면 다음 페이즈로 넘어갔다. 하지만 Poll Phase에서는 조금 다르게 동작한다.

페이즈 자신이 관리하는 큐만 확인하고 다음 페이즈로 넘기는 다른 페이즈들과는 달리 Poll Phase는 조금 더 영리하게 동작한다. Node.js가 다음 페이즈로 이동해 다시 Poll Phase로 올 때까지 실행할 수 있는 작업이 있는지를 고려한다.

Blocking I/O

  1. FD와 watcher_queue를 이용해 I/O 요청이 완료되면 콜백을 실행한다.
  2. watcher_queue에서 현재 완료된 I/O 요청이 없다면 결정된 대기시간(timeout)만큼 기다리다가 다음 페이즈로 넘어간다.
  • 만약 timeout이 0인 경우 I/O 요청이 완료되는 것을 기다리지 않는다. 완료된 I/O 요청이 없다면 바로 다음 페이즈로 넘어간다[구현]. 이미 완료된 I/O 요청이 있다면 콜백을 실행하고[구현] 다음 페이즈로 넘어간다[구현].
  • 만약 timeout이 0보다 큰 경우
  • 만약 timeout이 -1인 경우 I/O 요청이 완료될 때까지 최대 30분 기다린다. 일부 I/O 요청만 완료되고 아직 완료되지 않은 I/O 요청이 있다면 다시 최대 30분까지 I/O 요청이 완료되는 것을 기다린다.

정리

  • 이벤트 루프가 종료되었다면 바로 다음 페이즈로 넘어간다.
  • 만약 Close Callbacks Phase, Pending Callbacks Phase에서 실행할 작업이 있다면 바로 다음 페이즈로 넘어간다.
  • 만약 Timer Phase에서 즉시 실행할 수 있는 타이머가 있다면 바로 다음 페이즈로 넘어간다.
  • 만약 Timer Phase에서 즉시 실행할 수 있는 타이머는 없지만 n초 후에 실행할 수 있는 타이머가 있다면 n초 기다린 후 다음 페이즈로 넘어간다.

Check Phase

setImmediate의 콜백만을 위한 페이즈다. setImmediate가 호출되면 Check Phase의 큐에 담기고 Node.js가 Check Phase에 진입하면 차례대로 실행된다.

  • process.nextTick은 같은 페이즈에서 호출한 즉시 실행된다.
  • setImmediate는 다음 틱에서 실행된다. 정확히는 Node.js가 틱을 거쳐 Check Phase에 진입하면 실행된다.
    따라서 동작만 보면 process.nextTick은 즉시 실행되고 setImmediate는 다음 틱에 실행된다. 공식 문서에서도 두 이름은 바뀌어야 한다고 이야기한다. 하지만 이미 많은 모듈을이 process.nextTick과 setImmediatge의 뒤바뀐 동작에 의존해 동작하고 있어 이름을 바꾸지 못했다고 공식 문서에서 이야기하고 있다.

Close Callbacks Phase

socket.on('close', () => {});과 같은 close 이벤트 타입의 핸들러를 처리하는 페이즈다. 정확하게는 uv_close()를 부르면서 종료된 핸들러의 콜백들을 처리하는 페이즈다.

시스템 실행 한도를 초과하기 전까지 closing_handles에 담긴 작업을 순서대로 실행한다.

nextTickQueue, microTaskQueue

nextTickQueue와 microTaskQueue는 이벤트 루프의 일부가 아니다. 정확히는 libuv에 포함되어 있지 않고 Node.js에 구현되어 있다. 따라서 이벤트 루프의 페이즈와 상관없이 동작한다.

nextTickQueue는 process.nextTick()의 콜백을 관리하며 microTaskQueue는 Resolve된 프라미스 콜백을 가지고 있다. nextTickQueue와 microTaskQueue는 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 그 즉시 바로 실행한다.

nextTickQueue는 microTaskQueue보다 높은 우선순위를 가지므로 더 먼저 실행된다.

Node v11.0.0을 기점으로 달라진 변화

Node v11.0.0 이전에는 한 페이즈에서 다음 페이즈로 넘어가기 전에 nextTickQueue와 microTaskQueue를 검사했다. 즉, 매 틱마다 검사했다.
하지만 Node v11.0.0 이후에는 현재 실행하고 있는 작업이 끝나면 즉시 실행하도록 변경되었다.

🧐 변화된 이유는 무엇일까?
바로 브라우저와의 일관성때문이다. Node.js는 브라우저에서 실행하던 Javascript를 로컬에서 실행하게 해주는 런타임이다. 그러나 브라우저는 Node v11.0.0의 실행 순서를 따르고 있었기에 같은 자바스크립트 코드더라도 Node v10과 브라우저의 실행 결과가 동일하지 않았다.
이러한 문제를 해소하기 위해서 Node v11.0.0에서는 브라우저와 같은 실행 순서를 가지도록 변경되었다.

References

profile
기록을 통한 성장을

0개의 댓글