Javascript의 Event Loop란?

Javascript의 Event Loop란?

Tag
Javascript
Node.js

😍 TL;DR

  1. 코드가 콜 스택에 쌓인 후 실행이 되지만, 비동기 작업이라면 이벤트 루프는 비동기 작업을 위임
  1. Node를 구성하는 libuv가 OS커널 / thread pool 중 어디서 작업을 할 것인지 판단 후 처리
  1. 처리 이후, 콜백 함수를 이벤트 루프를 통해 이벤트큐 ( 콜백큐 ) 에 넘김
  1. 이벤트 루프는 콜스택에 쌓여 있는 함수가 없을 때, 콜백큐에 대기하고 있던 콜백 함수를 콜스택으로 넘김
  1. 콜스택에 쌓인 콜백함수가 실행 되고, 콜스택에서 제거

자바스크립트를 처음 공부하게 되면, 제일 처음 듣는 이야기는 이와 같다.
자바스크립트는 싱글 쓰레드 언어이며, 여러 작업을 처리 하기 위해 비동기 작업을 진행한다.
여기에서 대다수의 사람들은 이벤트 루프라는 용어를 많이 들어보고, 공부했을 것이라 생각한다.
하지만 정확하게 알고 있지 않은 사람 또한 많다고 생각하여, 오늘은 이 주제에 대해 알아보려 한다.
 

도대체 이벤트 루프는 무엇인가?

Javascript 런타임

기본부터 잡고 들어가려고 한다.
자바스크립트 런타임이란, 간단하게 생각해서 우리가 만든 자바스크립트 코드들을 구동하기 위한 환경을 의미한다.
런타임의 종류로는 웹 브라우저들 ( 크롬❤️, 파이어폭스 등 ) 과 NodeJS 라는 프로그램이 있다.
우리는 브라우저 혹은 NodeJS를 이용해서 Javascript를 구동시킬 수 있기 때문에 위 프로그램들을 Javascript Runtime 이라고 한다.
NodeJS를 찾아보게 되면 많이 볼 수 있는 말이 있다.
구글 v8 자바스크립트 엔진 기반의 논블로킹, 이벤트 기반 플랫폼
 
잘 모르겠고, V8 이라는 엔진부터 한번 알아보자.

V8 Engine

이벤트 루프에 관련된 내용들을 찾아보게 되면, 심심치 않게 찾아볼 수 있는 내용이다.
V8 Engine은 C++로 작성 되었으며, 오픈 소스 자바스크립트 엔진이다.
우리가 사용하는 브라우저 중 크롬❤️ 브라우저, NodeJS 에 탑재되어 있다.
 
V8 Engine의 특징들은 다음과 같다.
  • 자바스크립트 코드 컴파일 및 실행
  • 콜스택을 핸들링하여 자바스크립트 함수를 특정 순서에 따라 실행
  • 힙메모리 객체에 대한 메모리 할당 관리
  • Garbage Collector
  • 모든 데이터 타입, 연산자, 객체, 함수를 제공
  • 이벤트 루프 제공
 
notion image
사실 자바스크립트 엔진 내부에는 이렇게 두개가 있다는 것만 알아둬도 좋을 것 같다.
  • Memory Heap: 참조 타입 ( 객체, 배열, 함수 등 ) 데이터가 저장된다. 메모리 할당이 일어나는 곳
  • 콜 스택: 원시 타입 ( number, string 등 ) 데이터가 저장된다. Execution Context를 통해 변수 이름 저장, this 관리, 코드 실행 순서 관리 등을 수행
 
Java를 사용한 사람이라면 들어본 적이 있는 JIT 컴파일러로서, 2 개의 주요 부분으로 구성된다.
( 컴파일러와 인터프리터가 둘다 보인다. )
notion image
첫 번째 부분은 코드를 바이트코드로 해석하는 구분 문석을 담당하며, Ignition 인터프리터를 사용한다.
Ignition 인터프리터를 사용하는 이유는 메모리 사용량을 줄이는 것이다.
말 그대로 인터프리터 이기 때문에 필요한 라인만 컴파일 하기 때문이다.
 
하지만 이 Ignition 인터프리터는 코드를 처음 실행할 때만 동작한다. 이후 생성된 바이트코드는 Turbofan 이라는 컴파일러에 의해 사용된다. 코드 실행 중 받는 데이터를 기반으로 코드를 최적화하고 보다 최적화된 버전을 다시 컴파일 해주는 것이다.
 
추가로 Ignition 인터프리터의 경우 추상 구문 트리 ( Abstract Syntax Tree )를 입력으로 사용한다고 한다.
소스코드를 ( lazy ) parser를 사용하여 구문 분석 이후 최적화 과정을 거친 후에 AST 자료 구조를 이용하여 컴파일 작업을 돕게 한다는데, 이 이상은 주제에서 벗어남으로 궁금하면 찾아보도록 하자.
 

자, 이제 다시 이벤트 루프로 돌아와보자.
우리가 처음 이벤트 루프에 대해서 알아보려 할 때, 자바스크립트는 싱글 스레드 언어라고 이야기한 바가 있다.
예를 들어 DB에서 데이터를 긁어 온다거나, 외부 API 콜을 하게 된다면 이는 블로킹 I/O 작업이 될 것이다.
한 마디로, 다른거 못하고 위의 작업들이 끝날 때까지 기다리는 것이다. 얼마나 비효율적인가.
한 번에 한가지 일밖에 못하는 바보가 되어 버리는 것인데, 이를 위해 I/O 작업이 발생한 경우 비동기적으로 처리할 수 있게 도와주는 이벤트 루프가 존재하는 것이다.
 

Node.js 의 구조

 
notion image
Node.js 의 구조는 위와 같다.
Node.js Core Library는 내장 모듈 세트, Node.js Bindings는 Node.js와 C/C++ 라이브러리 간의 상호 작용을 가능하게 해주는 기술로서,
  • Core Library: fs, http, crypto, stream, child_process…
  • Binding: libuv
와 같은 기능을 제공한다.
우리가 알아봤던 V8 엔진 또한 존재하는 것을 볼 수 있으며, 처음 보는 libuv ( 리버브 라고 읽는다 ) 또한 볼 수 있다.
 
그렇다면 이번에는 리버브를 알아보러 가보자
 

libuv란?

notion image
libuv는 윈도우 커널, 리눅스 커널을 추상화해서 wrapping 하고 있다.
libuv 에는 thread pool 이 존재하는 것을 볼 수 있는데, 우리가 node 인스턴스를 생성하게 되면 기본적으로 워커 쓰레드 ( 기본적으로 4개 )가 생성된다. 이 정도만 보고 이벤트 루프를 한번 제대로 까볼까?
 

이벤트루프

notion image
흐름이 아주 잘 설명되어 있는 아키텍쳐 그림이 있기에 가져왔다.
 
일반적인 코드 실행의 경우 콜스택 ( 호출 스택 ) 에 쌓여서 실행하지만, 비동기 작업이라면 이벤트 루프는 이 작업을 uv_io 에게 보내준다.
libuv는 OS 커널 ( IOCP, AISO 등 ) 에서 어떤 비동기 작업을 지원해주는지 알고 있기 때문에, 그런 종류의 작업을 받으면 커널에게 넘겨주고, 이 작업들은 다시 libuv 에게 던져준다.
만약 OS 커널에서 지원하지 않는 작업의 경우는 uv_io 내부에 있는 스레드풀에서 작업을 하게 되는 것이다. ( ex: fs 작업은 커널단 함수에서 동기 방식으로 동작할 수 있다고 한다 )
이 처리된 콜백 함수들은 이벤트 루프를 통해 각각의 phase에 대한 이벤트큐 ( 콜백큐 ) 에 넘겨지게 된다.
이후 이벤트 루프는 콜스택에 쌓여있는 함수가 없을 때, 이벤트큐에 대기하고 있던 콜백 함수를 콜스택으로 넘긴다.
그렇게 되면 콜스택에 쌓인 콜백 함수가 실행되고, 콜스택에서 제거 되는 방식으로 비동기 작업이 진행 되는 것이다.
 

이벤트 루프 내부의 phase는 어떤 것들이 있는가?

notion image
이벤트 루프는 볼 수 있는 것처럼 Timers, I/O Callbacks와 같이 많은 phase 들이 내부에 존재한다.
하나하나 간단하게 알아보도록 하자. 적은 순서는 페이즈를 순회하는 순서대로 적어보았다.
 

timers

  • 여러분이 아는 setTimeout(), setInterval() 이다.
  • 타이머 콜백의 경우 poll 큐에 등록 된다.
  • 타이머 콜백 내부 로직의 경우 poll 큐에 원래 등록되어있던 콜백이 처리되고 나중에 처리될 수 있기 때문에, 딱 지정된 시간에 실행될 것이라는 보장을 해주지 않는다.

Pending ( I/O ) callbacks

  • setImmediate() 를 제외한 거의 모든 콜백들을 집행 ( http, apiCall, DB read… )
  • io 관련 작업들의 성공, 실패 여부를 기다리며, 콜백이 없다면 끝. 있다면 FD에 쓰는 역할. 쓰여진 FD ( File Descriptor) 콜백들은 poll 단계에서 꺼내어져 수행 ( epoll 방식 )

idle, prepare

  • 그냥 내부에서 사용되는 친구들, 딱히 영향을 끼치지 않는다.

poll

  • 이벤트 루프가 uv__io_poll()을 호출하면, poll 큐에 있는 이벤트, 콜백들을 처리
  • poll 큐가 비어있는 경우: setImmediate()가 있으면 check로 넘어감. 없으면 이벤트루프가 phase를 돌며 콜백을 무한히 기다림. poll 이 끝나면 uv__run_check()가 호출된다.
  • poll 큐에 뭐가있음 : 이벤트루프가 큐를 순회하며 처리함.

check

  • setImmediate() 콜백 호출. 집행.

close callbacks

  • ~~~.on(’close’, callback) 와 같은 것들 실행
 

nextTickQueue, microTaskQueue

시스템의 실행 한도 영향을 받지 않아 Blocking될 수 있다.
  • 이 둘은 이벤트 루프의 일부가 아니다. 정확히 말하자면 libuv 내부가 아닌, Node.js에 구현이 되어있다. 그렇기에 이벤트 루프의 페이즈와는 상관없이 동작한다.
  • nextTickQueue는 process.nextTick()의 콜백을 관리하며 microTaskQueue는 Resolve된 Promise 콜백을 가지고 있다. nextTickQueue와 microTaskQueue는 현재 페이즈와 상관없이 지금 수행하고 있는 작업이 끝나면 그 즉시 바로 실행한다. ( node ≥ v11 )
  • 예를 들어 Phase에 접근하여 하나를 실행한 뒤에, nextTickQueue, microTaskQueue에 작업이 있는지 확인하고 이 부터 실행하는 것이다.
 
총 6개의 페이즈로 구성되어 있으며, 한 페이즈에서 다음 페이즈로 넘어가는 것을 tick 이라고 한다.
또한 각 페이즈는 각각의 큐 ( 이벤트큐 / 콜백큐 ) 를 가지고 있으며, 이벤트루프가 Round Robin 방식으로 순서대로 페이즈를 방문하여 큐에 쌓인 작업을 하나씩 실행하는 것이다.
페이즈의 큐에 담긴 작업을 모두 실행하거나 시스템의 실행 한도 에 다다르면 Node.js는 다음 페이즈로 넘어간다. ( 실제로 while 문안에서 차례대로 페이즈를 수행한다. )
 
정리하면서도 생각했지만, 진짜 무지하게 복잡한 것 같다.. 자바스크립트 어려워..
 

References