서론

 

Node.js 딥다이브 (1) - JavaScript 를 "비동기"로 "실행"하기 위해 해야 할 일

간단히 요약하면..구성 요소V8은 ECMAScript 명세를 구현한 JavaScript 실행 엔진으로, 자체적으로 힙(heap), 스택(stack), 실행 컨텍스트(Execution Context) 구조를 가진다. 즉, V8만으로도 JavaScript 언어 자체 기

9436188.tistory.com

 

 

Node.js 딥다이브 (2) - libuv 의 I/O 멀티플렉싱 살펴보기

서론ibuv는 Node.js와 독립적인 비동기 I/O 추상화 계층이지만, Node.js의 이벤트 루프와 비동기 작업 흐름을 담당하므로 함께 정리하였다.libuv는 어떻게 비동기 작업과 그 콜백을 제어할까비동기 작

9436188.tistory.com

 

Node.js를 사용하며 ibuv가 js 코드를 비동기로 처리하는 방식 이외에도 프로그래머가 프로세스 또는 스레드를 명시적으로 생성하여 다룰 수 있다. 다음의 경우 유용하다.

  1. CPU 집약적 작업이 포함된 애플리케이션
    • 멀티스레드(worker_threads) 사용.
    • 작업을 병렬화하여 이벤트 루프 블로킹을 방지.
  2. 대규모 트래픽 처리 서버
    • 멀티프로세스(cluster) 사용.
    • 프로세스를 여러 개 생성해 부하 분산.
  3. 서로 다른 유형의 작업을 분리해야 할 때
    • 멀티프로세스(child_process) 사용.
    • 부모 프로세스는 관리/모니터링, 자식 프로세스는 계산 또는 데이터 처리.
  4. 메모리 공유가 필요한 병렬 데이터 처리
    • 멀티스레드(worker_threads) 사용.
    • SharedArrayBuffer를 활용하여 데이터 처리 효율 극대화.

멀티 프로세스

노드는 멀티 프로세스 프로그래밍을 지원하는 내장 모듈 child_process와 cluster 를 제공한다.

cluster 샘플 코드

  • 출처: doc/api/cluster.md
import cluster from 'node:cluster';
import http from 'node:http';
import { availableParallelism } from 'node:os';
import process from 'node:process';

const numCPUs = availableParallelism();

if (cluster.isPrimary) {
  console.log(`Primary ${process.pid} is running`);

  // Fork workers.
  for (let i = 0; i < numCPUs; i++) {
    cluster.fork();
  }

  cluster.on('exit', (worker, code, signal) => {
    console.log(`worker ${worker.process.pid} died`);
  });
} else {
  // Workers can share any TCP connection
  // In this case it is an HTTP server
  http.createServer((req, res) => {
    res.writeHead(200);
    res.end('hello world\n');
  }).listen(8000);

  console.log(`Worker ${process.pid} started`);
}

cluster 의 프로세스 생성과 관리

  1. cluster 파일이 실행되면 process.env.NODE_UNIQUE_ID를 확인하여 primary/worker 여부(isPrimary)를 포함한 cluster 객체를 반환한다.(lib/cluster.js) 최초에는 NODE_UNIQUE_ID가 없어 primary이다 
  2. 이 primary 프로세스에서 cluster.fork 함수를 실행하면 NODE_UNIQUE_ID를 0부터 1씩 증가한 값으로 설정한 후  child_process 모듈의 fork 함수를 호출 시 인자로 넘긴다. (lib/internal/cluster/primary.js #L161 -> #L118)
  3. child_process 모듈의 fork 함수는 일련의 유효성 검사를 거쳐 ChildProcess 생성자 함수를 호출하고 반환값에 대해 spawn 메서드를 호출한다. (lib/child_process.js #L122)
    1. ChildProcess 에서 생성한 Process 객체에 대해 spawn 함수를 호출한다 (lib/internal/child_process.js #L255, #L355)
    2. 현재 Environment 의 event loop를 uv_spawn 를 호출 시 인자로 넘긴다(src/process_wrap.cc #L153 > #L286)
      • 프로세스는 각자의 이벤트루프를 갖지만 부모 프로세스의 이벤트루프는 자식 프로세스 상태관련 이벤트를 추적할 수 있다.
    3. uv_spawn은 uv__spawn_and_init_child ~ uv__spawn_and_init_child_fork 를 거쳐 프로세스를 생성하고, uv__process_child_init 에서 인자로 받은 options (cwd와 파일경로 등)를 이용하여 자식프로세스를 새로운 노드 프로세스로 대체한다.(deps/uv/src/unix/process.c #L953, execvp 참고) 새 노드 프로세스가 실행되면서 별도의 event loop 도 실행된다. 이 때부터 일어나는 일들은 이 포스트에 정리해두었다.
  *pid = fork();

  if (*pid == 0) {
    /* Fork succeeded, in the child process */
    uv__process_child_init(options, stdio_count, pipes, error_fd);
    abort();
  }
  //...
  execvp(options->file, options->args);

멀티 스레드

worker_threads 모듈을 이용하여 멀티스레드 프로그래밍이 가능하다. 

worker_threasds 샘플코드

  • 출처: doc/api/worker_threads.md
const {
  Worker, isMainThread, parentPort, workerData,
} = require('node:worker_threads');

if (isMainThread) {
  module.exports = function parseJSAsync(script) {
    return new Promise((resolve, reject) => {
      const worker = new Worker(__filename, {
        workerData: script,
      });
      worker.on('message', resolve);
      worker.on('error', reject);
      worker.on('exit', (code) => {
        if (code !== 0)
          reject(new Error(`Worker stopped with exit code ${code}`));
      });
    });
  };
} else {
  const { parse } = require('some-js-parsing-library');
  const script = workerData;
  parentPort.postMessage(parse(script));
}

worker_threads 의 동작

worker_threads 모듈은 worker 내장모듈을 로드한다. (lib/worker_threads.js - lib/internal/worker.js)

Worker 생성자 내부에서 Worker 객체를 생성(lib/internal/worker.js #L214) 및 스레드 시작함수를 호출(lib/internal/worker.js #L290)한다.

  • Worker 클래스는 node_main.cc가 아닌 node_worker.cc 파일에 정의되어 있다.
The Worker class represents an independent JavaScript execution thread. Most Node.js APIs are available inside of it. (....) Creating Worker instances inside of other Workers is possible.
  • 워커 객체 생성 시 부모 프로세스와 연결하기 위한 메시지 포트를 생성하고 부모와 연결 (src/node_worker.cc #L71, 78)
If this thread is a Worker, this is a MessagePort allowing communication with the parent thread.
Messages sent using parentPort.postMessage() are available in the parent thread using worker.on('message'),
and messages sent from the parent thread using worker.postMessage() are available in this thread using 
parentPort.on('message').
  • StartThread 함수 (src/node_worker.cc #L708)
    • uv_thread_create_ex 를 호출하여 스레드를 생성(deps/uv/src/unix/thread.c #L172)하고, 해당 스레드에서 실행할 람다를 전달
      • 람다: Worker::Run (src/node_worker.cc #L283) 은 SpinEventLoopInternal을 호출하여 워커의 이벤트 루프를 실행함. 이벤트 루프가 종료되면 워커 스레드는 종료됨
      • 워커는 부모 스레드로부터 전달받은 workerData 또는 메시지 포트에서 작업을 꺼내 처리한다.
    • w->env()에 대해 add_sub_worker_context를 호출하여 워커를 해당 환경(env)의 서브 워커 컨텍스트에 추가하여, 워커가 실행 중인 환경에서 관리될 수 있도록 함

궁금한 점

diagnostics_channel이 스레드 간 직접 IPC (Inter-Process Communication) 또는 공유 메모리 대신 구독 기반의 하이레벨 메시징으로 구현된 이유는 무엇인지 알아보았다. 설계 목표 자체가 “관찰(observation)”이지 “데이터 교환(data exchange)”이 아니기 때문인가?

 

유의할 점

Child processes support a serialization mechanism for IPC that is based on the serialization API of the node:v8 module, based on the HTML structured clone algorithm. This is generally more powerful and supports more built-in JavaScript object types, such as BigInt, Map and Set, ArrayBuffer and TypedArray, Buffer, Error, RegExp etc. However, this format is not a full superset of JSON, and e.g. properties set on objects of such built-in types will not be passed on through the serialization step. Additionally, performance may not be equivalent to that of JSON, depending on the structure of the passed data. Therefore, this feature requires opting in by setting the serialization option to 'advanced' when calling child_process.spawn() or child_process.fork().

 

child_process 를 이용하여 만든 프로세스간 통신 시 객체 프로퍼티 중 structured clone으로 표현 불가능한 값(BigInt, 함수, Symbol 등)은 직렬화되지 않는다.

 

(참고) 스레드풀과 워커 스레드

  • 스레드풀
    • Libuv의 내부에서 사용
    • 비동기 작업 중 운영체제가 비동기 I/O를 직접 지원하지 않는 작업을 처리하기 위해 사용됨
      • 예: 파일 시스템 작업, DNS 조회, 일부 암호화 작업 (예: pbkdf2), 압축/해제 등.
      • 기본적으로 4개의 스레드가 생성되며 UV_THREADPOOL_SIZE 로 설정 가능.
        • 최대값: 128
  • 워커스레드
    • Node.js의 표준 모듈(worker_threads)로
    • 사용자가 직접 생성하고 관리
    • 기본 스레드 수의 제한은 없으며, 개발자가 원하는 만큼 생성 가능.
    • 스레드 간에는 메시지 패싱을 통해 데이터 교환(SharedArrayBuffer 지원 가능).
    • 주요 역할
      • CPU 집약적 작업
      • 독립적인 실행 환경(메인 스레드의 블로킹 없이 병렬 작업 가능).

+ Recent posts