Node.js의 worker threads를 사용하는 방법에 대해 알아본다.
Worker threads는 자바스크립트 코드를 parallel하게 돌릴 수 있도록 해주는 모듈로, 이전 Node 버전에서는 지원되지 않다가 Node v10부터 experimental feature로서, 그리고 Node v12부터는 stable하게 지원되기 시작했다.
worker threads는 따로 npm 등으로 설치할 필요 없고, Node를 설치해 사용하고 있다면 아래와 같이 import하는 것만으로도 worker threads의 사용이 가능하다.
// CommonJS
const { Worker } = require('worker_threads')
// ESM
import { Worker } from "worker_threads";
어쩌면 여러분은 자바스크립트가 싱글 스레드라는 말을 들어본 적이 있었을지 모르겠다.
실제로, 자바스크립트는 웹페이지에서 발생하는 동시성 문제를 아예 없애버리기 위해 싱글 스레드로 구성되어 있다.
하지만, 우리가 오늘 실습할 Node.js(자바스크립트 런타임)에서는 worker_threads 모듈을 이용해 멀티스레드 환경을 구축할 수 있다.
따라서 Node.js를 사용할 때에는 동시성 문제에 주의하여야 한다.
이 글에서는 범위를 벗어나는 부분이기에 동시성 문제에 대해 더 설명하지 않지만, 이러한 동시성 문제를 처리하는 데에 사용하는 라이브러리로 async-mutex
와 semaphore
가 있으니 참고하자.
기본적인 사용법을 알아보기 위해, 먼저 두 개의 파일, index.js
와 worker.js
을 만들어보자.
index.js에는 부모 스레드가, worker.js에는 자식 스레드 코드를 넣었다.
주의: 이 글의 모든 실행 결과는 환경에 따라 언제든 바뀔 수 있음에 유의하자.
// index.js
import { Worker } from 'worker_threads';
const worker = new Worker("./worker.js");
setTimeout(() => console.log("parent"));
// worker.js
console.log('child')
# 실행 결과
> node ./index.js
parent
child
이것을 그림으로 나타내보면, 아래와 같다.
위 코드를 더 자세히 설명해보자.
새로운 worker, 즉 스레드를 만든다. 다른 언어들에서는 스레드의 엔트리포인트로 fork가 일어나는 지점이나 함수 등을 지정하기도 하지만, node.js worker threads의 스레드 엔트리포인트는 파일로 정해진다.
즉, const worker = new Worker(’./worker.js’);
라는 코드가 실행되면, 자식 스레드가 생겨 worker.js 함수에서 병렬적으로(parallelly) 실행되게 되는 것이다.
실행 환경에 따라 다를 수 있지만 위 예제 코드는 parent, child 순서대로 출력을 해주는데, 몇 번을 실행해봐도 그 출력 결과의 순서가 바뀌지 않아 병행적으로 실행되는 것인지 의문을 가질 수도 있을 것이다.
하지만 아래와 같이 index.js
에서 parent thread에 조금의 딜레이를 줘본다면 결과가 달라질 것이다.
// index.js
import { Worker } from "worker_threads";
const worker = new Worker("./worker.js");
setTimeout(() => console.log("parent"), 100);
그리고, Worker 생성자에 ‘./worker.js’
라는 인자를 넣어주었는데, 그렇게 하지 않고 하나의 파일만으로도 스레드를 만들어 실행시킬 수 있다.
// index.js
import { Worker, isMainThread } from 'worker_threads';
if (isMainThread) {
const worker = new Worker("./worker.js");
console.log('parent');
} else {
console.log('child');
}
# 실행 결과
> node ./index.js
parent
child
비슷한 동작을 하는 C 코드를 작성하니, 참고하자.
#include <sys/types.h>
#include <stdio.h>
#include <unistd.h>
int main() {
pid_t pid;
pid = fork();
if (pid > 0) {
printf("parent");
} else if (pid == 0) {
printf("child");
} else {
fprintf(stderr, "fork failed");
}
}
그 외에도, 예제는 따로 만들지 않으나 Worker 생성자를 여러 번 호출함으로써 여러 개의 worker를 만들 수 있다.
스레드에 있어 중요한 기능 중 하나는 parent와 child 사이의 messaging일 것이다.
Node에서는 부모와 자식은 메세지 채널을 이용해 서로 통신이 가능하다.
아주 간단한 메시징 예제를 들어보자.
// index.js
import { Worker } from "worker_threads";
const worker = new Worker("./worker.js");
worker.on("message", (msg) => {
console.log(msg);
});
console.log("parent");
// worker.js
import { parentPort } from "worker_threads";
parentPort.postMessage("hello parent!");
console.log("child");
# 실행 결과
> node ./index.js
parent
hello parent!
child
index.js에서 생성한 worker가 parent에게 ‘hello parent!’
라는 메시지를 보낸 뒤, 그 이벤트를 받은 parent가 메시지를 출력하는 코드이다.
worker.on()
은 첫 번째 인자로 무슨 종류의 이벤트를 받아 핸들링할 것인지를 받는다.
예를 들어, 위 코드에서 사용한 ‘message’
의 경우 worker로부터 메세지가 온다면 두 번째 인자로 넘겨준 함수를 실행시킨다.
worker.on()
의 첫 번째 인자에 들어갈 수 있는 인자들은 ‘message’
, ‘error’
, ‘exit’
등이 있다.
postMessage()
의 첫 번째 인자로 주어진 값을 채널의 수신단에 전달한다.
원시값을 포함한 Array, Boolean, String, Set, Map 등 여러 타입을 전달할 수 있지만, Object, Error와 같은 경우 보낼 수 있는 조건이 따로 정해져 있으니 주의하자.
자세한 것은 아래 링크를 참고하자.
https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Structured_clone_algorithm
워커 스레드의 모든 실행을 ‘가능한 한’ 빨리 멈춘다. Promise를 반환하는데, 이는 ‘exit’ 이벤트가 발생했을 때 약속이 이행된다(fulfilled).
부모와 자식 모두 끝나지 않고 서로의 이벤트를 기다리기만 해 프로그램이 종료되지 않는 경우가 있는데, 이때 worker를 종료시키기 위해 사용될 수 있다.
경우에 따라 process.exit()도 사용될 수 있으니 참고하자.
위 코드와 같은 동작을 그림으로 나타내면 다음과 같다.
조금 더 복잡한 코드를 작성해보자.
아래 코드는 parent에서 메세지를 전달해, 그 메세지를 받은 worker가 팩토리얼을 계산한 뒤 parent에게 다시 메세지를 전달하는 것이다.
parent는 worker가 계산하는 동안 코드의 실행을 멈추고 기다리다가, worker의 계산이 마치면 팩토리얼 값을 출력한다.
pthread에서의 join과 유사한 역할을 한다고 보면 된다.
// index.js
import { Worker } from "worker_threads";
const worker = new Worker("./worker.js");
function get_factorial(num) {
return new Promise((resolve, reject) => {
worker.on("message", (message) => {
resolve(message);
});
worker.on("error", (err) => {
reject(err);
});
worker.postMessage(num);
});
}
console.log(await get_factorial(10));
worker.terminate();
// worker.js
import { parentPort } from "worker_threads";
function factorial(number) {
if (number == 0 || number == 1) return 1;
return factorial(number - 1) * number;
}
parentPort.on("message", (number) => {
parentPort.postMessage(factorial(number));
});
# 실행 결과
> node ./index.js
3628800
위 코드에서는 promise를 사용해 worker로부터 message가 도착하면 promise를 resolve한다.
worker를 기다려 결과값을 얻은 후에는, worker.terminate()
로 worker를 종료시킨다.
위 코드를 그림으로 나타낸다면 다음과 같을 것이다.
위 예제들에서는 최대한 쉽게 코드를 짜서 이해를 돕고자 하였는데, thread의 이점을 더 살리고 싶다면 thread의 일을 분배하여 실행시키는 것이 좋을 것이다.
아래 블로그에서 구간을 나눠 8개의 worker를 이용해 소수를 구하는 코드가 있으니, 관심 있다면 참고하자.
https://inpa.tistory.com/entry/NODE-📚-workerthreads-모듈
Operating System Concepts (Abraham Silberschatz, Peter Baer Galvin, Greg Gagne; WILEY)
Learn all about Nodejs Worker Threads with Examples
Node.js v20.5.0 documentation - Worker threads
The structured clone algorithm
[NODE] 📚 Worker_Threads 모듈 (멀티 쓰레드 구현)
[Javascript] 자바스크립트는 싱글 스레드인가?
잘 읽었습니다. 좋은 정보 감사드립니다.