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

Main·2024년 9월 26일
0

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개의 댓글