개요

4지선다를 하나 받았다. 상황 네 개를 주고 뮤텍스와 세마포어 중 무엇을 쓸지 고르는 문제였다.

상황 내 답 정답
a. 여러 스레드가 같은 Inventory를 수정 세마포어 뮤텍스
b. 로딩이 끝나면 메인 스레드가 씬을 시작 뮤텍스 세마포어
c. 텍스처 디코딩을 최대 4스레드까지만 뮤텍스 세마포어
d. 잡 큐에 작업이 올 때까지 워커 8개가 대기 세마포어 세마포어

네 개 중 세 개를 틀렸는데, 문제는 그게 아니다. b는 20분 전에 내가 직접 설명한 케이스였다.

앞선 데이터 레이스, 뮤텍스, 아토믹 편에서 Atomic을 “가벼운 Mutex”로 놓았다가 경계를 다시 그었던 적이 있다. 이번에도 똑같은 모양이었다. 그때는 가벼운 Mutex였고, 이번엔 카운트가 N인 Mutex였다.

세마포어는 락을 푸는 key를 여러 개로 나눈 것이고, 카운트가 1이면 뮤텍스와 기능적으로 같다.

이 문장을 들고 시작했다가, 이 문장으로는 b를 절대 못 맞힌다는 걸 알게 되는 과정이 이번 편이다.


1. 이미 말했던 답

문제를 받기 전에 이런 코드를 먼저 봤다. 로딩 스레드가 unlock을 하고, 메인 스레드가 lock을 한다.

// 예시 — 로딩 스레드와 메인 스레드가 각각 다른 쪽을 호출한다
std::mutex m;

void LoadingThread() {
    LoadScene();    // 무거운 로딩 작업
    m.unlock();     // 로딩 스레드가 푼다
}

void MainThread() {
    m.lock();       // 메인 스레드가 잠근다 (?)
    StartGame();
}

보자마자 되물었다.

“로딩 스레드랑 메인 스레드가 하는 역할이 뭔데 mutex면 성립하고 안 하고가 정해져? 이해가 하나도 안 되는데”

그런데 같은 문장 끝에서 스스로 답이 나왔다.

“뮤텍스면 동일한 작업 내에서 해결되어야 하니까 성립이 안 되는구나”

이해가 안 된다고 말하면서 정답을 말했다. 개념은 있었는데, 이게 왜 중요한 구분인지가 없었던 셈이다. 그래서 20분 뒤 b에서 “뮤텍스”를 골랐다.


2. 축은 개수가 아니라 소유권이다

내가 잡고 있던 축은 몇 명이 들어갈 수 있는가였다. 1명이면 뮤텍스, N명이면 세마포어. 이 축으로는 b가 안 풀린다. b는 들어갈 인원이 한 명이니까.

진짜 축은 다른 데 있었다.

  Mutex Semaphore
본질 Mutual Exclusion — 배제 개수 허가증 / 신호
소유권 있다 — 잠근 스레드만 해제 없다 — 누구나 release
다른 스레드가 해제 UB 합법, 오히려 주력 용도
두 번 해제 UB 합법 — 카운트만 증가
아는 것 누가 쥐고 있는지 몇 개 남았는지

이름부터가 그렇다. Mutex는 Mutual Exclusion의 줄임말이다. 배제가 이름에 박혀 있다. 세마포어(semaphore)는 원래 철도의 신호기다. 배제가 아니라 신호가 이름에 있다.

cppreference의 std::counting_semaphore 문서도 같은 자리를 짚는다.

Unlike std::mutex a counting_semaphore is not tied to threads of execution — acquiring a semaphore can occur on a different thread than releasing the semaphore, for example.

그리고 바로 다음 문단에서 이렇게 잇는다.

Semaphores are also often used for the semantics of signaling/notifying rather than mutual exclusion, by initializing the semaphore with 0 and thus blocking the receiver(s) that try to acquire(), until the notifier “signals” by invoking release(n).

0으로 초기화해서 수신자를 막아두는 것이 b 케이스 그대로다. 표준 문서가 예시로 들고 있는 용도를 나는 “뮤텍스”라고 답했던 것이다.

// 예시 — b 케이스를 세마포어로
std::binary_semaphore sceneReady{0};   // 0으로 시작해서 수신자를 막아둔다

void LoadingThread() {
    LoadScene();            // 로딩 스레드가 무거운 일을 한다
    sceneReady.release();   // "끝났다"를 내보낸다 — 이 스레드는 acquire한 적이 없다
}

void MainThread() {
    sceneReady.acquire();   // 신호가 올 때까지 여기서 막힌다
    StartGame();            // 신호를 받고 진행
}

뮤텍스로는 이 구조를 만들 수 없다. 잠그지 않은 뮤텍스를 unlock하는 건 UB라서, 문법이 아니라 규격이 먼저 막는다.


3. 판단 필터 두 줄

개수를 먼저 묻는 습관을 뒤집으려고 순서를 고정했다.

1. 잠그는 스레드와 푸는 스레드가 같은가?
     아니다  → 세마포어 ( 뮤텍스는 물리적으로 불가능 )
     그렇다  → 2번으로
2. 동시에 들어갈 수 있는 인원이 몇 명인가?
     1명 → 뮤텍스 / N명 → 세마포어

이 순서로 다시 풀면 네 문제가 전부 갈린다.

  1번 — 잠그고 푸는 쪽이 같은가 2번 — 인원 결론
a. Inventory 수정 같다 (한 스레드가 잡고 고치고 논다) 1명 뮤텍스
b. 로딩 완료 통지 다르다 (로딩이 내고 메인이 받는다) 세마포어
c. 디코딩 4스레드 제한 같다 4명 세마포어
d. 워커 8개 대기 다르다 (생산자가 내고 워커가 받는다) 세마포어

핵심은 “개수”를 먼저 묻지 않는 것이다. 개수를 먼저 물으면 b에서 “1명”이 나오고, 그러면 자동으로 뮤텍스로 간다. 내가 걸렸던 자리가 정확히 여기다.


4. binary_semaphore는 왜 mutex가 아닌가

카운트를 1로 두면 상호 배제 자체는 구현된다. 그런데도 a에는 뮤텍스를 써야 한다. 이유가 세 개 나왔다.

4-1. 우선순위 역전에 대응할 수 없다

시나리오부터 세운다. 스레드가 셋 있다.

  • L (낮은 우선순위) — 락을 쥐고 작업 중
  • H (높은 우선순위) — 같은 락을 기다리는 중
  • M (중간 우선순위) — 락과 무관한 계산 중

스케줄러는 L보다 M을 먼저 돌린다. L이 CPU를 못 받으니 락을 놓지 못하고, H는 계속 막혀 있다. 결과적으로 H가 자기보다 낮은 M을 기다리는 꼴이 된다. 이게 우선순위 역전(priority inversion)이다.

여기서 내가 요약하며 한 번 더 틀렸다. “소유권이 없어서 우선순위 역전이 일어난다”고 말했는데, 역전 자체는 양쪽 다 일어난다. 갈리는 건 대응이다.

  역전 발생 Priority Inheritance 대응
뮤텍스 일어난다 가능 — 쥐고 있는 스레드를 특정해 우선순위를 끌어올린다
세마포어 일어난다 불가능 — 끌어올릴 대상을 모른다

Priority Inheritance는 “H가 기다리는 락의 소유자를 H만큼 올려준다”는 해법이다. 소유자를 지목할 수 있어야 성립한다. 세마포어는 카운트만 들고 있어서 지목할 대상이 없다.

이게 API 구조에 그대로 나타난다. POSIX는 PTHREAD_PRIO_INHERIT뮤텍스 속성 객체(pthread_mutexattr_t)의 프로토콜 속성으로만 정의한다. POSIX 세마포어(sem_t)에는 붙일 속성 객체 자체가 없다. 기능을 안 넣은 게 아니라, 넣을 자리가 없다.

1997년 Mars Pathfinder의 리셋 반복이 이 문제였다. Wind River CTO의 키노트를 Mike Jones가 정리한 기록에 따르면, 높은 우선순위의 정보 버스 스레드가 낮은 우선순위의 기상 데이터 스레드를 기다리는 사이 중간 우선순위의 통신 작업이 스케줄을 가져갔다. 워치독이 버스 작업이 한동안 안 돈 걸 보고 전체 시스템 리셋을 걸었다. 고친 방법은 VxWorks 뮤텍스가 갖고 있던 priority inheritance boolean을 FALSE에서 TRUE로 바꾸는 것이었고, C 인터프리터로 그 변수를 화성에 있는 기체에 올려서 바꿨다.

화성까지 올려보낸 그 스위치가 뮤텍스에만 달려 있는 스위치라는 게 이 절의 요점이다.

4-2. RAII를 못 쓴다

std::counting_semaphoreBasicLockable 요구사항을 만족하지 않는다. lock() / unlock()이 아니라 acquire() / release()라서, std::lock_guardstd::scoped_lock에 넣을 수 없다. 표준 위원회에 이 불편을 개선해달라는 제안이 올라온 기록도 있다.

RAII가 없으면 이렇게 된다.

// 예시 — 예외 하나가 permit을 영구히 떼어간다
std::counting_semaphore<4> slots{4};   // 디코딩 슬롯 4개

void DecodeTexture() {
    slots.acquire();     // 카운트 4 → 3
    Decode();            // 여기서 예외가 나면
    slots.release();     // 이 줄을 건너뛴다
}

Decode()가 던지면 release()가 실행되지 않고, 카운트는 3인 채로 남는다. 같은 일이 네 번 반복되면 4 → 3 → 2 → 1 → 0이 되고, 0이 되는 순간 전원이 영구 대기한다.

이걸 나는 “Undefined Behavior”라고 답했는데 틀렸다. 표준 위반이 아니다. 코드는 규격대로 정확히 동작하고, 단지 자원이 샌다. 오히려 UB보다 고약한 이유가 여기 있다. 크래시로 티가 나지 않고, 몇 시간 뒤에 조용히 멈춘다.

뮤텍스는 같은 코드를 lock_guard 한 줄로 막는다.

void UpdateInventory() {
    std::lock_guard<std::mutex> lock(m);   // 생성자가 lock
    Modify();                              // 예외가 나도
}                                          // 소멸자가 반드시 unlock

4-3. 실수가 조용히 통과한다

release()를 실수로 두 번 부르면 카운트가 2가 된다. 그러면 두 스레드가 동시에 임계 영역에 들어가는데, 아무도 에러를 내지 않는다. 세마포어 입장에서는 정상 동작이다. 카운트를 올리는 게 원래 하는 일이니까.

뮤텍스였다면 같은 실수가 UB로 드러난다. 잠그지 않은 뮤텍스를 unlock하는 건 규격이 막아둔 자리다.

뮤텍스가 안전한 건 기능이 더 많아서가 아니라 제약이 더 많아서다.

스레드를 몇 개 둘 것인가 편에서 “싸다/비싸다는 무엇을 안 하기 때문인지로 답한다”를 계속 확인했었다. 이번엔 그게 뒤집혀서 나왔다. 안전은 무엇을 금지하기 때문인지로 답한다.


5. 그럼 RAII 래퍼를 직접 만들면?

생성자에서 acquire, 소멸자에서 release하는 래퍼를 만들면 4-2의 누수는 막힌다. 실제로 c 케이스에는 이게 답이다.

// 예시 — 세마포어용 RAII 래퍼
class SemaphoreGuard {
    std::counting_semaphore<4>& sem;
public:
    explicit SemaphoreGuard(std::counting_semaphore<4>& s) : sem(s) {
        sem.acquire();      // 생성자가 허가증을 가져온다
    }
    ~SemaphoreGuard() {
        sem.release();      // 소멸자가 반드시 돌려준다
    }
};

그런데 이걸 만들고 나서 든 생각은 이거였다.

“해결되는 대신 여러 스레드에서 신호를 공유하는 방식이 제한된다.”

래퍼는 acquirerelease같은 스코프, 같은 스레드에 강제로 묶는다. 그 순간 세마포어는 “카운트 N짜리 뮤텍스”로 격하되고, b 케이스는 아예 표현할 수 없게 된다. 로딩 스레드가 release하는데 소멸자를 메인 스레드에서 돌게 만들 방법이 없다.

그래서 이게 용도를 구분하는 리트머스가 된다.

용도 RAII 래퍼 이유
c — 동시 4개 제한 써야 한다 잃는 게 없다. 어차피 같은 스레드가 잡고 논다
b, d — 신호 전달 못 쓴다 묶이는 순간 신호가 아니게 된다

세마포어를 RAII로 감쌀 수 있으면 배제 용도고, 못 감싸면 신호 용도다.

표준이 counting_semaphorelock_guard를 붙여두지 않은 이유도 이것으로 읽힌다 — 붙이면 절반의 용도를 부정하게 되니까. 다만 이건 내 해석이고, 위원회의 실제 논거는 확인하지 못했다.


6. 세마포어로 안 되는 것 — 조건 대기

“큐에 데이터가 있다”는 세마포어로 된다. d 케이스가 그거다. 그런데 조건을 한 칸만 복잡하게 만들면 무너진다.

“우선순위 5 이상인 작업이 큐에 있으면 깨어나라”

세마포어는 정수 하나뿐이다. 몇 개인지는 알아도 어떤 것인지는 모른다. 큐 안을 들여다볼 방법이 없다.

그럼 뮤텍스로 큐를 지키면서 직접 짜보면 되지 않나 싶었는데, 두 군데서 막힌다.

락을 쥔 채 잠들면 — 생산자가 같은 뮤텍스를 못 잡아서 큐에 아무것도 넣지 못한다. 자기가 기다리는 조건을 자기가 막는다.

락을 먼저 풀고 잠들면 — 푸는 순간과 잠드는 순간 사이에 생산자가 끼어들어 신호를 보내버린다. 아직 대기 큐에 들어가기 전이라 그 신호를 못 받고, 다음 신호가 올 때까지 계속 잔다. lost wakeup이다.

두 번째가 09-07 편에서 정리한 TOCTOU와 같은 모양이다. 확인(조건 검사)과 사용(잠들기) 사이가 0이 되지 않는다.

그래서 condition_variable이 있다.

std::unique_lock<std::mutex> lock(m);
cv.wait(lock, []{ return HasHighPriorityJob(); });   // 락 해제와 잠들기가 원자적으로 묶인다

wait(lock, pred)락을 푸는 것과 대기 큐에 들어가는 것을 원자적으로 처리한다. 그 사이에 신호가 끼어들 틈이 없다.

TOCTOU가 이번엔 문제로 나온 게 아니라 해법의 설계 근거로 돌아왔다.


7. Unity Job System이 같은 모양이다

Job System에서 워커가 계산하고 메인 스레드가 결과를 반영한다. 워커가 “끝났다”를 내보내고 메인이 받는 구조다. 잠그는 쪽과 푸는 쪽이 다르니 뮤텍스로는 구현할 수 없는, b 케이스와 정확히 같은 구조다.

09-18 편에서 “Unity는 락을 거는 대신 소유자를 메인 스레드로 못박아 동기화를 없앴다”고 정리했었다. 여기에 한 층이 얹힌다.

상호 배제를 없앤 설계에도 신호는 남는다.

나눠 가진 것을 다시 합치는 지점에는 동기화가 필요하고, 거기 남는 건 배제가 아니라 신호다. 그래서 세마포어 계열은 락을 없앤 시스템에서 오히려 더 중요해진다. 락이 없다고 동기화가 없는 게 아니었다.


8. 반복된 실수 — 이상하면 다 UB

이번 정리에서 “이상하게 동작하면 UB”라고 답한 게 두 번 나왔다. permit leak에서 한 번, lost wakeup에서 한 번. 둘 다 UB가 아니다.

현상 정체
자원이 안 돌아옴 leak → 결국 deadlock
신호를 놓침 lost wakeup
실행 순서에 따라 결과가 달라짐 race condition
동기화 없이 공유 메모리 접근 UB
잠그지 않은 뮤텍스를 unlock UB

UB는 표준이 정의를 포기한 자리에만 붙는 이름이다. 위 표의 앞 세 줄은 표준이 정의를 포기한 게 아니라, 규격대로 동작한 결과가 내가 원한 것과 다른 경우다.

09-18 편에서 race condition / isolation / synchronization문제 / 보장 / 수단으로 갈랐는데, 이번엔 그 축을 문제의 이름들 안에서 한 번 더 그어야 했다.


9. 90초 요약이 원점으로 돌아간 자리

세션 막바지에 면접용으로 90초 설명을 만들어보라고 해서 이렇게 썼다.

“뮤텍스는 자원을 하나의 작업이 독점하도록 만들어주는 것이고, 세마포어는 이 독점을 느슨하게 만들어 여러 작업이 자원을 동시에 사용할 수 있도록 해주는 것이다.”

세션 내내 틀렸다고 결론 낸 “1개 vs N개”를 단어만 바꿔 쓴 문장이다. 독점이 느슨해졌다는 건 결국 1이 N이 됐다는 말이니까. 그리고 이 설명으로는 b를 설명할 수 없다. 카운트 1짜리 세마포어는 전혀 느슨하지 않은데, 그걸로 신호를 보내기 때문이다.

09-07 편에 “복습은 눈이 아니라 입으로 해야겠다”고 적었는데, 여기서 한 겹이 더 필요해졌다. 말로 꺼내는 것과 요약하는 것도 다르다. 말할 때는 맞는 소리가 나왔는데 줄이라고 하니까 제일 익숙한 문장이 다시 올라왔다..

다시 쓰면 이렇다.

뮤텍스는 누가 쥐고 있는지를 알고, 세마포어는 몇 개 남았는지만 안다. 그래서 뮤텍스는 쥔 스레드만 풀 수 있고, 세마포어는 아무나 풀 수 있다. 세마포어는 소유권을 버린 대가로 스레드를 넘나드는 신호를 얻었고, 같은 이유로 안전장치를 잃었다.


정리

맞게 잡고 있던 것

  • 세마포어가 동시 접근 인원을 N명으로 제한한다는 것 — c는 이유는 못 댔어도 방향은 있었다
  • 우선순위 역전의 L / M / H 시나리오 — 시나리오 자체는 정확히 나왔다
  • Job System의 워커·메인 구조가 세마포어의 신호와 같은 모양이라는 것

틀리게 잡고 있던 것

  • 세마포어는 카운트가 N인 뮤텍스가 아니다. 축은 개수가 아니라 소유권이다
  • 우선순위 역전은 양쪽 다 일어난다. 갈리는 건 발생이 아니라 대응 가능 여부다
  • permit leak은 UB가 아니다. 규격대로 동작하면서 자원만 샌다 — 그래서 더 안 잡힌다
  • 락을 푼 뒤 잠드는 것도 UB가 아니다. lost wakeup이라는 이름이 따로 있다
  • binary_semaphore로 상호 배제를 하면 안 된다. 되긴 되는데 실수가 조용히 통과한다

이번에 새로 얹은 것

  • 판단 필터는 “잠그는 쪽과 푸는 쪽이 같은가”부터 묻는다. 개수를 먼저 물으면 b에서 걸린다
  • counting_semaphore는 BasicLockable이 아니다. lock_guard에 못 넣는다
  • RAII로 감쌀 수 있는지가 용도를 가르는 리트머스다. 감싸도 되면 배제, 못 감싸면 신호
  • Priority Inheritance는 소유자를 지목할 수 있어야 성립한다. POSIX가 PTHREAD_PRIO_INHERIT를 뮤텍스 속성으로만 둔 이유
  • 조건이 “개수 > 0”보다 복잡하면 mutex + condition_variable이다. 세마포어는 큐 안을 못 본다
  • 상호 배제를 없앤 설계에도 신호는 남는다

앞 편에서 “싸다/비싸다는 무엇을 안 하기 때문인지로 답한다”가 계속 나왔는데, 이번 편에서는 그게 한 번 뒤집혔다. 안전하다는 건 무엇을 금지하기 때문인지로 답한다. 뮤텍스가 안전한 이유가 정확히 그거였다.

아직 확인 못 한 것

  • 표준이 counting_semaphorelock_guard를 붙이지 않은 위원회의 실제 논거 — “절반의 용도를 부정하게 되니까”는 내 해석이다
  • 세마포어에서 순환 대기 형태의 deadlock이 가능한가 — 이번엔 leak으로 인한 정지만 봤다
  • condition_variablespurious wakeupwait에 술어를 넘기는 이유가 lost wakeup 말고 하나 더 있다는데 안 파봤다
  • std::shared_mutex (Reader-Writer Lock) — 판단 필터의 “몇 명인가”를 행위별로 다르게 잡는 경우
  • Vulkan/그래픽스의 semaphore와 fence — 이름은 같은데 다른 물건이다. 게임 클라이언트 면접에서 섞이기 딱 좋은 자리라 따로 봐야 한다

참고 자료

소감

학습량은 적었지만 깊이있게 공부할 수 있어서 좋았다.