개요

넥토리얼 대비로 CS 기본기를 다시 훑는 중이다. 멀티스레드는 이론 정리 - < OS >이론 정리 - < 멀티 스레드 >에서 이미 두 번 정리해뒀던 주제라, 이번엔 가볍게 복습만 하고 넘어갈 생각이었다.

그래서 방식을 조금 바꿔봤다. 눈으로 읽는 대신 말로 설명해봤다. 읽으면 다 아는 내용인데, 입 밖으로 꺼내니 속도가 확 달라진다.. 크게 세 종류로 갈렸다.

  • 그대로 나오는 것 — Mutex가 하는 일, 임계 영역, 컨텍스트 스위칭 비용. 여기는 예전 정리가 잘 버텨줬다
  • 잠시 잊고 있던 것 — 알고는 있었는데 바로 안 나오는 것들. Lock을 굵게 잡을지 잘게 잡을지 같은 판단 기준
  • 반대로 기억하고 있던 것 — Stack과 Heap이 각각 누구 것인지. 이건 한 군데뿐이었는데 하필 뒤에 오는 설명 전부가 여기에 얹혀 있었다

예전 두 편은 개념을 한 줄로 세워두는 카드에 가까웠다. 정의는 다 맞게 적혀 있는데, 그 개념이 실제로 어디서 터지는지가 없었다. 이번 편은 그 카드 위에 한 겹을 더 얹는 편이다. Data Race, Atomic, TOCTOU처럼 그때 이름조차 안 적어뒀던 것들이 여기 들어간다.

각 단계가 안전하면, 전체도 안전한가? 복습만 하려다 제일 오래 붙잡고 있던 질문이다.

( 코드 예시는 C++로 적는다. RAII와 스마트 포인터, 소유권 이야기는 분량상 다음 편으로 넘긴다 )


1. Process와 Thread — 비유를 한 칸 정확하게

장보기로 먼저

Process와 Thread를 설명해보라고 하면 예전엔 이렇게 말했다.

Process는 장보기라는 큰 일이고, Thread는 목록에 적힌 물건을 하나씩 가져오는 작업이다.

방향은 맞는데 한 칸이 어긋나 있다. Thread는 작업이 아니라, 작업을 하는 쪽이다. 우유를 사는 건 “Thread”가 자체가 아니라 “Thread가 하는 일”이다.

용어 장보기로 옮기면
Process 장보기 프로젝트 전체
Thread 장보러 간 사람
Task 우유 사기, 계란 사기 같은 개별 심부름

사람이 넷이면 심부름을 나눠서 동시에 할 수 있다. 그게 멀티스레드다.

Process와 Thread

정의로 다시 쓰면 OS 편에 적어둔 그대로다. Process는 실행 중인 프로그램의 주소 공간과 자원을 묶은 단위고, Thread는 그 안에서 명령을 수행하는 실행 흐름이다. 여긴 그때 정리가 정확했다.

Stack과 Heap은 누구 것인가

여기가 반대로 기억하고 있던 곳이다. 입에서 이렇게 나왔다.

Stack은 Process가 공유하고, Heap은 Thread가 하나씩 가진다.

정확히 뒤집혀 있다. 각 Thread는 자기만의 Stack을 가지고, Heap과 전역 데이터는 같은 Process의 Thread들이 같이 본다.

Stack과 Heap의 소속

사실 OS 편에 “스레드는 프로세스의 메모리 공간을 공유함” 이라고 이미 적어놨었다. 맞는 문장인데 무엇을 공유하는지를 안 적어둬서, 1년 반 지나니 방향이 반대로 굳어 있었던 셈이다..

이 한 칸이 중요한 이유는, 뒤에 나올 이야기가 전부 여기서 출발하기 때문이다. 공유되는 게 Heap과 전역 데이터라서 Data Race가 생기고, 그래서 Mutex가 필요해진다. 여길 거꾸로 잡고 있으면 그 다음 설명이 다 같이 기운다.

한 가지 단서는 달아야 한다. Heap에 있다고 무조건 공유되는 건 아니다. 한 Thread만 그 주소를 들고 있으면 사실상 개인 것이다. 기준은 “어디에 있느냐”가 아니라 “누가 그 주소를 들고 접근하느냐” 다.

같이 다시 본 것 둘

소속을 바로 세우면서 옆에 있던 두 가지도 같이 손봤다.

“Stack은 정적, Heap은 동적” — Stack에 있는 변수도 함수가 실행되는 동안 생겼다가 사라진다. 그것도 충분히 동적이다. 진짜 차이는 정적이냐 동적이냐가 아니라 수명을 누가 관리하느냐다. Stack은 함수를 벗어나면 자동으로 정리되고, Heap은 내가 직접 챙겨야 한다.

“배열은 Stack, vector는 Heap” — 자료구조와 저장 위치를 1:1로 붙여둔 거였다. new로 잡은 배열은 Heap에 있다. 반대로 vector 객체 자체는 Stack에 있을 수 있고, 원소를 담는 버퍼만 Heap에 있다. 이 둘을 나눠서 봐야 한다.

더 쉬운 비유로

Stack = 각자 손에 든 개인 메모장
Heap / 전역 데이터 = 여럿이 돌려보는 공용 장부
Mutex = 장부를 한 번에 한 명만 쓰게 하는 규칙

내 메모장에 뭘 적든 남이 볼 일이 없으니 사고가 안 난다. 문제는 공용 장부다. 두 사람이 동시에 같은 줄을 고치면 한 명이 쓴 게 사라진다.


2. Data Race — 이름을 몰랐던 것에 이름 붙이기

멀티 스레드 편“실수를 조금만 하여도 크리티컬한 오류를 불러오기 쉬움” 이라고 적어뒀었다. 맞는 말인데 어떤 실수가 어떤 오류를 부르는지가 없다. 그 자리에 들어갈 이름이 Data Race다.

한 줄이 세 걸음인 이유

gold -= 10 은 코드로 보면 한 줄이다. 그런데 실제로 하는 일은 세 걸음이다.

  1. 지금 gold가 얼마인지 읽고
  2. 거기서 10을 빼고
  3. 그 결과를 다시 쓴다

한 줄이라 한 덩어리처럼 보이지만 아니다. 저 사이사이에 다른 Thread가 끼어들 수 있다.

Lost Update

둘이 각각 10씩 깎았으니 100에서 80이 돼야 하는데 90이 나온다. B가 읽은 시점에 A는 아직 쓰지 않았기 때문이다. A가 쓴 90을 B가 90으로 덮어쓰면서 A의 작업이 통째로 사라진다. 이걸 Lost Update라고 부른다.

정리하면, 두 Thread가 동기화 없이 같은 메모리에 접근하고 그중 하나 이상이 값을 바꾸면 Data Race다. 그리고 C++에서 Data Race는 “값이 좀 이상해지는” 수준이 아니라 Undefined Behavior다. 크래시가 나도, 안 나도, 디버그 빌드에서만 멀쩡해도 다 규격대로다.. 이게 제일 무섭다.

Data Race와 Race Condition은 다른 말이다

둘을 같은 뜻으로 섞어 쓰고 있었는데, 층이 다르다.

  Data Race Race Condition
Layer 메모리 접근 프로그램 로직
조건 동기화 없이 같은 메모리, 하나 이상이 쓰기 실행 순서에 따라 결과가 달라짐
C++에서 Undefined Behavior 언어 차원의 UB는 아님
대처 없애야 한다 순서를 설계로 못박아야 한다

Data Race를 다 없애도 Race Condition은 남을 수 있다. 이게 5번 섹션의 이야기다.

Ducktopia 때는 왜 안 겪었나

Ducktopia에서 게임 서버를 만들 때 이런 문제로 고생한 기억이 없다. Node.js였기 때문이다.

이것도 이론 정리 - < 자바스크립트(2) >에 정리해둔 게 있었다.

사실은 Node.js의 싱글 스레드는 요청을 처리하는 부분(javascript)만 싱글 스레드로 돌아가고, I/O(입출력) 과 같은 비동기 작업은 libuv를 활용하여 멀티 스레드로 처리하는 것이다!

당시엔 “그런데 Node.js는 비동기로 도는데 어떻게 싱글 스레드지?” 라는 궁금증을 푸는 맥락으로 적었다. 이번에 다시 보니 여기가 내가 Data Race를 안 겪은 이유를 그대로 설명하고 있었다.

Ducktopia에도 스레드는 여러 개 돌고 있었다. libuv가 기본 4개짜리 Thread Pool을 굴리고 있었으니까. 그런데 그 스레드들이 만지는 건 파일과 소켓이지 player.gold가 아니다. 내가 쓴 코드가 실행되는 순간은 언제나 하나라서, player.gold -= 10 한 줄이 중간에 잘릴 일이 없었다.

  Ducktopia (Node.js) C++ 멀티스레드
스레드 개수 여러 개 (libuv Thread Pool) 여러 개
내 코드가 도는 곳 한 줄기 (EventLoop) 여러 줄기
공유 상태를 만지는 주체 사실상 나 하나 여러 Thread
그래서 Data Race 안 났다 내가 막아야 한다

멀티스레드가 없던 게 아니라, 런타임이 공유 상태를 내 코드 바깥으로 밀어내고 있었다. 개념은 정리해뒀는데 몸으로 겪을 일이 없었으니, 정의가 흐릿하게 남아 있을 만했다.


3. Mutex — 알고 있던 것 옆에 청구서 붙이기

여긴 예전 정리가 제일 잘 버텨준 구간이다. 임계 영역과 Mutex를 같은 맥락으로 묶어둔 것도, 시리얼 병목 얘기를 붙여둔 것도 그대로 유효했다. 이번엔 그 옆에 비용을 적어둔다.

한 명씩 들어가게 만든다

위험한 구간을 한 번에 한 Thread만 들어가게 막는다. 그 구간이 Critical Section(임계 영역), 문을 잠그는 도구가 Mutex다.

#include <mutex>

using namespace std;

mutex goldMutex;
int gold = 100;

void spend(int cost) 
{
    lock_guard<mutex> lock(goldMutex);
    gold -= cost;
}   // 여기서 lock 객체가 사라지면서 자동으로 풀린다

lock_guard가 잠금을 들고 있다가, 함수를 벗어나는 순간 알아서 풀어준다. 중간에 return을 하든 예외가 터지든 똑같이 풀린다. 잠금의 수명을 lock 객체의 수명에 묶어둔 것이다 ( 이 방식이 다음 편에서 다룰 RAII 이다 )

여기까진 예전에 정리한 그대로다. 문제는 그 다음이다.

청구서 1 — Lock Contention

Mutex 하나에 Thread가 몰리면 줄을 선다. 병렬로 돌리려고 Thread를 늘렸는데 결국 한 줄로 서서 기다리면, Thread를 늘린 의미가 없어진다. 오히려 문을 잠그고 푸는 비용만 더 든다..

예전 글의 시리얼 병목이 딱 이 얘기였다. 그때는 현상까지만 적어뒀고, 그래서 어떻게 하라는 게 없었다. 원칙은 하나다. Critical Section은 짧게. 6번 섹션에서 다시 다룬다.

Lock을 어떻게 나눌지도 선택지가 있다. 알고는 있었는데 기준이 바로 안 나오던 부분이다.

  Coarse-grained (굵게) Fine-grained (잘게)
방식 큰 자료구조 하나에 Lock 하나 조각마다 Lock 따로
구현 단순하다 복잡하다
일관성 관리하기 쉽다 놓치기 쉽다
Contention 크다 작다
Deadlock 위험 낮다 높아진다

정답이 있는 게 아니라 굵게 시작하고, 경합이 실제로 측정되면 그때 쪼개는 순서가 맞다. 처음부터 잘게 나누면 아래 문제가 바로 따라온다.

청구서 2 — Deadlock

예전 글에서 취소선 안에 Do you Know DEADLOCK!? 한 줄로만 던져놓고 넘어갔던 부분이다. OS 편엔 정의를 적어뒀지만 예방법이 없었다.

Deadlock

A는 Lock A를 쥔 채로 Lock B를 기다리고, B는 Lock B를 쥔 채로 Lock A를 기다린다. 둘 다 상대가 놓기를 기다리는데, 둘 다 놓을 생각이 없다. 영원히 안 풀린다.

무서운 건 양쪽 코드가 다 정상이라는 점이다. 각각 따로 보면 잘못된 게 하나도 없다. 만나야만 터진다. 그래서 재현도 어렵다..

예방법 중 제일 단순한 건 Lock ordering이다. 여러 Lock을 잡을 일이 있으면 잡는 순서를 하나로 통일한다. 누가 짜든 항상 A 다음 B라면, 위 그림 같은 순환이 애초에 안 만들어진다.

Mutex는 문제를 없애는 도구라기보다 문제를 다른 문제로 바꾸는 도구에 가깝다. 예전 정리엔 앞쪽 절반만 있었다.


4. Atomic — 경계를 다시 그은 곳

예전 두 편에 아예 없던 개념이다. Mutex만 알고 있으니, 새로 배운 걸 아는 것 옆에 붙여서 이해하려다 경계가 흐려졌다.

흐릿하게 잡고 있던 것

말로 꺼내보니 이렇게 나왔다.

Atomic은 한 작업이 진행되는 동안 다른 Thread의 접근을 막아주는 기능이다.

이건 Mutex 설명이다. Atomic을 “가벼운 Mutex” 정도로 놓고 있었던 거다. 둘은 막는 대상 자체가 다르다.

  Mutex Atomic
무엇을 보장하나 구간을 한 명만 쓰게 연산 하나가 안 쪼개지게
범위 내가 정한 코드 블록 전체 그 한 줄
여러 줄 묶기 된다 안 된다
기다림 다른 Thread는 대기 대기 없이 처리되는 경우가 많다
쓸 곳 여러 상태를 함께 바꿀 때 카운터 하나 증감 같은 단순 변경
#include <atomic>

using namespace std;

atomic<int> killCount{0};

void onZombieKilled() 
{
    killCount++;   // 읽고-더하고-쓰기가 쪼개지지 않는다
}

Mutex 없이도 이 한 줄은 안전하다. 다만 안전한 건 딱 이 한 줄뿐이다.

더 쉬운 비유로

Mutex = 방에 들어가서 문을 잠그는 것
Atomic = 종이 한 장을 남이 못 건드리게 한 번에 낚아채는 것

방을 잠그면 그 안에서 뭘 몇 개를 하든 아무도 못 들어온다. 대신 다른 사람은 문 앞에서 기다린다.

낚아채기는 기다림이 없다. 대신 낚아채는 그 동작 하나만 안전하다. 종이를 집어서 읽고, 뭔가 계산하고, 다시 내려놓는 건 세 동작이니까 그 사이에 얼마든지 끼어들 수 있다.

Atomic은 범위를 지키는 도구가 아니라 동작 하나를 쪼개지지 않게 하는 도구다. 이 한 문장이 경계선이다.


5. 각 단계가 Atomic이어도 전체는 아니다

앞 섹션이 경계를 그은 거라면, 여기는 그 경계가 실제로 어디서 드러나는지다. 이번 복습에서 제일 오래 붙잡고 있었다.

상점에서 검을 산다

게임 상점 구매를 코드로 쪼개면 대충 이렇게 된다.

1. Gold가 충분한지 확인
2. 인벤토리에 자리가 있는지 확인
3. Gold를 차감
4. Sword를 지급

여기서 잠깐 이렇게 생각했다. “3번과 4번을 각각 Atomic으로 만들면 되는 거 아닌가?”

아니다. 이 흐름의 위험은 3번이나 4번 안에 있는 게 아니라 1번과 3번 사이에 있다.

Thread A: Gold 100 확인 (충분!)
Thread B: Gold 100 확인 (충분!)
Thread A: Gold 차감 → 0
Thread B: Gold 차감 → -100

두 Thread 모두 “확인” 시점엔 정직하게 통과했다. 차감도 각각 Atomic이라 값이 깨지지도 않았다. 그런데 골드가 마이너스가 됐다.

각 걸음이 안전한 것과 걸음 전체가 하나로 묶여 있는 것은 전혀 다른 얘기다.

그래서 필요한 게 Transaction이다

두 개념을 나란히 두면 이렇게 된다.

  하는 일
Atomic operation 특정 연산 하나가 쪼개지지 않게 한다
Transaction 여러 상태 변경을 하나의 논리적 작업으로 묶어, 전부 되거나 전부 안 되게 한다

구매는 사용자 입장에서 한 가지 일이다. “골드가 빠졌는데 검은 안 왔다”는 결과가 나오면 안 된다. 그러니 1~4번을 통째로 묶어야 하고, 이건 Atomic 연산으로는 안 되고 Mutex 같은 구간 보호가 필요하다.

이론 정리 - < DB >에서 트랜잭션을 정리해뒀었는데, 그때는 DB 안의 이야기로만 봤다. 같은 원리가 메모리 상태에도 그대로 적용된다는 걸 이번에 이어붙였다.

TOCTOU — 시간 차 문제가 아니다

이 패턴에 이름이 붙어 있다. TOCTOU (Time Of Check To Time Of Use). 확인한 시점과 실제로 쓰는 시점 사이에 상태가 바뀌는 문제다.

이름을 처음 듣고 이렇게 받아들였다. “확인하고 나서 오래 있다가 쓰면 위험하니까, 쓰기 직전에 확인하면 되겠네.”

여기가 두 번째로 흐릿했던 곳이다. 아무리 붙여놔도 그 사이는 0이 되지 않는다.

if (gold >= 100)     // ← 확인
{   
    gold -= 100;     // ← 사용. 붙어 있어도 사이는 있다
}

두 줄 사이에서 다른 Thread가 실행될 수 있다. 문제는 간격의 길이가 아니라, 확인부터 변경까지가 하나로 지켜지고 있느냐다. 붙여놓는 건 사고 확률을 낮출 뿐이지 없애지 못한다.. 확률이 낮은 버그는 오히려 잡기가 더 어렵다.

“충분히 가깝게 붙이면 되겠지”는 해법이 아니라 재현이 어려운 버그를 만드는 방법이다.

유니티에서 봤던 것과 같은 모양

유니티 학습 프로젝트 - < 4 > 끝에 알면서 안 고친 것 일곱 개를 적어뒀는데, 그중 마지막이 이거였다.

7. EnemyChase._target에 null 체크가 없다. 플레이어가 죽어서 사라지면 좀비 전원이 매 프레임 예외를 던진다.

그때는 그냥 “null 체크를 빼먹었다”로 적어뒀다. 이번에 TOCTOU를 정리하고 나서 다시 보니, 이게 그 모양이다.

좀비는 태어날 때 스포너에게서 추격 대상을 한 번 받는다. 그 시점엔 플레이어가 분명히 살아 있었다.

// 스포너가 좀비를 만들 때 — 여기가 "확인"
chase.Init(_target);

그리고 좀비는 매 프레임 그 대상을 쓴다. 확인은 태어날 때 한 번, 사용은 그 뒤로 계속이다. 그 사이가 몇 분씩 벌어진다. 그 동안 플레이어가 죽으면 대상은 사라지는데, 좀비는 여전히 예전에 받아둔 것을 들고 쫓아간다.

유니티는 대부분 한 줄기 흐름이라 Data Race는 아니다. 그런데 모양이 정확히 같다.

  상점 구매 (멀티스레드) 좀비 추격 (싱글스레드)
확인 Gold가 충분한가 추격 대상이 있는가
사용 Gold를 차감 대상 쪽으로 이동
사이에 끼어드는 것 다른 Thread의 차감 플레이어의 죽음
결과 골드가 마이너스 매 프레임 예외

여기서 확인이 늦었냐 빨랐냐를 따지는 건 의미가 없다. 확인한 값이 계속 유효하다고 가정한 것이 문제다. 그래서 답도 “확인을 쓰기 직전으로 옮기기”가 아니라 쓰는 쪽에서 매번 유효한지 보기가 된다.

Thread가 하나여도 확인과 사용 사이에 상태를 바꾸는 무언가가 끼어들 수 있으면 같은 사고가 난다. 멀티스레드는 그 간격을 훨씬 짧고 잦게 만들 뿐이다.


6. Critical Section을 짧게 — Lock 밖으로 뺄 것들

3번에서 미뤄둔 얘기다. 시리얼 병목을 줄이라는 원칙은 예전에도 적어뒀는데, 구체적으로 뭘 빼라는 것인지가 없었다. 그 자리를 채운다.

처음 짜면 이렇게 된다.

void buySword() 
{
    lock_guard<mutex> lock(shopMutex);

    gold -= 100;
    inventory.push_back(Sword{});

    ui.showPopup("검을 구매했다");        // UI 갱신
    sound.play("purchase.wav");          // 효과음
    logger.writeToFile("sword bought");  // 파일 기록
}

돌아는 간다. 그런데 아래 세 줄이 Lock 안에 있을 이유가 없다. UI를 그리고 효과음을 틀고 파일에 쓰는 동안, 다른 Thread는 상점 문 앞에서 계속 기다린다. 특히 파일 쓰기는 앞의 두 줄보다 훨씬 느리다.

void buySword() 
{
    {
        lock_guard<mutex> lock(shopMutex);
        gold -= 100;
        inventory.push_back(Sword{});
    }   // 상태 변경이 끝나는 즉시 푼다

    ui.showPopup("검을 구매했다");
    sound.play("purchase.wav");
    logger.writeToFile("sword bought");
}

기준은 하나다. 공유 상태를 바꾸는 데 꼭 필요한가. 필요하면 안, 아니면 밖.

Lock 안에 들어가면 특히 위험한 것들을 적어둔다.

Lock 안에 두면 위험한 것
파일 I/O 다른 코드보다 자릿수가 다르게 느리다
네트워크 요청 응답이 언제 올지 모른다
긴 계산 그동안 다른 Thread는 전부 대기다
UI / 사운드 같은 후처리 상태와 무관한데 줄만 길게 만든다
또 다른 Lock 잡기 Deadlock으로 가는 지름길이다

Critical Section은 넣을 걸 고르는 자리가 아니라 뺄 걸 고르는 자리다.


정리

이번 복습을 세 칸으로 나눠서 정리해둔다.

기억이 확실했던 것

  • Process는 주소 공간과 자원의 단위, Thread는 그 안의 실행 흐름. OS 편 정의가 정확했다
  • Critical Section을 Mutex로 보호한다. 시리얼 병목이라는 현상 인식도 맞았다
  • lock_guard가 알아서 풀어준다. 직접 unlock을 부르지 않는 이유까지

알고는 있었는데 바로 안 나오던 것

  • Lock을 굵게 잡을지 잘게 잡을지. 굵게 시작하고 경합이 측정되면 쪼갠다
  • Deadlock 예방의 기본은 Lock ordering — 잡는 순서를 하나로 통일한다
  • Critical Section은 뺄 걸 고르는 자리다. 파일 I/O, 네트워크, UI 후처리는 밖으로

반대로 / 흐릿하게 잡고 있던 것

  • Stack은 Thread마다 하나씩, Heap과 전역 데이터는 같이 본다. 이걸 거꾸로 잡고 있었다
  • 다만 Heap에 있다고 전부 공유되는 건 아니다. 기준은 위치가 아니라 누가 그 주소를 들고 있느냐
  • Mutex는 구간을, Atomic은 연산 하나를 지킨다. Atomic은 가벼운 Mutex가 아니다
  • TOCTOU의 핵심은 시간 차가 아니라 일관성이다. 붙여놓는 건 해법이 아니라 확률 낮추기다

이번에 새로 얹은 것

  • gold -= 10 한 줄은 읽기 → 계산 → 쓰기 세 걸음이고, 그 사이에 끼어들면 Lost Update가 난다
  • C++에서 Data Race는 Undefined Behavior다. 값이 이상해지는 정도로 끝나지 않는다
  • Data Race와 Race Condition은 다른 층이다. 앞을 다 없애도 뒤는 남는다
  • 각 단계가 Atomic이어도 전체는 Atomic이 아니다. 여러 상태를 하나로 묶는 건 Transaction의 영역이다

참고 자료

한줄 평

  • 읽으면 다 아는 내용이었는데 말로 꺼내보니 흐릿한 데가 나왔다. 복습은 눈이 아니라 입으로 해야겠다!