14. 동기화 프리미티브 비교: mutex·semaphore·condition variable·barrier·latch·event·spinlock
난이도 중난이도: 중
1. 상호배제: mutex · spinlock · reader-writer lock
mutex 는 한 번에 한 스레드만 임계구역에 들이는 배타 락이다. 경합 시 대기 스레드를 블록(커널로 재움) 한다 — 임계구역이 길거나 대기가 길 때 CPU 를 낭비하지 않는다. 비재귀(std::mutex)는 같은 스레드가 두 번 잡으면 교착(자기 데드락)이고, 재귀(std::recursive_mutex)는 같은 스레드가 중첩 획득을 허용한다(하지만 재귀 락 필요는 대개 설계 냄새).
spinlock 은 락이 풀릴 때까지 바쁜 대기(busy-wait, CPU 회전) 한다. 커널 전이가 없어 임계구역이 아주 짧고(수십 ns) 경합이 낮으면 mutex 보다 빠르다. 위험: 락 소유자가 선점(preempt) 되면 대기 스레드가 그 타임슬라이스 내내 헛돈다(특히 코어 수보다 스레드가 많을 때). 우선순위 낮은 소유자를 높은 스레드가 스핀하며 기다리는 우선순위 역전도. 그래서 스핀락은 "짧고, 소유자가 선점 안 되는(또는 인터럽트 비활성) 구간"에만.
reader-writer lock(shared_mutex/ReaderWriterLockSlim)은 읽기 공유·쓰기 배타를 구분한다. 읽기 우세(랭킹 조회 다수 vs 갱신 소수)면 읽기들이 병렬로 통과해 처리량이 오른다. 단, 쓰기 기아·읽기 중 승급(upgrade) 교착 같은 오용이 잦다(concurrency_memory/problem30). 경합이 낮으면 단순 mutex 가 오히려 빠를 수 있다(RW 락은 부기 비용이 큼).
2. 카운팅 세마포어 vs 뮤텍스
세마포어는 "허가(permit) N개"를 세는 카운터다. acquire 로 하나 줄이고(0이면 대기) release 로 하나 늘린다. 뮤텍스는 사실상 이진 세마포어처럼 보이지만 결정적 차이는 소유권(ownership) 이다.
- 뮤텍스는 소유권이 있다: 잠근 스레드만 풀 수 있다. 그래서 우선순위 상속(우선순위 역전 완화)·재귀·데드락 검출이 가능하다.
- 세마포어는 소유권이 없다: 아무 스레드나
release할 수 있고, 획득/반납이 짝이 아니어도 된다. 그래서 신호(signal) 전달·자원 개수 제한에 자연스럽다.
세마포어가 맞는 예: 동시 접속/연결 수 상한, 커넥션 풀·워커 풀 크기 제한(concurrency_memory/problem26 허가 반납 누락 주의), 생산자-소비자의 빈 자리/채워진 자리 수 세기(concurrency_memory/problem32). "임계구역 보호"엔 뮤텍스, "개수 제한/신호"엔 세마포어가 기본값.
3. 조건 변수(condition variable)
cv 는 "어떤 술어(predicate) 가 참이 될 때까지 대기"를 락과 함께 구현한다. 반드시 연관 뮤텍스를 쥔 채:
lock(m);
while (!predicate()) cv.wait(m); // 술어 재검사
// predicate 참 — 임계구역
unlock(m);
while 재검사가 필수인 이유: (a) 가짜 깨어남(spurious wakeup) — OS 가 이유 없이 깨울 수 있다, (b) 깨어남 유실/다중 소비 — 깬 사이 다른 스레드가 상태를 바꿔 술어가 다시 거짓일 수 있다(concurrency_memory/problem25). wait 는 원자적으로 "락 해제 + 대기"하고 깨어날 때 "락 재획득"한다.
notify_one vs notify_all: 대기자들이 모두 같은 술어면 notify_one(하나만 깨워 효율적). 이질적 술어가 섞이면(예: 생산자·소비자가 한 cv) notify_one 은 엉뚱한 대상을 깨워 신호 유실·교착이 나므로 notify_all 이나 조건변수 분리가 필요하다(concurrency_memory/problem32).
4. 일회성/집결 도구 — latch · barrier · event
- CountDownLatch(
std::latch, C#CountdownEvent): 카운트를 N 으로 시작해count_down으로 줄이고, 0이 되면 영구히 열린다(일회성). "N개의 초기화 작업이 다 끝나면 시작" 같은 1회 집결에 적합. - barrier(
std::barrier, C#Barrier): 재사용 가능. 참가자 전원이arrive_and_wait로 모이면 함께 통과하고, 다음 페이즈를 위해 자동 리셋된다. 틱 루프의 "모든 워커가 이 단계(AI→물리→송신)를 끝낼 때까지 대기 후 다음 단계"에 딱 맞는다(concurrency/problem13fork-join 의 join 배리어). - event: ManualResetEvent(수동 리셋 — 한 번 신호하면 리셋 전까지 계속 열림, 여러 대기자 동시 통과)와 AutoResetEvent(자동 리셋 — 신호가 대기자 하나만 통과시키고 즉시 닫힘). "게이트를 열어 둔다"엔 Manual, "한 명씩 깨운다"엔 Auto.
틱 루프의 단계 배리어엔 barrier(반복), 서버 기동 시 "구성요소 N개 초기화 완료 대기"엔 latch(일회).
5. 선택 기준과 오용
| 도구 | 대기 | 소유권 | 일회성/반복 | 커널 전이 | 대표 용도 |
|---|---|---|---|---|---|
| spinlock | 짧음(스핀) | 있음 | 반복 | 없음 | 아주 짧은 임계구역 |
| mutex | 김(블록) | 있음 | 반복 | 있음 | 일반 임계구역 |
| RW lock | 김(블록) | 있음 | 반복 | 있음 | 읽기 우세 공유 |
| semaphore | 김(블록) | 없음 | 반복 | 있음 | 개수 제한·신호 |
| cond var | 김(블록) | (락과 함께) | 반복 | 있음 | 술어 대기 |
| latch | 김 | 없음 | 일회 | 있음 | 1회 집결 |
| barrier | 김 | 없음 | 반복 | 있음 | 페이즈 동기화 |
| event | 김 | 없음 | 반복 | 있음 | 신호/게이트 |
- (a) 대기 시간: 짧으면 spinlock/스핀-후-블록 하이브리드, 길면 블로킹.
- (b) 소유권 필요(우선순위 상속·재귀·해제자 제한)면 mutex, 개수/신호면 semaphore.
- (c) 일회성이면 latch, 반복 집결이면 barrier.
- (d) 커널 전이 비용: 스핀락·
SemaphoreSlim/ManualResetEventSlim(경량, 짧은 대기 스핀 후 블록)은 저비용, 커널 객체(Semaphore,Mutex,ManualResetEvent)는 프로세스 간엔 가능하나 비싸다.
대표 오용: 스핀락으로 블로킹 I/O(전체 세션 브로드캐스트 송신)를 감싸 락 호위(concurrency_memory/problem27); 세마포어 허가를 예외 경로에서 반납 안 함(permit leak → throttle 영구 블록, concurrency_memory/problem26); cv 를 락 없이 신호하거나 if 로만 술어 검사(concurrency_memory/problem25); 재귀가 필요 없는데 recursive_mutex 로 설계 냄새를 덮기.
C++ ↔ C# 대응: std::mutex↔lock/Monitor, std::recursive_mutex↔lock(C# Monitor 는 재귀 허용), std::shared_mutex↔ReaderWriterLockSlim, std::counting_semaphore(C++20)↔SemaphoreSlim, std::condition_variable↔Monitor.Wait/Pulse, std::latch↔CountdownEvent, std::barrier↔Barrier, 스핀은 std::atomic_flag/spin↔SpinLock.
면접 포인트
- 뮤텍스와 세마포어의 소유권 차이가 왜 우선순위 상속·해제 규칙을 가르는가.
- 조건변수의
while재검사·락 결합 필요성(가짜 깨어남·유실)과notify_one/all선택. - 스핀 vs 블록의 트레이드오프(임계구역 길이·선점·코어 수), latch(일회) vs barrier(반복) 구분.