Node.js가 싱글 쓰레드인지 멀티 쓰레드인지 알기 위해 Node.js가 자바스크립트 코드를 어떠한 방식으로 실행하는지 알아야 한다.
자바스크립트는 기본적으로 브라우저에서 실행하기 위한 언어이다.
그리고 자바스크립트를 브라우저에서 독립시켜 실행하기 위한 런타임이 바로 Node.js이다.
그래서 자바스크립트의 동작 방식이 브라우저 환경과 Node.js 환경에서 조금 달라진다.
이번 포스팅에서는 Node.js에서 자바스크립트 코드가 실행되는 방식을 알아보려고 한다.
다음은 기본적인 node.js의 구조이다.
call stack
V8 엔진의 메모리 구조에는 call stack이라는 것이 존재한다.
자바스크립트 코드는 이 call stack에 하나씩 저장되어 실행된다.
다음 node.js에서 자바스크립트 코드가 실행될 때 call stack의 변화를 살펴보자.
function sayHello(){
console.log("hello world!");
}
console.log("프로그램 시작");
sayHello();
말 그대로 stack 형식으로 명령어들을 하나씩 쌓아가고 실행하면 스택에서 제거하는 형식이다.
자바스크립트는 하나의 호출 스택만 가지고 있고 이는 한 번에 하나의 일만 할 수 있다는 뜻이다.
libuv
node.js는 libuv라는 C++로 작성된 라이브러리를 사용하여 비동기를 지원한다.
node.js에서 비동기 요청이 들어오면 이 요청을 libuv에게 넘겨준 뒤 libuv에서 처리하도록 하는 것이다.
node.js의 이벤트 루프는 이벤트 큐에 존재하는 콜백 함수들을 하나씩 가져와서 call stack에 넣어주는 방식으로 콜백 함수들을 처리한다.
libuv 라이브러리는 운영체제의 커널이 지원하는 비동기 API에 대해 알고 있다.
만약 비동기 함수를 실행하면 libuv는 커널의 해당 비동기 요청 지원 여부를 확인하고 가능하다면 커널에게 비동기 작업을 요청하고 응답이 오면 응답을 전달해준다.
커널이 해당 비동기 요청을 지원하지 않는다면 내부의 쓰레드 풀의 쓰레드를 사용하여 비동기 작업을 진행한다.
libuv는 비동기 작업들을 관리하는 event loop를 가지고 있다.
위 그림을 자세히 보면 이벤트 큐가 총 6개가 있는 것을 볼 수 있다.
큐가 6개인 이유는 페이즈(phase)가 6개이고 각 페이즈에서는 큐를 가지고 있기 때문이다.
event loop는 현재 페이즈에 해당하는 큐의 콜백들을 가져와서 실행한다.
페이즈들의 종류는 다음과 같다.
- Timer Phase : setTimeout, setInterval가 같은 타이머들을 다룬다.
- Pending Callbacks Phase : 이전 이벤트 루프 반복에서 미처 수행되지 못했던 I/O 콜백을 다룬다.
- Idle, Prepare Phase : node.js 내부 관리를 한다.
- Poll Phase : I/O 관련 콜백을 다룬다.
- Check Phase : setImmediate 콜백을 다룬다.
- Close Callbacks Phase : close 타입의 핸들러를 처리한다.
페이즈의 전환 순서는 아래와 같다.
Timer-> Pending Callbacks -> Idle, Prepare -> Poll -> Check -> Close Callbacks -> Timer
페이즈 전환을 틱(Tick)이라고 틱은 다음 두 가지 조건 중 하나를 만족할 때 발생한다.
- 큐 안의 모든 작업 실행 완료
- 시스템 실행 한도 초과
이제 libuv의 nextTickQueue와 microTaskQueue에 대해 알아보자.
nextTickQueue와 microTaskQeuue는 이벤트 루프의 페이즈와 관계없이 동작한다.
nextTickQueue는 process.nextTick의 콜백을 관리하고 microTaskQueue는 resolve 된 Promise의 콜백을 관리한다.
nextTickQueue의 우선순위는 microTaskQueue보다 높고 nextTickQueue와 microTaskQueue는 현재 페이즈의 작업이 끝나면 바로 실행한다.
즉 현재 페이즈의 작업이 끝나면 nextTickQueue 안의 모든 콜백들을 실행한 다음 microTaskQueue의 모든 콜백들을 실행한 뒤 다음 페이즈로 넘어가는 것이다.
node.js는 싱글 쓰레드일까? 멀티 쓰레드일까?
node.js 동작 그림을 다시 보면서 위 질문에 대답해보자.
흔히 node.js가 싱글 쓰레드라고 불리는 이유는 event loop 자체가 싱글 쓰레드로 동작하기 때문에 한 페이즈의 한 작업만 실행할 수 있기 때문이다.
물론 libuv 커널이나 내부의 쓰레드 풀을 통해 여러 비동기 작업을 멀티 쓰레드 방식으로 진행을 하지만 결국 이벤트 큐 안의 콜백들을 가져와서 실행하는 event loop 자체는 싱글 쓰레드로 동작한다.
Node.js의 비동기 작업들을 관리하고 처리하는 event loop가 싱글 쓰레드로 동작한다.
node.js가 비동기 I/O 작업을 처리하는 과정
이제 node.js가 비동기 I/O 작업을 처리하는 과정을 알아보자.
우선 I/O 작업을 하는 주체는 운영체제의 커널인데 커널에 대해 잘 모른다면 아래 링크를 보고 오는 것을 추천한다.
(커널에 대해 모른다면 아래 링크 참조)
https://growth-coder.tistory.com/304
libuv는 운영체제의 커널이 지원하는 비동기 API에 대해 알고 있기 때문에 I/O 작업을 커널 API를 사용하여 커널에게 넘길 수 있다.
아래처럼 파일을 비동기로 읽는 자바스크립트의 동작 과정을 살펴보자.
const fs = require('fs')
fs.readFile('./test.txt', (err, data)=>{
if (err){
throw err;
}
console.log(data.toString());
})
- fs.readFile 코드를 실행한다.
- node.js가 비동기 I/O 작업을 libuv에게 위임한다.
- libuv는 파일 읽기에 관련된 api를 커널이 지원하는지 확인한다.
- 지원하기 때문에 커널 api를 사용하여 커널에게 I/O 작업을 맡긴다.
- 커널이 I/O 작업을 마치면 libuv에게 알려준다.
- poll 페이즈 안의 이벤트 큐에 callback 함수를 넣는다.
- 이벤트 루프가 해당 페이즈 안의 큐를 확인하고 callback 함수를 실행한다.
node.js가 타이머 작업을 처리하는 과정
이제 node.js가 I/O가 아닌 타이머 작업을 처리하는 과정을 알아보자.
setTimeout(()=>{
console.log("hello");
}, 3000);
console.log("start");
- setTimeout 코드를 실행한다.
- node.js가 비동기 작업을 libuv에게 위임한다.
- timer phase의 min-heap에 타이머를 넣는다.
- 만약 타이머의 시간이 되었다면 timer phase의 이벤트 큐에 콜백 함수를 넣는다.
- 이벤트 루프가 해당 페이즈 안의 큐를 확인하고 callback 함수를 실행한다.
혹시 잘못된 내용이 있다면 댓글로 알려주신다면 감사하겠습니다.
출처
https://www.korecmblog.com/blog/node-js-event-loop
https://smit90.medium.com/deep-dive-into-the-event-loop-understanding-node-js-internals-f9263ef91233
https://nodejs.org/en/learn/asynchronous-work/event-loop-timers-and-nexttick
댓글