핵심 정리
import { Worker } from "node:worker_threads";
import os from "node:os";
const POOL_SIZE = os.availableParallelism();
const pool = Array.from({ length: POOL_SIZE }, () => new Worker("./compute-worker.js"));
let next = 0;
function dispatch(task) {
return new Promise((resolve, reject) => {
const worker = pool[next % POOL_SIZE];
next += 1;
worker.once("message", resolve);
worker.once("error", reject);
worker.postMessage(task);
});
}풀과 채널
먼저 구분해 둘 기본형은 아래입니다.
- 반복 CPU 작업: worker pool
- 요청별 통신 분리:
MessageChannel - 복사 대신 이동:
transferList - 진짜 공유 메모리:
SharedArrayBuffer+Atomics - 풀 크기 기준: CPU 코어 수와 작업 성격 함께 보기
반복 작업이면 worker를 재사용한다
한 번성 CPU 계산이면 단일 worker로도 되지만, 반복 요청이면 생성 비용이 금방 병목이 됩니다. 이 카드의 핵심은 "요청마다 새 worker"를 기본값으로 두지 않는 것입니다.
MessageChannel은 통신 경계를 분리할 때 쓴다
기본 parentPort만으로 부족할 때, 특정 요청용 채널이나 worker 간 별도 채널이 필요하면 꺼냅니다.
큰 바이너리는 복사보다 transfer를 먼저 본다
대용량 ArrayBuffer를 복사해 주고받기 시작하면 worker 도입 이점이 줄어듭니다. zero-copy transfer가 필요할 수 있습니다.
메모리 공유가 진짜 필요한지 따로 묻는다
SharedArrayBuffer와 Atomics는 강력하지만 복잡도가 높습니다. 단순 큐잉과 결과 반환이면 풀 + 메시지 모델만으로 충분한 경우가 많습니다.
풀 크기는 "많을수록 좋다"가 아니다
CPU 코어 수보다 훨씬 많은 워커를 두면 문맥 전환 비용과 큐 관리 비용이 먼저 튈 수 있습니다. 그래서 보통은 코어 수 근처에서 시작하고, 실제 작업 길이와 메시지 크기를 보며 조정하는 편이 낫습니다.
MessageChannel은 parentPort를 대체한다기보다 분리한다
기본 채널 하나로 모든 요청과 제어 메시지를 섞기 시작하면 흐름이 금방 복잡해집니다.
특정 작업 단위 채널이나 워커 간 별도 통신 경계가 필요할 때 MessageChannel을 붙이는 편이 읽기 쉽습니다.
언제 풀을 만들까
체크포인트
- 반복 CPU 작업: worker pool
- 요청별 전용 채널:
MessageChannel - 큰 바이너리 전달: transferList
- 진짜 공유 메모리 필요:
SharedArrayBuffer+Atomics - I/O 중심 작업: worker보다 이벤트 루프 우선
- 풀 크기는 코어 수와 작업 길이를 같이 보고 조정
주의할 점
worker 수를 늘리면 무조건 빨라지는 게 아니다. 생성 비용과 postMessage 직렬화 비용이 큰데 요청마다 새 worker까지 띄우면 오히려 느려질 수 있다.
참고 링크
1 sources