(부제목 : 스레드 간의 공유 변수)
node.js의 worker_threads를 공부하는 과정에서 겪은 시행착오에 대한 기록이다. (node.js의 싱글 스레드 동작방식 및 worker_threads의 개념에 대한 충분한 학습 없이 일단 부딪쳐보다가 겪은 시행착오임을 미리 알려드립니다. 아마 개념이 탄탄하신 분들은 왜 이런 바보 같은 삽질을 했는지 이해하기 어려우실 수 있습니다..ㅎ)
필자는 Java 문법을 배우면서 스레드라는 것을 처음 배웠다.
스레드에 대한 간단한 문법을 배우고 나서는, 공유 변수를 사용하는 경우에 발생하는 동시성 이슈를 보여주면서 락을 걸어 해결하는 법에 대해 배웠던 기억이 난다.
이후 node.js에서도 worker_threads를 배우면서 Java의 스레드와 비슷하지 않을까, 하는 지레짐작을 했었다. 그래서 node.js에서도 Java에서 공유 변수로 인한 동시성 이슈를 재현해봐야겠다는 생각을 했다.
Java에서는 Thread 클래스를 상속받아 사용하거나 Runnable 인터페이스를 구현하여 스레드를 만들어볼 수 있다.
이제 Java에서 스레드 간 공유 변수 사용 모습을 한번 관찰해보자.
다음과 같은 코드에서 공유 변수 SharedNumber, 스레드 100개가 100번씩 count해서 토탈 10000이 나오길 기대한다.
public class Main {
public static void main(String[] args) {
SharedNumber s = new SharedNumber();
for(int sId = 0; sId < 100; sId++) {
final int sid=sId;
new Thread() {
public void run() {
for(int cnt = 0; cnt < 100; cnt++) {
System.out.println("["+sid+"]"+s.up());
}
}
}.start();
}
}
}
class SharedNumber {
private int num;
public int up() {return ++num;}
}
결과는 아래와 같다. 매번 돌려볼 때마다 다르지만, 아래 모습에서는 10000을 예상했으나 9998까지밖에 나오지 않는 모습을 관찰할 수 있다.
...
[34]9994
[34]9995
[34]9996
[34]9997
[34]9998
이런 문제를 ‘동시성 문제’라고 한다. 동시에 여러 스레드가 접근하게 되면서 발생하는 일이다.
이를 해결하기 위해 Java에서는 synchronized
키워드를 사용하여 lock을 걸어볼 수 있다.
→ 예제에서 public synchronized int up() {return ++num;}
해주어 해결 가능하다.
Node.js에서는 worker_threads
모듈을 활용하여 멀티 스레드 환경을 만들어 볼 수 있다.
worker_threads 문법을 알아보고자 아래와 같이 스레드 간의 통신을 구현해봤다.
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
//현재 위치 worker 생성
const worker = new Worker(__filename);
//작업이 완료되면 메시지 받기
worker.on("message", (value) =>
console.log(`부모가 받은 메시지 : [${value}]`)
);
//worker thread 종료
worker.on("exit", () => console.log("worker 종료"));
//worker thread에게 메시지 보내기
worker.postMessage("부모가 보내는 메시지 : 아들~ 밥은 먹었어?");
} else {
//parentPort를 통해 메시지 받아옴
parentPort.on("message", (value) => {
console.log(`자식이 받은 메시지 : [${value}]`);
parentPort.postMessage("자식이 보내는 메시지 : 그럼요~");
parentPort.close();
});
}
그럼 node.js에서도 동시성 문제를 만들어볼까? 일단 공유 변수를 전달하는 코드부터 작성해보자.
위의 Java 코드와 동일한 일을 하도록 아래처럼 만들어보았다.
const { Worker, isMainThread, parentPort } = require("worker_threads");
class SharedNumber {
#num = 0;
up() {
return ++this.#num;
}
}
if (isMainThread) {
const s = new SharedNumber();
//현재 위치 worker 생성
for (let sId = 0; sId < 100; sId++) {
const worker = new Worker(__filename);
const data = { sid: sId, shared: s };
worker.postMessage(data);
}
} else {
//parentPort를 통해 메시지 받아옴
parentPort.on("message", (value) => {
for (let cnt = 0; cnt < 100; cnt++) {
console.log(`[${value.sid}]${value.shared.up()}`);
}
//워커 스레드가 종료
parentPort.close();
});
}
위 코드를 실행하니 다음과 같은 오류가 난다.
node:internal/event_target:1094
process.nextTick(() => { throw err; });
^
TypeError [Error]: value.shared.up is not a function
위와 같은 오류가 발생하는 이유는 스레드 간 데이터 전달 방식 때문이다.
postMessage()
를 사용하여 데이터를 전달할 때 HTML structured clone algorithm
이 사용되는데, 이 알고리즘에 의해 인스턴스가 직렬화되어 전달된다. 직렬화 결과 #num
이라는 private 필드도 손실되며, up
메서드 또한 사라진다. 이러한 이유로 worker 입장에서는 shared.up() 호출이 불가능했던 것이다.
위 실험을 통해 알게 된 점은, JS에선 스레드간 통신에 있어 공유 변수 전송이 불가능하다는거다. 무조건 직렬화를 해서 넘겨야 한다. 당연히 클래스 인스턴스도, 클로저도 보낼 수가 없다.
그렇다면 worker_threads는 언제 써야할까? node.js의 공식문서에 따르면, Workers (threads) are useful for performing CPU-intensive JavaScript operations.
라고 한다. 다시 말해 워커의 가장 중요한 목표는 CPU 집약적인 작업의 퍼포먼스를 향상시키는 데에 있다. 그러니 하드한 CPU 작업만을 분리하여 스레드에 시키는 게 솔루션이라고 생각한다.
이러한 결론을 기반으로 worker_threads를 다시 사용해보자.
Java 코드의 목표를 먼저 생각해보면, 각 스레드가 1을 100번 더하는 데에 있다. 그러니 굳이 SharedNumber클래스를 만들 것이 아니고, 각 스레드에게 반복문을 통해 1을 100번 더하라고 시킨 뒤, 그 값을 메인 스레드에서 모두 합쳐 10000을 만들면 되는 문제이다. 굳이 SharedNumber 객체에 매몰될 이유가 없다.
const { Worker, isMainThread, parentPort } = require("worker_threads");
if (isMainThread) {
let result = 0;
let completedWorkers = 0;
// 현재 위치 worker 생성
for (let sId = 0; sId < 100; sId++) {
const worker = new Worker(__filename);
worker.postMessage(sId);
worker.on("message", (value) => {
result += parseInt(value);
completedWorkers += 1;
// 모든 워커가 완료되었을 때 결과 출력
if (completedWorkers === 100) {
console.log("최종결과 : ", result);
}
});
}
} else {
//parentPort를 통해 메시지 받아옴
parentPort.on("message", (sid) => {
let sum = 0;
for (let cnt = 0; cnt < 100; cnt++) {
++sum;
}
parentPort.postMessage(sum);
//워커 스레드가 종료
parentPort.close();
});
}
어쨌든 node.js에서 worker_thread 사용하기의 결론은!
Thread
는 CPU 집약적인 작업뿐만 아니라 IO 집약적인 작업 등 다양한 일에 모두 적합하다.) 그러니 worker에게는 메인 스레드에서 하기엔 너무 CPU를 많이 사용해야 하는 일을 시켜야 한다. 본질에 알맞게 접근해야 한다.