JS로 레이스 컨디션 시뮬레이터 만들기
JS로 레이스 컨디션과 원자적 연산 시뮬레이션 해보기
운영체제선수지식
들어가기 전 아래의 선수지식을 필요로 합니다. 하나라도 모르면 이해하기 정말 어려우니 잘 짚고 넘어가기~
각 개념을 클릭하면 해당 블로그 글로 이동할 수 있습니다!
운영체제
JS
🤖 시뮬레이터 목표
- 눈에 보이지 않는 멀티 스레딩 환경의 동작 원리를 코드로 증명하고 깊이 있게 이해
- 레이스 컨디션을 직접 확인
- 동기화 기법이 왜 필요하고 어떻게 데이터의 무결성을 보장하는지 체득
- Node.js에서 실제 멀티 스레드 프로그램을 작성하고, 스레드의 생성, 데이터의 전달, 실행 대기, 종료 등 전체 생명주기를 관리하는 방법을 익히기
⚙️ 기능 구체화
1. 멀티 스레드 환경 구성
- 메인 스레드는 최소 2개 이상의 워커 스레드를 생성해야 한다.
- 생성된 각 워커 스레드는 독립적으로 코드를 실행할 수 있어야 한다.
2. 공유 자원 정의 및 생성
- 모든 스레드(메인+워커)가 함께 접근할 수 있는 공유 메모리 공간을 생성
- 이 공간에는 정수형 숫자 하나를 저장하며, 초기값은 0이어야 함
- 나중에 1씩 더하는 연산으로 레이스 컨디션을 실험할 거라 0을 저장함
- 이 숫자가 바로 레이스 컨디션의 대상이 될 '공유 자원'
3. 레이스 컨디션 발생 시나리오 구현
- 각 워커 스레드는 정해진 횟수만큼 반복하며 공유 자원의 숫자를 1씩 증가시키는 작업을 수행
- 이때, 숫자 증가 연산은 **비원자적(Non-Atomic)**으로 처리되야함
- 즉, 현재값 읽기 -> 값에 1 더하기 -> 계산된 값을 다시 쓰기라는 여러 단계로 나누어져, 단계 사이에 다른 스레드가 끼어들 수 있어야 함
4. 동기화를 통한 문제 해결 시나리오 구현
- 레이스 컨디션 발생 시나리오와 별개로, 동기화가 적용된 시나리오를 구현
- 이 시나리오에서는 숫자 증가 연산을 **원자적(Atomic)**으로 처리
5. 명확한 실행 및 결과 검증
- 프로그램 실행 시, 터미널 출력을 통해 현재 어떤 시나리오 (레이스 컨디션 or 동기화)가 실행 중인지 명확히 표시
- 모든 스레드의 작업이 완료도니 후, 최종 결과를 출력
- 실행 흐름을 추적할 수 있도록 주요 이벤트 로그를 터미널에 출력
6. 스레드 제어 및 통신
- 메인 스레드는 워커 스레드에게 공유 자원의 정보와 수행할 작업의 종류(레이스 컨디션 or 동기화)를 전달할 수 있어야 함
- 메인 스레드는 모든 워커 스레드가 작업을 마칠 때까지 기다렸다가, 최종 결과를 출력
- 워커 스레드는 작업이 끝나면 메인 스레드에게 완료되었음을 알릴 수 있어야 함
세 줄 요약
- 메인스레드에서 워커 스레드 생성해 멀티 스레드 환경 구성
- 워커 스레드는 메인 스레드에서 IPC로 임무를 받아 숫자 증가 연산 수행 (레이스 컨디션을 발생시키기 위한 행동)
- 레이스 컨디션 환경과 동기화로 레이스 컨디션을 해결한 환경을 분석
🥞 기술 스택 선정
멀티 스레딩 구현
worker_threads모듈을 사용- Node.js 환경에서 멀티 스레딩(병렬 처리)을 가능하게 하는 핵심 내장 모듈
공유 자원 생성
SharedArrayBuffer배열 버퍼를 사용- 레이스 컨디션을 발생시키려면 여러 스레드가 '하나의 변수'를 두고 경쟁해야 하는데,
SharedArrayBuffer는 바로 그 '하나의 변수'가 저장될 공유 공간을 만드는 데 필수적인 기술
- 레이스 컨디션을 발생시키려면 여러 스레드가 '하나의 변수'를 두고 경쟁해야 하는데,
동기화
Atomics객체를 사용SharedArrayBuffer에 담긴 데이터에 대해 원자적 연산을 수행하도록 보장하는 내장 객체
- 레이스 컨디션을 해결하기 위한 가장 직접적이고 확실한 방법
Atomics객체의 메소드를 사용해 값을 읽고, 더하고, 다시 쓰는 과정을 방해할 수 없는 하나의 동작으로 묶어 데이터의 무결성 보장
🛠️ 시스템 구조 설계
🏛️ 메인 스레드
1. 실행 시작 및 환경 준비
-
동작
- 메인 스레드가 실행을 시작
- 앞으로 진행할 테스트(몇 개의 스레드를 쓸지, 몇 번이나 반복할지 등)에 필요한 기본 환경 설정
-
💡 연관 개념
- 하나의 프로세스가 시작될 때, 운영체제는 기본적으로 하나의 실행 흐름인 메인 스레드를 생성
- 즉, 프로그램의 출발점 == 메인 스레드
2. 보조 작업자(워커 스레드) 생성
-
동작
- 메인 스레드는 혼자서 무거운 계산을 다 처리하면 블로킹이 발생
- 이를 피하기 위해 실제 계산을 대신 수행할 여러 개의 워커 스레드를 생성
-
💡 연관 개념
worker_threads를 사용해 여러 작업을 물리적으로 동시에 처리하는 병렬성 확보- CPU 집약적 작업에 취약한 이벤트 루프의 단점을 보완
3. 작업 지시 및 자원 공유 (IPC)
-
동작
- 메인 스레드는 모든 워커가 함께 사용할 공유 메모리 공간(
SharedArrayBuffer)을 생성 - 각 워커에게 작업 지시와 공유 자원 정보를 전달
- 메인 스레드는 모든 워커가 함께 사용할 공유 메모리 공간(
-
💡 연관 개념
- 스레드 간 데이터 통신을 돕는 IPC
- 여러 스레드가 데이터를 복사하지 않고 하나의 저장 공간에 직접 접근하는 공유 메모리 방식을 사용해 통신 효율을 극대화
4. 모든 작업자의 작업 완료 대기
-
동작
- 메인 스레드는 모든 워커 스레드가 각자 맡은 계산을 끝내고 종료될 때까지 대기
-
💡 연관 개념
- 메인 스레드가 워커들의 작업이 끝나기 전까지 먼저 결과를 확인하는 오류를 막기 위해, 모든 작업이 완료되는 시점까지 기다리는 것 역시 넓은 의미의 동기화 메커니즘
5. 최종 결과 확인 및 비교
- 동작
- 모든 워커가 작업을 마치면, 메인 스레드는 공유 메모리에 기록된 최종 결과값을 확인
- 기대값과 실제 결과를 비교해 터미널에 출력
👷 워커 스레드
1. 임무 수령 및 작업 준비
- 동작
- 메인 스레드에 의해 생성되면 주어진 임무를 확인
2. 공유 자원 접근 및 연산 수행
-
동작
- 워커는 메인 스레드가 알려준 공유 메모리 공간에 접근해 그곳에 저장된 숫자를 1씩 더하는 연산을 반복 수행
- 메인 스레드가 지시한 방식에 따라 행동이 달라짐
race(레이스 컨디션 발생) 모드: 숫자 증가 과정을 여러 단계로 나누어 수행atomic(원자적 연산) 모드: 숫자 증가 과정을 방해할 수 없는 하나의 묶음으로 처리
-
💡 연관 개념
- 공유 자원: 모든 워커가 동일한 메모리(변수)에 접근
- 임계 구역: 공유 자원에 접근하여 값을 변경하는 코드 영역
- 연산이 비원자적이기 때문에 컨텍스트 스위칭이 일어나 레이스 컨디션 발생
- 원자적 연산을 통해 임계 구역을 보호하고 레이스 컨디션 차단
3. 임무 완수 및 종료
-
동작
- 자신의 임무가 끝나면 조용히 활동을 멈추고 종료됨
-
💡 연관 개념
- 스레드는 메인 스레드에 의해
생성되고, 할당된 코드를실행하며, 모든 작업이 끝나면종료되는 생명주기를 가짐
- 스레드는 메인 스레드에 의해
☠️ 구현
워커 스레드
일단 워커 스레드 worker.js부터 구현한다.
const { parentPort, workerData } = require("worker_threads");
parentPort: 부모(메인 스레드)와의 통신 창구- 워커가 작업 결과를 메인스레드에 보고해야할 때 사용
workerData: 메인 스레드가 워커를 생성할 때 넘겨준 초기 데이터 묶음- 즉, 무슨 일을 할지 알려주는 작업 지시서
const { buffer, iterations, mode, threadId } = workerData;
buffer: 메인 스레드가 만든SharedArrayBuffer- 모든 워커가 함께 사용할 공유 자원
iterations: 숫자를 몇 번이나 더해야 하는지에 대한 정보mode: 레이스 컨디션을 일으키는 모드 or 원자적 연산으로 레이스 컨디션을 막는 모드threadId: 몇 번째 워커 스레드인지 알리는 식별 번호
const sharedArray = new Int32Array(buffer);
- 메인 스레드에서 받은
buffer는 단순히 '연속된 메모리 공간'일 뿐이다 - 이 메모리를 '32비트 정수를 담는 배열'처럼 다룰 수 있도록
Int32Array라는 View를 씌워주는 과정 - 친숙한 배열 문법으로 공유 메모리에 값을 읽고 쓸 수 있도록 해준다
console.log(`Worker ${threadId} started with mode: ${mode}`);
- 자신이 어떤 워커 스레드이고 어떤 모드로 실행될 건지 확인
// 레이스 컨디션 발생 모드
if (mode === "race") {
for (let i = 0; i < iterations; i++) {
const value = sharedArray[0];
sharedArray[0] = value + 1;
}
}
- 레이스 컨디션을 발생시키는 모드일 때
- N번의 연산을 반복하기 위한 루프 생성
-
- 공유 메모리의 첫 번째 칸에 있는 값을 읽어서 저장
-
- 1을 더한 값을 다시 공유 메모리의 첫번째 칸에 씀
-
- 위 읽기와 쓰기는 철저히 '분리'된 동작, 즉 비원자적 연산이다.
- CPU가 읽기를 실행하고 쓰기를 하기 전에 컨텍스트 스위칭으로 다른 워커에게 실행 순서를 넘겨버리면, 다른 워커도 예전 값을 읽게 되어 연산이 누락되는 레이스 컨디션이 발생한다.
// 원자적 연산 모드
else if (mode === "atomic") {
for (let i = 0; i < iterations; i++) {
Atomics.add(sharedArray, 0, 1);
}
}
- 원자적 연산을 수행하는 모드
Atomics객체를 이용해 덧셈 과정을 하나의 단일 작업으로 보장- 이 연산이 실행되는 동안에는 컨텍스트 스위칭이 발생해도 다른 스레드가 끼어들 수 없음
console.log(`Worker ${threadId} finished with mode: ${mode}`);
- 자신이 맡은 연산을 모두 마쳤음을 알림
한 파일 안에 스레드의 생명주기를 녹여냈다. 생성되어 임무 확인 -> 임무 수행 -> 종료(소멸)
메인 스레드
const { Worker, isMainThread } = require("worker_threads");
const path = require("path");
Worker: 새로운 워크 스레드를 생성할 때 사용하는 클래스new Worker(...)
isMainThread: 현재 이 코드를 실행하는 스레드가 '메인'인지 아닌지 (boolean)worker.js도 내부적으로는main.js를 참조할 수 있기 때문에 메인 스레드의 코드가 워커 스레드에 의해 실행되는 것을 막는 안전장치라고 볼 수 있음
path: 파일 경로를 안전하게 만들기 위해 사용하는 모듈
const NUM_THREADS = 4; // 워커 스레드 개수
const ITERATIONS = 1000000; // 각 스레드가 연산을 반복할 횟수
- 테스트에 사용할 설정값들을 상수로 정의
const runTest = (mode) => {
console.log(`Starting test with mode: ${mode}`);
return new Promise((resolve, reject) => {
// ...
});
};
- 워커의 생성과 실행은 비동기적으로 진행
- 즉, 메인 스레드는 워커에게 일을 시키고 결과를 기다리지 않고 바로 다음 코드를 실행한다
Promise는 이 비동기 작업을 감싸서 모든 워커의 일이 끝났을 때(resolve호출) 다음 작업을 수행하겠다 라고 약속하는 역할
const sharedBuffer = new SharedArrayBuffer(4); // 4바이트 크기의 공유 메모리 공간
const sharedArray = new Int32Array(sharedBuffer);
sharedArray[0] = 0; // 공유 자원 초기화
- 공유 자원의 실체를 만듦 -> 4바이트 크기의 순수한 메모리 공간
- 이 메모리 공간을 32비트 정수 배열로 다룰 수 있게 View를 씌움
- 공유 변수의 초기값을 0으로 설정
let finishedWorkers = 0;
const workerPath = path.join(__dirname, "worker.js");
console.log(`Making ${NUM_THREADS} worker threads...`);
// 워커 스레드 생성 및 작업 지시
for (let i = 0; i < NUM_THREADS; i++) {
const worker = new Worker(workerPath, {
workerData: {
buffer: sharedBuffer,
iterations: ITERATIONS,
mode: mode,
threadId: i + 1,
},
});
// ...
}
- 설정한 개수만큼 워커 스레드를 생성
Worker클래스로 워커 생성 및 공유 메모리, 반복 횟수, 모드, 고유 번호 전달
worker.on("exit", () => {
finishedWorkers++;
if (finishedWorkers === NUM_THREADS) {
console.log(`All workers finished their tasks`);
console.log(`Expected value: ${NUM_THREADS * ITERATIONS}`);
console.log(`Actual value: ${sharedArray[0]}`);
resolve();
}
});
worker.on("error", reject); // 에러 발생시 Promise에 실패를 알림
- 메인 스레드가 워커들의 작업이 끝나기를 기다림
'exit'이벤트는 워커가 자신의 모든 코드를 실행하고 종료될 때 발생- 워커가 하나 종료될 때마다
finishedWorkers카운터를 1씩 올림 - 카운터가 스레드 총 개수와 같아지면, 모든 워커가 작업을 마쳤다고 판단
resolve로 모든 작업이 끝났음을Promise에게 알림- 이것이 스레드들의 작업 흐름을 제어하는 동기화
const main = async () => {
await runTest("race");
await runTest("atomic");
};
main();
- 레이스 컨디션을 발생시키는 시나리오와 동기화로 레이스 컨디션을 막는 시나리오를 각각 실행
🏎️ 전체 코드
main.js: 메인 스레드worker.js: 워커 스레드
🧪 테스트
레이스 컨디션
Starting test with mode: race
Making 4 worker threads...
Worker 3 started with mode: race
Worker 4 started with mode: race
Worker 1 started with mode: race
Worker 2 started with mode: race
Worker 1 finished with mode: race
Worker 2 finished with mode: race
Worker 3 finished with mode: race
Worker 4 finished with mode: race
All workers finished their tasks
Expected value: 4000000
Actual value: 1326634
실제 결과값(1326634)이 기대값(4000000)보다 터무니없이 작다. 그리고 실행할 때마다 이 값은 계속 바뀐다.
또한 워커들의 시작과 종료 순서가 (3, 4, 1, 2 -> 1, 2, 3, 4) 뒤죽박죽이다. 이는 운영체제의 스케줄러가 어떤 스레드를 언제 실행할지 예측할 수 없으며, 스레드들이 정말로 동시에 경쟁하며 실행된다는 것을 보여주는 증거다.
- 원인 분석
- 이것이 바로 레이스 컨디션의 전형적인 증상
- 연산이 원자적이지 않기 때문에 읽기와 쓰기 단계 사이에서 다른 스레드가 개입한 것
동기화를 통해 레이스 컨디션을 없애면...
Starting test with mode: atomic
Making 4 worker threads...
Worker 2 started with mode: atomic
Worker 4 started with mode: atomic
Worker 1 started with mode: atomic
Worker 3 started with mode: atomic
Worker 4 finished with mode: atomic
Worker 3 finished with mode: atomic
Worker 2 finished with mode: atomic
Worker 1 finished with mode: atomic
All workers finished their tasks
Expected value: 4000000
Actual value: 4000000
기대값과 실제 결과가 일치하는 걸 볼 수 있다.
그러나 시작 및 종료 순서(2, 4, 1, 3 -> 4, 3, 2, 1)는 여전히 뒤죽박죽이다.
스레드들이 경쟁하는 혼란스러운 환경 자체는 변하지 않았다는 중요한 증거다.
이러한 혼란 속에서 질서를 찾아서 동작을 수행시킬 수 있었던 것은 원자적 연산의 역할이 크다고 볼 수 있다.
🥲 리뷰
이번 과제에서 만든 프로그램을 복습하는 차원에서 개념, 동작, 의도, 효과에 집중하여 그 과정을 정리했다.
1. "문제"의 의도적 재현: 레이스 컨디션은 왜 발생하는가?
-
의도
- 레이스 컨디션이 일어나는 환경을 의도적으로 구축
- 이는 동기화 기법이 왜 필요한지를 몸소 체감하기 위한 필수 과정이었음
-
동작 원리
-
- 여러 워커 스레드가 단 하나의 공유 메모리(
SharedArrayBuffer)에 접근
- 여러 워커 스레드가 단 하나의 공유 메모리(
-
- 읽기 -> 더하기 -> 쓰기로 분리된 비원자적 연산을 수행
-
- 운영체제의 예측 불가능한 스케줄링(컨텍스트 스위칭)이 이 단계들 사이에 개입하면서 연산 누락이 발생
-
-
효과 및 증명
- 기대값과 전혀 다른 최종 결과값을 통해 연산이 누락됐음을 명확히 증명
2. "해결책"의 구현: 원자적 연산으로 데이터 무결성 보장하기
-
의도
- 스레드 간의 경쟁 환경은 그대로 두되, 공유 자원에 접근하는 과정에만 질서를 두는 동기화를 구현하는 것이 목표
-
동작 원리
-
Atomics.add를 사용해 읽기 -> 더하기 -> 쓰기 과정을 누구도 방해할 수 없는 하나의 원자적 연산으로 묶음
-
- 이를 통해 임계 구역(공유 자원에 접근하는 코드 영역)을 안전하게 보호
-
-
효과 및 증명
- 스레드 실행 순서는 여전히 무질서함
- 그러나 최종 결과값은 기대값과 정확히 일치했음
- 혼란스러운 환경 속에서도 동기화를 통해 데이터의 무결성이 지켜졌음을 증명함
3. 스레드 생명주기 관리: 안정적인 프로그램 구조 설계
-
의도
- 메인 스레드가 작업을 지시하고, 워커 스레드들이 수행하며, 모든 작업이 끝날 때까지 기다렸다가 결과를 정리하는 역할 수행
-
동작 원리
-
Promise와worker.on('exit')이벤트를 사용
-
- 모든 워커의 작업이 끝날 때까지 메인 스레드의 실행을 제어하는, 거시적 관점의 동기화를 구현
-
-
효과 및 증명
- 비동기적으로 실행되는 여러 스레드의 작업을 안정적으로 관리
- 정확한 시점에 결과를 검증하는 신뢰성 있는 프로그램 구조를 만듦