Event Loop와 non-blocking I/O 실행 과정
Node.js가 Single Thread 임에도 어떻게 non-blocking I/O를 처리하는지 알아보자
사전 지식
스레드
콜백
Node.js는 Single Thread이다
우선 node.js가 정말 Single Thread인지 알아보자.
// 1번 예시
setTimeout(() => {
console.log('test');
}, 1000);
// 결과
test
test
test
...
// 2번 예시
setTimeout(() => {
console.log('test');
}, 1000);
while (true) {
}
// 무한루프로 인한 blocking 상태 진입
위 소스코드는 node.js가 Single Thread 임을 증명할 수 있는 간단한 예시 코드다.
2번 예시를 실행했을 때 무한루프로 인한 blocking 상태에 진입한다. 그리고 1번 예시와 다르게 앞서 등록했던 callback 함수도 실행되지 않는다. callback 함수는 별도의 Thread가 아닌 기존의 Main Thread에서 동작한다는 것을 알 수 있다.
즉, node.js에서 개발자가 작성하는 자바스크립트 코드는 Single Thread로 동작한다.
Node.js의 주요 구성 요소
node.js는 여러 라이브러리로 구성되어 있으며 I/O 동작을 이해하기 위해 알아야하는 것은 V8 Engine과 libuv이다.
V8 Engine (JS 실행 엔진)
- heap memory 할당
- call stack 실행
- GC
- bytecode compile
libuv (비동기 I/O 멀티플랫폼 라이브러리)
- I/O 작업 실행
- Thread Pool 보유
- event loop를 통한 event queue 관리
libuv는 node.js가 Single Thread 임에도 멈추지 않고 실행되도록 I/O 작업을 non-blocking 방식으로 처리한다. 그리고 내부에 Thread Pool을 보유하고 있다.
libuv가 I/O 작업을 non-blocking 방식으로 처리하는 과정
libuv는 1개의 Event Loop와 여러개의 Worker Thread로 구성되어 있으며 I/O 작업을 non-blocking 방식으로 처리할 수 있다. node.js는 Single Thread인데 libuv는 Multi Thread로 실행될 수 있다는 뜻이다.
I/O 작업이 요청되면 해당 작업이 blocking, non-blocking 중에서 어느 방식이냐에 따라 다르게 처리한다.
커널의 비동기 I/O 지원을 받을 수 있는 I/O 작업인 경우 : 커널의 인터페이스를 통해 작업을 전달하고 실질적인 작업은 커널에서 비동기 방식으로 처리된다. 작업이 완료되면 해당 작업의 완료 이벤트를 libuv로 전달한다. libuv는 이러한 완료 이벤트를 받아서 Event Queue에 callback을 등록한다.
blocking I/O 작업인 경우 : libuv 내의 Thread Pool에서 Worker Thread를 선택하여 작업을 위임한다. Worker Thread는 작업을 완료하고 Event Queue에 callback을 등록한다. Main Thread와는 다른 Thread를 실행하기 때문에 병렬로 처리된다.
결국에는 모든 I/O 작업이 Main Thread가 멈추지 않는 구조로 실행된다.
Event Loop와 Event Queue
완료된 I/O 작업들은 앞에서 설명한 방법대로 callback의 형태로 Event Queue에 적재되었다. 이 callback들이 실행되기 위해서는 V8 Engine의 Call Stack으로 옮겨질 필요가 있다.
Event Loop는 Main Thread에 의해 실행되며, Event Queue에 적재된 callback을 Call Stack이 비어있을 때 Call Stack으로 옮기는 작업을 지속적으로 수행하여 callback이 Main Thread에 의해 실행될 수 있도록 한다.
그런데 Event Queue는 1개가 아니다.
libuv는 효율적인 비동기 처리를 위해 Queue를 6개로 세분화하였다. Event Loop는 이 6개의 Queue를 순차적으로 검사하면서 callback을 Call Stack으로 이동시킨다.
(여기서 callback을 queue에서 Call Stack으로 이동하는 하나의 작업을 tick이라고 한다.)
- Timer : setTimeout(), setInterval()로 예약된 타이머 callback을 처리
- Pending callbacks (I/O cycle) : 이전 Loop에서 완료된 callback 또는 error callback을 처리
- Idle, Prepare (I/O cycle) : Event Loop의 초기화와 관련된 작업을 수행. Poll 단계를 준비
- Poll (I/O cycle) : 새로운 I/O 이벤트의 커넥션과 데이터 처리 허용. 이 단계에서 Call Stack으로 제일 많이 이동함
- Check : setImmediate()로 예약된 callback을 처리
- Close callbacks : socket.on('close', () => {}) 처럼 자원 해제류의 callback을 처리
그리고 이러한 Queue들 사이에서 nextTickQueue와 microTaskQueue가 실행된다.
다음 코드를 통해서 callback의 실행 순서를 간단하게 확인할 수 있다.
// Check queue에서 실행됨
setImmediate(() => {
console.log('Immediate');
});
// Timer queue에서 실행됨
setTimeout(() => {
console.log('Timeout');
}, 0);
// 결과
Timeout
Immediate
Event Loop에서 Timer Queue가 Check Queue보다 먼저 처리되므로 setTimeout()의 callback이 먼저 실행되었다.
Node.js는 결국 무슨 구조인가
결론적으로 node.js는 Single Thread Event Loop와 Multi Thread libuv 라이브러리를 통한 비동기 I/O 처리를 결합한 복합적인 구조라고 할 수 있다.
참조
'Programming > Node.js' 카테고리의 다른 글
[Node.js] parameter가 있는 middleware (1) | 2020.03.27 |
---|---|
[Node.js] PayloadTooLargeError: request entity too large (1) | 2019.08.11 |
[Node.js] .env 환경변수 (1) | 2019.05.24 |
댓글