NodeJS

이벤트 루프(Event Loop)란?

iKay 2019. 1. 27. 20:22
반응형

이번에는 이벤트 루프[각주:1]란 무엇이고 어떻게 동작 하는지를  다뤄보고자 한다. 내용이 조금 어렵다면 이벤트 루프가 무엇인지만 알고 넘어가도 될 것 처럼 보인다. 


이벤트 루프란 무엇인가?


Node.js단일 쓰레드(sigle thread)로 구성된다고 흔히 들어본 적이 있을 것이지만 이것은 착각이다. 정확히는 메인 쓰레드(main thread), 워커 쓰레드(worker threads) 이렇게 두 가지의 쓰레드(thread) 종류로 구성된다. 메인 쓰레드단일 쓰레드로 구성되지만 워커 쓰레드는 여러 쓰레드로 구성된 쓰레드 풀(thread pool)이다. 이번에 게재할 주제인 이벤트 루프는 단지 메인 쓰레드의 별명일 뿐이다.


이벤트 루프는 싱글 스레드로 구성되어 있지만, 블로킹(blocking) 될 만한 작업들을 논-블로킹(non-blocking)되게 만들기 때문에 다른 종류의 웹 애플케이션과 비교해 충분히 빠르고 서버 자원 대비 효율적으로 동작하도록 하는 역할을 하는 것이다.



그렇다면 블로킹(Blocking)이란 무엇인가? 블로킹이란 현재의 JavaScript 코드가 다음 JavsScript 코드를 실행하기 위해 다른 작업이 완료될 때까지 기다려야 해서 아무것도 하지 못하는 상황을 말한다. 보통 Sync가 붙은 I/O 작업이 이에 해당된다. JavaScript 코드가 직접 CPU 집약적인 작업을 하는 경우 JavaScript 코드 전체성능이 떨어질 수도 있다. 하지만 이런 경우, 블로킹된다고 보기 애매하다. JavaScript 코드를 계속 수행하고 있기 대문이다. 그래서 Crypto와 같은 CPU 집약적인 작업의 경우 쓰레드 풀(libuv)에 작업을 맡겨서 블로킹되지 않게 해 성능을 좋게 만든다. 하지만 Node.js가 사용할 수 있는 쓰레드 풀의 수도 제한적이기 때문에 CPU 집약적인 요청을 비동기적으로 쓰레드 풀에 맡긴다 하더라도, 무한정으로 논블로킹 처리가 되지는 않는다. 블로킹과 논블로킹에 대한 더 자세한 설명은 Node.JS 공식 문서에 잘 설명돼 있다.


그래서, event loop 무엇이냐고 묻는다면 한 마디로 이렇게 대답할 수 있을 것 같다.

 

Event loop란, Node.js의 메인 스레드로서 싱글 스레드로 구성되지만 블로킹 될만한 것을 논블로킹되게 만들어 Node.js가 자원대비 효율적으로 충분히 빠르게 동작하도록 한다.


이벤트 루프는 어떻게 동작하는가?

이벤트 루프는 Node.js 애플리케이션에서 핵심이다. 따라서 이벤트 루프가 어떻게 동작하는지, 어떻게 구성되는지를 깊게는 아니더라도 어느 정도 이해하는 것이 Node.js를 더 잘 사용하기 위해서 중요하다고 생각한다.


event loop phase 개요

 

event loop는 개략적으로 다음과 같은 phase를 갖는다고 한다.[각주:2]


  • timers: setTimeout( ), setInterval( ) 에 의해 스케줄된 callback들이 수행된다.

  • pending callbacks: 다음 loop로 미뤄진 I/O 콜백이 수행된다.
  • idle, prepare: 내부적으로 사용.
  • poll: 새로운 I/O 이벤트를 가져와서 I/O와 관련된 callback을 수행한다.
  • check: setImmediate( ) 에 의해 스케줄된 callback들이 수행된다.
  • close callbacks: socket.on('close', ...) 와 같이 close와 관련된 callback이 수행된다.

 

각각의 phase는 FIFO queue를 갖고있는데, 현재 loop에 실행되어야 할 callback들이 callback의 종류에 따라 각 phase에 맞는 곳에 들어가게 된다. 각 phase 별로 queue에 callback들을 모두 소진하거나 callback size가 최대를 초과하게 되면, 다음 차례의 phase의 queue에 있는 callback이 실행된다. timers, pending callbacks, ..., close callbacks, timers, ... 로 무한히 phase 가 순환하게 되는데, 만약 각 phase마다 실행할 callback이 없다면 Node.js 프로세스가 종료된다.


poll 단계에서 사실 엄연히 따지면 event loop는 실제로 queue를 갖고 있지 않다[각주:3]. 대신, 리닉스 시스템과 같은 경우, epoll과 같은 것으로 파일 디스크립터를 감시해 그 변화를 통해 I/O의 변화를 알 수 있게되고 I/O task가 완료되면, callback 처리를 하는 것이다.

 

파일 디스크립터란, 유닉스 시스템은 실제 파일 뿐만 아니라 소켓, 파이프 등 모든 객체를 파일로써 관리하는데, 이 객체들에 접근하기 위해 만든 0을 포함한 양의 정수 테이블을 말한다.


epoll이란, 이 파일 디스크립터를 감시해 변화가 감지되면 그것을 알려주는 역할을 한다. 

 

이에 대해 더 자세히는 이 영상을 보면 도움될 것이다.


event loop phase 설명

timers


타이머(timer)는 콜백의 수행이 정확한 시간이 아니라, 명시된 시점(threshold)[각주:4] 이후에 수행되도록 한다. 타이머 콜백은 명시된 시점이 지난 이후 스케줄 될 수 있는 한 빨리 실행된다. 그러나 OS 스케줄링이나 다른 실행 중인 콜백이 타이머를 지연시킬 수도 있다.  


Note: 기술적으로, 타이머가 언제 실행 될지는 poll phase에 의해 제어된다.  


예를 들어, 아래 스크립트는 100ms 시점 이후에 타임아웃(timeout)이 실행되고, 비동기적으로 파일을 읽기 시작하며, 파일을 읽는데 95ms가 걸린다고 가정하자. 


const fs = require('fs');

function someAsyncOperation(callback) {
// Assume this takes 95ms to complete
fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

setTimeout(() => {
const delay = Date.now() - timeoutScheduled;

console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// do someAsyncOperation which takes 95 ms to complete
someAsyncOperation(() => {
const startCallback = Date.now();

// do something that will take 10ms...
while (Date.now() - startCallback < 10) {
// do nothing
}
});


이벤트 루프가 poll phase에 들어갈 때, fs.readFile( )가 끝나지 않았기 때문에 큐는 비어있다. 그래서 이벤트 루프는 가장 빠른 타이머의 임계값에 도달할 때 까지 몇 ms를 기다리게 된다. 95ms이 지나는 동안, fs.readFile( )은 파일 읽기를 끝내고, 10ms이 걸리는 콜백이 poll queue에 더해지고 실행된다. 콜백이 끝났을 때, 큐에 더이상 콜백이 없고, 이벤트 루프는 가장 빠른 타이머의 임계값에 도달했는지 볼 것이며 타이머의 콜백을 실행시키기 위해 timers phase로 되돌아 간다. 이 예제에서 타이머가 스케줄링 되고 타이머의 콜백이 실행되는 총 시간은 105ms가 된다. 이 예제의 설명을 그림과 같이 네 단계로 나타낼 수 있다. 



Note[각주:5]: poll phase가 이벤트 루프를 독점[각주:6]하게 하지 않기 위해, libuv(NodeJS 이벤트 루프와 플랫폼의 모든 비동기 행동을 구현하는 C 라이브러리) 또한 더 많은 이벤트를 폴링하는 것을 막는 하드 최대 값을 가진다. 



pending callbacks


이 phase에서 TCP 에러와 같은 형태의 시스템 연산에 대한 콜백을 수행된다. 예를 들어 TCP 소켓이 연결을 시도할 때 ECONNREFUSED를 받는 경우, Unix/Linux 시스템은 에러를 보고하기 위해 기다리기를 원한다. 이와 같은 경우 후에 수행되기 위해 pending callbacks phase에 들어가게 된다. 



poll   


poll phase 는 2개의 main 함수가 있다. 


1. I/O를 위해 얼마나 오랫동안 블록하고 poll 해야하는지 계산 

2. poll 큐에서 이벤트를 처리 


이벤트 루프가 poll phase로 들어가고 타이머가 예약 돼 있지 않으면, 둘 중 하나가 발생한다. 

    • 만약 poll 큐가 비어있지 않다면, 이벤트 루프는 큐가 모두 소진되거나, system-dependent hard limit에 도달할 때까지 콜백 큐를 동기적으로 실행시키는 것을 반복할 것이다. 
    • 만약 poll 큐가 비어있다면, 둘 중 하나가 발생한다. 
      • 만약 스크립트가 setImmediate( )에 의해 스케줄 됐다면, 이벤트 루프는 poll phase를 끝내고 check phase 를 하기 시작한다. 
      • 만약 스크립트가 setImmediate( )에 의해 스케줄 되지 않았다면, 이벤트 루프는 콜백이 더해지길 기다리고 즉시 수행한다.    

일단 poll queue가 비게되면 이벤트 루프는 타이머의 시간이 임계에 도달했는지를 확인한다. 만약 하나 이상의 타이머가 준비되면, 이벤트 루프는 타이머 콜백을 수행하기 위해 timer phase로 되돌아간다. 


*(참고) micro task queue


I/O 이후 결과가 poll pahse에 들어가서 callback 이 실행된다. 하지만 Promise는 poll phase, 심지어 event loop의 어느 큐에도 들어가지 않는다. Promise는 결과가 micro task queue에 들어가는데, micro task queue는 각 phase가 시작되지 전에 check 되고 실행된다. 


check


check phase는 poll phase가 완료된 후 콜백이 즉시 수행될 수 있도록 한다. 만약 poll phase가 idle 상태이고 스크립트가 setImmediate( ) 로 큐에 추가됐다면, 이벤트루프는 기다리지 않고, check phase로 넘어간다. 


setImmediate( ) 는 사실 특이한 타이머인데, 이벤트 루프의 분리된 phase 안에서 실행된다. setImmediate( )는 poll phase가 완료된 후 콜백이 실행되도록 스케줄링 하는 libuv API를 사용한다.  


일반적으로, 코드가 실행될 때, 이벤트 루프는 결국 들어오는 연결, 요청 등을 기다리는 poll phase를 들리게 된다. 그러나 만약 콜백이 setImmediate( )로 스케줄 되고 poll phase가 idle 상태라면, poll event를 기다리지 않고 check phase로 넘어가게 된다.  



close callbacks


소켓, 핸들이 갑자기 종료된다면(예를 들어 socket.destroy( ) ), 'close' 이벤트가 close callback에서 발생한다. 그렇지 않을 경우 process.nextTick( )을 통해 발생한다. 



결론


이렇게 오늘은 이벤트 루프에 대해 알아봤다. 다음 번에는 setImmdiate( ), setTimeout( ) 그리고 process.nextTick( )이 어떻게 동작하는지 살펴 볼 것이다.  




  1. https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/ 를 많이 참고했다. 번역한 내용이 어색하다면 원문을 참고하면 될 것 같다. [본문으로]
  2. https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/#phases-overview [본문으로]
  3. https://nodejs.org/ko/docs/guides/dont-block-the-event-loop/#how-does-node-js-decide-what-code-to-run-next [본문으로]
  4. 시간과 관련돼 시점이라고 번역했으나 threshold를 보통 '임계'정도로 해석하는 것이 자연스러울 때가 많다. [본문으로]
  5. 원문의 내용이 잘 이해되지 않아 이상하게 번역했다. [본문으로]
  6. starving the event loop [본문으로]
반응형