동기화는 동시에 여러 프로세스나 스레드가 자원을 처리하는 과정에서 공유 자원에 접근할 때 발생할 수 있는 경쟁 상태를 방지하기 위해 작업의 실행 순서를 관리하고 제어하는 메커니즘을 이야기 합니다.
여기서 중요한 것은 동시에, 여러 프로세스/스레드 라는 특징을 갖고 작업을 수행할 때 생기는 레이스 컨디션을 해결하기 위해 순서를 제어한다는 것입니다.
하지만 우리의 JavaScript는 싱글 스레드 언어라는 것은 모두가 알고 계실 것입니다. 그렇다면 자바스크립트에서 동기화 문제는 발생하지 않을까요?
JavaScript는 싱글 스레드이기 때문에 동기화 문제가 발생하지 않는 것이 맞습니다! 다만, 최근 JavaScript에서도 멀티스레딩 환경인 Web Worker와 Node.js의 Worker Threads가 등장하면서 JavaScript에서도 또한 동기화 문제, race condition이 발생할 수 있게 되었고 이를 처리하기 위한 개념이 등장했습니다.
해결책에 대한 것에 이야기 하기 이전에 Node.js의 Worker Thread에 대해서 잠시 이야기하고 지나가겠습니다.
앞으로 Node.js의 Worker를 Worker라고 정의하여 작성하겠습니다. Worker 스레드는 스레드와 동일하게 각 스레드 당 V8 Isolate를 지니고 있습니다. V8 Isolate는 각 스레드가 독립적인 변수의 상태를 유지하고 함수가 원자적 연산(atomic operation)를 수행할 수 있게끔 하기 위한 용도입니다. 이외에도 각각의 Thread는 Event Loop를 갖고 있습니다.
Node.js에서 기존의 하나의 스레드로 작업을 수행함으로 인해 생기는 작업의 지연을 해결하기 위해 Event Loop를 사용 했습니다. 이로인해 Promise, callback, async/await을 통해 가능한 시스템 커널에 작업을 오프로딩함으로써 Node.js의 비동기 특성과 non-blocking I/O에 중요한 역할을 수행할 수 있게 되었습니다.
하지만 각각의 스레드에서 I/O 작업을 수행해서는 안됩니다. 이러한 이유는 메인 스레드에서 libuv 라이브러리를 통해 내부적으로 이미 I/O 작업을 위한 스레드 풀을 관리하므로, 파일 I/O를 위해 별도로 worker threads를 사용하는 것은 중복된 오버헤드를 발생시킬 수 있기 때문에 Worker Thread에서는 I/O 작업을 수행하지 않는 것이 좋습니다.
그렇기 때문에 Worker를 잘 사용하기 위해서는 CPU 작업과 I/O(입출력) 작업을 구분하는 것이 중요합니다. I/O 작업만 비동기적으로 실행되기 때문에 동시에 실행됩니다. 따라서 자바스크립트에서 Worker threads의 주요 목표는 I/O 작업이 아닌 CPU 집약적인 작업의 성능을 향상시키는 것입니다.
디스크에서 파일을 읽거나 대규모 데이터셋에서 복잡한 연살을 수행하는 것과 같은 CPU 집약적인 코드가 있는 경우, 이는 메인 스레드를 차단하고 다른 프로세스가 실행되지 못하게 할 수 있습니다. Worker Thread는 이러한 단점을 해결하기 위한 것입니다.
이제 Node.js에서 Worker Thread 를 어떻게 사용하는지 또한 동기화 문제를 다른 언어에서는 이러한 문제를 어떻게 다루는지에 대해서 가장 대표적인 생산자와 소비자 문제를 예시로 설명 해보겠습니다.
생산자와 소비자 문제
C++에서의 생산자 소비자 문제
여러 스레드를 쉽게 생성할 수 있고, 동기화 문제를 쉽게 경험할 수 있고 해결책 또한 쉽게 코드로 작성하면서 경험할 수 있는 C++ 코드를 활용하여 이 문제를 자세하게 다루어 보겠습니다.
위의 코드는 단순히 공용 변수 sum 을 증가 시키는 함수이지만 위의 로직을 동작 시키면 결과는 저희가 기대한 결과와 굉장히 다른 결과가 출력 되는 것을 확인할 수 있습니다.
위의 코드는 스레드가 동시에 수행이 될 경우 공용 자원에 동시에 접근함으로 인해서임계 구역 문제가 발생하여 생긴 일입니다.
임계구역이란 여러 스레드가 동시에 접근하면 문제가 생길 수 있는 공유 자원이나 코드 영역을 이야기 합니다. 위의 코드에서는 sum 변수가 바로 그런 공유 자원입니다.
위의 작업은 단순합니다. 생산자 스레드가 sum++ 명령을 실행합니다. 소비자 스레드가 sum-- 명령을 실행합니다. 이 작업은 실제로 다음과 같은 세 단계로 이루어져 있습니다.
메모리에서 현재 sum 값을 읽기
값을 1 증가하거나 감소시키기
결과 값을 메모리에 저장하기 위한 문맥 교환
결과 값 메모리에 저장하기
위의 명령어들을 여러 스레드가 동시에 작업을 수행하게 되면 다음과 같은 문제가 생깁니다.
위와 같이 생산자의 작업이 소비자의 작업에 의해서 무시되고 최종 값은 9가 되게 되는 문제가 발생합니다. 이러한 상황은 공유 자원에 대한 접근을 모두 공정하게 갖고 있기 때문에 값의 업데이트에 대한 타이밍이 겹쳐서 발생하게 되는 경쟁 상태, (race condition) 문제로 인해 Dead Lock, 교착 상태가 발생하게 됩니다.
그럼 이 문제를 JavaScript에서 다루어 보겠습니다.
JavaScript에서의 생산자, 소비자 문제
JavaScript에서는 우리가 의도하는 대로 작동하는 것을 볼 수 있습니다.
로직만 비교 했을 경우 C++와 JavaScript에서 작성한 로직의 차이는 없는데 결과는 어째서 큰 차이가 발생 했을 까요?
JavaScript는 싱글 스레드이기 때문에 문맥 교환 이 발생하지 않기 때문에 공용 변수인sum 변수를 순서대로 접근하여 값을 변경 시키기 때문에 C++와 같은 Dead Lock, 교착 상태 문제가 발생하지 않았습니다.
그럼 이제 JavaScript에서 Worker를 사용해 멀티 스레드를 사용하여 race condition이 발생하게끔 코드를 작성 해보겠습니다.
Web Worker와 Node.js의 Worker는 메인 스레드와 분리된 독립적인 실행 환경을 제공하기 위해 파일 또는 파일과 유사한 URL을 기반으로 동작합니다. 각 워커들은 파일과 URL을 기반으로 동작함으로 메인 스레드와 별도의 전역 컨텍스트와 이벤트 루프 인스턴스를 가지며, 이는 실행 중인 코드의 상태나 변수들이 서로 간섭하지 않도록 보장합니다.
하지만 위에서는 Shared Array를 사용하여 공용 전역 변수를 사용하고 있고 이 작업은 교착 상태를 만들고 이로 인해 위와 같은 결과가 만들어지게 됩니다.
공용 변수 값을 JavaScript의 Atomics 객체를 활용하여 load 후 store 를 수행하여 공용 변수의 값을 변경하는 작업을 수행합니다. 이러한 작업을 앞 서 작업한 C++ 와 동일하게 수행할 경우 동일한 문제가 발생합니다.
위의 결과가 멀티 스레드를 사용하여 공용 자원을 사용할 경우 race conditon으로 인한 문제가 발생하는 것을 확인할 수 있습니다.
C++에서의 해결 방법
C++에서 동기화 문제를 해결하는 방법은 아래와 같습니다.
Mutex : 이진 값의 전역 상태를 lock, unlock 메서드를 활용하여 변경 시킴으로써 임계 구역에 접근할 수 있는 순서를 통제하여 문제를 해결하는 방법입니다.
Semaphore : 앞선 뮤텍스에서의 문제, 다른 스레드에서 이미 lock으로 인해서 변경된 제어 값을 변경할 수 없으므로 생길 수 있는 문제를 해결하기 위하여 이진 값을 통해 자원의 접근을 제어하는 것이 아닌 임계 구역에 진입할 수 있는 프로세스의 개수(사용 가능한 공유 자원의 개수)를 나타내는 전역 변수 S 와 P 연산, 혹은 wait 을 통해 증가 시키고 대기중인 작업을 수행할 수 있게 신호를 주는 V 연산, 혹은 signal 을 사용하여 문제를 해결합니다.
위와 같은 방법들이 전통적으로 사용되는 동기화 문제를 해결하는 방법입니다. 저희는 이러한 방법 중 JavaScript에서 동기화를 해결하는 방식과 유사한 Fast Userspace muTEX, Futex를 이용하여 C++에서 문제를 해결하는 방법에 대해서 다루어 보려고 합니다.
futex는 사용자 공간과 커널 공간의 장점을 결합한 하이브리드 동기화 메커니즘입니다. 전동적인 mutex와는 달리 futex는 다음과 같은 핵심 원칙에 기반합니다.
경쟁이 없을 때는 사용자 공간에서 해결: lock 획득 시도를 먼저 사용자 공간의 원자적 연산으로 시도합니다.
경쟁이 있을 때만 커널 호출: 경쟁 상태가 발생할 때만 시스템 콜을 사용합니다.
우선 위와 같이 사용할 공용 데이터에 대한 데이터를 생성을 해줍니다. 위의 SharedBuffer는 C++의 condition_variable를 사용하여 스레드간의 통신을 통해서 현재 버퍼의 조건에 따라 다른 스레드로 작업을 기다려야 하는지, 작업을 수행해도 되는지 신호를 전송합니다.
전통적인 mutex와 다른 점은 사용자공간에서 연산을 수행하고 시스템 콜은 조건에 해당하는 경우에만 호출을 할 수 있다는 점입니다.
위의 코드는unidque_lock을 이용하여 {} 구역 내에서 수행하는 작업들이 자동으로 unlock을 하게 mutex를 사용하고 있습니다. 대부분의 현대적인 C++는 내부적으로 앞서 이야기 했던 조건하에 작동하고 있습니다.
이 후 사용자 공간에서 선언한 버퍼의 크기가 수용가능한 크기보다 작을 경우만 실행이 가능하게끔 합니다. 앞서 이야기 했지만 condition_variable를 이용하여 스레드간 조건을 확인 하고 사용자 공간에서 원자적 연산을 통해 정말 필요한 경우에 시스템 콜이 발생하기 때문에 가짜 깨움(spurious wakeup) 없이 작업을 처리할 수 있기 때문에 효율적으로 작업을 수행할 수 있습니다. 전체 코드는 아래와 같습니다.
JavaScript에서의 해결 방법
JavaScript에서 문제를 해결하기 위해 기존의 1개의 인덱스로 사용하던 버퍼를
counter[0] : 실제 카운터 값 (예: 현재 공유 자원의 값)
counter[1] : 생산된 항목의 총 개수 (생산자가 증가시킴)
counter[2] : 소비된 항목의 총 개수 (소비자가 증가시킴)
위와 같이 단일 카운터가 아닌 여러 개의 누적 카운터를 사용할 경우 생산자와 소비자가 각자의 카운터에만 접근하므로 경쟁 상태가 줄어듭니다. 또한 이렇게 구분하여 관리를 할 경우 특정 조건에 해당할 경우에만 다른 스레드에 알림을 보내기가 유용합니다.
위의 두 로직이 앞서 C++에서 사용했던 Futex에서 사용한 wait, notify 와 역할이 동일합니다. 다른 스레드의 통신을 대기하고 사용이 모두 종료 되었을 경우 다른 스레드에 알림을 보냄으로써 깨우는 역할을 수행합니다.
생산자의 작업을 수행하기 이전에 작업이 가능한 상태인지를 확인하는 조건 문입니다. 작업 할 수 있는 환경이 되기 전까지 Atomics.wait 을 이용하여 2번 인덱스에서 작업이 모두 수행될 때 까지 기다립니다.
1번 인덱스, 생산된 항목 보다 2번 인덱스, 소비된 항목이 더 클 경우 버퍼가 비었으므로 소비가 불가능하기 때문에 생산자가 작업을 수행할때까지 Atmoics.wait을 1번 인덱스로 대상으로 하여 notify()를 통해 작업 수행이 가능하기 이전까지 대기합니다.
Atomics 객체를 이용하여 작업을 수행할 경우 앞서 겪었던 경쟁 상태 문제를 해결하여 결과 값이 기대한대로 나온 것을 알 수 있습니다. 이 작업을 수행한 전체 코드는 아래와 같습니다.
결론
지금까지 "자바스크립트에서도 동기화 문제가 발생할까?"라는 주제로 글을 작성 했습니다. JavaScript는 싱글 스레드라는 문제를 해결하기 위하여 Web Worker, Worker를 사용하여 작업의 효율성을 높이고자 하였습니다.
하지만 여러 개의 스레드를 사용할 경우 JavaScript 또한 동기화 문제, race conditoin 문제에서 벗어날 수 없기 때문에 이를 어떻게 해결할 수 있을지에 대해서 알아보는 것이 이번 글의 주된 주제였습니다.
오늘날 대부분의 mutex 작업은 콜 스택의 호출 횟수를 감소 시키고 더 활용성이 좋은 Futex로 대체되어서 사용되고 있고 JavaScript 또한 동일하지는 않지만 Futex의 많은 부분을 차용하여 Atomic 객체가 만들어졌고 동기화 문제를 효율적으로 작업을 수행할 수 있게 되었음을 알 수 있었습니다.
초기 합계: 10producer, consumer 스레드 실행 이후 합계: -38292
프로세스A 프로세스B 현재 sum 현재 r1 현재 r2-------------------------------------------------------------r1 = sum | 10 10r1 = r1 + 1 | 10 11문맥교환 | 10 11 r2 = sum | 10 11 10 r2 = r2 -1 | 10 11 9 문맥교환 | 10 11 9sum = r1 | 11 11 9문맥 교환 | 11 11 9 sum = r2 | 9 11 9
초기 합계: 10producer, consumer 스레드 실행 이후 합계: 10
순서: 1실행 내용: let sum = 10;변수 상태 (sum): sum = 10순서: 2실행 내용: main() 함수 호출변수 상태 (sum): sum = 10순서: 3실행 내용: console.log("초기 합계: ", sum); → 초기값 10 출력변수 상태 (sum): sum = 10순서: 4실행 내용: producer() 실행 - for 루프: 반복마다 sum += 1 수행 - 최종적으로 1,000,000번 반복하여 10 + 1,000,000 = 1,000,010변수 상태 (sum): sum = 1,000,010순서: 5실행 내용: consumer() 실행 - for 루프: 반복마다 sum -= 1 수행 - 최종적으로 1,000,010 - 1,000,000 = 10변수 상태 (sum): sum = 10순서: 6실행 내용: console.log("producer, consumer 스레드 실행 이후 합계: ", sum); → 최종값 10 출력변수 상태 (sum): sum = 10
초기 합계: 10Consumer 작업 완료Producer 작업 완료producer, consumer 스레드 실행 이후 합계: -844예상 합계(10)와 실제 합계의 차이: -854
소비: 카운터 = 11, 소비 = 999소비: 카운터 = 10, 소비 = 1000Consumer 작업 완료producer, consumer 스레드 실행 이후 합계: 10예상 합계(10)와 실제 합계의 차이: 0
let sum = 10;function producer() { for (let i = 0; i < 1000000; i++) { sum += 1; }}function consumer() { for (let i = 0; i < 1000000; i++) { sum -= 1; }}function main() { console.log("초기 합계: ", sum); producer(); consumer(); console.log("producer, consumer 스레드 실행 이후 합계: ", sum);}main();
//worker.jsimport { Worker } from "node:worker_threads";import path from "node:path";const INIT_VALUE = 10;console.log("초기 합계: ", INIT_VALUE);const sharedBuffer = new SharedArrayBuffer(4);const counter = new Int32Array(sharedBuffer);counter[0] = INIT_VALUE;const producer = new Worker(path.resolve(__dirname, "producer.js"), { workerData: { sharedBuffer },});const consumer = new Worker(path.resolve(__dirname, "consumer.js"), { workerData: { sharedBuffer },});let completedWorkers = 0;function checkCompletion() { completedWorkers++; if (completedWorkers === 2) { console.log("producer, consumer 스레드 실행 이후 합계: ", counter[0]); console.log("예상 합계(10)와 실제 합계의 차이:", counter[0] - 10); }}producer.on("message", (message) => { console.log(message); checkCompletion();});consumer.on("message", (message) => { console.log(message); checkCompletion();});
import { parentPort, workerData } from "worker_threads";const sharedBuffer = workerData.sharedBuffer;const counter = new Int32Array(sharedBuffer);function consumer() { for (let i = 0; i < 1000; i++) { const currentValue = Atomics.load(counter, 0); const start = Date.now(); while (Date.now() - start < 1) {} Atomics.store(counter, 0, currentValue - 1); }}consumer();parentPort.postMessage("Consumer 작업 완료");
//worker.jsconst sharedBuffer = new SharedArrayBuffer(4 * 3);const counter = new Int32Array(sharedBuffer);// 초기화: 카운터 = 0, 생산된 아이템 = 0, 소비된 아이템 = 0counter[0] = 0;counter[1] = 0;counter[2] = 0;