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

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

  • 타이머들은 최소 힙(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