seungjun.dev

JS로 레이스 컨디션 시뮬레이터 만들기

JS로 레이스 컨디션과 원자적 연산 시뮬레이션 해보기

선수지식

들어가기 전 아래의 선수지식을 필요로 합니다. 하나라도 모르면 이해하기 정말 어려우니 잘 짚고 넘어가기~

각 개념을 클릭하면 해당 블로그 글로 이동할 수 있습니다!

운영체제

JS

🤖 시뮬레이터 목표

  • 눈에 보이지 않는 멀티 스레딩 환경의 동작 원리를 코드로 증명하고 깊이 있게 이해
  • 레이스 컨디션을 직접 확인
  • 동기화 기법이 왜 필요하고 어떻게 데이터의 무결성을 보장하는지 체득
  • Node.js에서 실제 멀티 스레드 프로그램을 작성하고, 스레드의 생성, 데이터의 전달, 실행 대기, 종료 등 전체 생명주기를 관리하는 방법을 익히기

⚙️ 기능 구체화

1. 멀티 스레드 환경 구성

  • 메인 스레드는 최소 2개 이상의 워커 스레드를 생성해야 한다.
  • 생성된 각 워커 스레드는 독립적으로 코드를 실행할 수 있어야 한다.

2. 공유 자원 정의 및 생성

  • 모든 스레드(메인+워커)가 함께 접근할 수 있는 공유 메모리 공간을 생성
  • 이 공간에는 정수형 숫자 하나를 저장하며, 초기값은 0이어야 함
    • 나중에 1씩 더하는 연산으로 레이스 컨디션을 실험할 거라 0을 저장함
    • 이 숫자가 바로 레이스 컨디션의 대상이 될 '공유 자원'

3. 레이스 컨디션 발생 시나리오 구현

  • 각 워커 스레드는 정해진 횟수만큼 반복하며 공유 자원의 숫자를 1씩 증가시키는 작업을 수행
  • 이때, 숫자 증가 연산은 **비원자적(Non-Atomic)**으로 처리되야함
    • 즉, 현재값 읽기 -> 값에 1 더하기 -> 계산된 값을 다시 쓰기라는 여러 단계로 나누어져, 단계 사이에 다른 스레드가 끼어들 수 있어야 함

4. 동기화를 통한 문제 해결 시나리오 구현

  • 레이스 컨디션 발생 시나리오와 별개로, 동기화가 적용된 시나리오를 구현
  • 이 시나리오에서는 숫자 증가 연산을 **원자적(Atomic)**으로 처리

5. 명확한 실행 및 결과 검증

  • 프로그램 실행 시, 터미널 출력을 통해 현재 어떤 시나리오 (레이스 컨디션 or 동기화)가 실행 중인지 명확히 표시
  • 모든 스레드의 작업이 완료도니 후, 최종 결과를 출력
  • 실행 흐름을 추적할 수 있도록 주요 이벤트 로그를 터미널에 출력

6. 스레드 제어 및 통신

  • 메인 스레드는 워커 스레드에게 공유 자원의 정보와 수행할 작업의 종류(레이스 컨디션 or 동기화)를 전달할 수 있어야 함
  • 메인 스레드는 모든 워커 스레드가 작업을 마칠 때까지 기다렸다가, 최종 결과를 출력
  • 워커 스레드는 작업이 끝나면 메인 스레드에게 완료되었음을 알릴 수 있어야 함

세 줄 요약

  1. 메인스레드에서 워커 스레드 생성해 멀티 스레드 환경 구성
  2. 워커 스레드는 메인 스레드에서 IPC로 임무를 받아 숫자 증가 연산 수행 (레이스 컨디션을 발생시키기 위한 행동)
  3. 레이스 컨디션 환경과 동기화로 레이스 컨디션을 해결한 환경을 분석

🥞 기술 스택 선정

멀티 스레딩 구현

  • 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. 공유 메모리의 첫 번째 칸에 있는 값을 읽어서 저장
      1. 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();
  • 레이스 컨디션을 발생시키는 시나리오와 동기화로 레이스 컨디션을 막는 시나리오를 각각 실행

🏎️ 전체 코드

GitHub Gist 보러가기

  • 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. "문제"의 의도적 재현: 레이스 컨디션은 왜 발생하는가?

  • 의도

    • 레이스 컨디션이 일어나는 환경을 의도적으로 구축
    • 이는 동기화 기법이 왜 필요한지를 몸소 체감하기 위한 필수 과정이었음
  • 동작 원리

      1. 여러 워커 스레드가 단 하나의 공유 메모리(SharedArrayBuffer)에 접근
      1. 읽기 -> 더하기 -> 쓰기로 분리된 비원자적 연산을 수행
      1. 운영체제의 예측 불가능한 스케줄링(컨텍스트 스위칭)이 이 단계들 사이에 개입하면서 연산 누락이 발생
  • 효과 및 증명

    • 기대값과 전혀 다른 최종 결과값을 통해 연산이 누락됐음을 명확히 증명

2. "해결책"의 구현: 원자적 연산으로 데이터 무결성 보장하기

  • 의도

    • 스레드 간의 경쟁 환경은 그대로 두되, 공유 자원에 접근하는 과정에만 질서를 두는 동기화를 구현하는 것이 목표
  • 동작 원리

      1. Atomics.add를 사용해 읽기 -> 더하기 -> 쓰기 과정을 누구도 방해할 수 없는 하나의 원자적 연산으로 묶음
      1. 이를 통해 임계 구역(공유 자원에 접근하는 코드 영역)을 안전하게 보호
  • 효과 및 증명

    • 스레드 실행 순서는 여전히 무질서함
    • 그러나 최종 결과값은 기대값과 정확히 일치했음
    • 혼란스러운 환경 속에서도 동기화를 통해 데이터의 무결성이 지켜졌음을 증명함

3. 스레드 생명주기 관리: 안정적인 프로그램 구조 설계

  • 의도

    • 메인 스레드가 작업을 지시하고, 워커 스레드들이 수행하며, 모든 작업이 끝날 때까지 기다렸다가 결과를 정리하는 역할 수행
  • 동작 원리

      1. Promiseworker.on('exit') 이벤트를 사용
      1. 모든 워커의 작업이 끝날 때까지 메인 스레드의 실행을 제어하는, 거시적 관점의 동기화를 구현
  • 효과 및 증명

    • 비동기적으로 실행되는 여러 스레드의 작업을 안정적으로 관리
    • 정확한 시점에 결과를 검증하는 신뢰성 있는 프로그램 구조를 만듦