기본

Node.js에서의 비동기

초혼 2023. 5. 7. 00:18

Node.js

비동기 처리 지원

CPU를 많이 사용하는 작업보다 I/O이 잦은 환경에서 효율적

WEB 환경이 그렇다 -> 네트워크가 I/O이기 때문

 

 

 

Libuv

Node.js가 사용하는 비동기 I/O 라이브러리

 

운영체제(Windows, Linux)의 커널을 추상화한 wrapping 라이브러리

-> 운영체제가 어떤 비동기 작업을 지원하는지 알고 있음

 

 

 

Node.js가 libuv에 비동기 작업 요청

libuv는 해당 작업을 커널에서 지원하는지 확인

 

지원할 때

libuv는 커널에 해당 작업을 비동기적으로 요청

커널이 응답하면 libuv는 응답을 Node.js에 전달

 

지원하지 않을 때

libuv는 기본적으로 4개의 쓰레드로 이루어진 쓰레드풀을 가짐(가변적)

libuv는 해당 작업을 쓰레드풀에게 맡김

쓰레드가 작업이 끝나면 응답을 libuv에 전달

libuv는 받은 응답을 Node.js에 전달

 

 

 

이벤트 루프

Node.js가 여러 비동기 작업을 관리하기 위한 구현체

 

6개의 페이즈(Phase)로 구성

  1. Timer
  2. Pending Callbacks
  3. Idle, Prepare
  4. Poll
  5. Check
  6. Close Callbacks

한 페이즈에서 다음 페이즈로 넘어가는 것을 틱(tick)이라고 한다.

각 페이즈는 자신만의 큐를 관리한다.

Node.js는 순서대로 페이즈를 방문하면서 큐에 쌓인 작업을 하나씩 실행한다.

페이즈의 큐에 담긴 작업을 모두 실행하거나 시스템의 실행 한도에 다다르면 Node.js는 다음 페이즈로 넘어간다.

이벤트 루프가 살아있는 한 Node.js는 이벤트 루프를 반복한다.

 

Node.js는 페이즈에 진입해 큐에 쌓인 작업을 처리한다

쌓인 작업을 처리하던 중 이전 페이즈에서 실행했던 작업의 콜백이나 커널이 스케줄링한 새로운 작업이 큐에 추가될 수 있다.

Node.js가 큐에 계속 추가되는 작업을 처리하느라 다음 페이즈로 넘어가지 못할 수 있다.

단, 페이즈는 시스템의 실행 한도의 영향을 받으므로 Node.js가 한 페이즈에 영원히 갇히는 일은 없다.

 

Node.js는 코드를 실행하기 전에 우선 이벤트 루프를 생성한다.

Node.js는 이벤트 루프 바깥에서 코드를 처음부터 끝까지 실행한다.

이벤트 루프가 살아있는지 확인하고 진입하거나 [Exit Callbacks]을 실행하고 프로그램을 종료한다.

이벤트 루프에 진입하면 페이즈를 차례대로 돌면서 실행할 수 있는 작업을 실행한다.

매 반복마다 이벤트 루프가 살아있는지 확인하고 죽었다면 [Exit Callbacks]을 실행하고 프로그램을 종료한다.

 

1. Timer Phase

Timer Phase는 min-heap을 이용해서 타이머를 관리한다

setTimeout(fn, 1000)을 호출했다고 하더라도 정확하게 1초가 지난 후에 fn이 호출됨을 보장하지 않는다

-> 1초가 흐르기 전에 실행되지 않는 것을 보장한다

-> 1초 이상의 시간이 흘렀을 때 fn이 실행됨을 보장한다.

2. Pending Callbacks Phase

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

시스템의 실행 한도 제한에 의해 큐에 쌓인 모든 작업을 실행하지 못하고 다음 페이즈로 넘어갈 수도 있다.

-> 이때 처리하지 못하고 넘어간 작업들을 쌓아놓고 실행하는 페이즈

3. Idle, Prepare Phase

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

4. Poll Phase

이 페이즈는 새로운 I/O 이벤트를 다루며 [watcher_queue]의 콜백들을 실행한다.

[watcher_queue]에는 I/O에 대한 거의 모든 콜백들이 담긴다.

쉽게 말하면 setTimeout, setImmediate, close 콜백 등을 제외한 모든 콜백이 여기서 실행된다.

Ex) HTTP 요청, DB 요청, 파일 요청 등

 

이벤트 루프가 n개의 열린 소켓을 가지고 있고 n개의 완료되지 않은 요청이 있다고 하자

이 n개의 소켓에 대해 소켓과 메타 데이터를 가진 watcher를 관리하는 큐가 watcher_queue이다

각 watcher는 FD(File Descriptor)를 가지고 있다

이 FD는 네트워크 소켓, 파일 등등을 가리킨다

OS가 FD가 준비되었다고 알리면 이벤트 루프는 이에 해당하는 watcher를 찾을 수 있고 watcher가 맡고 있던 콜백을 실행할 수 있다.

 

Node.js가 다음 페이즈로 이동해 다시 Poll Phase로 올 때까지 실행할 수 있는 작업이 있는지를 고려한다.

이벤트 루프가 종료되었다면 바로 다음 페이즈로 넘어간다.

만약 [Close Callbacks Phase], [Pending Callbacks Phase]에서 실행할 작업이 있다면 바로 다음 페이즈로 넘어간다.

만약 [Timer Phase]에서 즉시 실행할 수 있는 타이머가 있다면 바로 다음 페이즈로 넘어간다.

만약 [Timer Phase]에서 즉시 실행할 수 있는 타이머는 없지만 n초 후에 실행할 수 있는 타이머가 있다면 n초 기다린 후 다음 페이즈로 넘어간다.

5. Check Phase

setImmediate의 콜백만을 위한 페이즈다.

setImmediate가 호출되면 Check Phase의 큐에 담기고 Node.js가 Check Phase에 진입하면 차례대로 실행된다.

6. Close Callbacks Phase

close 이벤트 타입의 핸들러를 처리

정확하게는 uv_close()를 부르면서 종료된 핸들러의 콜백들을 처리

 

nextTickQueue, microTaskQueue

[nextTickQueue]와 [microTaskQueue]는 이벤트 루프의 일부가 아니다.

libuv에 포함되어 있지 않고 Node.js에 구현되어 있다.

→ 이벤트 루프의 페이즈와 상관없이 동작

 

[nextTickQueue]는 process.nextTick()의 콜백을 관리

[microTaskQueue]는 Resolve된 Promise 콜백을 관리

 

두 큐는 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 그 즉시 바로 실행한다.

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

 

시스템의 실행 한도의 영향을 받지 않는다.