운영체제 · 6장

동기화 도구 Synchronization Tools

협력하는 프로세스·스레드가 같은 데이터를 동시에 만진다. 그 순간 “누가 먼저였나”에 결과가 달라지는 경쟁 조건이 태어나고, 우리는 그것을 길들이기 위해 하드웨어의 원자 명령부터 모니터·조건 변수까지 한 층씩 쌓아 올린다. 이 장은 그 도구 상자 전체의 지도다.
기반: Operating System Concepts 10판 6장 · 대학원 수준으로 확장 · 이론 + 구현 코드 + 다이어그램

이 장을 마치면

  • 경쟁 조건(race condition)count++의 기계어 인터리빙으로 정확히 설명하고, “원자적으로 보이는 한 줄”이 왜 원자적이지 않은지 말할 수 있다.
  • 임계구역 문제의 세 요건 — 상호 배제·진행·유한 대기 — 를 정의하고, 어떤 해법이 어떤 요건을 만족/위반하는지 판별할 수 있다.
  • Peterson의 해법을 증명 수준으로 이해하고, 왜 현대 아키텍처에서 명령 재배열로 깨질 수 있는지를 메모리 모델과 연결해 논증한다.
  • 메모리 배리어·test_and_set·compare_and_swap(CAS)·원자 변수를 구현 코드로 다루고, CAS로 lock-free 알고리즘의 첫 벽돌을 놓을 수 있다.
  • 뮤텍스 락·스핀락의 바쁜 대기 비용과, 세마포어의 대기 큐 기반 구현(바쁜 대기 제거)을 설명한다.
  • 모니터와 조건 변수를 Hoare(signal-and-wait) vs Mesa(signal-and-continue) 의미론으로 구분하고, 세마포어로 모니터를 구현할 수 있다.
  • 라이브니스(liveness) 실패 — 데드락·우선순위 역전 — 을 진단하고, 우선순위 상속 프로토콜이 Mars Pathfinder를 어떻게 구했는지 안다.
  • 각 도구의 트레이드오프를 경합 수준(낮음·중간·높음)별로 비교하고, 언제 무엇을 쓸지 결정할 수 있다.
🏷️ 출처 표시 — 이 페이지 읽는 법

이 자료는 교재(Operating System Concepts 10판 6장) 원문대학원 수준 확장을 더한 것입니다. 절·소절 제목 옆 배지로 출처를 구분했습니다.

📘 OSC 6.x 교재 6장 핵심 내용 ⊕ 교재 외 확장 교재 범위 밖 심화(메모리 모델 연결·futex·lock-free·C++ 실무 등)

9.5절(메모리 모델 연결)·10절(현대 실무)은 절 전체가 확장입니다. 배지 없는 소절은 교재 본문에 해당합니다(11절 복습엔 확장 주제 문항도 포함).

4장에서 우리는 스레드가 주소 공간을 공유한다는 사실을, 그리고 그것이 동시성 버그의 근원임을 보았다. 이 장은 그 버그의 정확한 이름 — 경쟁 조건 — 을 붙이고, 그것을 막는 도구를 가장 낮은 하드웨어 명령부터 가장 높은 언어 구문까지 차례로 세운다. 핵심 통찰 하나: “한 줄처럼 보이는 연산”도 기계어로는 여러 단계이며, 그 사이에 다른 실행 흐름이 끼어들 수 있다.

0학습 지도

주제핵심 질문
1배경 — 경쟁 조건의 해부count++는 왜 원자적이지 않은가?
2임계구역 문제 — 세 요건올바른 해법이 갖춰야 할 최소 조건은?
3Peterson의 해법 (소프트웨어)왜 현대 CPU에서 이게 깨지나?
4하드웨어 지원 — 배리어·CAS·원자 변수원자성을 하드웨어로 어떻게 사나?
5뮤텍스 락과 스핀락“짧게” 잡으면 왜 스핀이 이기나?
6세마포어 — 계수·이진, 대기 큐바쁜 대기를 어떻게 없애나?
7모니터와 조건 변수Hoare와 Mesa는 무엇이 다른가?
8라이브니스 — 데드락·우선순위 역전화성에서 무슨 일이 있었나?
9평가 — 도구 선택 + 메모리 모델 연결경합 수준에 따라 무엇이 빠른가?
10현대 실무 — futex·lock-free·RCU·C++실전에서는 무엇을 쓰나?
11요약 · 복습

1배경 — 경쟁 조건의 해부 📘 OSC 6.1

협력하는 프로세스(cooperating process)는 다른 프로세스에 영향을 주거나 받는 프로세스다. 이들이 논리적 주소 공간(코드·데이터)이나 공유 메모리를 함께 쓰면, 그 공유 데이터에 대한 동시 접근(concurrent access)이 데이터 비일관성을 낳을 수 있다. 가장 고전적인 예가 유한 버퍼(bounded buffer)의 항목 수를 세는 count 변수다.

생산자는 항목을 넣을 때 count++를, 소비자는 꺼낼 때 count--를 한다. 각각은 따로 보면 옳다. 그러나 동시에 실행되면 — count가 현재 5라 할 때 — 결과가 4, 5, 6 중 무엇이든 될 수 있다. 유일하게 옳은 값은 5인데도.

한 줄처럼 보이지만 기계어로는 세 단계 (의사 어셈블리)
; count++ 는 (전형적 머신에서) 다음 세 명령으로 번역된다:
    register1 = count            ; (1) load:  메모리 → 레지스터
    register1 = register1 + 1    ; (2) add:   레지스터에서 +1
    count     = register1        ; (3) store: 레지스터 → 메모리

; count-- 도 마찬가지:
    register2 = count
    register2 = register2 - 1
    count     = register2

count++count--를 동시에 실행하는 것은, 위 저수준 명령들이 어떤 임의의 순서로 인터리빙(interleaving — 두 명령 흐름이 한 명령씩 번갈아 끼어 실행)되는 것과 같다(단, 각 고수준 문장 내부의 순서는 유지). 다음은 그런 인터리빙 하나다.

파랑 = 생산자(count++) · 주황 = 소비자(count--) · count 초기값 = 5 생산자 소비자 r1 = count (5) r1 = r1+1 (6) count = r1 (6) r2 = count (5) r2 = r2-1 (4) store(4) T0 → T5 T0T1T2T3·T4T5 생산자가 6을 쓴 뒤 소비자가 4를 덮어씀 → 최종 count = 4 (틀림!) 소비자가 stale한 값 5를 읽어 가서 생산자의 증가가 사라진다 (lost update)
경쟁 조건: 두 명령 흐름의 load·store가 인터리빙되면 한쪽의 갱신이 사라진다(lost update). T4·T5 순서를 뒤집으면 count == 6이라는 또 다른 틀린 값이 나온다.

이처럼 여러 프로세스가 같은 데이터에 동시 접근·조작하고, 실행 결과가 접근이 일어난 특정 순서에 의존하는 상황경쟁 조건(race condition)이라 한다. 이를 막으려면 한 번에 하나의 프로세스만 count를 만질 수 있도록 프로세스들을 어떤 식으로든 동기화(synchronize)해야 한다.

⚠️ 경쟁 조건은 커널 자신의 문제이기도 하다

커널은 열린 파일 목록·메모리 할당·프로세스 리스트·인터럽트 처리 구조를 유지한다. 예를 들어 두 프로세스가 동시에 fork()를 호출하면, 다음에 줄 PID를 담은 커널 변수 next_available_pid에 경쟁 조건이 생겨 같은 PID가 두 프로세스에 배정될 수 있다. 그래서 커널 개발자는 이런 구조들이 경쟁 조건에서 자유롭도록 설계해야 한다.

1.1 단일 코어의 손쉬운 해법과 그 한계

단일 코어라면 공유 변수를 수정하는 동안 인터럽트를 끄면(disable interrupts) 그 명령열이 선점(preemption) 없이 끝까지 실행되어 경쟁 조건이 사라진다. 하지만 멀티프로세서에서는 모든 코어에 인터럽트 비활성 메시지를 전파해야 해 느리고, 시스템 시계가 인터럽트로 갱신된다면 그것까지 망가진다.

커널 종류정의경쟁 조건장점
비선점(nonpreemptive)커널 모드 프로세스는 빠져나가거나 블록·양보할 때까지 선점 안 됨사실상 자유로움(커널에 한 번에 하나)설계 단순
선점(preemptive)커널 모드에서도 선점 허용SMP에서 두 커널 흐름 동시 실행 → 신중한 설계 필요응답성↑·실시간 적합

2임계구역 문제 — 세 요건 📘 OSC 6.2

n개의 프로세스 {P0, …, Pn−1}가 있다고 하자. 각 프로세스에는 다른 프로세스와 공유하는 데이터를 접근·갱신하는 코드 구간, 즉 임계구역(critical section)이 있다. 핵심 요구: 한 프로세스가 임계구역에서 실행 중이면, 다른 어떤 프로세스도 자기 임계구역에서 실행될 수 없다. 임계구역 문제(critical-section problem)는 프로세스들이 데이터를 협력적으로 공유하도록 동기화하는 프로토콜을 설계하는 것이다.

while (true) { … } 안의 네 구역 entry section (진입) critical section (임계) exit section (퇴출) remainder section (나머지) ① 상호 배제 (mutual exclusion) 한 프로세스가 임계구역이면 다른 누구도 못 들어감 ② 진행 (progress) 임계구역이 비었으면 다음 진입 결정을 무한히 미룰 수 없음 ③ 유한 대기 (bounded waiting) 요청 후 다른 프로세스 진입 횟수에 상한이 있음
임계구역 문제의 골격과 올바른 해법의 세 요건. ①은 안전성(safety), ②③은 라이브니스(liveness)에 해당한다.
요건의미위반 시
상호 배제Pi가 임계구역이면 다른 어떤 프로세스도 자기 임계구역에 못 들어간다데이터 손상(race)
진행아무도 임계구역에 없고 들어가려는 프로세스가 있으면, 나머지 구역에 있지 않은 프로세스들만 다음 진입자를 정하고 그 선택을 무한히 미룰 수 없다교착·기아(deadlock·starvation)
유한 대기한 프로세스가 진입을 요청한 뒤 그 요청이 허가되기 전, 다른 프로세스들이 임계구역에 들어갈 수 있는 횟수에 상한이 존재한다기아(특정 프로세스 영구 대기)

가정: 각 프로세스는 0이 아닌 속도로 실행되지만, 상대 속도에 대해서는 어떤 가정도 하지 않는다. 즉 어떤 프로세스가 언제 얼마나 느려질지 모른다.

📐 안전성 vs 라이브니스 — 두 종류의 정확성

동시성 정확성은 두 축으로 나뉜다. 안전성(safety): “나쁜 일이 절대 일어나지 않는다”(상호 배제 — 두 프로세스가 동시에 임계구역에 있는 일은 없다). 라이브니스(liveness): “좋은 일이 결국 일어난다”(진행·유한 대기 — 들어가려는 프로세스는 결국 들어간다). 8절에서 보겠지만, 데드락과 기아는 라이브니스 실패다.

3Peterson의 해법 — 그리고 왜 깨지나 📘 OSC 6.3

Peterson의 해법은 두 프로세스(P0, P1)를 위한 고전적 소프트웨어 해법이다. 특별한 하드웨어 명령이나 OS 지원 없이 알고리즘만으로 임계구역 문제를 푼다. 두 데이터를 공유한다: int turn(누구 차례인가)과 boolean flag[2](누가 들어갈 준비가 됐나).

Peterson의 해법 — 프로세스 Pi의 구조 (j = 1 − i)
while (true) {
    flag[i] = true;                  /* "나 들어갈 준비됨" */
    turn = j;                        /* "그래도 너 먼저 해" (양보) */
    while (flag[j] && turn == j)
        ;                            /* 상대가 준비됐고 상대 차례면 대기 */

        /* critical section */

    flag[i] = false;                 /* 나옴 */

        /* remainder section */
}

핵심 논리: Piflag[j] == false(상대가 준비 안 됨)이거나 turn == i(내 차례)일 때만 임계구역에 들어간다. 둘 다 동시에 임계구역에 있으려면 flag[0] == flag[1] == true여야 하는데, turn은 0이거나 1이지 둘 다일 수 없다. 따라서 둘 중 하나만 while을 통과하므로 상호 배제가 보존된다. 진행·유한 대기도 만족한다(Pj가 임계구역을 나오며 flag[j]=false로 두므로 Pi는 최대 Pj 한 번 진입 후에 들어간다).

flag[i] = true turn = j (양보) while(flag[j] && turn==j)조건 거짓이면 진입 critical section 둘이 동시에 시도하면? turn = j 와 turn = i 가 거의 동시에 실행되나 하나만 "마지막으로" 살아남는다. 그 최종 turn 값이 누가 먼저 들어갈지 결정. "나 양보할게(turn=j)" 를 둘 다 외치면 나중에 쓴 쪽이 손해 → 상대가 먼저. 단정한 대칭 — 단, 재배열이 없을 때만!
Peterson: “준비 표시(flag) + 상대에게 양보(turn=j)”. 동시 시도 시 turn의 최종 값이 진입 순서를 가른다. 두 store의 순서가 보존된다는 전제가 핵심이다.

3.1 왜 현대 아키텍처에서 깨지나 — 명령 재배열

문제는 마지막 문장에 있다. 성능을 위해 프로세서와 컴파일러는 의존성이 없는 read·write 연산을 재배열(reorder)할 수 있다. 단일 스레드에서는 최종 값이 동일하므로 무해하다(수표책 잔액 맞추기처럼 입출금 순서는 무관). 그러나 공유 데이터를 가진 멀티스레드에서는 재배열이 비일관 결과를 낳는다.

재배열이 깨뜨리는 리트머스 — flag/x 예제
boolean flag = false;   int x = 0;

/* Thread 1 */            /* Thread 2 */
while (!flag)             x = 100;
    ;                     flag = true;
print x;                  // 기대: x == 100

Thread 2에서 xflag 사이에 데이터 의존성이 없으므로, 프로세서가 flag = truex = 100보다 먼저 가시화할 수 있다. 그러면 Thread 1은 flag==true를 보고 빠져나가 x == 0을 출력한다. 덜 분명하지만, Thread 1 자신도 x 로드를 flag 로드보다 먼저 할 수 있어 같은 오류가 난다.

진입 구역 두 문장(flag·turn)이 재배열되면… (가로 = 시간 흐름) process 0 turn = 1 flag[0] = true CS process 1 turn = 0, flag[1] = true CS 두 프로세스가 동시에 임계구역(CS)에 — 상호 배제 붕괴!
Peterson 진입 구역의 flag[i]=trueturn=j가 재배열되면 둘 다 임계구역에 진입할 수 있다. 해결: 두 문장 사이에 메모리 배리어(4.1절)를 넣는다.
심화 ⊕ 4장 메모리 모델과의 연결

이 “재배열로 깨짐”은 4장에서 다룬 메모리 일관성 모델의 직접적 귀결이다. 단일 스레드 의미론(program order)과 멀티스레드 가시성(visibility)이 어긋나는 지점이 바로 여기다. 올바른 동기화 도구는 결국 happens-before 관계를 강제하는 장치다 — “A가 B보다 먼저 일어났음이 보장되면, A의 모든 쓰기가 B에게 보인다.” acquire/release 의미론(10.1절)이 이 관계를 만드는 표준 방법이고, Peterson을 고치는 메모리 배리어는 그 가장 낮은 수준의 도구다. 락 안에서는 이걸 신경 쓸 필요가 없다 — 락의 acquire/release가 happens-before를 대신 세워주기 때문이다.

4동기화용 하드웨어 지원 — 배리어·CAS·원자 변수 📘 OSC 6.4

소프트웨어 해법(Peterson)은 현대 아키텍처에서 보장되지 않는다. 이제 하드웨어가 제공하는 세 가지 — 메모리 배리어, 원자 명령(test-and-set·CAS), 원자 변수 — 를 본다. 이들은 직접 동기화 도구로 쓰이거나, 더 추상적인 도구(뮤텍스·세마포어)의 토대가 된다.

4.1 메모리 배리어 (memory barrier / fence)

아키텍처가 메모리에 대해 어떤 보장을 주는지를 그 메모리 모델(memory model)이라 한다. 크게 두 부류다.

메모리 모델의미
강한 순서(strongly ordered)한 프로세서의 메모리 수정이 다른 모든 프로세서에 즉시 보인다(거의 없음 — 이상적)
약한 순서(weakly ordered)한 프로세서의 수정이 다른 프로세서에 즉시 보이지 않을 수 있다ARM·POWER

메모리 배리어(memory barrier) 혹은 메모리 펜스(memory fence)는 “이 명령 이전의 모든 load·store가 이후의 어떤 load·store보다 먼저 완료되고 다른 프로세서에 가시화됨”을 강제한다. 앞의 flag/x 예제를 배리어로 고친다.

메모리 배리어로 가시성 순서를 강제 (의사 코드)
/* Thread 1 */              /* Thread 2 */
while (!flag)               x = 100;
    memory_barrier();       memory_barrier();   /* x 쓰기가 flag 쓰기보다 먼저 */
print x;                    flag = true;
/* flag 로드가 x 로드보다 먼저임을 보장 */

배리어는 매우 저수준 연산으로, 보통 상호 배제를 보장하는 특수 코드를 짜는 커널 개발자만 직접 쓴다. (Peterson을 고치려면 진입 구역의 두 대입문 사이에 배리어를 넣으면 된다.)

4.2 원자 하드웨어 명령 — test_and_set과 compare_and_swap

많은 시스템이 한 워드를 원자적으로(atomically — 인터럽트 불가능한 한 단위로) 테스트·수정하거나 두 워드를 교환하는 특수 명령을 제공한다. 두 추상 명령으로 그 본질을 본다.

test_and_set() 의 원자적 정의와 상호 배제 사용 (C)
boolean test_and_set(boolean *target) {
    boolean rv = *target;     /* 기존 값을 읽고 */
    *target = true;           /* 무조건 true로 — 이 전체가 원자적 */
    return rv;                /* 기존 값 반환 */
}

/* lock 은 false로 초기화 */
do {
    while (test_and_set(&lock))
        ; /* 누군가 잡고 있으면(=기존값 true) 계속 회전(spin) */

        /* critical section */

    lock = false;
        /* remainder section */
} while (true);

compare_and_swap(CAS)는 세 피연산자를 받아, *value == expected일 때만 *value = new_value로 바꾸고, 항상 원래 값을 반환한다. 이 전체가 원자적이다.

compare_and_swap() 의 정의와 상호 배제 (C)
int compare_and_swap(int *value, int expected, int new_value) {
    int temp = *value;
    if (*value == expected)
        *value = new_value;
    return temp;              /* 성공 여부와 무관하게 '원래 값' 반환 */
}

/* lock 은 0으로 초기화 */
while (true) {
    while (compare_and_swap(&lock, 0, 1) != 0)
        ; /* 0(unlocked)일 때만 1로 바뀌고 진입, 아니면 spin */

        /* critical section */

    lock = 0;
        /* remainder section */
}
CAS(value, expected, new) — 원자적 한 단위 temp = *value(원래 값 보관) *value == expected ?분기 YES → *value = new (성공) NO → 변경 없음 (실패) return temp(항상 원래 값) x86: lock cmpxchg — lock 접두사로 버스를 잠가 원자성 보장
CAS는 “예상값과 같으면 바꾸고, 어쨌든 원래 값을 돌려준다”. 반환값을 expected와 비교해 성공/실패를 안다. 낙관적 동기화(optimistic)·lock-free의 기본 벽돌이다.

위 단순 CAS 락은 상호 배제는 만족하지만 유한 대기를 보장하지 못한다(운 나쁜 프로세스가 무한히 밀릴 수 있다). 교재는 waiting[] 배열과 순환 스캔으로 유한 대기까지 만족하는 버전을 제시한다.

유한 대기를 보장하는 CAS 상호 배제 — 순환 양보 (C)
boolean waiting[n];   /* 모두 false 초기화 */
int lock;             /* 0 초기화 */

while (true) {
    waiting[i] = true;
    key = 1;
    while (waiting[i] && key == 1)
        key = compare_and_swap(&lock, 0, 1);
    waiting[i] = false;

        /* critical section */

    j = (i + 1) % n;                    /* 다음 대기자를 순환 탐색 */
    while ((j != i) && !waiting[j])
        j = (j + 1) % n;
    if (j == i)
        lock = 0;                        /* 대기자 없음 → 락 해제 */
    else
        waiting[j] = false;              /* 있음 → 그 프로세스에 차례 넘김 */

        /* remainder section */
}

퇴출 시 (i+1, i+2, …, i−1) 순환 순서로 첫 대기자를 다음 진입자로 지정하므로, 어떤 프로세스도 최대 n − 1턴 안에 진입한다 — 유한 대기 만족.

4.3 원자 변수 (atomic variables)

보통 CAS를 직접 쓰지 않고 원자 변수를 만드는 데 쓴다. 원자 변수는 정수·불리언 같은 기본 타입에 원자적 연산을 제공한다. 카운터 증가처럼 단일 변수에 데이터 레이스가 있을 때 상호 배제를 보장한다.

CAS로 구현한 원자 증가 — 실패 시 재시도 (C)
void increment(atomic_int *v) {
    int temp;
    do {
        temp = *v;
    } while (temp != compare_and_swap(v, temp, temp + 1));
    /* 다른 스레드가 끼어들어 값이 바뀌었으면 CAS가 실패(반환값≠temp) → 재시도 */
}
⚠️ 원자 변수가 만능은 아니다

원자 변수는 단일 갱신은 원자적으로 만들지만 모든 경쟁 조건을 풀지는 못한다. 유한 버퍼에서 count를 원자 정수로 만들어도, 생산자·소비자의 while(count == …) 조건과 그 뒤의 동작 사이가 원자적이지 않다. 버퍼가 비었고 두 소비자가 count > 0을 기다릴 때 생산자가 1개를 넣으면, 둘 다 루프를 빠져나와 소비를 시도할 수 있다(count가 1뿐인데도). 그래서 원자 변수는 보통 카운터·시퀀스 생성기 같은 단일 갱신에 국한해 쓰고, 더 일반적 상황엔 락·세마포어·모니터가 필요하다.

5뮤텍스 락 — acquire/release와 스핀락 📘 OSC 6.5

하드웨어 해법은 복잡하고 응용 프로그래머에게 접근하기 어렵다. OS 설계자는 더 높은 수준의 도구를 만든다. 가장 단순한 것이 뮤텍스 락(mutex lock)이다 — mutexmutual exclusion의 줄임말이다. 임계구역 진입 전 락을 획득(acquire)하고, 나올 때 해제(release)한다.

뮤텍스의 acquire / release — 가장 단순한 정의 (C)
/* available: 락이 사용 가능한지 나타내는 boolean (true = 사용 가능) */

acquire() {
    while (!available)
        ; /* busy wait — 사용 가능해질 때까지 회전 */
    available = false;
}

release() {
    available = true;
}
/* acquire()와 release() 자체는 원자적으로 실행되어야 한다 → CAS로 구현 가능 */

락이 사용 가능하면 acquire()가 성공하고 락은 사용 불가가 된다. 사용 불가인 락을 획득하려는 프로세스는 해제될 때까지 블록된다. acquire()와 release()는 반드시 원자적으로 실행되어야 하며, 이는 4.2절의 CAS로 구현할 수 있다.

5.1 바쁜 대기와 스핀락

위 구현의 단점: 바쁜 대기(busy waiting)다. 한 프로세스가 임계구역에 있는 동안 다른 프로세스는 acquire()의 루프를 계속 돌며 CPU 사이클을 낭비한다. 이렇게 “회전(spin)”하는 락을 스핀락(spinlock)이라 한다.

스핀락 — 회전 대기 블로킹 락 — 잠들기 CPU에서 while 루프 회전 + 문맥 교환 없음 (락 빨리 풀리면 이득) − CPU 사이클 낭비 멀티코어: 한 코어가 spin 다른 코어가 짧게 임계구역 → 스핀이 유리 waiting 상태로 sleep + 대기 중 CPU를 남에게 양보 − 문맥 교환 2회 (잠들기 + 깨우기) 락을 오래 잡을 때 유리 규칙: 보유 시간 < 문맥 교환 2회 → 스핀
스핀락 vs 블로킹. 대기는 문맥 교환 2회(잠들기·깨우기)를 부른다. 그래서 락 보유 시간이 문맥 교환 2회보다 짧으면 스핀이 이긴다 — 멀티코어에서 스핀락이 널리 쓰이는 이유.
💡 “짧은 시간”이란 얼마인가 · 경합(contention)이란

락은 경합(contended)이거나 비경합(uncontended)이다. 획득 시도 시 락이 사용 가능하면 비경합, 블록되면 경합이다. 경합은 높은 경합(많은 스레드가 다툼)과 낮은 경합으로 나뉘고, 높은 경합 락은 전체 성능을 떨어뜨린다. “짧은 시간”의 기준: 대기에는 문맥 교환 2회가 들므로(잠들기 위해 한 번, 깨어나 복원하며 한 번), 락 보유 시간이 문맥 교환 2회보다 짧으면 스핀락이 더 낫다. 멀티코어에서는 한 스레드가 한 코어에서 spin하는 동안 다른 코어가 짧은 임계구역을 끝내면 문맥 교환을 통째로 아낀다.

단일 CPU를 여러 프로세스가 공유하는 진짜 멀티프로그래밍에서는 바쁜 대기가 명백한 문제다(임계구역 안의 프로세스가 CPU를 못 받으면 스핀하는 쪽이 CPU를 점유). 다음 절의 세마포어는 대기 프로세스를 잠시 재워서 바쁜 대기를 피한다.

6세마포어 — 계수·이진, 대기 큐 구현 📘 OSC 6.6

세마포어(semaphore) S는 정수 변수로, 초기화 외에는 두 표준 원자 연산 wait()signal()로만 접근한다. Dijkstra가 도입했으며, 원래 wait()는 P(네덜란드어 proberen, “테스트”), signal()은 V(verhogen, “증가”)였다.

세마포어의 고전적 정의 (바쁜 대기 버전)
wait(S) {
    while (S <= 0)
        ; // busy wait
    S--;
}

signal(S) {
    S++;
}
/* wait·signal 내부의 정수 갱신은 모두 원자적으로 실행되어야 한다 */

6.1 세마포어 사용 — 계수 vs 이진

계수 세마포어(counting)이진 세마포어(binary)
값 범위무제한0 또는 1
용도유한한 자원 인스턴스 개수 제어상호 배제 (뮤텍스처럼)
초기값가용 자원 수1
비고0이 되면 모든 자원 사용 중 → 이후 wait는 블록뮤텍스 락이 없는 시스템에서 대용

계수 세마포어는 자원 수로 초기화하고, 자원을 쓰려면 wait()(감소), 반납하면 signal()(증가)한다. 또 순서 강제에도 쓴다: synch를 0으로 두고 P1S1; signal(synch);, P2wait(synch); S2;를 하면 S2는 반드시 S1 후에 실행된다.

6.2 바쁜 대기 없는 구현 — 대기 큐

고전 정의는 여전히 바쁜 대기를 한다. 이를 없애려면 wait()에서 값이 양수가 아닐 때 스스로 정지(suspend)해 세마포어의 대기 큐(waiting queue)에 들어가고 대기 상태로 전환한 뒤 CPU 스케줄러에 제어를 넘긴다. 다른 프로세스가 signal()하면 wakeup()으로 깨워 준비 큐로 옮긴다.

대기 큐 기반 세마포어 — 바쁜 대기 제거 (C)
typedef struct {
    int value;
    struct process *list;     /* 이 세마포어에서 자고 있는 프로세스 큐 */
} semaphore;

wait(semaphore *S) {
    S->value--;
    if (S->value < 0) {                 /* 음수면 = 자원 없음 */
        add this process to S->list;    /* 대기 큐에 추가 */
        sleep();                        /* 스스로 정지 → 스케줄러로 */
    }
}

signal(semaphore *S) {
    S->value++;
    if (S->value <= 0) {                /* 대기자가 있었음 */
        remove a process P from S->list;
        wakeup(P);                      /* 하나 깨움 */
    }
}
semaphorevalue = −2 음수 크기 = 대기 수 list (FIFO 대기 큐) — 2개 잠듦 P_a (sleep) P_b (sleep) signal(): value++→ wakeup(P_a) value < 0 이면 |value| = 대기 프로세스 수 wait: value-- 후 음수면 큐에 넣고 sleep() signal: value++ 후 ≤0이면 큐에서 하나 꺼내 wakeup() 감소·테스트 순서를 바꿔 값이 음수가 될 수 있게 한 것이 핵심 트릭
대기 큐 세마포어: 값이 음수면 그 절댓값이 대기 프로세스 수다. 바쁜 대기 대신 sleep()/wakeup()으로 CPU를 양보한다. 대기 큐는 보통 FIFO로 유한 대기를 보장한다.

대기 큐는 보통 PCB(프로세스 제어 블록)의 링크 필드로 구현하며, FIFO로 두면 유한 대기를 보장한다. 단 바쁜 대기를 완전히 없앤 것은 아니다: wait()·signal() 자체의 임계구역(짧은, 보통 10 명령 이내)에는 여전히 짧은 바쁜 대기가 남는다. 단일 코어에서는 인터럽트 비활성으로, SMP에서는 CAS·스핀락으로 이 짧은 구간을 보호한다.

7모니터와 조건 변수 📘 OSC 6.7

세마포어는 강력하지만 잘못 쓰기 쉽다. 순서를 뒤집거나 빠뜨리면 재현조차 어려운 타이밍 오류가 난다.

해법: 동기화를 고수준 언어 구문으로 캡슐화한다 — 그것이 모니터(monitor)다.

7.1 모니터 사용 — ADT + 자동 상호 배제

모니터는 추상 데이터 타입(ADT)으로, 프로그래머 정의 연산들에 모니터 내부의 상호 배제를 자동으로 제공한다. 한 번에 하나의 프로세스만 모니터 안에서 활성이므로 프로그래머가 동기화를 명시할 필요가 없다. 하지만 이것만으로는 부족해 조건 변수(condition variable)를 더한다.

모니터 의사 문법과 조건 변수
monitor monitor_name {
    /* 공유 변수 선언 */
    condition x, y;          /* 조건 변수 */

    function P1(...) { ... }
    function P2(...) { ... }
    ...
    initialization_code(...) { ... }
}

/* 조건 변수에 쓸 수 있는 연산은 wait()와 signal() 둘뿐 */
x.wait();    /* 호출 프로세스를 정지 → 누군가 x.signal() 할 때까지 */
x.signal();  /* 정지된 프로세스 정확히 '하나'를 재개. 없으면 아무 효과 없음 */

중요한 대비: 조건 변수의 x.signal()대기 중인 프로세스가 없으면 아무 효과도 없다(상태를 안 바꿈). 반면 세마포어의 signal()은 항상 값을 올린다. 이 차이가 둘의 사용법을 가른다.

monitor entry queue (진입 큐) — 모니터에 들어가려는 대기 p1 p2 p3 shared data operations (한 번에 하나)+ initialization code 조건 변수 큐 x q1 q2 y q3 x.wait()로 조건 큐에 잠들고, x.signal()로 거기서 하나 깨어남
모니터 구조: 진입 큐(모니터 자체에 들어가려는 대기)와 조건 변수 큐(특정 조건을 기다리는 대기, x·y마다 별도). 둘을 구분하는 것이 모니터 이해의 핵심이다.

7.2 Hoare vs Mesa — signal-and-wait vs signal-and-continue

프로세스 P가 x.signal()을 호출했는데 조건 x에 정지된 프로세스 Q가 있다면, P와 Q 둘 다 모니터 안에서 활성일 수는 없다. 두 선택지가 있다.

Signal-and-wait (Hoare)Signal-and-continue (Mesa)
누가 계속?Q가 즉시 재개, P가 대기P가 계속, Q는 나중에
조건 보장강함: Q 재개 시 조건이 참 보장약함: Q 재개 시 조건이 다시 거짓일 수 있음
코드 패턴if (조건) x.wait()while (조건) x.wait() (재확인 필수)
구현 비용문맥 교환 즉시단순·효율적
채택이론적으로 깔끔Java·C#·Pthreads 등 거의 모든 실무
⚠️ 실무는 거의 Mesa — 그래서 while로 재확인하라

Mesa 의미론(signal-and-continue)에서는 signal을 받아 깨어났어도 그 사이 다른 스레드가 조건을 다시 거짓으로 만들 수 있다(가짜 깨어남까지 더하면 더더욱). 그래서 반드시 while (!조건) cond.wait(); 루프로 깨어난 뒤 조건을 재확인해야 한다. Java Condition.await(), Pthreads pthread_cond_wait(), C++ condition_variable이 모두 Mesa식이며 술어(predicate) 형태를 권장하는 이유가 이것이다.

7.3 세마포어로 모니터 구현하기

모니터를 세마포어로 구현할 수 있다(signal-and-wait 방식). 각 모니터에 상호 배제용 이진 세마포어 mutex(초기 1)를 둔다. 입장 시 wait(mutex), 퇴장 시 signal(mutex). signal하는 프로세스가 재개된 프로세스를 기다려야 하므로 추가 세마포어 next(초기 0)와 카운터 next_count를 둔다.

모니터 외부 함수 F의 변환
wait(mutex);
    ... F의 본문 ...
if (next_count > 0)
    signal(next);        /* signal 대기 중인 프로세스가 있으면 그쪽으로 */
else
    signal(mutex);       /* 없으면 모니터 락 해제 */
조건 변수 x의 wait/signal 구현 (x_sem, x_count 초기 0)
/* x.wait() */                    /* x.signal() */
x_count++;                         if (x_count > 0) {
if (next_count > 0)                    next_count++;
    signal(next);                     signal(x_sem);
else                                  wait(next);
    signal(mutex);                    next_count--;
wait(x_sem);                       }
x_count--;

7.4 재개 순서와 conditional-wait

조건 x에 여러 프로세스가 정지돼 있을 때 누구를 먼저 깨울까? 단순히 FCFS로 둘 수 있지만, 부족할 때는 conditional-wait 구문 x.wait(c)를 쓴다. c우선순위 번호(priority number)로, x.signal() 시 가장 작은 번호의 프로세스가 재개된다. 교재의 ResourceAllocator 모니터는 자원을 가장 짧은 시간 요청한 프로세스에 먼저 주는 예다. 단, 모니터조차 사용자가 acquire/release를 올바른 순서로 호출하는지는 강제하지 못한다(그 문제는 17장의 보호·접근 제어로).

8라이브니스 — 데드락·우선순위 역전 📘 OSC 6.8

동기화 도구로 임계구역을 조율하면 무한 대기가 생길 수 있다 — 진행·유한 대기 요건의 위반이다. 라이브니스(liveness)는 프로세스가 실행 생애 동안 진행을 보장하기 위해 시스템이 만족해야 할 속성들의 집합이며, 무한 대기는 “라이브니스 실패”다.

8.1 데드락 (deadlock)

대기 큐를 가진 세마포어 구현은 두 프로세스가 서로가 일으켜야만 하는 이벤트(signal)를 영원히 기다리는 상태를 낳을 수 있다. P0wait(S); wait(Q);, P1wait(Q); wait(S); 순서로 두 세마포어(둘 다 1)를 잡으려 하면 교착이 생긴다.

P0 P1 S Q 보유 보유 P0 → Q 요청 P1 → S 요청 순환 대기 → 둘 다 영원히 멈춤 (8장에서 상세)
데드락: 집합 안의 모든 프로세스가 그 집합 안의 다른 프로세스만 일으킬 수 있는 이벤트를 기다린다. 여기서는 S·Q를 교차로 보유·요청해 순환 대기가 생긴다.

8.2 우선순위 역전 (priority inversion)

고우선순위 프로세스 H가 저우선순위 L이 쥔 자원(락)을 기다려야 할 때, 중간 우선순위 M이 L을 선점하면 — 간접적으로 더 낮은 우선순위의 M이 H의 대기 시간에 영향을 준다. 이것이 우선순위 역전이며 셋 이상의 우선순위가 있어야 발생한다.

해법은 우선순위 상속 프로토콜(priority-inheritance protocol): 고우선순위 프로세스가 필요로 하는 자원을 쥔 프로세스는 그 자원을 끝낼 때까지 고우선순위를 일시 상속한다. 위 예에서 L이 H의 우선순위를 상속하면 M이 L을 선점하지 못해, L이 빨리 끝내고 H가 진행한다.

우선순위: L < M < H · 시간 → L (저) M (중) H (고) L 잡음 S H가 S 요청 → 블록 L이 H 우선순위 상속 → 계속 실행 M 실행가능하나 선점 못함 L 반납 H 진행 (M보다 먼저!) 상속이 없으면 M이 L을 선점 → H가 M+L만큼 추가 대기 (역전)
우선순위 상속: L이 H의 우선순위를 일시 상속하면 M이 L을 선점하지 못해, L이 자원을 빨리 반납하고 H가 진행한다. 끝나면 L은 원래 우선순위로 복귀한다.
심화 Mars Pathfinder (1997)

NASA의 Mars Pathfinder가 화성에 내린 Sojourner 로버는 작동 직후 잦은 시스템 리셋을 겪었다. 원인은 전형적 우선순위 역전이었다: 고우선순위 bc_dist 태스크가 저우선순위 ASI/MET가 쥔 공유 자원을 기다리는데, 그 사이 중우선순위 태스크들이 ASI/MET를 선점했다. 결국 bc_sched 워치독이 문제를 감지해 리셋을 걸었다. OS는 VxWorks였고, 모든 세마포어에 우선순위 상속을 켜는 전역 변수가 있었다. 테스트 후 그 변수를 화성에 있는 로버에 원격으로 켜자 문제가 해결됐다. 동기화 버그가 우주선을 죽일 뻔한 실화다.

9평가 — 도구 선택과 메모리 모델 연결 📘 OSC 6.9

여러 도구를 봤다. 언제 무엇을 쓸까? 핵심 축은 경합 수준(contention)이다.

9.1 낙관적 vs 비관적, 그리고 경합별 성능

CAS 기반 접근은 낙관적(optimistic)이다 — 일단 갱신을 시도하고 충돌을 감지하면 재시도. 락 기반은 비관적(pessimistic)이다 — 충돌을 가정하고 먼저 락을 잡는다.

경합 수준CAS 기반 (lock-free)전통 동기화 (뮤텍스·세마포어)
비경합(uncontended)둘 다 빠르나 CAS가 다소 더 빠름빠름
중간 경합(moderate)CAS가 더 빠름 — 때로 훨씬대기 시 문맥 교환 비용
높은 경합(high)재시도 폭주 → 결국 느려짐전통 동기화가 결국 더 빠름

중간 경합이 흥미롭다: CAS는 대부분 성공하고, 실패해도 루프를 몇 번만 돌면 된다. 반면 락은 경합 시 스레드를 정지·대기 큐에 넣고 문맥 교환하는 무거운 경로를 탄다.

도구별 무게: 원자 정수는 카운터 같은 단일 갱신에 가장 가볍다. 스핀락은 멀티프로세서에서 짧은 보유에 좋다. 뮤텍스는 이진 세마포어보다 단순·저비용이라 임계구역 보호에 선호된다. 계수 세마포어는 유한 자원 개수 제어에 적합하다. 리더-라이터 락은 다중 리더 동시성을 허용해 읽기 위주 워크로드에 유리하다. 모니터·조건 변수는 단순·안전하지만 오버헤드가 있고 높은 경합에서 확장성이 떨어질 수 있다.

9.2 메모리 모델과의 연결 — 동기화의 진짜 토대 ⊕ 확장

이 장의 모든 도구는 결국 두 가지를 보장한다: ① 원자성(중간 상태가 안 보임), ② 가시성·순서(한 스레드의 쓰기가 다른 스레드에 보이고, 올바른 순서로). ②가 바로 4장 메모리 모델의 영역이며, 동기화의 진짜 토대다.

acquire/release 의미론과 happens-before (C++ <atomic>)
#include <atomic>
std::atomic<bool> flag{false};
int data = 0;

// 생산자
data = 42;                                  // (1) 일반 쓰기
flag.store(true, std::memory_order_release); // (2) release: (1)이 (2) 이전으로 고정

// 소비자
while (!flag.load(std::memory_order_acquire)) // (3) acquire
    ;
assert(data == 42);  // 보장됨! release-(2) → acquire-(3) 가 happens-before를 세움
/* (1)은 (2)보다 먼저, (3)은 (2)를 봤으므로 (1)의 결과가 (3) 이후에 보인다 */
Thread A (생산자) Thread B (소비자) data = 42 (일반 쓰기) store(flag, release) load(flag, acquire) read data → 42 보장 synchronizes-with release → acquire 가 happens-before 사슬을 만든다 → data 쓰기가 B에 보임
acquire/release: release store가 그 이전의 모든 쓰기를 “봉인”하고, 같은 변수의 acquire load가 그 봉인을 “관측”하면 happens-before가 성립한다. 락의 unlock/lock도 정확히 이 의미론이다.
📌 핵심 통찰 — 왜 락 안에서는 메모리 순서를 신경 안 써도 되나

뮤텍스 unlock은 release, lock은 acquire 의미론을 갖는다. 따라서 한 스레드가 락 안에서 한 모든 쓰기는, 그 락을 다음에 잡는 스레드에게 happens-before로 보인다. 이것이 “락만 잘 걸면 메모리 모델을 몰라도 되는” 이유다. 반대로 lock-free(CAS·atomic)를 직접 쓰는 순간, happens-before를 스스로 세워야 하므로 memory_order를 정확히 골라야 한다(카운터처럼 순서 무관이면 relaxed로 충분, 잠금이면 acquire/release).

10현대 실무 — futex·lock-free·RCU·C++ ⊕ 교재 외 확장

10.1 futex — 경합할 때만 커널로

Linux의 뮤텍스는 futex(fast userspace mutex) 위에 선다. 핵심: 경합이 없으면 사용자 공간 원자 연산만으로 끝내고, 경합할 때만 커널로 내려가 잠들고 깨운다. 비경합 락/언락에 시스템 콜이 없어 거의 공짜다.

futex 기반 뮤텍스의 골격 — 경합 없으면 syscall 없음 (C, 의사)
/* 0=unlocked, 1=locked, 2=locked & waiters */
void lock(atomic_int *m) {
    int c;
    if ((c = cmpxchg(m, 0, 1)) != 0) {          /* fast path: 비경합이면 즉시 획득 */
        if (c != 2) c = xchg(m, 2);             /* 대기자 있음 표시 */
        while (c != 0) {
            futex(m, FUTEX_WAIT, 2, NULL);      /* slow path: 커널에서 잠듦 */
            c = xchg(m, 2);
        }
    }
}
void unlock(atomic_int *m) {
    if (atomic_fetch_sub(m, 1) != 1) {          /* 1이 아니면 = 2였음 → 대기자 있음 */
        store(m, 0);
        futex(m, FUTEX_WAKE, 1, NULL);          /* 대기자 하나 깨움 */
    }
}

10.2 lock-free와 RCU 미리보기

CAS는 락 없이 자료구조를 만드는 토대다. 교재 연습 문제의 lock-free 스택이 대표 예다: top 포인터를 CAS로 갱신하되 실패하면 재시도한다.

lock-free 스택의 push — CAS 재시도 루프 (C)
void push(value_t item) {
    Node *old_node, *new_node = malloc(sizeof(Node));
    new_node->data = item;
    do {
        old_node = top;
        new_node->next = old_node;
    } while (compare_and_swap(top, old_node, new_node) != old_node);
    /* 다른 스레드가 그 사이 top을 바꿨으면 CAS 실패 → 재시도 */
}
⚠️ lock-free의 함정 — ABA 문제

CAS는 “값이 expected와 같은가”만 본다. 그래서 top이 A→B→A로 돌아오면 CAS는 변화를 놓치고 성공해 버린다(ABA 문제). 해법: 태그된 포인터(버전 카운터 동반), hazard pointer, 또는 RCU(Read-Copy-Update). RCU는 “읽기는 락 없이, 쓰기는 복사본을 만들어 교체하고, 기존 읽기가 모두 끝난 뒤(grace period) 옛 복사본을 회수”하는 기법으로, Linux 커널의 읽기 위주 자료구조(라우팅 테이블 등)에 광범위하게 쓰인다.

10.3 C++ std::mutex / std::atomic — 실무 RAII

C++ — RAII 락과 원자 변수 (C++17/20)
#include <mutex>
#include <atomic>

std::mutex m;
long shared = 0;
void safe_add(long v) {
    std::lock_guard<std::mutex> lk(m);   /* RAII: 스코프 벗어나면 자동 unlock */
    shared += v;                         /* 예외가 나도 unlock 보장 */
}

std::atomic<long> counter{0};
void bump() {
    counter.fetch_add(1, std::memory_order_relaxed); /* 순서 무관 카운터 → relaxed */
}

/* 데드락 회피: 두 락을 한 번에 — std::scoped_lock (C++17) */
std::mutex a, b;
void transfer() {
    std::scoped_lock lk(a, b);           /* 교착 없는 다중 락 (내부적으로 순서 정렬) */
    /* ... */
}

실무 규칙: ① RAII 락(lock_guard·scoped_lock)으로 해제 누락·예외 누수를 원천 차단, ② 카운터는 원자 변수 + relaxed, ③ 잠금은 acquire/release, ④ 다중 락은 전역 순서로 잡거나 scoped_lock으로 데드락 회피, ⑤ 조건 대기는 술어 형태 cv.wait(lk, pred)로 가짜 깨어남 방어.

11요약 · 복습 📘 OSC 6.10

11.1 한 장 정리

🎯 6장 핵심

  • 경쟁 조건: 여러 흐름이 공유 데이터를 동시 접근하고 결과가 순서에 의존. count++는 load·add·store 3단계라 인터리빙되면 lost update.
  • 임계구역 문제의 해법은 ① 상호 배제(안전성) ② 진행 ③ 유한 대기(라이브니스)를 만족해야 한다.
  • Peterson(flag/turn)은 알고리즘으로 셋을 만족하나, 명령 재배열 때문에 현대 아키텍처에서 깨진다 → 메모리 모델의 문제.
  • 하드웨어 지원: 메모리 배리어(가시성·순서 강제), test_and_set·CAS(원자 명령), 원자 변수(단일 갱신).
  • 뮤텍스 락은 acquire/release. 단순 구현은 스핀락(바쁜 대기) — 보유 시간이 문맥 교환 2회보다 짧으면 유리.
  • 세마포어는 정수 + wait/signal. 계수(자원 수)·이진(상호 배제). 대기 큐로 바쁜 대기를 (거의) 없앤다.
  • 모니터는 자동 상호 배제 ADT + 조건 변수. 실무는 Mesa(signal-and-continue)while 재확인 필수. 세마포어로 구현 가능.
  • 라이브니스 실패: 데드락(순환 대기)·우선순위 역전(우선순위 상속으로 해결 — Mars Pathfinder).
  • 평가: 비경합·중간 경합엔 CAS(낙관적)가, 높은 경합엔 전통 락(비관적)이 유리. 모든 도구의 진짜 토대는 happens-before(acquire/release).

11.2 복습 — 답을 가리고

Q1. count++를 두 스레드가 동시에 해서 값이 틀어지는 과정을 기계어 인터리빙으로 설명하라.

++는 load→add→store다. 생산자가 count(5)를 load해 6을 만드는 사이 소비자도 5를 load해 4를 만들면, 둘이 각자 store하며 한쪽 갱신이 사라진다(lost update). 결과는 4 또는 6 — 둘 다 틀림.

Q2. 임계구역 해법의 세 요건을 들고, 안전성/라이브니스로 분류하라.

① 상호 배제(한 번에 하나 — 안전성), ② 진행(빈 임계구역의 진입 결정을 무한히 미루지 않음 — 라이브니스), ③ 유한 대기(요청 후 다른 프로세스 진입 횟수에 상한 — 라이브니스).

Q3. Peterson의 해법은 왜 현대 CPU에서 보장되지 않으며 어떻게 고치나?

진입 구역의 flag[i]=trueturn=j가 데이터 의존성이 없어 재배열될 수 있고, 그러면 두 프로세스가 동시에 임계구역에 들어간다. 두 문장 사이에 메모리 배리어를 넣어 순서를 강제하면 고쳐진다.

Q4. CAS의 정의와, 그것으로 원자 증가를 어떻게 만드는지 설명하라.

CAS(value, expected, new)는 *value==expected일 때만 new로 바꾸고 항상 원래 값을 반환한다. 원자 증가는 do { t=*v; } while(t != CAS(v,t,t+1)) — 그 사이 값이 바뀌었으면 CAS가 실패(반환값≠t)해 재시도한다.

Q5. 스핀락이 단일 코어엔 부적합하나 멀티코어엔 쓰이는 이유는?

단일 코어에서는 스핀하는 동안 임계구역 안의 스레드가 CPU를 못 받아 진전이 없다(낭비). 멀티코어에서는 한 코어가 spin하는 동안 다른 코어가 짧은 임계구역을 끝낼 수 있어, 보유 시간이 문맥 교환 2회보다 짧으면 스핀이 더 빠르다.

Q6. 대기 큐 세마포어에서 값이 −3이라는 것은 무슨 뜻이며, wait/signal의 핵심 트릭은?

값이 음수면 그 절댓값이 대기 프로세스 수다(−3 = 3개 대기). 핵심 트릭은 wait()에서 감소를 먼저 하고 음수면 잔다는 것(테스트와 감소 순서를 바꿈) — 그래서 값이 음수가 되며 대기 수를 자연히 센다.

Q7. 모니터의 Hoare와 Mesa 의미론 차이와, 그 차이가 코드에 주는 영향은?

Hoare(signal-and-wait)는 signal 시 대기 프로세스를 즉시 재개해 조건이 참임을 보장 → if로 충분. Mesa(signal-and-continue)는 signal한 쪽이 계속 실행하므로 재개 시 조건이 다시 거짓일 수 있다 → while 루프로 재확인 필수. 거의 모든 실무 라이브러리가 Mesa식.

Q8. 우선순위 역전이 무엇이며, 우선순위 상속이 어떻게 그것을 막는가?

고우선순위 H가 저우선순위 L이 쥔 락을 기다리는데 중우선순위 M이 L을 선점하면, 간접적으로 M이 H의 대기를 늘린다. 우선순위 상속은 L이 H의 우선순위를 일시 상속하게 해 M의 선점을 막고, L이 빨리 락을 반납해 H가 진행하게 한다(끝나면 L은 원래 우선순위 복귀).

Q9. “락만 잘 걸면 메모리 순서를 신경 안 써도 된다”는 왜 맞고, 언제 깨지나?

락 unlock은 release, lock은 acquire라서 락 안의 모든 쓰기가 다음 락 획득자에게 happens-before로 보인다 → 맞다. 그러나 lock-free(CAS·atomic)를 직접 쓰는 순간 happens-before를 스스로 세워야 하므로 memory_order(relaxed/acquire/release)를 정확히 골라야 한다 — 그때 깨진다.

11.3 연관 자료 · 더 깊이

🚧 이 코스의 다른 장에 대해

본문은 같은 OS 코스의 3·4·7·8·17장과 “N절”을 자주 참조한다. 둘은 가리키는 곳이 다르다. “N절” 참조(예: 6.4절·6.6절)는 이 페이지 안의 앵커(#s4·#s6 등)로 바로 이동한다. “N장” 참조 — 3장 프로세스·4장 스레드와 동시성·7장 동기화 예제·8장 교착 상태·17장 보호 — 는 이 운영체제 코스에 순차적으로 추가되는 페이지를 가리킨다. 동기화·메모리 일관성의 하드웨어 측면은 위의 메모리 코스 링크(10강 MESI/메모리 순서)로 보강하라.

심화 참고 문헌

Silberschatz·Galvin·Gagne, Operating System Concepts 10e, Ch.6 · Dijkstra, “Cooperating Sequential Processes” (1965, 세마포어) · Hoare, “Monitors” (CACM 1974) · Brinch-Hansen, Operating System Principles (1973) · McKenney, “Memory Barriers: a Hardware View for Software Hackers” (2010) · Herlihy & Shavit, The Art of Multiprocessor Programming (lock-free·메모리 모델) · Bahra, “Nonblocking Algorithms and Scalable Multicore Programming” (ACM Queue 2013) · Mars Pathfinder authoritative account (M. B. Jones).