Worker Threads는 멀티 스레드 처리를 위해 제공되는 기능으로, JavaScript의 싱글 스레드 특성을 보완하고 병렬 처리를 가능하게 합니다. Node.js는 기본적으로 싱글 스레드 기반으로 동작하지만, 복잡한 작업이나 CPU 집약적인 작업을 처리할 때 단일 스레드만 사용하면 응답 지연이나 성능 저하가 발생할 수 있습니다. 이를 해결하기 위해 Worker Threads가 도입되었습니다.
Node.js가 실행되면 아래가 실행됩니다.
하나의 프로세스 : 어디서든 접근 가능한 전역 객체, 실행된 순간의 정보를 가지고 있는 프로세스
하나의 스레드 : 단일 스레드는 주어진 프로레스에서 오직 한 번에 하나의 명령만이 실행됩니다.
하나의 이벤트 루프 : 비동기 작업을 위해 callback, promise, async/await 를 통해 시스템 커널에 작업을 넘기게됩니다.
하나의 js 엔진 인스턴스 : js 코드를 실행하는 컴퓨터 프로그램입니다.
하나의 노드 js 인스턴스 : 노드 js 코드를 실행하는 컴퓨터 프로그램입니다.
노드는 단일 스레드에서 실행되고, 이벤트 루프에는 한 번에 하나의 프로세만 발생하게됩니다. 하나의 코드 하나의 실행으로 코드는 병렬로 실행되지 않습니다. 이것을 통해 동시성 문제를 해결하고 자바스크립트를 사용하는 방법을 단순하게 만들어줍니다.
하지만 CPU 자원을 많이 사용하는 코드가 있으며, 이 코드가 다른 프로세스가 실행 되는걸 차단할 수도 있습니다. CPU 자원을 많이 사용하는 코드가 있는 서버에 요청하는 경우, 이 코드가 이벤트 루프를 차단하고 다른 요청들이 처리되지 않게 할 수 있는 문제가 발생할 수도 있습니다.
따라서, CPU 작업과 I/O 작업을 구분하는 게 중요합니다. Node.js 코드는 병렬로 실행되지 않습니다.오직 I/O 작업만 비동기식으로 실행되므로, 병렬로 실행됩니다. 그래서 워커 스레드는 I/O 집약적인 일에는 별로 효과적이지 못한데, 왜냐하면 비동기적 I/O 작업이 워커가 하는 것보다 더 효율적이기 때문입니다. 워커의 가장 중요한 목표는 I/O 작업이 아닌 CPU 집약적인 작업의 퍼포먼스를 향상시키는 겁니다.
postMessage()
와 on('message')
이벤트를 통해 이루어집니다.new Worker(filename[, options])
: 워커 스레드를 생성합니다. 파일 경로를 통해 실행할 코드를 지정하며, 옵션을 통해 추가 설정을 할 수 있습니다.workerData
: 워커 스레드로 전달할 데이터.eval
: true
로 설정하면 filename
이 파일 경로가 아닌 문자열로 평가됩니다.stderr
, stdout
: 워커 스레드의 입출력을 처리할 수 있는 옵션.postMessage(value)
: 메인 스레드와 워커 스레드 사이에 메시지를 전송합니다. 워커는 parentPort.postMessage()
를 사용해 데이터를 메인 스레드로 전송하고, 메인 스레드는 worker.postMessage()
를 사용해 워커로 데이터를 보냅니다.terminate()
: 워커 스레드를 즉시 종료합니다. 이 메서드를 호출하면 해당 워커 스레드는 더 이상 작업을 수행하지 않고 종료됩니다. 리소스 관리 측면에서 불필요한 워커 스레드를 종료할 때 유용합니다.on(event, listener)
: 워커 스레드에서는 다양한 이벤트를 처리합니다.message
: 워커 스레드와 메인 스레드 간의 데이터 통신을 담당합니다. 워커가 parentPort.postMessage()
를 호출하면 메인 스레드에서 on('message')
로 데이터를 받을 수 있습니다. 반대로 메인 스레드에서 worker.postMessage()
를 호출하면 워커 스레드에서 데이터를 수신할 수 있습니다.error
: 워커 스레드에서 처리되지 않은 오류가 발생할 경우 이 이벤트가 발생합니다. 워커가 비정상적으로 종료되거나 오류를 일으켰을 때, 메인 스레드에서 이를 감지하여 적절한 처리를 할 수 있습니다.exit
: 워커 스레드가 종료될 때 발생하는 이벤트입니다. 정상적으로 종료되었는지 또는 비정상적으로 종료되었는지를 확인할 수 있습니다. 종료 코드가 0이 아니면 비정상 종료를 의미합니다.workerData
: 워커 스레드가 생성될 때 메인 스레드에서 전달된 데이터를 받는 객체입니다. 이 데이터는 워커가 초기화될 때 한 번만 전달되며, 이후 변경할 수 없습니다.parentPort
: 워커 스레드 내에서 메인 스레드와 메시지를 주고받기 위한 객체입니다. parentPort.postMessage()
를 통해 메인 스레드로 데이터를 보낼 수 있습니다.1 ) 기본 예제 코드
workerData
로 전달된 숫자를 두 배로 계산한 후, 메인 스레드로 그 결과를 전송합니다.exit
이벤트가 발생하며, 모든 워커 스레드가 종료되면 "워커 종료"라는 메시지를 출력합니다.const {
Worker,
isMainThread,
parentPort,
workerData,
} = require("worker_threads");
if (isMainThread) {
const threads = new Set();
// 메인 스레드: Worker 스레드를 생성
threads.add(
new Worker(__filename, {
workerData: { num: 1 },
})
);
threads.add(
new Worker(__filename, {
workerData: { num: 2 },
})
);
for (let worker of threads) {
// 워커 스레드로부터 메시지를 받음
worker.on("message", (result) => {
console.log(`워커로부터 결과: ${result}`);
});
// 워커가 오류를 발생시켰을 때
worker.on("error", (err) => {
console.error("워커에서 에러 발생:", err);
});
// 워커가 작업을 종료했을 때
worker.on("exit", () => {
threads.delete(worker);
if (threads.size === 0) {
console.log("워커 종료");
}
});
}
} else {
// 워커 스레드: 메인 스레드로부터 받은 데이터를 처리
const result = workerData.num * 2;
parentPort.postMessage(result); // 처리 결과를 메인 스레드로 전달
parentPort.close();
}
2 ) 소수 찾기 예제
1에서 100,000까지의 숫자 중 소수를 찾는 예제입니다.
워커 스레드를 사용했을때와 사용하지 않았을 때의 처리 시간을 비교합니다.
처리 시간은 구성 환경 마다 달라질 수 있습니다.
워커 스레드를 사용하지 않은 코드
console.time("Without Worker Thread"); // 처리 시간 측정 시작
// 주어진 숫자가 소수인지 확인하는 함수
function isPrime(num) {
if (num < 2) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
// 2부터 max까지의 숫자 중 소수를 찾는 함수
function findPrimes(start, end) {
const primes = [];
for (let i = start; i <= end; i++) {
if (isPrime(i)) {
primes.push(i);
}
}
return primes;
}
// 2부터 10,000,000까지의 소수를 찾음
const primes = findPrimes(2, 10000000);
console.log(`소수의 개수: ${primes.length}`); // 소수의 개수를 출력
console.timeEnd("Without Worker Thread"); // 처리 시간 측정 종료
대략 9.3s 시간이 소요되었습니다.
워커 스레드를 사용한 코드
const {
Worker,
isMainThread,
workerData,
parentPort,
} = require("worker_threads");
if (isMainThread) {
// 메인 스레드
console.time("With Worker Thread"); // 처리 시간 측정 시작
const range = 10000000; // 10,000,000까지 소수 찾기
const threadCount = 8; // 4개의 워커 스레드를 생성
const segment = Math.ceil(range / threadCount); // 각 스레드가 처리할 범위 크기
const workers = new Set();
let primes = []; // 소수 결과를 저장할 배열
// 각 워커 스레드에 범위를 나눠서 작업을 할당
for (let i = 0; i < threadCount; i++) {
const start = i === 0 ? 2 : i * segment + 1; // 첫 번째 워커는 2부터 시작
const end = (i + 1) * segment;
workers.add(new Worker(__filename, { workerData: { start, end } }));
}
for (let worker of workers) {
worker.on("exit", () => {
workers.delete(worker);
// 모든 워커가 완료되었을 때 처리 종료
if (workers.size === 0) {
console.log(`소수의 개수: ${primes.length}`);
console.timeEnd("With Worker Thread"); // 처리 시간 측정 종료
}
});
worker.on("message", (data) => {
primes = primes.concat(data);
});
worker.on("error", (err) => {
console.error("워커에서 오류 발생:", err);
});
}
} else {
// 워커 스레드: 메인 스레드로부터 받은 범위 내에서 소수를 찾음
const { start, end } = workerData;
function isPrime(num) {
if (num < 2) return false;
for (let i = 2; i <= Math.sqrt(num); i++) {
if (num % i === 0) return false;
}
return true;
}
function findPrimes(start, end) {
const primes = [];
for (let i = start; i <= end; i++) {
if (isPrime(i)) {
primes.push(i);
}
}
return primes;
}
// 소수 찾기 결과를 메인 스레드로 전달
const primes = findPrimes(start, end);
parentPort.postMessage(primes);
parentPort.close(); // 작업이 끝나면 통신 채널 닫기
}
워커 스레드를 사용하지 않았을 때 보다 4배 빠른 약 2.1s 시간이 소요되었습니다.
💡 워커 스레드를 8개를 사용하였지만 속도가 약 8배 빨라지지 않았을까요?
워커 스레드를 많이 늘린다고 해서 항상 시간이 비례해서 줄어들지는 않습니다.
워커 스레드가 늘어날수록 성능이 비례해서 증가하지 않는 것은 하드웨어의 한계(CPU 코어 수), 스레드 관리 오버헤드, 작업 분할의 불균형 및 메모리 자원 경쟁 등 다양한 요인 때문입니다.
따라서 워커 스레드의 성능을 극대화하기 위해서는 다음 사항을 고려해야 합니다.