포스트

동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건

세마포어와 조건 변수, 생산자-소비자 문제, 데드락의 네 가지 조건과 예방·회피·탐지 — 식사하는 철학자로 보는 동기화 심화.

동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건

“락 하나로는 표현되지 않는 것들”

앞 글의 뮤텍스는 “한 번에 하나만”을 보장합니다. 그런데 현실의 동기화 문제는 그 모양이 아닙니다.

  • 자원이 여러 개: 커넥션 풀에 연결이 10개 있다. “한 번에 하나”가 아니라 “한 번에 열까지”를 세고 싶다.
  • 순서·조건: 소비자는 버퍼가 비어 있으면 기다려야 하고, 생산자가 채우면 그때 깨어나야 한다. 단순 상호배제로는 “조건이 충족될 때까지 잠들기”를 표현할 수 없다.

이 두 가지를 위해 세마포어와 조건 변수가 있습니다. 그리고 락을 둘 이상 쓰는 순간, 동기화의 가장 악명 높은 함정이 열립니다 — 데드락. 이 글은 두 도구를 정확히 익히고, 데드락이 수학적으로 언제 발생하는지(네 조건)와 어떻게 끊는지를 끝까지 봅니다.

세마포어: 자원의 개수를 세는 카운터

세마포어는 정수 카운터 + 두 원자 연산입니다. P(wait, 프루번): 카운터를 1 줄이고, 음수가 되면 블록. V(signal, 페르호헌): 카운터를 1 늘리고, 대기자가 있으면 하나 깨움.

  • 카운팅 세마포어: 초깃값 N → 동시에 N개까지 자원 사용 허용(커넥션 풀·세마포어 기반 throttling).
  • 바이너리 세마포어: 초깃값 1 → 뮤텍스처럼 동작. 단, 결정적 차이가 있습니다.

현실 체크 — “바이너리 세마포어 ≠ 뮤텍스.” 뮤텍스에는 소유권(ownership) 이 있습니다. 잠근 스레드만 풀 수 있죠. 세마포어는 소유 개념이 없어 아무 스레드나 V 할 수 있습니다. 그래서 “락을 건 쪽이 푼다”는 상호배제엔 뮤텍스를, “한쪽이 신호를 보내 다른 쪽을 깨운다”는 시그널링엔 세마포어를 씁니다. 뮤텍스는 우선순위 상속(priority inheritance)으로 우선순위 역전도 막을 수 있는데, 세마포어는 소유자가 없어 그게 불가능합니다.

1
2
3
4
5
6
sem_t slots;
sem_init(&slots, 0, 10);   /* 동시 10개까지 */

sem_wait(&slots);          /* P: 빈 슬롯 없으면 블록 */
do_work_with_connection();
sem_post(&slots);          /* V: 슬롯 반납 */

조건 변수: “조건이 될 때까지 자고, 되면 깨워줘”

세마포어로도 만들 수 있지만, “어떤 술어(predicate)가 참이 될 때까지 대기”를 직접 표현하는 도구가 조건 변수입니다. 핵심 규칙 세 가지를 어기면 100% 버그가 납니다.

  1. 항상 락과 함께: wait는 호출 시 락을 원자적으로 풀고 잠들었다가, 깨어날 때 다시 잡습니다. 술어 검사와 대기 사이의 틈(lost wakeup)을 없애기 위해서입니다.
  2. if가 아니라 while: 깨어났다고 조건이 참이라는 보장이 없습니다. 스푸리어스 웨이크업(가짜 깨움)과, 깨어난 뒤 다른 스레드가 자원을 가로채는 경우 때문에 조건을 다시 검사해야 합니다.
  3. 상태를 바꾼 뒤 signal: 술어를 참으로 만들고 나서 깨웁니다.
1
2
3
4
5
pthread_mutex_lock(&m);
while (count == 0)                  /* ★ if가 아니라 while */
    pthread_cond_wait(&not_empty, &m);  /* 락 풀고 잠 → 깨면 락 재획득 */
item = buffer[--count];
pthread_mutex_unlock(&m);

wait 안에서 락이 풀리기 때문에, 그 사이 다른 스레드가 들어와 count를 바꿀 수 있습니다. 그래서 깨어나면 반드시 while로 다시 확인하는 것이 모니터(monitor) 패턴의 핵심입니다(자바 synchronized+wait/notify, 파이썬 threading.Condition이 모두 이 모델).

데드락: 락 둘이 만나는 순간

생산자-소비자에서 한 걸음만 나아가면, 서로 다른 락을 두 개 잡는 코드가 흔해집니다. 그리고 잡는 순서가 엇갈리면 — 아무도 영원히 진행하지 못합니다.

1
2
3
/* 스레드 1 */            /* 스레드 2 */
lock(A);                  lock(B);
lock(B);  // ← B 대기     lock(A);  // ← A 대기

스레드 1이 A를, 스레드 2가 B를 쥔 채 서로의 것을 기다리면 둘 다 멈춥니다. 식사하는 철학자 문제가 이 교착을 가장 선명하게 보여줍니다. 5명이 둥글게 앉아, 각자 양옆 포크 두 개가 있어야 먹을 수 있습니다. 모두가 동시에 왼쪽 포크를 집으면, 오른쪽 포크는 옆 사람이 쥐고 있어 — 다섯 명 전원이 영원히 기다립니다.

P0 P1 P2 P3 P4 ① 모두 왼쪽 포크 집음 ② 오른쪽 포크 대기 순환 대기 = DEADLOCK ③ 락 순서 통일 → 해소 5명 · 포크 5개 · 각자 양옆 두 개가 필요

데드락이 성립하는 네 가지 조건 (Coffman)

데드락은 운이 나빠서가 아니라, 네 조건이 동시에 모두 성립할 때만 발생합니다. 하나라도 깨면 데드락은 불가능합니다 — 이게 모든 예방 전략의 출발점입니다.

조건의미이걸 깨는 방법
상호배제자원을 한 번에 하나만 점유자원을 공유 가능하게(읽기 전용·lock-free)
점유와 대기가진 채로 다른 것을 더 기다림필요한 락을 한 번에 모두 획득(아니면 전부 포기)
비선점남이 쥔 걸 강제로 못 뺏음일정 시간 못 얻으면 가진 것도 놓기(trylock+백오프)
순환 대기대기 사슬이 원을 이룸락에 전역 순서를 부여, 항상 그 순서로만 획득

이 네 조건의 “순환 대기”는 자원 할당 그래프의 사이클로 정확히 드러납니다. 프로세스(원)와 자원(사각형) 사이의 “점유” 화살표와 “요청” 화살표가 닫힌 고리를 만들면 — 그게 데드락입니다.

P1 R1 P2 R2 R1→P1 점유 · P2→R1 요청 P1→R2 R2→P2 사이클 = 데드락!

현실 체크 — “실무 데드락의 99%는 락 순서 불일치다.” 거창한 은행원 알고리즘을 돌리는 서비스는 거의 없습니다. 대신 모든 락에 전역 순서를 정하고(예: 주소 오름차순·ID 순), 항상 그 순서로만 획득합니다. 순환 대기 조건이 원천 차단되니까요. 리눅스 커널은 lockdep이 런타임에 락 획득 순서를 추적해 잠재적 역순을 실제 교착 전에 경고합니다. 두 계좌 이체에서 lock(min(a,b)); lock(max(a,b));가 교과서적 예입니다.

데드락에 대한 네 가지 태도

  • 예방(prevention): 네 조건 중 하나를 설계로 원천 봉쇄. 가장 흔한 게 락 순서화(순환 대기 차단).
  • 회피(avoidance): 매 할당마다 “이걸 주면 안전 상태가 유지되나”를 검사 → 은행원 알고리즘. 최대 자원 요구를 미리 알아야 해 현실 적용이 드뭅니다.
  • 탐지 & 복구(detection): 일단 허용하고, 주기적으로 자원 할당 그래프의 사이클을 찾아 한 프로세스를 죽이거나 롤백. DB가 이 방식(교착 감지 후 victim 트랜잭션 abort).
  • 무시(ostrich algorithm): “거의 안 일어나니 무시”하고 생기면 재부팅. 데스크톱 OS의 현실적 태도.

직접 들여다보기

1
2
3
4
5
6
7
8
9
10
# 멈춘(D/blocked) 스레드들이 어디서 대기 중인지 — 데드락 디버깅의 시작
gdb -p <pid> -batch -ex "thread apply all bt"
cat /proc/<pid>/stack          # 커널 스택: 어떤 락/대기에 걸렸나
cat /proc/<pid>/task/*/status  # State: D (uninterruptible) 가 흔한 신호

# 세마포어/IPC 객체 확인
ipcs -s

# 커널 락 순서 역전 경고 (개발 커널)
dmesg | grep -i lockdep
1
2
3
4
5
6
7
8
/* 조건 변수로 만든 유한 버퍼 — 생산자/소비자 */
void produce(int x){
  pthread_mutex_lock(&m);
  while (count == CAP) pthread_cond_wait(&not_full, &m);
  buf[count++] = x;
  pthread_cond_signal(&not_empty);   /* 소비자 깨움 */
  pthread_mutex_unlock(&m);
}

면접/리뷰 단골 질문

  • Q. 뮤텍스와 세마포어의 차이는? → 뮤텍스는 소유권이 있어 잠근 쪽만 푼다(상호배제·우선순위 상속 가능). 세마포어는 소유 개념 없는 카운터로 아무나 V 할 수 있다(시그널링·N개 자원 카운팅).
  • Q. 조건 변수에서 왜 while로 검사하나? → 스푸리어스 웨이크업과, 깨어난 뒤 다른 스레드가 자원을 가로채는 경우 때문. if면 조건이 거짓인데 진행해 버린다.
  • Q. 데드락 4조건은? 어느 걸 깨는 게 현실적인가? → 상호배제·점유와대기·비선점·순환대기. 실무는 락 전역 순서화로 순환 대기를 차단하는 게 가장 흔하다.
  • Q. 데드락과 라이브락·기아의 차이는? → 데드락은 아무도 진행 못 하고 멈춤. 라이브락은 서로 양보하다 계속 상태만 바뀌며 진전 없음. 기아는 특정 스레드만 계속 자원을 못 얻음(우선순위·운).
  • Q. 은행원 알고리즘이 실무에서 드문 이유는? → 모든 프로세스의 최대 자원 요구를 사전에 알아야 하고 매 할당마다 안전성 검사 비용이 커서. 대신 예방(순서화)이나 탐지(DB)가 쓰인다.

정리

  • 세마포어는 자원 개수를 세는 카운터(P/V), 소유권이 없어 시그널링·N개 자원 throttling에 맞다. 상호배제엔 소유권 있는 뮤텍스를 쓴다.
  • 조건 변수는 “조건이 될 때까지 대기” — 항상 락과 함께, while로 재검사, 상태 변경 후 signal(모니터 패턴).
  • 데드락은 상호배제·점유와대기·비선점·순환대기 네 조건이 동시에 성립할 때만 발생 → 하나만 깨면 불가능.
  • 순환 대기는 자원 할당 그래프의 사이클로 드러나며, 실무 해법은 대부분 락 전역 순서화다.
  • 대응은 예방·회피(은행원)·탐지&복구(DB)·무시(타조) 네 갈래이고, 상황에 따라 선택한다.

다음 글: 동기화의 세계를 떠나, 모든 프로세스가 “나만의 거대한 메모리”라는 환상을 갖는 비밀 — 가상 메모리로 내려갑니다. 락의 출발점이 궁금하면 동기화 (1) 경쟁 상태와 락으로.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

시리즈 · 운영체제 A-Z (20편)

  1. 운영체제란 무엇인가 — 커널·유저모드·시스템콜, 왜 프로그램은 하드웨어를 직접 못 만지나
  2. 부팅 — 전원 버튼에서 커널까지, 그 1초 사이에 일어나는 모든 일
  3. 프로세스란 무엇인가 — 주소 공간·PCB·fork와 exec, 그리고 COW의 마법
  4. 스레드와 동시성 — 한 주소 공간 속 여러 실행 흐름, 그리고 비결정성의 시작
  5. CPU 스케줄링 — 누구에게 CPU를 줄까, FCFS부터 리눅스 CFS까지
  6. 컨텍스트 스위치 — 전환은 공짜가 아니다, 직접비용과 캐시·TLB 오염
  7. 프로세스 간 통신(IPC) — 격리된 프로세스가 협력하는 법, 파이프부터 공유 메모리까지
  8. 동기화 (1) — 경쟁 상태와 임계 구역, 뮤텍스 vs 스핀락
  9. 동기화 (2) — 세마포어·조건 변수, 그리고 데드락의 네 가지 조건 지금 보는 글
  10. 가상 메모리 — 모든 프로세스가 같은 주소에서 시작하는데 왜 안 겹치나
  11. 페이징·페이지 테이블·TLB — 4단계 주소 변환과 그 캐시
  12. 페이지 폴트·디맨드 페이징·스왑 — malloc 1GB가 즉시 성공하는 이유
  13. 페이지 교체 알고리즘 — 메모리가 꽉 찼을 때 누구를 쫓아낼까
  14. 메모리 할당자 — malloc은 어떻게 힙을 쪼개나, buddy와 slab까지
  15. 파일 시스템 (1) — 파일명은 파일이 아니다, inode와 VFS의 세계
  16. 파일 시스템 (2) — 쓰는 중 전원이 꺼지면? 저널링과 크래시 일관성
  17. I/O·인터럽트·DMA — CPU보다 백만 배 느린 장치와 효율적으로 대화하기
  18. 블록 I/O와 페이지 캐시 — 같은 파일을 두 번째 읽으면 왜 빠른가
  19. 가상화와 컨테이너 — VM과 컨테이너, 격리 스펙트럼의 양 끝
  20. 성능 관찰과 병목 분석 — "느려요"를 추측이 아니라 측정으로 (시리즈 마무리)