← 문제로

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/problem13 fork-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::mutexlock/Monitor, std::recursive_mutexlock(C# Monitor 는 재귀 허용), std::shared_mutexReaderWriterLockSlim, std::counting_semaphore(C++20)↔SemaphoreSlim, std::condition_variableMonitor.Wait/Pulse, std::latchCountdownEvent, std::barrierBarrier, 스핀은 std::atomic_flag/spinSpinLock.

면접 포인트

  • 뮤텍스와 세마포어의 소유권 차이가 왜 우선순위 상속·해제 규칙을 가르는가.
  • 조건변수의 while 재검사·락 결합 필요성(가짜 깨어남·유실)과 notify_one/all 선택.
  • 스핀 vs 블록의 트레이드오프(임계구역 길이·선점·코어 수), latch(일회) vs barrier(반복) 구분.