Skip to main content

Node.js 이벤트 루프

동기 vs 비동기 / 블로킹 vs 논블로킹​

비동기 vs 동기​

  • 동기(Synchronous): 작업이 순차적으로 진행된다. 각 작업이 완료될 때까지 프로그램의 실행이 중단되며, 그 다음 작업으로 넘어가지 않는다.
  • 비동기(Asynchronous): 작업의 완료 여부와 상관없이 즉시 다음 작업을 시작한다. 작업이 끝나면 콜백이나 이벤트가 작업의 완료를 처리한다.

동기와 비동기는 프로그램 실행 흐름에서 작업 완료를 확인하는 주체에 따라 구분할 수 있다.

블로킹 vs 논블로킹​

  • 블로킹(Blocking): 작업이 완료될 때까지 해당 작업에 시스템 자원을 독점하고, 다른 작업이 중단된다.
  • 논블로킹(Non-blocking): 작업이 완료될 때까지 시스템 자원을 독점하지 않고, 다른 작업이 계속해서 실행된다.

블로킹과 논블로킹은 작업이 진행되는 동안 실행 흐름이 대기 상태에 있는지에 따라 구분할 수 있다.

  • 블로킹 작업은 프로세스나 스레드가 작업이 완료될 때까지 실행을 멈추고 기다린다.
  • 논블로킹 작업은 작업이 완료되지 않더라도 즉시 반환되며, 다른 작업을 이어서 실행할 수 있다.

이벤트 루프​

이벤트 루프는 Node.js가 기본적으로 단일 JavaScript 스레드를 사용함에도 불구하고 논블로킹 I/O 작업을 수행할 수 있도록 해주는 기능이다.

libuv​

libuv는 C++로 작성된, Node.js가 사용하는 비동기 I/O 라이브러리이다. 대부분의 최신 커널은 멀티스레드이므로 백그라운드에서 여러 작업을 처리할 수 있다. libuv는 OS의 커널을 추상화한 라이브러리로 커널이 지원하는 비동기 API 내역을 알고 있기 때문에, 비동기 작업 요청에 대해 커널에서 지원되는 작업이라면 커널에 요청을 보내고, 커널에서 논블로킹 버전을 지원하지 않는 I/O 작업이거나 CPU 집약적인 특정 작업들의 경우 별도의 워커 스레드 풀에서 처리한다.

libuv의 워커 스레드 풀 작업​

  • I/O 집약

    • DNS: dns.lookup(), dns.lookupService()
    • 파일시스템: fs.FSWatcher()와 명시적으로 동기식인 API를 제외한 모든 파일시스템 API
  • CPU 집약

    • Crypto: crypto.pbkdf2(), crypto.scrypt(), crypto.randomBytes(), crypto.randomFill(), crypto.generateKeyPair()
    • Zlip: 명시적으로 동기식인 API를 제외한 모든 Zlib API

Phase​

Node.js가 시작되면 이벤트 루프를 초기화하고 제공된 입력 스크립트를 처리한다. 이 때 비동기 API 호출, 타이머 예약, process.nextTick() 호출 등을 할 수 있다. 스크립트가 처리된 후에 Node.js는 이벤트 루프 안에서 해야할 작업이 있는지를 확인하고 해야할 작업이 남아있다면 이벤트 루프의 첫 페이즈를 시작한다.

   ┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ connections, │
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘

각 박스에 있는 이벤트 루프의 단계들을 페이즈(Phase)라고 부른다. 각 페이즈는 실행할 콜백의 FIFO 큐를 갖고 있다. 일반적으로 이벤트 루프가 각 페이즈에 진입하면 해당 페이즈의 특정한 작업들을 수행한 다음 큐가 모두 소진되거나 최대 콜백 수에 도달할 때까지 큐에 있는 콜백들을 실행한다. 큐가 소진되거나 콜백 제한에 도달하면 이벤트 루프는 다음 페이즈로 이동한다. 이 때 다음 페이즈로 이동하는 것을 틱(Tick)이라고 부른다.

timers​

setTimeout 및 setInterval과 같은 타이머 기반 함수들의 콜백을 처리하는 페이즈이다. 이 페이즈에서는 타이머가 등록된 시간에 따라 실행되어야 할 콜백들을 저장하고 관리한다.

  • 타이머들은 최소 힙(min-heap) 형태로 저장되어 가장 실행 시간이 임박한 타이머가 효율적으로 선택된다.
  • 만약 힙의 루트에 위치한 타이머의 실행 시간이 아직 도달하지 않았다면, 이벤트 루프는 다음 페이즈로 넘어간다.
  • 실행할 타이머가 있으면, 콜백 제한에 도달할 때까지 해당 타이머의 콜백들을 실행한다.

pending callbacks​

이벤트 루프의 두 번째 페이즈로, 특정 I/O 작업이 완료된 후 지연된 콜백을 실행하는 페이즈이다. 이 페이즈에서 실행되는 콜백은 주로 시스템 수준에서 완료된 작업들로, Node.js가 내부적으로 사용하는 작업들의 후속 처리가 이루어진다. 네트워크 I/O 작업에서 에러가 발생했을 때나, 파일을 읽다가 발생한 에러 이벤트와 같은 콜백들이 이 페이즈에서 처리된다.

  • 파일 시스템이나 네트워크 소켓 등, 특정 I/O 작업이 완료된 후 바로 실행되지 않고 지연된 콜백들을 처리한다.

idle, prepare​

사용자가 직접적으로 관여하지 않는 Node.js의 내부 관리를 위한 페이즈이다.

  • idle: 주로 libuv 내부에서 CPU 낭비를 줄이기 위해 사용되며, 사용자 코드에는 직접적인 노출이 없다.
  • prepare: poll 페이즈가 시작되기 전에 libuv가 이벤트 루프를 준비하는 단계로, 주로 내부적인 최적화 용도로 사용된다.

poll​

이벤트 루프에서 비동기 I/O 작업을 처리하는 핵심 페이즈이다. poll 페이즈의 주요 역할은 파일 시스템, 네트워크 요청과 같은 비동기 I/O 이벤트를 처리하는 것이다. 이벤트 루프에서 가장 오랜 시간을 차지하는 페이즈로, 대부분의 비동기 I/O 처리가 이 단계에서 이루어진다. 만약 타이머가 설정되어 있지 않다면, poll 페이즈는 새로운 I/O 이벤트가 발생할 때까지 대기할 수 있으며, 타이머가 설정된 경우 그에 따라 대기 시간이 조정된다.

  • I/O 이벤트 처리 대기: 완료된 I/O 이벤트가 없다면, 새로운 I/O 이벤트가 발생할 때까지 대기하거나, 다음 페이즈로 넘어간다. 만약 실행할 타이머(setTimeout, setInterval)나 setImmediate로 예약된 작업, 완료된 I/O 작업이 없다면, 다음 I/O 이벤트가 발생할 때까지 대기한다. 이 대기 시간은 타이머나 예약된 작업의 상태에 따라 libuv가 관리한다.

  • I/O 콜백 실행: 커널에서 완료된 I/O 작업들의 콜백을 처리한다. 파일 읽기/쓰기, 네트워크 요청, 데이터베이스 쿼리 등의 콜백이 이 페이즈에서 실행된다. 커널에서 처리된 I/O 작업이 끝난 후, 그 작업의 콜백은 poll 큐에 쌓이고, 해당 콜백은 이 페이즈에서 실행된다.

check​

setImmediate로 예약된 콜백을 처리하는 단계이다. poll 페이즈가 끝난 후에 실행되며, 대기 중인 setImmediate 콜백들을 처리하는 역할을 한다.

close callbacks​

이벤트 루프의 마지막 페이즈로, 소켓이나 핸들이 닫힐 때 발생하는 특정 이벤트 처리와 관련된 작업을 수행하는 페이즈이다. 소켓이 제대로 닫히지 않거나 갑작스럽게 닫힐 때 필요한 클린업 작업을 수행하는 데 사용된다.

nextTickQueue, microTaskQueue​

nextTickQueue와 microTaskQueue에 있는 콜백들은 이벤트 루프의 페이즈들과 다르게 콜백 제한이 없이 큐가 비워질 때까지 모든 콜백들을 실행한다.

Node v11.0.0 버전 이전에는 한 페이즈에서 다음 페이즈로 넘어가기 전, 매 틱마다 해당 큐들을 검사하여 실행했다. Node v11.0.0 버전부터는 현재 실행하고 있는 작업이 끝나면 즉시 큐들을 검사하고 실행하도록 변경되었다.

nextTickQueue​

process.nextTick() 메서드를 통해 예약된 콜백을 저장하는 큐이다. nextTickQueue는 항상 우선적으로 실행되며, microTaskQueue보다 우선순위가 높다.

microTaskQueue​

Promise의 콜백들을 저장하는 큐이다.


참조

https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick

https://nodejs.org/en/learn/asynchronous-work/understanding-processnexttick

https://nodejs.org/en/learn/asynchronous-work/dont-block-the-event-loop

https://www.korecmblog.com/blog/node-js-event-loop

https://gednanetwork.com/new-changes-to-the-timers-and-microtasks-in-node-v11-0-0-and-above

https://www.voidcanvas.com/nodejs-event-loop

https://black7375.tistory.com/90