[Node.js] 내장 모듈 - worker threads

JT·2024년 9월 26일

Node.js

목록 보기
6/20
post-thumbnail

Worker Threads는 멀티 스레드 처리를 위해 제공되는 기능으로, JavaScript의 싱글 스레드 특성을 보완하고 병렬 처리를 가능하게 합니다. Node.js는 기본적으로 싱글 스레드 기반으로 동작하지만, 복잡한 작업이나 CPU 집약적인 작업을 처리할 때 단일 스레드만 사용하면 응답 지연이나 성능 저하가 발생할 수 있습니다. 이를 해결하기 위해 Worker Threads가 도입되었습니다.


Node.js의 실행 구조

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 집약적인 작업의 퍼포먼스를 향상시키는 겁니다.


Worker Thread

  • Main thread (메인 스레드): 기본 Node.js 스레드로, 애플리케이션의 메인 코드가 실행되는 곳입니다.
  • Worker thread (워커 스레드): 메인 스레드에서 CPU 집약적인 작업을 따로 처리할 수 있도록 추가된 스레드입니다. 서로 다른 스레드는 독립적인 이벤트 루프를 가지고 있으며, 별도의 V8 인스턴스를 사용합니다.


Worker Thread의 특징

  • 병렬 처리: CPU 집약적인 작업을 워커 스레드에서 처리함으로써 메인 스레드의 이벤트 루프를 방해하지 않고, 동시에 여러 작업을 처리할 수 있습니다.
  • 독립된 실행 환경: 각 워커 스레드는 자체 이벤트 루프와 V8 엔진 인스턴스를 사용하므로 메모리를 공유하지 않습니다. 단, 데이터는 메시지 패싱(Message Passing)을 통해 서로 통신할 수 있습니다.
  • 메시지 기반 통신: 워커 스레드와 메인 스레드 간에는 메시지(데이터)를 주고받을 수 있으며, 이는 postMessage()와 on('message') 이벤트를 통해 이루어집니다.

Worker Threads 사용 시 주의할 점

  • CPU 집약적인 작업에 적합: 워커 스레드는 I/O 작업이 아닌 CPU 부하가 큰 작업에 적합합니다. I/O 작업은 비동기 처리로 충분히 해결할 수 있습니다.
  • 메모리 사용: 각 워커 스레드는 별도의 메모리 공간을 사용하므로, 많은 워커 스레드를 생성하면 메모리 사용량이 증가할 수 있습니다.
  • 오버헤드: 워커 간의 통신은 메시지 패싱을 통해 이루어지므로, 통신 오버헤드가 발생할 수 있습니다. 따라서 모든 작업에 적합하지 않을 수 있습니다.

Worker Threads 메서드

  • new Worker(filename[, options]) : 워커 스레드를 생성합니다. 파일 경로를 통해 실행할 코드를 지정하며, 옵션을 통해 추가 설정을 할 수 있습니다.
    • options
    • workerData: 워커 스레드로 전달할 데이터.
    • eval: true로 설정하면 filename이 파일 경로가 아닌 문자열로 평가됩니다.
    • stderr, stdout: 워커 스레드의 입출력을 처리할 수 있는 옵션.
  • postMessage(value) : 메인 스레드와 워커 스레드 사이에 메시지를 전송합니다. 워커는 parentPort.postMessage()를 사용해 데이터를 메인 스레드로 전송하고, 메인 스레드는 worker.postMessage()를 사용해 워커로 데이터를 보냅니다.
  • terminate() : 워커 스레드를 즉시 종료합니다. 이 메서드를 호출하면 해당 워커 스레드는 더 이상 작업을 수행하지 않고 종료됩니다. 리소스 관리 측면에서 불필요한 워커 스레드를 종료할 때 유용합니다.
  • on(event, listener) : 워커 스레드에서는 다양한 이벤트를 처리합니다.
    • event
    • message : 워커 스레드와 메인 스레드 간의 데이터 통신을 담당합니다. 워커가 parentPort.postMessage()를 호출하면 메인 스레드에서 on('message')로 데이터를 받을 수 있습니다. 반대로 메인 스레드에서 worker.postMessage()를 호출하면 워커 스레드에서 데이터를 수신할 수 있습니다.
    • error : 워커 스레드에서 처리되지 않은 오류가 발생할 경우 이 이벤트가 발생합니다. 워커가 비정상적으로 종료되거나 오류를 일으켰을 때, 메인 스레드에서 이를 감지하여 적절한 처리를 할 수 있습니다.
    • exit : 워커 스레드가 종료될 때 발생하는 이벤트입니다. 정상적으로 종료되었는지 또는 비정상적으로 종료되었는지를 확인할 수 있습니다. 종료 코드가 0이 아니면 비정상 종료를 의미합니다.
  • workerData : 워커 스레드가 생성될 때 메인 스레드에서 전달된 데이터를 받는 객체입니다. 이 데이터는 워커가 초기화될 때 한 번만 전달되며, 이후 변경할 수 없습니다.
  • parentPort : 워커 스레드 내에서 메인 스레드와 메시지를 주고받기 위한 객체입니다. parentPort.postMessage()를 통해 메인 스레드로 데이터를 보낼 수 있습니다.

Worker Thread 사용하기

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 코어 수), 스레드 관리 오버헤드, 작업 분할의 불균형 및 메모리 자원 경쟁 등 다양한 요인 때문입니다.

따라서 워커 스레드의 성능을 극대화하기 위해서는 다음 사항을 고려해야 합니다.

  • CPU 코어 수에 맞는 워커 스레드 수 설정: 너무 많은 워커 스레드는 성능 향상에 기여하지 못하고 오히려 자원 낭비가 될 수 있습니다.
  • 작업 부하의 균등한 분배: 작업이 스레드 간에 균등하게 나눠지도록 설계해야 합니다.
  • 스레드 오버헤드 최소화: 불필요한 스레드 생성과 메시지 패싱을 줄이는 방향으로 설계합니다.

참고 사이트

https://inpa.tistory.com/entry/NODE-📚-workerthreads-모듈

profile
함께 개선하는 프론트엔드 개발자

0개의 댓글