싱글 쓰레드인 Node 이벤트 루프 환경에서 비동기 처리를 지원하는 libuv의 백그라운드 쓰레드 수와, 이를 실행시키는 CPU 코어 수와의 상관관계를 알아보려고 한다.
"Node는 싱글 쓰레드니까, CPU 코어 개수랑 상관없이 성능은 일정해요!"
라고 생각했었는데, 비동기 처리 함수는 백그라운드 쓰레드를 사용하니까 코어수와 상관 관계가 있지 않을까? 생각했었던 것에서부터 이 검증과정을 시작했다.
결론은 코어개수랑 상관이 있다!! 코어가 많을 수록, Background Thread를 병렬로 처리할 수 있게 된다.
코어가 많아질 수록 성능이 올라가는 것은 이해가 되는데, 백그라운드 쓰레드를 많이 설정하면 성능이 올라갈까?
그렇지 않다. 바로 Context Switching의 비용때문이다. 자세한건 테스트를 보며 설명하겠다.
테스트할 함수는 다음과 같다.
const argon2 = require('argon2');
const start = Date.now();
async function asyncArgon(){
const promises = [];
for (let i = 0; i < 4; i++) {
promises.push(
console.log(`Start hash1 ${i}`),
console.log(`Start hash2 ${i}`),
argon2.hash('1234567').then(hash => {
console.log(`Hash ${i}:`, hash);
console.log(`Time hash done:`, Date.now() - start);
})
);
}
await Promise.all(promises);
console.log('All tasks completed');
console.log(`Time :`, Date.now() - start);
// 무한 루프로 컨테이너가 종료되지 않도록 함
setInterval(() => {
console.log('Keeping the container alive...');
}, 10000); // 10초마다 메시지를 출력
}
asyncArgon();
기본적으로 CPU코어 수, libUV thread 수를 변인으로 Node 런타임 환경을 실행해야한다. 로컬에서 테스트하게 되면, 컴퓨터의 코어수를 그대로 사용할 것이므로 검증이 힘들다.
깔끔하게 테스트하기 위한 좋은 방법은 도커를 활용해서 CPU의 수를 제한을 주는 방법이다. (물론 가상서버 하나 파서 테스트해보면 되지만 굳이..?)
정의한 도커파일은 다음과 같다.
여기서 node 버전을 local에서 돌려본 node 버전이랑 맞추자. 안그러면 버전 이슈로 실행되지 않는다.
또한 libuv thread수와 cpu수를 환경변수로 일일이 넘겨야 하므로, 이 환경변수는 컨테이너를 실행시킬 때 넘기기로 하자.
먼저 hash함수를 100번 시키도록 바꿔봤다.
const argon2 = require('argon2');
const start = Date.now();
async function asyncArgon(){
const promises = [];
for (let i = 0; i < 100; i++) {
promises.push(
console.log(`Start hash1 ${i}`),
console.log(`Start hash2 ${i}`),
argon2.hash('1234567').then(hash => {
console.log(`Hash ${i}:`, hash);
console.log(`Time hash done:`, Date.now() - start);
})
);
}
await Promise.all(promises);
console.log('All tasks completed');
console.log(`Time :`, Date.now() - start);
// 무한 루프로 컨테이너가 종료되지 않도록 함
setInterval(() => {
console.log('Keeping the container alive...');
}, 10000); // 10초마다 메시지를 출력
}
asyncArgon();
거진 두배차이의 시간이 걸린다. 즉 쓰레드가 많을 수록, 컨텍스트 스위칭이 발생해서 문제가 발생한다. CPU코어수가 2개일 경우엔 어떨까?
CPU-Bound한 작업들이 오래동안 CPU를 사용해야 함 + Context Switching 비용이 매우 늘어나면서 시간이 지연되었다고 추측이된다.
정해진 Time Quantum을 실행은 해야겠는데, 한번 실행되어야할 쓰레드는 오랜시간을 잡아먹어서 시간이 지나 다시 thread switching이 일어나고, 이에 따라 switching비용이 발생되었다고 추측된다.
결국 CPU-Bound한 작업들은 Thread의 수로 조절하다기 보다는, Core의 개수로 조절하는게 맞지 않을까 싶다.
CPU-Bound가 아닌 I/O Bound의 작업이라면 어떨까?
I/O Bound는 libuv의 백그라운드 쓰레드를 사용하지 않는다. 그래서 쓰레드의 수랑도 상관이 없다. 이 친구는 kernel의 비동기 작업에 맡겨서, 메인 쓰레드에 영향을 주지 않는다고 한다.
https://www.youtube.com/watch?v=qCC56uJh3bk&list=PLC3y8-rFHvwh8shCMHFA5kWxD9PaPwxaY&index=42
따라서, asynchronous한 작업들이 kernel에서 async하게 작동하는지, libuv 쓰레드에서 async하게 작동하는지는 어떠한 Job을 하는지에 따라 다르다. 이는 libuv의 내부 구조를 뜯어보면 알 수 있을 것 같다.
background thread pool을 사용하는 async작업들은 Core에 영향을 받지만, 그렇지 않은 작업들은 OS Kernel에게 이관되므로, Core와 Thread에 영향을 받지 않는다.
와 저도 오늘 이거 비슷한 주제로 유튜브 영상 봤는데 후덜덜...
자매품으로 cpu-bound하지 않은 작업(network I/O)은 core나 thread_pool size를 늘려도 성능이 향상되는가? 도 있습니다