Javascript를 브라우저 밖에서도 실행할 수 있도록 하는 Javascript의 런타임
node.js를 접하다보면 자주 나오는 말은 다음과 같다
- 이벤트 기반
- 싱글 쓰레드 논블로킹 모델
1. 이벤트 기반
node.js는 이벤트 리스너에 등록해둔 콜백함수를 실행하는 방식으로 동작한다.
1
2
router.get("/", (req, res, next) => {
})
이러한 이벤트 기반 모델에서는 이벤트에 따라 호출되는 콜백함수를 관리하는 것이 “이벤트 루프”이다. (브라우저의 이벤트 루프와는 다름)
이벤트 루프 동작 원리
node.js가 시작되면 스레드가 생기고, 이벤트 루프가 생성된다. 이벤트 루프는 6개의 페이지를 라운드 로빈으로 순회하며 동작한다.
각 단계는 FIFO 큐를 가지며 다음과 같은 작업을 수행한다.
- timers
setTimeout
,setInterval
같은 timer 함수를 처리한다- 페이즈를 순회하면서 처리할 수 있는 timer 함수를 확인하고 콜백함수를 실행한다.
- pending callbacks
- 다음 루프 반복으로 연기된 I/O 완료 결과가 큐에 담긴다.
- I/O 작업이 완료되면 다음 루프에서 이 단계 큐에 들어와 있고, I/O 작업 블록 내의 콜백함수들은 poll 단계의 큐로 넘겨준다.
- 또한 TCP 오류 같은 시스템 작업의 콜백을 실행한다.
- idle, prepare : 내부용으로만 사용됨
- poll
- I/O와 연관된 콜백을 실행하며 timer 단계에서의 실행시간 제어를 담당한다.
setTimeout
,setInterval
,setImmediate
로 등록한 콜백 제외 대부분이 여기서 처리됨.- poll 큐에 쌓인 콜백함수들을 한도가 넘지 않을때까지 모두 동기적으로 실행한다.
- 한도가 넘거나 더이상 실행항 콜백함수가 없을 경우 아래 규칙에 따라 동작한다.
- check 단계를 검사하여
setImmediate
가 있는지 확인한다. 있으면 check 단계로 넘어간다 - timer 단계를 검사하여 실행할 timer 함수가 있는지 확인한다. 없으면 있을때까지 대기 한후 timer 단계로 넘어간다.
- timerㄷ단계를 대기하는 동안 poll 큐에 콜백함수가 쌓이면 즉시 실행한다
- check 단계를 검사하여
- check
setImmediate
의 콜백함수를 실행한다.
- close callbacks - close, destroy 이벤트에 따른 콜백함수를 실행한다 ex)
socket.on("close", ...)
예시)
1
2
3
4
5
6
7
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
- 이 코드를 실행시 timer 단계라면
setTimeout
을 먼저 실행함. timer 단계를 지났다면setImmediate
를 먼저 실행한다
1
2
3
4
5
6
7
8
9
10
const fs = require('fs');
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout');
}, 0);
setImmediate(() => {
console.log('immediate');
});
});
- i/o 콜백 함수는 poll 단계에서 실행되기에 항상
setImmediate
먼저 수행되게 된다.
2. 싱글 쓰레드 논블로킹 모델
Node.js를 접하다보면 자주 보이는 말이 Node.js는 싱글 쓰레드 논블로킹이라는 말이다.
먼저 싱글쓰레드에 대해서 살펴보면, Node.js는 이론적으로는 싱글 쓰레드지만 완전한 싱글 쓰레드는 아니다.
구체적으로는 node.js는 동기 작업이 동작하는 main thread(이벤트루프)와 libuv 라이브러리로 관리되는 스레드풀에서 블로킹 작업이 수행된다. 네트워크 I/O 등 비동기 작업이 발생해도 main thread를 막지 않고, 비동기 작업은 백그라운드에서 처리하고 main thread 는 그동안 다른 작업을 수행할 수 있다. 이것이 싱글스레드 논블로킹의 의미이다.
- 동기 작업 : main thread 에서 실행 (이것도 libuv에서 관리되긴 함)
- 비동기 작업 (I/O, 네트워크 작업) : libuv를 통해 백그라운드에서 처리됨.
예를들어, 각각 3초, 5초, 6초, 8초가 걸리는 요청이 매우 짧은 간격으로 들어온다면 아래와 같이 동작한다
- 전부 동기작업만 있을경우, event loop에서 차례대로 처리되어 총 3 + 5 + 6 + 8 = 22초 + a 걸림. (a는 context switching)
- 전부 비동기작업만 있을경우, libuv에서 비동기로 동작하기에 가장 오래 걸리는 작업과 같은 8초가 걸림.
libuv
libuv는 비동기 작업을 처리해주는 라이브러리이다. fs, socket과 같이 libuv를 호출하는 함수가 있으면 libuv는 코드 제어권을 가지지 않고 다음 코드를 실행할 수 있도록 제어권을 넘긴다 (논블로킹)
libuv는 자신의 스레드풀을 가지고 있다. 따라서 호출된 작업을 검사후 네트워크 I/O와 OS 커널에서 지원해주는 작업이면 커널을 사용하고, 커널이 지원하지 않는 작업(Disk I/O 등)에 대해서만 자신의 스레드풀을 사용한다. 이것이 완전한 싱글쓰레드가 아니다라는 것의 의미이다.
libuv의 스레드풀을 사용하는 작업들
- DNS resolver (because most OS provide only synchronous API for this)
- File system API (because this is messy to do asynchronously cross-platform)
- Crypto (Because this uses the CPU)
- Zlib (zip compression)
따라서 libuv의 스레드 풀을 늘릴 수는 있으나 libuv의 스레드풀을 사용하는 작업이 아닌 것에 대해서는 큰 의미가 없다. (Disk I/O 또한 Disk가 하나이면 한번에 하나씩 접근 가능하기에 의미 없음) (기본은 4개)
worker_threads
node.js 에서 의도적으로 멀티스레드를 사용할 수 있는 방법
3. 클러스터 모드 (멀티 코어 사용)
물리 코어는 동일 시점에 하나의 스레드만 실행한다. 이때 하나의 node.js 인스턴스는 하나의 스레드를 사용함으로 아무리 많이 사용해봤자 싱글 코어 하나만 차지한다. (libuv 스레드풀에서 여러 스레드를 더 사용할 수 있지만, 일반적으로 하나의 코어만 사용한다고 봄)
최신 CPU는 멀티코어가 기본이므로 멀티코어 CPU에서 하나의 node.js만 띄우면 비효율적이다. 따라서 다음과 같이 멀티 코어를 전부 활용하는 방법을 사용할 수 있다.
- node.js cluster 모듈 사용
- PM2 사용
- worker threads 사용 : node.js 10.5+ 버전부터 CPU 집약 작업을 병렬로 처리할 수 있도록 함. 프로세스는 하나로 동작함.
하지만 이러한 최적화 기법이 항상 통하는 것이 아니다. 만약 성능 bottleneck이 javascript 코드 자체에 있다면 클러스터가 도움이 된다. 하지만 만약 network i/o나 db 작업에 있다면 큰 도움이 안된다. 따라서 어디가 문제인지를 찾아야한다.
For example if you are CPU limited in your nodejs process with the running of your own Javascript, then perhaps you want to implement nodejs clustering to get multiple CPUs all running different requests. But, if the real bottleneck is in your database, then cluster your nodejs request handlers won’t help with the database bottleneck.
Benchmark, measure, come up with theories based on the data for what to change, design specific tests to measure that, then implement one of the theories and measure. Adjust based on what you measure. You can only really do this properly (without wasting a lot of time in unproductive directions) by measuring first, making appropriate adjustments and then measuring progress.
참고
- https://velog.io/@tennfin1/NodeJS%EA%B0%80-Spring%EB%B3%B4%EB%8B%A4-%EB%B9%A0%EB%A5%B4%EB%8B%A4%EA%B3%A0-%EC%89%BD%EA%B2%8C-%EC%84%A4%EB%AA%85%ED%95%B4%EB%93%9C%EB%A6%BC\
- https://jdm.kr/blog/166
- https://medium.com/@vdongbin/node-js-%EB%8F%99%EC%9E%91%EC%9B%90%EB%A6%AC-single-thread-event-driven-non-blocking-i-o-event-loop-ce97e58a8e21
- https://stackoverflow.com/questions/63369876/nodejs-what-is-maximum-thread-that-can-run-same-time-as-thread-pool-size-is-fo