동기화 예제 Synchronization Examples
이 장을 마치면
- 유한 버퍼·readers–writers·식사하는 철학자 세 고전 문제를 세마포어와 모니터로 구현하고, 각각의 데드락·기아(starvation) 위험을 정확히 진단할 수 있다.
- 식사 철학자 데드락의 네 가지 해법(인원 제한·원자적 집기·비대칭·중재자/자원 계층)을 비교하고 왜 “데드락 프리 ≠ 기아 프리”인지 설명할 수 있다.
- Windows 디스패처 객체(signaled/nonsignaled)와 critical-section 객체, Linux 커널 동기화(원자 정수·스핀락·세마포어·RCU·preemption 비활성화)를 구조 수준에서 설명한다.
- POSIX 뮤텍스·named/unnamed 세마포어·조건 변수를 코드로 다루고, “조건은 반드시
while루프에서 재검사”의 이유를 말할 수 있다. - Java의 모니터(synchronized·wait/notify, entry set·wait set)·ReentrantLock·Semaphore·Condition·
java.util.concurrent를 구분해 쓸 수 있다. - 트랜잭셔널 메모리(STM/HTM)·OpenMP·함수형 언어의 불변성이 어떻게 락 없이 안전성을 얻는지 이해한다.
- 확장: Rust의 소유권 기반 안전성과 Go의 채널·sync 패키지가 “데이터 레이스를 컴파일·설계로 차단”하는 방식을 비교한다.
이 자료는 교재(Operating System Concepts 10판 7장) 원문에 대학원 수준 확장을 더한 것입니다. 절·소절 제목 옆 배지로 출처를 구분했습니다.
📘 OSC 7.x 교재 7장 핵심 내용 ⊕ 교재 외 확장 교재 범위 밖 심화(Rust·Go·STM 상세·구현 코드 등)
※ 8절(Rust·Go)은 절 전체가 확장입니다. 배지 없는 소절은 교재 본문에 해당합니다(9절 복습엔 확장 주제 문항도 포함). 본문 “6장”은 동기화 도구(세마포어·모니터 정의), “8장”은 교착 상태를 가리킵니다.
동기화 도구는 그 자체로는 의미가 없다. 의미는 “어떤 문제를, 어떤 불변식(invariant)을 지키며 푸는가”에서 나온다. 이 장은 거의 50년간 모든 새 동기화 프리미티브를 시험해 온 세 개의 고전 문제로 시작해, 그 문제를 실제 커널과 언어 런타임이 어떻게 푸는지로 내려간다 — 같은 임계 구역 문제에 대한 일곱 가지 답.
0학습 지도
| 절 | 주제 | 핵심 질문 |
|---|---|---|
| 1 | 유한 버퍼 — 생산자·소비자 | empty·full·mutex 세 세마포어는 각각 무엇을 센다? |
| 2 | Readers–Writers | 첫째/둘째 변형은 누구를 굶기나? |
| 3 | 식사하는 철학자 — 데드락과 해법 | 왜 “각자 왼쪽 젓가락”이 영원히 굶기나? |
| 4 | 커널 내부 — Windows | 디스패처 객체의 signaled/nonsignaled란? |
| 5 | 커널 내부 — Linux | 스핀락 vs preemption 비활성화, 언제? |
| 6 | POSIX 동기화 | 조건 변수를 왜 뮤텍스와 묶나? |
| 7 | Java 동기화 | entry set과 wait set은 어떻게 다른가? |
| 8 | Rust·Go — 데이터 레이스를 설계로 차단 | 락 없이 안전을 어떻게 보장하나? |
| 9 | 대안적 접근 · 요약 · 복습 | 락 없는 동기화는 가능한가? |
1유한 버퍼 문제 — 생산자·소비자 📘 OSC 7.1
고전 동기화 문제들은 보통 세마포어(semaphore — 정수 카운터와 wait()/signal() 연산으로 자원 개수와 대기를 관리하는 도구, 6장에서 정의)로 제시한다. 실제 구현에서는 이진 세마포어 대신 뮤텍스 락(mutex lock)을 써도 된다.
유한 버퍼(bounded-buffer) 문제는 6장에서 소개됐다. n개의 칸을 가진 공유 버퍼 풀에 생산자는 항목을 채우고 소비자는 비운다. 두 프로세스가 공유하는 자료는 세 개의 세마포어다.
int n; /* 버퍼 칸 수 */
semaphore mutex = 1; /* 버퍼 풀 접근의 상호 배제 (이진) */
semaphore empty = n; /* 비어 있는 칸의 개수 (계수) */
semaphore full = 0; /* 채워진 칸의 개수 (계수) */핵심은 세 세마포어의 역할 분담이다. mutex는 버퍼라는 자료구조의 일관성을 지키고(한 번에 하나만 손댐), empty·full은 흐름 제어를 한다 — “채울 빈칸이 있나?”, “꺼낼 항목이 있나?”라는 조건을 카운팅으로 표현한다.
while (true) {
/* ... 항목 next_produced 생산 ... */
wait(empty); /* 빈칸을 하나 소비 (없으면 블록) */
wait(mutex); /* 버퍼 잠금 */
/* ... next_produced 를 버퍼에 추가 ... */
signal(mutex); /* 버퍼 해제 */
signal(full); /* 채워진 칸이 하나 늘었음을 알림 */
}while (true) {
wait(full); /* 채워진 칸을 하나 소비 (없으면 블록) */
wait(mutex); /* 버퍼 잠금 */
/* ... 버퍼에서 항목을 꺼내 next_consumed 로 ... */
signal(mutex); /* 버퍼 해제 */
signal(empty); /* 빈칸이 하나 늘었음을 알림 */
/* ... next_consumed 소비 ... */
}생산자와 소비자 사이에는 아름다운 대칭성이 있다. 생산자가 empty를 소비하고 full을 생산하는 것을 “소비자를 위해 채워진 버퍼를 생산한다”로, 거꾸로 소비자가 full을 소비하고 empty를 생산하는 것을 “생산자를 위해 빈 버퍼를 생산한다”로 읽을 수 있다.
empty는 빈칸 수, full은 채워진 칸 수를 센다. 둘이 흐름을 막고, mutex가 자료구조 일관성을 지킨다. empty+full은 항상 n(불변식).생산자가 wait(mutex)를 먼저, wait(empty)를 나중에 하면? 버퍼가 가득 찬 상태에서 생산자가 mutex를 쥔 채 empty를 기다리고, 소비자는 그 mutex를 얻지 못해 항목을 꺼낼 수 없다 — 아무도 진행하지 못한다(데드락). 그래서 흐름 제어 세마포어(empty/full)를 항상 mutex보다 먼저 획득한다. 이것이 “자원을 일관된 순서로 획득하라”는 데드락 예방 원칙(8장)의 최초 등장이다.
2Readers–Writers 문제 📘 OSC 7.1
데이터베이스를 여러 프로세스가 공유한다고 하자. 어떤 프로세스는 읽기만(reader), 어떤 프로세스는 읽고 쓰기(writer)를 한다. 독자 둘이 동시에 읽는 것은 무해하다. 그러나 기록자가 다른 누구(독자든 기록자든)와 동시에 접근하면 혼돈이 일어난다. 따라서 기록자에게는 배타적 접근이 필요하다.
2.1 두 가지 변형 — 누구를 우선할 것인가
- 첫째 readers–writers 문제: 기록자가 이미 허가를 받은 게 아니라면 어떤 독자도 기다리지 않는다. 즉 “독자 우선” — 기록자가 대기 중이라는 이유만으로 새 독자가 막히지 않는다. 결과: 기록자가 굶을(starve) 수 있다.
- 둘째 readers–writers 문제: 기록자가 준비되면 가능한 한 빨리 쓴다. 기록자가 대기 중이면 새 독자는 읽기를 시작할 수 없다. 즉 “기록자 우선”. 결과: 독자가 굶을 수 있다.
둘 다 기아의 위험이 있어, 기아 없는 변형들이 제안되었다. 아래는 첫째 문제의 해법이다.
semaphore rw_mutex = 1; /* 기록자 상호 배제 + 첫/마지막 독자가 사용 */
semaphore mutex = 1; /* read_count 갱신 보호 */
int read_count = 0; /* 현재 읽고 있는 프로세스 수 */while (true) {
wait(rw_mutex); /* 배타적 접근 획득 */
/* ... 쓰기 수행 ... */
signal(rw_mutex);
}while (true) {
wait(mutex);
read_count++;
if (read_count == 1) /* 첫 독자가 기록자를 잠금 */
wait(rw_mutex);
signal(mutex);
/* ... 읽기 수행 ... */
wait(mutex);
read_count--;
if (read_count == 0) /* 마지막 독자가 기록자를 풀어줌 */
signal(rw_mutex);
signal(mutex);
}핵심 메커니즘: rw_mutex는 첫 번째 독자가 들어올 때 한 번 잠그고, 마지막 독자가 나갈 때 한 번 푼다. 중간에 들어오고 나가는 독자들은 rw_mutex를 건드리지 않는다 — 그래서 여러 독자가 동시에 읽을 수 있다. 기록자가 임계 구역에 있고 n명의 독자가 대기 중이면, 한 독자는 rw_mutex에, 나머지 n−1명은 mutex에 줄 선다.
rw_mutex를 토글.2.2 Reader–Writer 락으로의 일반화
이 문제의 해법은 여러 시스템에서 reader–writer 락으로 일반화되었다. 락을 획득할 때 모드(읽기 또는 쓰기)를 지정한다. 여러 프로세스가 읽기 모드로 동시에 락을 잡을 수 있지만, 쓰기 모드는 단 하나만 — 기록자에게 배타적 접근이 필요하기 때문이다.
Reader–writer 락이 유용한 상황:
- 어떤 프로세스가 읽기만 하고 어떤 프로세스가 쓰기만 하는지 쉽게 식별되는 응용.
- 기록자보다 독자가 훨씬 많은 응용. reader–writer 락은 일반 뮤텍스보다 설정 오버헤드가 크지만, 다중 독자의 동시성 이득이 그 오버헤드를 상쇄한다.
Windows의 SRW 락은 독자·기록자 어느 쪽도 편애하지 않고 FIFO 정렬도 하지 않는다(연습문제 7.2). 이 “무편애” 설계는 정책 판단을 포기하는 대신 락 자료구조를 포인터 하나 크기로 줄여 극도로 가볍다 — 공정성보다 처리량·메모리가 중요할 때의 선택이다. Linux의 rwlock_t, Java의 ReentrantReadWriteLock(7절)이 같은 계열이다.
3식사하는 철학자 문제 — 데드락과 그 해법 📘 OSC 7.1
다섯 철학자가 원형 탁자에 앉아 생각하고 먹기를 반복한다. 탁자 위에는 밥 한 그릇과 젓가락 다섯 개가 놓여 있고, 각 철학자 사이에 하나씩이다. 배가 고프면 철학자는 양옆 두 젓가락을 집으려 한다. 한 번에 하나씩만 집을 수 있고, 이미 이웃 손에 있는 젓가락은 집을 수 없다. 두 젓가락을 모두 들면 먹고, 다 먹으면 둘 다 내려놓고 다시 생각한다.
이 문제가 고전인 이유는 실용성 때문이 아니라, 여러 자원을 여러 프로세스에게 데드락·기아 없이 분배하는 거대한 부류의 문제를 단순하게 대표하기 때문이다.
3.1 세마포어 해법 — 그리고 그것이 실패하는 이유
가장 단순한 해법: 젓가락마다 세마포어 하나. 철학자는 양옆 세마포어에 wait()로 젓가락을 집고 signal()로 내려놓는다.
semaphore chopstick[5]; /* 모두 1로 초기화 */
while (true) {
wait(chopstick[i]); /* 왼쪽 젓가락 */
wait(chopstick[(i+1) % 5]); /* 오른쪽 젓가락 */
/* ... 잠시 먹는다 ... */
signal(chopstick[i]);
signal(chopstick[(i+1) % 5]);
/* ... 잠시 생각한다 ... */
}이 해법은 “이웃 둘이 동시에 먹지 않음”은 보장하지만 거부해야 한다 — 데드락을 만들 수 있기 때문이다. 다섯 철학자가 동시에 배고파져 각자 왼쪽 젓가락을 집으면, chopstick 배열이 모두 0이 된다. 이제 각자 오른쪽 젓가락을 집으려 하지만 영원히 기다린다 — 순환 대기다.
3.2 데드락의 네 가지 해법 비교 ⊕ 확장
교재는 세 가지 구제책을 든다. 여기에 모니터 기반 “중재자(arbitrator)”와 일반화된 “자원 계층(resource hierarchy)”을 더해 다섯을 비교한다. 모두 데드락의 네 조건(8장: 상호 배제·점유와 대기·비선점·순환 대기) 중 하나를 깬다.
| 해법 | 깨는 조건 | 장점 | 단점 |
|---|---|---|---|
| 인원 제한 (최대 4명만 착석) | 순환 대기 (자리 세마포어 추가) | 단순·코드 최소 | 한 명은 항상 놀고, 동시성 살짝 손해 |
| 원자적 집기 (둘 다 가능할 때만) | 점유와 대기 | 데드락 불가 | 임계 구역(또는 모니터) 필요, 기아 가능 |
| 비대칭 (홀수=왼쪽 먼저, 짝수=오른쪽 먼저) | 순환 대기 | 추가 자료구조 없음 | 대칭성 깨는 영리함 필요 |
| 자원 계층 (젓가락에 전역 순서, 낮은 번호 먼저) | 순환 대기 | 일반화 쉬움(N자원) | 전역 순서 강제, 동시성 약간 저하 |
| 중재자/모니터 (웨이터가 허가) | 점유와 대기 | 데드락 불가·관리 명확 | 웨이터가 병목, 여전히 기아 가능 |
“홀수 철학자는 왼쪽, 짝수는 오른쪽 먼저”라는 비대칭 해법은 사실 자원 계층 해법의 특수 케이스다. 두 방법 모두 “모든 철학자가 젓가락을 같은 전역 순서로 집게” 만들어 순환 대기를 불가능하게 한다. 자원 계층(항상 낮은 번호 젓가락부터)을 일반화하면, 마지막 철학자만 “오른쪽 먼저” 집는 꼴이 되어 한 사람이 사이클을 끊는다. 8장 데드락 예방의 “자원 순서화(ordering)”가 바로 이 아이디어다.
3.3 모니터 해법 — 데드락 프리
6장의 모니터(monitor — 공유 자료와 그것을 조작하는 연산을 한 데 묶어 상호 배제를 자동 보장하는 고수준 동기화 구조)로 데드락 없는 해법을 만든다. 제약: 철학자는 양쪽 젓가락이 모두 가능할 때만 집는다. 세 상태를 구분한다.
monitor DiningPhilosophers
{
enum {THINKING, HUNGRY, EATING} state[5];
condition self[5]; /* 젓가락을 못 얻은 철학자가 대기 */
void pickup(int i) {
state[i] = HUNGRY;
test(i); /* 먹을 수 있는지 시도 */
if (state[i] != EATING)
self[i].wait(); /* 못 먹으면 대기 */
}
void putdown(int i) {
state[i] = THINKING;
test((i + 4) % 5); /* 왼쪽 이웃을 깨워봄 */
test((i + 1) % 5); /* 오른쪽 이웃을 깨워봄 */
}
void test(int i) {
if ((state[(i + 4) % 5] != EATING) &&
(state[i] == HUNGRY) &&
(state[(i + 1) % 5] != EATING)) {
state[i] = EATING;
self[i].signal(); /* 대기 중이면 깨움 */
}
}
initialization_code() {
for (int i = 0; i < 5; i++)
state[i] = THINKING;
}
}철학자 i는 먹기 전 pickup(i), 다 먹고 putdown(i)를 호출한다. 핵심은 test(): 철학자 i가 EATING이 되려면 양 이웃이 EATING이 아니어야 한다. 이 “둘 다 가능할 때만”이 점유와 대기 조건을 깨므로 데드락이 불가능하다.
이 모니터 해법은 데드락이 없음을 쉽게 보일 수 있다. 그러나 교재가 명시하듯, 철학자가 굶어 죽을 가능성은 여전히 남는다. 예: 두 이웃이 번갈아 빠르게 먹으면 가운데 철학자는 “양쪽이 동시에 안 먹는 순간”을 영영 못 만날 수 있다. 기아 해결은 연습문제로 남겨진다(예: HUNGRY 상태에 타임스탬프를 주어 오래 기다린 철학자를 우선). 데드락 자유는 진행의 필요조건이지 충분조건이 아니다.
4커널 내부 동기화 — Windows 📘 OSC 7.2
이제 OS가 자기 자신(커널 자료구조)을 어떻게 보호하는지로 내려간다. Windows와 Linux는 서로 다른 접근으로 좋은 대조를 이룬다.
Windows 커널은 멀티스레드이며 실시간 응용과 다중 프로세서를 지원한다. 단일 프로세서에서 전역 자원에 접근할 때는 그 자원을 건드릴 수 있는 모든 인터럽트 핸들러에 대해 일시적으로 인터럽트를 마스킹한다. 다중 프로세서에서는 스핀락(spinlock — 락을 얻을 때까지 바쁘게 회전하며 대기)으로 짧은 코드 구간만 보호한다. 효율을 위해 커널은 스핀락을 쥔 스레드는 절대 선점(preemption — 실행 중 스레드를 강제로 멈추고 CPU를 빼앗음)되지 않게 보장한다.
4.1 디스패처 객체와 signaled/nonsignaled 상태
커널 밖(사용자 스레드)의 동기화를 위해 Windows는 디스패처 객체(dispatcher object)를 제공한다. 이를 통해 스레드는 뮤텍스 락·세마포어·이벤트·타이머 등 여러 메커니즘으로 동기화한다.
- 뮤텍스: 데이터에 접근하려면 소유권을 얻고, 끝나면 놓는다.
- 세마포어: 6장의 정의 그대로 동작.
- 이벤트(event): 조건 변수와 유사 — 원하는 조건이 발생하면 대기 스레드에게 통지.
- 타이머: 지정 시간이 지나면 하나(또는 그 이상)의 스레드에게 통지.
디스패처 객체는 signaled(신호됨) 또는 nonsignaled(비신호) 상태에 있다. signaled = 사용 가능 → 획득 시 블록 안 함. nonsignaled = 사용 불가 → 획득 시도 시 블록.
디스패처 객체 상태와 스레드 상태 사이에는 관계가 있다. nonsignaled 객체에 스레드가 블록하면 그 스레드는 ready→waiting으로 바뀌고 그 객체의 대기 큐에 들어간다. 객체가 signaled로 바뀌면 커널은 대기 스레드를 확인해 하나(또는 그 이상)를 waiting→ready로 옮긴다.
커널이 대기 큐에서 선택하는 스레드 수는 디스패처 객체의 종류에 달렸다. 뮤텍스는 단 한 스레드만 “소유”할 수 있으므로 큐에서 하나만 깨운다. 이벤트는 그 이벤트를 기다리는 모든 스레드를 깨운다. 이 차이가 “정확히 필요한 만큼만 깨워 thundering herd를 줄인다”는 설계 의도다.
4.2 Critical-Section 객체 — 경합 없으면 커널을 건드리지 않는다
critical-section 객체는 사용자 모드 뮤텍스로, 대개 커널 개입 없이 획득·해제된다. 다중 프로세서에서 처음에는 스핀락으로 상대 스레드의 해제를 기다리다가, 너무 오래 회전하면 커널 뮤텍스를 할당하고 CPU를 양보한다. 핵심 효율: 커널 뮤텍스는 경합이 실제로 있을 때만 할당된다 — 실무에서 경합은 매우 드물어 절감 효과가 크다.
“짧게 회전 → 안 되면 커널로”라는 전략은 Linux의 futex(6장에서 본 fast userspace mutex), Java의 biased/adaptive locking, glibc의 PTHREAD_MUTEX_ADAPTIVE_NP와 본질적으로 같다. 이유: 스핀은 락이 곧 풀릴 때 싸고(컨텍스트 스위치 회피), 오래 기다릴 때 비싸다(CPU 낭비). 그래서 “경합이 짧으면 스핀, 길면 블록”이라는 적응형(adaptive) 전략이 모든 현대 락 구현의 공통 패턴이 되었다. 보유 시간을 예측할 수 없으니, 짧은 스핀 후 블록으로 폴백하는 것이 안전한 절충이다.
5커널 내부 동기화 — Linux 📘 OSC 7.2
버전 2.6 이전 Linux는 비선점(nonpreemptive) 커널이었다 — 커널 모드에서 실행 중인 프로세스는 더 높은 우선순위 프로세스가 와도 선점되지 않았다. 지금은 완전 선점형이라, 커널에서 실행 중인 태스크도 선점될 수 있다. Linux는 커널 동기화를 위해 여러 메커니즘을 제공한다.
5.1 원자 정수(atomic integer)
대부분의 아키텍처가 간단한 수학 연산의 원자 버전을 제공하므로, 가장 단순한 동기화 기법은 atomic_t 타입의 원자 정수다. 이 타입에 대한 모든 수학 연산은 인터럽트 없이 수행된다.
atomic_t counter;
int value;
atomic_set(&counter, 5); /* counter = 5 */
atomic_add(10, &counter); /* counter = 15 */
atomic_sub(4, &counter); /* counter = 11 */
atomic_inc(&counter); /* counter = 12 */
value = atomic_read(&counter); /* value = 12 */원자 정수는 카운터처럼 정수 하나만 갱신할 때 락의 오버헤드 없이 효율적이다. 그러나 경쟁에 기여하는 변수가 여럿이면 더 정교한 락이 필요하다.
5.2 뮤텍스·스핀락·세마포어, 그리고 단일 코어의 묘수
커널 내 임계 구역에는 뮤텍스 락(mutex_lock()/mutex_unlock())을 쓴다. 락이 불가능하면 호출 태스크는 sleep 상태로 들어가 소유자가 mutex_unlock()을 호출할 때 깨어난다. Linux는 스핀락과 세마포어(및 둘의 reader–writer 버전)도 제공한다.
SMP(다중 프로세서)에서 기본 락은 스핀락이며, 짧게만 보유하도록 설계됐다. 그러나 단일 코어 기기(예: 단일 프로세싱 코어 임베디드)에서는 스핀락이 부적절하다 — 회전할 다른 코어가 없으니 의미가 없다. 대신 커널 선점을 비활성화/활성화한다.
| 단일 프로세서 | 다중 프로세서 | |
|---|---|---|
| 진입(락 획득 대신) | 커널 선점 비활성화preempt_disable() | 스핀락 획득 |
| 이탈(락 해제 대신) | 커널 선점 활성화preempt_enable() | 스핀락 해제 |
Linux 커널에서 스핀락과 뮤텍스는 모두 비재귀(nonrecursive)다 — 이미 락을 쥔 스레드가 같은 락을 또 얻으려 하면 (먼저 풀지 않는 한) 블록된다.
5.3 preempt_count — 락을 쥐면 선점 금지
커널 선점을 끄고 켜는 방식이 흥미롭다. preempt_disable()·preempt_enable() 두 시스템 콜이 있지만, 태스크가 락을 쥐고 있으면 커널은 선점되지 않아야 한다. 이를 강제하려고 각 태스크의 thread_info 구조에 preempt_count 카운터를 둔다.
/* 락 획득 시 */ preempt_count++;
/* 락 해제 시 */ preempt_count--;
/* 스케줄러 판단 */
if (preempt_count > 0)
/* 이 태스크가 락을 쥐고 있음 → 선점 불가(안전하지 않음) */ ;
else
/* 0 → 선점 가능(미해결 preempt_disable() 없다는 가정 하) */ ;요약: 락(또는 선점 비활성화)은 짧게 보유할 때만 스핀락·선점 비활성화를 쓴다. 길게 보유해야 하면 세마포어나 뮤텍스가 적절하다 — 그쪽은 대기 시 sleep하므로 CPU를 낭비하지 않는다.
5.4 RCU — Read-Copy-Update ⊕ 확장
교재 본문엔 깊이 다루지 않지만, Linux 커널 동기화의 백미는 RCU다. 핵심 통찰: 읽기가 압도적으로 많은 자료구조에서, 독자가 락을 전혀 쓰지 않게 하라.
- 독자(reader): 락 없이 그냥 읽는다(거의 공짜).
rcu_read_lock()은 단지 “선점 금지” 표시일 뿐 실제 락이 아니다. - 기록자(updater): 자료를 제자리에서 고치지 않고, 새 복사본을 만들어 갱신한 뒤 포인터를 원자적으로 바꿔 끼운다(publish).
- 회수(reclaim): 옛 버전은 즉시 free하지 않고, 그것을 읽던 모든 독자가 떠난 뒤(grace period)에 free한다.
이로써 독자는 ABA·use-after-free 걱정 없이 옛 버전을 끝까지 안전히 읽고, 기록자는 독자를 막지 않는다. 6장에서 본 lock-free의 메모리 회수 문제(위험 포인터·태그드 포인터)를 RCU는 “grace period 후 회수”로 우아하게 푼다. 라우팅 테이블·디렉터리 캐시 등 읽기 지배적 커널 구조의 핵심 메커니즘이다.
원자 정수·스핀락·뮤텍스·세마포어·rwlock·RCU·선점 비활성화 — Linux가 이토록 많은 도구를 두는 이유는 “보유 시간 × 코어 수 × 읽기/쓰기 비율”의 조합마다 최적해가 다르기 때문이다. 짧고 SMP면 스핀락, 짧고 단일코어면 선점 비활성화, 길면 뮤텍스/세마포어, 읽기 지배적이면 RCU. “하나의 락이 모두를 지배할 수 없다”가 커널 동기화의 제1원칙이다.
6POSIX 동기화 — 사용자 수준 표준 📘 OSC 7.3
앞 절의 메커니즘은 커널 내부용이라 커널 개발자만 쓴다. 반면 POSIX API는 사용자 수준 프로그래머용으로 특정 OS 커널에 묶이지 않는다(물론 결국 호스트 OS 도구로 구현된다). UNIX·Linux·macOS에서 널리 쓰인다.
6.1 POSIX 뮤텍스 락
뮤텍스는 Pthreads의 기본 동기화 기법이다. pthread_mutex_t 타입을 쓴다.
#include <pthread.h>
pthread_mutex_t mutex;
/* 뮤텍스 생성·초기화 (NULL = 기본 속성) */
pthread_mutex_init(&mutex, NULL);
/* ... */
pthread_mutex_lock(&mutex); /* 불가능하면 소유자의 unlock까지 블록 */
/* 임계 구역 */
pthread_mutex_unlock(&mutex);
/* 모든 뮤텍스 함수: 성공 시 0, 오류 시 0이 아닌 에러 코드 */6.2 POSIX 세마포어 — named와 unnamed
세마포어는 POSIX 표준이 아니라 POSIX SEM 확장에 속한다. POSIX는 두 종류를 정의한다: named(이름 있는)와 unnamed(이름 없는). 본질은 비슷하지만 생성·공유 방식이 다르다.
named 세마포어
#include <semaphore.h>
sem_t *sem;
/* 세마포어 생성·1로 초기화 */
sem = sem_open("SEM", O_CREAT, 0666, 1);
sem_wait(sem); /* = 고전 wait() */
/* 임계 구역 */
sem_post(sem); /* = 고전 signal() */named의 장점: 서로 무관한 여러 프로세스가 같은 이름을 참조하기만 하면 공통 세마포어로 쓸 수 있다. "SEM"이 한 번 만들어지면, 다른 프로세스가 같은 인자로 sem_open()을 부르면 기존 세마포어의 디스크립터를 받는다. Linux·macOS 모두 지원한다.
unnamed 세마포어
#include <semaphore.h>
sem_t sem;
/* (포인터, 공유 수준 플래그, 초깃값) */
sem_init(&sem, 0, 1); /* 플래그 0 = 생성 프로세스의 스레드끼리만 공유 */
sem_wait(&sem);
/* 임계 구역 */
sem_post(&sem);플래그가 0이면 그 세마포어를 만든 프로세스 내부의 스레드끼리만 공유한다. 0이 아니면 공유 메모리 영역에 두어 별개 프로세스 간에도 공유할 수 있다.
6.3 POSIX 조건 변수 — 왜 뮤텍스와 묶나
Pthreads의 조건 변수는 6장의 것과 비슷하게 동작한다. 다만 6장에서는 모니터 안에서 쓰였는데, C에는 모니터가 없으므로 조건 변수를 뮤텍스 락과 짝지어 잠금을 직접 구현한다.
pthread_mutex_t mutex;
pthread_cond_t cond_var;
pthread_mutex_init(&mutex, NULL);
pthread_cond_init(&cond_var, NULL);
/* 조건 a == b 가 참이 될 때까지 대기 */
pthread_mutex_lock(&mutex);
while (a != b) /* while! if 아님 — 아래 주의 */
pthread_cond_wait(&cond_var, &mutex);
pthread_mutex_unlock(&mutex);pthread_mutex_lock(&mutex);
a = b; /* 조건이 참이 되도록 변경 */
pthread_cond_signal(&cond_var); /* 대기 스레드 하나 신호 */
pthread_mutex_unlock(&mutex); /* 이 unlock이 실제로 뮤텍스를 놓음 */흐름: pthread_cond_wait()는 뮤텍스를 자동으로 풀고 대기에 들어간다 — 그래야 다른 스레드가 데이터를 바꿀 수 있다. 신호를 받으면 다시 뮤텍스를 재획득한 뒤 반환한다. 또한 pthread_cond_signal()은 뮤텍스를 풀지 않는다 — 이후의 pthread_mutex_unlock()이 푼다.
while 루프에서 — if는 버그교재는 “조건 검사는 루프 안에 두어 신호 후 조건을 재검사하라”고 명시한다. 이유: ① 가짜 깨어남(spurious wakeup): 신호 없이도 wait가 깨어날 수 있다. ② 도둑맞은 신호(stolen wakeup): 깨어나 뮤텍스를 재획득하는 사이 다른 스레드가 끼어들어 조건을 다시 거짓으로 만들 수 있다. if로 한 번만 검사하면 조건이 거짓인데 진행해 버린다. while로 재검사해야 안전하다 — 이는 6장 모니터의 “signal-and-continue” 의미론에서도 동일하게 요구된다.
7Java 동기화 📘 OSC 7.4
Java는 언어 탄생 때부터 풍부한 스레드 동기화를 지원했다. 먼저 모니터(원조 메커니즘), 다음으로 1.5에서 추가된 ReentrantLock·세마포어·조건 변수를 본다.
7.1 Java 모니터 — synchronized, wait/notify
Java의 모든 객체에는 락 하나가 딸려 있다. 메서드를 synchronized로 선언하면, 그 메서드 호출은 객체의 락 소유를 요구한다. 다른 스레드가 락을 쥐고 있으면 호출 스레드는 블록되어 entry set(락을 기다리는 스레드 집합)에 들어간다.
public class BoundedBuffer<E> {
private static final int BUFFER_SIZE = 5;
private int count, in, out;
private E[] buffer;
public BoundedBuffer() {
count = 0; in = 0; out = 0;
buffer = (E[]) new Object[BUFFER_SIZE];
}
/* 생산자 호출 */
public synchronized void insert(E item) {
while (count == BUFFER_SIZE) { /* 가득 차면 대기 */
try { wait(); }
catch (InterruptedException ie) { }
}
buffer[in] = item;
in = (in + 1) % BUFFER_SIZE;
count++;
notify(); /* 대기 스레드 하나 깨움 */
}
/* 소비자 호출 */
public synchronized E remove() {
E item;
while (count == 0) { /* 비면 대기 */
try { wait(); }
catch (InterruptedException ie) { }
}
item = buffer[out];
out = (out + 1) % BUFFER_SIZE;
count--;
notify();
return item;
}
}락 외에도 모든 객체는 wait set(대기 집합)을 갖는다. 스레드가 wait()를 호출하면: ① 객체 락을 놓고 ② 상태가 blocked가 되며 ③ wait set에 들어간다. 누군가 notify()를 부르면: ① wait set에서 임의의 스레드 T를 골라 ② entry set으로 옮기고 ③ T를 runnable로 만든다. T는 다시 락을 두고 경쟁한다.
wait()로 물러난 곳. notify()는 wait set → entry set으로 옮겨 락 경쟁에 복귀시킨다.락을 쥔 시간이 곧 락의 범위(scope)다. 메서드 전체를 synchronized로 하면 공유 데이터를 안 만지는 코드까지 직렬화돼 범위가 과하게 넓어진다. Java는 블록 동기화를 허용한다 — synchronized(this){ /* 임계 구역 */ }로 꼭 필요한 블록만 보호해 범위를 줄이면 동시성이 올라간다.
7.2 ReentrantLock — try/finally 관용구
가장 단순한 명시적 락. synchronized처럼 한 스레드가 소유하고 상호 배제를 주되, 공정성(fairness) 파라미터(가장 오래 기다린 스레드를 우선) 같은 기능을 더한다. “재진입(reentrant)”인 이유: 이미 락을 쥔 스레드가 lock()을 또 불러도 소유권을 받고 진행한다.
Lock key = new ReentrantLock();
key.lock();
try {
/* 임계 구역 */
}
finally {
key.unlock(); /* 예외가 나도 반드시 해제 */
}lock()을 try 안에 넣으면 안 되는 이유unlock()을 finally에 두는 건 “예외가 나도 락을 푼다”를 보장하기 위함이다. 그런데 lock()을 try 안에 넣고 lock() 자체가 unchecked 예외(예: OutOfMemoryError)를 던지면, finally가 unlock()을 호출하는데 락을 잡은 적이 없으므로 IllegalMonitorStateException이 터진다. 이 새 예외가 원래 실패 원인을 가려버린다. 그래서 lock()은 try 밖에 둔다(checked 예외를 던지지 않으므로 안전). 읽기 전용 다중 스레드엔 ReentrantReadWriteLock(2절의 reader–writer 락)을 쓴다.
7.3 Java 세마포어 · 조건 변수
Semaphore sem = new Semaphore(1); /* 음수 초깃값도 허용 */
try {
sem.acquire(); /* InterruptedException 가능 */
/* 임계 구역 */
}
catch (InterruptedException ie) { }
finally {
sem.release(); /* finally에서 해제 보장 */
}조건 변수는 ReentrantLock의 newCondition()으로 만든다. await()·signal()은 6장 모니터의 wait()·signal()처럼 동작한다. 중요한 차이: 언어 차원의 wait()/notify()는 모니터당 이름 없는 단일 조건 변수뿐이라 “왜 깨어났는지”를 모른다. 반면 Condition 객체는 여러 개의 이름 있는 조건 변수를 만들어 특정 스레드만 골라 깨울 수 있다.
Lock lock = new ReentrantLock();
Condition[] condVars = new Condition[5];
for (int i = 0; i < 5; i++)
condVars[i] = lock.newCondition();
public void doWork(int threadNumber) {
lock.lock();
try {
if (threadNumber != turn) /* 내 차례가 아니면 */
condVars[threadNumber].await(); /* 내 조건변수에서 대기 */
/* ... 잠시 일한다 ... */
turn = (turn + 1) % 5; /* 다음 차례로 */
condVars[turn].signal(); /* 그 스레드만 콕 집어 깨움 */
}
catch (InterruptedException ie) { }
finally {
lock.unlock();
}
}doWork()는 synchronized일 필요가 없다 — ReentrantLock이 이미 상호 배제를 준다. await()는 연관된 락을 풀어 다른 스레드가 들어오게 하고, signal()은 조건 변수만 신호하며 락은 이후 unlock()이 푼다. 이 모든 고급 도구는 java.util.concurrent 패키지에 있으며, 패키지는 그 밖에도 atomic 변수·CAS·동시 컬렉션·executor를 제공한다.
8Rust · Go — 데이터 레이스를 설계로 차단 ⊕ 교재 외 확장
교재는 C/C++(POSIX)·Java를 다룬다. 여기에 Rust(소유권으로 데이터 레이스를 컴파일 타임에 차단)와 Go(채널로 “공유하지 말고 통신”하되 sync 패키지도 제공)를 더해 “같은 임계 구역 문제의 일곱 번째·여덟 번째 답”을 본다.
8.1 Rust — Mutex<T>와 소유권 기반 안전성 ⊕ 확장
Rust의 핵심 통찰: 데이터 레이스는 “공유 + 가변 + 동기화 없음”에서만 생긴다. Rust는 소유권·빌림(borrow) 규칙으로 “공유(여러 참조)와 가변을 동시에”를 컴파일러가 금지한다. 그래서 락이 데이터를 감싸고, 락을 잡아야만 데이터에 닿을 수 있다 — 깜빡하고 락 없이 접근하는 것 자체가 컴파일되지 않는다.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
// 데이터를 Mutex가 감싸고, Arc로 스레드 간 공유 소유권
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..10 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
let mut num = c.lock().unwrap(); // 락 획득 → &mut 0 데이터 접근
*num += 1;
})); // num(MutexGuard)이 스코프 끝에서 drop → 자동 unlock (RAII)
}
for h in handles { h.join().unwrap(); }
println!("result = {}", *counter.lock().unwrap()); // 항상 10
}Rust는 두 마커 트레잇으로 동시성 안전을 타입 시스템에 새긴다. Send = 소유권을 다른 스레드로 옮겨도 안전. Sync = 여러 스레드가 참조를 공유해도 안전. Mutex<T>는 T: Send이면 Sync가 되어 공유 가능하지만, Rc(비원자 참조 카운트)는 Send가 아니라 스레드 간 이동을 컴파일 거부한다. 즉 “락 없이 가변 공유”나 “스레드 안전하지 않은 타입의 공유”는 실행 전에 막힌다 — 6장의 데이터 레이스 = UB를, Rust는 컴파일 에러로 끌어올린다.
8.2 Go — 채널과 sync 패키지 ⊕ 확장
Go의 표어: “메모리를 공유해 통신하지 말고, 통신해서 메모리를 공유하라.” 상태를 한 goroutine에 가두고 채널(channel)로 주고받으면 락이 필요 없다 — 데이터 레이스를 설계로 차단한다.
func producer(buf chan<- int) {
for i := 0; i < 100; i++ {
buf <- i // 버퍼가 가득 차면 자동 블록 (= empty 세마포어)
}
close(buf)
}
func consumer(buf <-chan int, done chan<- bool) {
for item := range buf { // 비면 자동 블록 (= full 세마포어), 닫히면 종료
process(item)
}
done <- true
}
func main() {
buf := make(chan int, 5) // 용량 5인 버퍼 채널 = n=5 유한 버퍼
done := make(chan bool)
go producer(buf)
go consumer(buf, done)
<-done
}버퍼 채널이 곧 1절의 유한 버퍼다 — 용량이 empty·full 세마포어 역할을 자동으로 한다. 그러나 Go도 공유 상태가 필요할 때를 위해 sync 패키지(Mutex·RWMutex·WaitGroup·Once)와 sync/atomic을 제공한다.
type Counter struct {
mu sync.Mutex
n int
}
func (c *Counter) Inc() {
c.mu.Lock()
defer c.mu.Unlock() // defer = 함수 반환 시 자동 해제 (Java finally 격)
c.n++
}
// 데이터 레이스 검출: go run -race (런타임에 레이스를 잡아 보고)| POSIX/C | Java | Rust | Go | |
|---|---|---|---|---|
| 레이스 차단 시점 | 런타임(개발자 책임) | 런타임 | 컴파일 타임 | 설계+-race 런타임 |
| 락 해제 | 수동 unlock | finally / synchronized | RAII(drop) 자동 | defer 자동 |
| 주 패러다임 | 공유 메모리+락 | 공유 메모리+모니터 | 소유권+락 | 채널(CSP)+락 병용 |
9대안적 접근 · 요약 · 복습 📘 OSC 7.5–7.6
멀티코어가 보편화되며, 락·세마포어·모니터의 전통적 방식 외에 데이터 레이스와 데드락을 근본적으로 피하는 언어·하드웨어 기능이 떠올랐다.
9.1 트랜잭셔널 메모리 (Transactional Memory)
데이터베이스 이론에서 온 개념이다. 메모리 트랜잭션은 원자적인 메모리 읽기–쓰기 연산의 시퀀스다. 모든 연산이 끝나면 커밋(commit)되고, 아니면 중단·롤백(abort & rollback)된다. atomic{S} 구문으로 표현하면, 개발자는 락의 순서·획득을 신경 쓸 필요가 없다.
/* 전통적 락 — 데드락·확장성 문제 */
void update() {
acquire();
/* 공유 데이터 수정 */
release();
}
/* 트랜잭셔널 메모리 — 시스템이 원자성 보장, 락 없음 → 데드락 불가 */
void update() {
atomic {
/* 공유 데이터 수정 */
}
}장점: 원자성을 개발자가 아니라 TM 시스템이 보장하고, 락이 없으니 데드락이 불가능하며, TM 시스템이 동시 실행 가능한 부분(예: 공유 변수 동시 읽기)을 자동 식별한다.
- STM(Software TM): 순수 소프트웨어. 컴파일러가 트랜잭션 블록 안에 계측 코드를 삽입해, 어디가 동시 실행 가능하고 어디에 저수준 락이 필요한지 관리한다. 특수 하드웨어 불필요.
- HTM(Hardware TM): 캐시 계층과 캐시 일관성 프로토콜(MESI 등)을 이용해 서로 다른 프로세서 캐시에 있는 공유 데이터의 충돌을 감지·해소한다. 계측 코드가 없어 STM보다 오버헤드가 적지만, 캐시 구조·코히어런스 프로토콜의 수정이 필요하다.
TM은 오래 연구됐지만 광범위한 구현은 더뎠다. 멀티코어와 병렬 프로그래밍의 부상으로 학계·상용 양쪽에서 연구가 활발하다.
HTM은 Intel TSX(RTM/HLE)로 상용화됐으나, 트랜잭션이 일정 크기를 넘거나 인터럽트가 끼면 반드시 abort되어 “폴백 경로(락)”가 항상 필요하다 — 즉 HTM은 best-effort이고 보증된 진행은 락 폴백이 책임진다. 또 사이드채널(예: TAA) 문제로 일부 CPU에선 비활성화됐다. STM은 GCC의 실험적 지원·Haskell의 STM 모나드(여기선 타입으로 “트랜잭션 안에서만 트랜잭션 변수 접근”을 강제해 STM이 가장 우아하게 산다)·Clojure의 ref가 대표적이다. 교훈: TM은 락을 “지운다”기보다 “낙관적 경로 + 비관적 폴백”으로 감춘다.
9.2 OpenMP
OpenMP는 공유 메모리 환경의 병렬 프로그래밍을 위한 컴파일러 지시문 + API다(4장 참조). #pragma omp parallel 뒤의 코드는 코어 수만큼의 스레드로 병렬 실행된다. 동기화를 위해 #pragma omp critical을 제공한다.
void update(int value) {
#pragma omp critical
{
counter += value; /* 한 번에 한 스레드만 이 블록 안에서 활성 */
}
}
/* critical 섹션이 여럿이면 이름을 붙여
#pragma omp critical(name) 으로 구분 — 같은 이름끼리만 상호 배제 */critical 지시문은 이진 세마포어/뮤텍스처럼 동작한다. 일반 뮤텍스보다 쓰기 쉽다는 장점이 있지만, 단점도 분명하다: 개발자가 여전히 경쟁 조건을 직접 식별해 보호해야 하고, critical 섹션이 둘 이상이면 데드락도 여전히 가능하다.
단일 변수 갱신이면 #pragma omp critical보다 #pragma omp atomic(하드웨어 원자 명령에 직접 매핑 — 더 가벼움)이 낫고, 누산이면 reduction(+:sum)(스레드별 사적 누산 후 최종 병합 — 경합 자체가 없음)이 가장 빠르다. “임계 구역을 만들기 전에, 임계 구역이 정말 필요한지부터 물어라.”
9.3 함수형 프로그래밍 언어 — 불변성
C·C++·Java·C#은 명령형(imperative) 언어로, 상태를 변수로 표현하고 그 상태가 가변(mutable)이다 — 데이터 레이스의 원천이다. 반면 함수형 언어는 상태를 유지하지 않는다: 변수에 한 번 값이 정해지면 불변(immutable)이라 바꿀 수 없다.
가변 상태가 없으니 경쟁 조건·데드락을 걱정할 필요가 없다 — 이 장에서 다룬 거의 모든 문제가 함수형 언어에선 애초에 존재하지 않는다. Erlang(동시성 지원과 병렬 시스템 개발 용이성으로 주목)과 Scala(함수형이면서 객체지향, Java·C#과 유사한 문법)가 대표적이다.
이 장을 관통하는 한 문장: 동기화 문제는 “공유(shared) + 가변(mutable) 상태”에서만 생긴다. 각 접근법은 이 둘 중 하나를 공격한다 — 락/모니터는 가변 접근을 직렬화하고, TM은 낙관적으로 시도 후 충돌 시 롤백하며, 채널·액터는 공유를 없애고, 함수형·불변성은 가변을 없앤다. Rust는 타입 시스템으로 “공유+가변 동시”를 금지한다. 도구가 다른 게 아니라, 같은 적의 다른 급소를 친다.
9.4 흔한 오해 바로잡기
- ❌ “유한 버퍼는 mutex 하나면 된다.” → 버퍼 일관성용
mutex외에, 빈칸/채운칸 흐름 제어용empty·full계수 세마포어가 따로 필요하다. - ❌ “readers–writers 해법은 공정하다.” → 첫째 변형은 기록자를, 둘째 변형은 독자를 굶긴다. 기아 없는 변형은 별도 설계.
- ❌ “데드락만 없으면 안전하다.” → 식사 철학자 모니터 해법처럼 데드락 프리여도 기아는 가능하다.
- ❌ “조건 변수는
if로 검사해도 된다.” → 가짜 깨어남·도둑맞은 신호 때문에 반드시while루프로 재검사. - ❌ “스핀락이 항상 빠르다.” → 단일 코어에선 무의미(회전할 상대 없음). Linux는 그때 선점 비활성화로 대체한다.
- ❌ “Windows critical-section은 항상 커널을 부른다.” → 경합이 없으면 사용자 모드에서 끝나고, 오래 회전할 때만 커널 뮤텍스를 할당한다.
- ❌ “트랜잭셔널 메모리는 락을 완전히 없앤다.” → HTM은 best-effort라 락 폴백 경로가 항상 필요하다.
9.5 한 장 정리
🎯 7장 핵심
- 고전 3문제: 유한 버퍼(empty·full·mutex 세마포어), readers–writers(첫째=기록자 기아, 둘째=독자 기아), 식사 철학자(순환 대기 → 데드락). 6장 도구로 푼다.
- 식사 철학자 해법: 인원 제한·원자적 집기·비대칭·자원 계층·중재자. 모니터 해법은 데드락 프리지만 기아는 남는다.
- Windows: 디스패처 객체(signaled/nonsignaled), 이벤트, critical-section 객체(경합 시에만 커널 뮤텍스). 스핀락은 짧게, 보유 중 선점 금지.
- Linux: 원자 정수·스핀락·뮤텍스·세마포어·RCU. SMP면 스핀락, 단일코어면 선점 비활성화,
preempt_count로 락 보유 중 선점 차단. 완전 선점형 커널. - POSIX: 뮤텍스, named/unnamed 세마포어, 조건 변수(뮤텍스와 짝,
while재검사 필수). - Java: 모니터(synchronized·wait/notify, entry set·wait set) + ReentrantLock(try/finally)·Semaphore·Condition(이름 있는 다중 조건변수)·
java.util.concurrent. - Rust·Go(확장): Rust는 소유권·Send/Sync로 레이스를 컴파일 타임 차단, Go는 채널(CSP)로 공유를 없애되
sync도 제공. - 대안: 트랜잭셔널 메모리(STM/HTM, 낙관적+롤백), OpenMP(critical/atomic), 함수형(불변성으로 문제 자체 소멸).
- 관통 원리: 모든 동기화는 “공유 + 가변 상태”라는 같은 적의 다른 급소를 친다.
9.6 복습 — 답을 가리고
Q1. 유한 버퍼에서 생산자가 wait(mutex)를 wait(empty)보다 먼저 하면 무슨 일이 생기나?
버퍼가 가득 찬 상태에서 생산자가
mutex를 쥔 채empty를 기다리고, 소비자는 그mutex를 못 얻어 항목을 꺼낼 수 없다 → 데드락. 흐름 제어 세마포어(empty/full)를 항상 mutex보다 먼저 획득해야 한다.
Q2. 첫째 readers–writers 해법에서 rw_mutex는 누가 잠그고 누가 푸는가?
첫 번째 독자(read_count가 1이 될 때)가
rw_mutex를 잠그고, 마지막 독자(read_count가 0이 될 때)가 푼다. 중간 독자들은 건드리지 않아 다중 독자 동시 읽기가 가능하다. 단점: 기록자가 굶을 수 있다.
Q3. 식사 철학자 “각자 왼쪽 젓가락”이 왜 데드락인가? 해법 하나를 들라.
다섯이 동시에 왼쪽을 집으면 모든 젓가락이 점유돼 각자 오른쪽을 영원히 기다린다(순환 대기). 해법 예: 자원 계층(항상 낮은 번호 젓가락부터) 또는 비대칭(홀짝이 반대 순서)으로 사이클을 끊거나, 모니터로 양쪽 다 가능할 때만 집는다.
Q4. Windows 디스패처 객체의 signaled / nonsignaled 차이는?
signaled = 사용 가능, 획득 시 블록 안 함. nonsignaled = 사용 불가, 획득 시도 시 스레드가 블록(ready→waiting)된다. 뮤텍스를 해제하면 signaled, 획득하면 nonsignaled로 전이한다.
Q5. Linux 단일 코어 기기에서 스핀락 대신 무엇을 쓰며 왜인가?
커널 선점 비활성화/활성화(
preempt_disable/enable)를 쓴다. 단일 코어에선 회전(spin)할 다른 코어가 없어 스핀락이 무의미하고 CPU만 낭비하기 때문이다.
Q6. POSIX 조건 변수에서 조건을 if가 아니라 while로 검사해야 하는 이유 두 가지.
① 가짜 깨어남: 신호 없이도
wait가 깨어날 수 있다. ② 도둑맞은 신호: 깨어나 뮤텍스를 재획득하는 사이 다른 스레드가 조건을 다시 거짓으로 만들 수 있다.while로 재검사하지 않으면 조건이 거짓인데 진행해 버린다.
Q7. Java의 entry set과 wait set의 차이는?
entry set은 객체 락을 처음 얻으려고 기다리는 스레드 집합. wait set은 락은 가졌으나 조건이 안 맞아
wait()로 물러나 통지를 기다리는 집합.notify()는 wait set의 스레드를 entry set으로 옮겨 락 경쟁에 복귀시킨다.
Q8. val이 원자 정수일 때 다음 연산 후 값은? atomic_set(&val,10); atomic_sub(8,&val); atomic_inc(&val); atomic_inc(&val); atomic_add(6,&val); atomic_sub(3,&val);
10 − 8 = 2, +1 = 3, +1 = 4, +6 = 10, −3 = 7. (연습문제 7.6)
Q9. STM과 HTM의 차이, 그리고 HTM의 현실적 한계는? ⊕
STM은 컴파일러가 트랜잭션 블록에 계측 코드를 넣어 소프트웨어만으로 구현(특수 HW 불필요). HTM은 캐시 계층 + 코히어런스 프로토콜로 충돌을 감지(계측 없음, 오버헤드 적음, 단 HW 수정 필요). HTM은 best-effort라 크기 초과·인터럽트 시 abort되므로 항상 락 폴백 경로가 필요하다.
Q10. Rust와 Go가 데이터 레이스를 막는 근본 전략의 차이는? ⊕
Rust는 소유권·빌림 규칙과 Send/Sync 트레잇으로 “공유+가변 동시” 또는 “스레드 안전하지 않은 타입의 공유”를 컴파일 타임에 거부한다. Go는 채널(CSP)로 상태 공유 자체를 피하는 것을 권장하되, 필요하면
sync.Mutex와-race런타임 검출기를 제공한다.
9.7 연관 자료 · 더 깊이
본문은 같은 OS 코스의 6장(동기화 도구)과 8장(교착 상태)을 자주 참조한다. “6장” 참조는 이 장이 쓰는 세마포어·모니터·조건 변수의 정의가 있는 곳(06.html), “8장” 참조는 식사 철학자 데드락의 네 조건과 예방·회피·탐지·회복을 다루는 곳(08.html)이다. “N절” 참조(예: 6절·8절)는 이 페이지 안의 앵커(#s6·#s8 등)로 이동한다. 인접 장 페이지가 준비되기 전까지, 동기화·메모리 일관성의 하드웨어 측면은 4장(스레드와 동시성)의 메모리 모델·캐시 절로 보강하라.
Silberschatz·Galvin·Gagne, Operating System Concepts 10e, Ch.7 · Solomon & Russinovich, Inside Microsoft Windows 2000(Windows 동기화) · Love, Linux Kernel Development 3e(스핀락·세마포어·RCU) · McKenney, Is Parallel Programming Hard…(RCU·메모리 배리어, 무료) · Herlihy & Shavit, The Art of Multiprocessor Programming(TM·lock-free) · Goetz et al., Java Concurrency in Practice · Klabnik & Nichols, The Rust Programming Language(Ch.16 동시성) · A. A. A. Donovan & B. W. Kernighan, The Go Programming Language(Ch.9 동시성).