#08 동기화(뮤텍스, 세마포어, 스핀락)
동기화 — 공유 데이터를 동시에 건드리면 무슨 일이 생겨?
프로세스/스레드 파트에서 "멀티스레드는 동기화 문제를 주의해야 한다"고 짧게 언급하고 넘어갔는데, 이번엔 그게 정확히 뭔지 파보자. 결론부터 말하면 — 같은 데이터를 여러 곳에서 동시에 수정하려 할 때 타이밍에 따라 결과가 달라지는 문제야.
데이터 접근의 기본 구조
컴퓨터에서 데이터를 처리하는 구조는 단순해.

데이터를 읽기만 하면 아무 문제 없어. 문제는 연산하고 수정할 때 생겨. 누가 먼저 읽어갔냐에 따라 결과가 달라질 수 있거든.
CPU 레벨에서 Count++ 하나도 사실 세 단계야.
load X, reg1 // 메모리에서 값 읽어옴
inc reg1 // 레지스터에서 +1 연산
store X, reg1 // 결과를 메모리에 저장
이 세 단계 사이에 다른 프로세스가 끼어들면 꼬여.
Race Condition (경쟁 상태)
여러 프로세스/스레드가 동시에 같은 데이터를 조작할 때, 타이밍이나 접근 순서에 따라 결과가 달라지는 상황이야.

Count++이 실행되는 동안 Count--도 연산 이전 데이터를 읽어가면, Count-- 연산이 끝나고 저장할 때 Count++은 반영이 안 된 채로 덮어씌워져버려.
OS에서 Race Condition이 발생하는 세 가지 상황
1) 커널 수행 중 인터럽트 발생

초기값 Counter = 10이라고 해보자.
커널이 Count++ 하는 중에 ①번에서 레지스터로 데이터를 읽어온 상태에서 ②번 인터럽트가 들어와. 인터럽트 핸들러가 Count--를 처리해서 9를 저장해. 그다음 ③번에서 아까 ①에서 읽어온 값(10)에 +1 해서 11을 저장해.
- Expected: Counter = 10 (++, --가 상쇄)
- Unexpected: Counter = 11 (-- 연산이 사라짐)
해결책: 커널 작업이 끝날 때까지 Disable Interrupt 상태로 유지해서 중간에 인터럽트가 끼어들지 못하게 해.
2) 시스템 콜 중 Context Switching 발생

P(a)가 시스템 콜로 커널 모드에서 Count++ 처리 중에 할당 시간이 만료되어 P(b)에게 CPU가 넘어가. P(b)도 Count++을 해. 나중에 P(a)가 CPU를 돌려받아 이어서 실행하면 P(b)의 연산이 반영이 안 돼.
해결책: 커널 모드에서 수행 중일 때는 CPU를 선점(Preempt)하지 않아. 커널 모드 → 유저 모드로 돌아갈 때 선점해. (CPU 할당 시간에 편차가 생길 수 있는 트레이드오프가 있어)
3) 멀티 프로세서에서 shared memory 내의 커널 데이터 접근

CPU가 여러 개일 때는 2번 방법이 먹히지 않아. 각 CPU가 독립적으로 커널 데이터에 동시 접근할 수 있거든.
해결책이 두 가지야.
- 방법 1: 한 번에 하나의 CPU만 커널에 들어갈 수 있도록 커널 전체에 lock/unlock. 비효율적이긴 해.
- 방법 2: 커널 내부의 각 공유 데이터에 접근할 때마다 그 데이터에만 lock/unlock. 더 세밀하고 효율적이야.
면접 포인트 ( •̀ ω •́ )✧
Q. Race Condition이란?
여러 프로세스/스레드가 동시에 같은 데이터를 조작할 때 타이밍이나 접근 순서에 따라 결과가 달라지는 상황. 이를 막으려면 동기화가 필요해.
Critical Section (임계 영역)
Race Condition을 제대로 이해하면 임계 영역 개념이 자연스럽게 나와.
임계 영역은 공유 데이터의 일관성을 보장하기 위해 하나의 프로세스/스레드만 진입해서 실행 가능한 영역이야. 이걸 Mutual Exclusion(상호 배제)이라고 해.

임계 영역 문제를 해결하는 코드의 기본 뼈대는 이래.
do {
entry section // 공유 데이터 접근 전 lock 획득
critical section // 공유 데이터 접근 코드
exit section // 끝나면 lock 해제
remainder section
} while(1);
critical section problem 해결책이 되려면 세 가지 조건을 모두 만족해야 해.
- Mutual Exclusion (상호 배제): 한 번에 하나의 프로세스/스레드만 critical section에서 실행 가능
- Progress (진행): 아무도 critical section에 없으면 들어가고자 하는 프로세스가 진입할 수 있어야 해
- Bounded Waiting (한정 대기): 무한정 기다리는 상황이 생기면 안 돼. 다른 프로세스들의 기아(Starvation)를 막기 위해
면접 포인트 ( •̀ ω •́ )✧
Q. 임계 영역이란? 만족해야 하는 조건은?
하나의 프로세스/스레드만 진입해서 실행 가능한 영역. 세 조건이 모두 만족해야 해 — 상호 배제(Mutual Exclusion), 진행(Progress), 한정 대기(Bounded Waiting).
해결책 — Lock
Mutual Exclusion을 보장하는 방법은 락(Lock)이야. 락을 획득해야 critical section에 진입할 수 있어.
do {
acquire lock
critical section
release lock
remainder section
} while(1);
락을 획득하는 방식에 따라 스핀락, 뮤텍스, 세마포어로 나뉘어.
1. 스핀락 (Spinlock)
락을 가질 수 있을 때까지 루프를 돌면서 계속 재시도하는 방식이야.
volatile int lock = 0; // 0 = 열림, 1 = 잠김
void critical() {
while (test_and_set(&lock) == 1); // lock이 1이면 계속 대기
// ... critical section
lock = 0; // 끝나면 해제
}
TestAndSet은 기존 lock 값을 반환하면서 동시에 lock을 1로 바꾸는 함수야.
int TestAndSet(int* lockPtr) {
int oldLock = *lockPtr; // 1. 현재 lock 값을 저장
*lockPtr = 1; // 2. lock을 무조건 1로 바꿈
return oldLock; // 3. 바꾸기 전 값을 반환
}
① T1: test_and_set(&lock) 호출
→ oldLock = 0 저장
→ lock을 1로 바꿈
→ 0 반환
→ while(0 == 1) → false → 루프 탈출 → critical section 진입!
② T2: test_and_set(&lock) 호출 (T1이 아직 실행 중)
→ oldLock = 1 저장 ← 이미 T1이 1로 바꿔놨으니까
→ lock을 1로 바꿈 ← 이미 1이라 변화 없음
→ 1 반환
→ while(1 == 1) → true → 루프 계속 돌며 대기 (Busy-Waiting)
③ T1: critical section 끝나고 lock = 0으로 해제
④ T2: test_and_set(&lock) 호출
→ oldLock = 0 저장 ← T1이 0으로 풀어줬으니까
→ lock을 1로 바꿈
→ 0 반환
→ while(0 == 1) → false → 루프 탈출 → critical section 진입!
test_and_set이 반환하는 값이 이전 lock 값이야.
- 반환값 0 → 내가 lock을 처음 잡은 것 → 진입 가능
- 반환값 1 → 누군가 이미 lock을 잡고 있었음 → 계속 대기
lock을 1로 바꾸는 건 항상 일어나. 근데 이미 1이면 반환값도 1이라 루프를 탈출 못 해. 처음으로 0을 만나는 스레드만 루프를 탈출할 수 있어.
"두 스레드가 동시에 호출하면 어떻게 돼?" 라는 의문이 생기는데 — CPU atomic 명령어라서 물리적으로 하나씩 순서대로 실행하기 떄문데 동시에 둘 다 0을 보는 상황 자체가 발생하지 않아.
- 실행 중간에 간섭받거나 중단되지 않아
- 같은 메모리 영역에 대해 동시에 실행되지 않아
- 두 개 이상이 동시에 호출해도 CPU 레벨에서 하나씩 순서대로 실행해
단점 — 기다리는 동안 CPU를 계속 점유해서 낭비가 생겨. 이게 Busy-Waiting 상태야.
나는 Java로 많이 공부해서 Java 코드로도 정리해볼게.
import java.util.concurrent.atomic.AtomicBoolean;
public class SpinLock {
private final AtomicBoolean lock = new AtomicBoolean(false);
public void lock() {
// false → true로 바꾸는 데 성공하면 진입
// 이미 true면 계속 루프 → Busy-Waiting
while (!lock.compareAndSet(false, true));
}
public void unlock() {
lock.set(false);
}
}
// 사용
SpinLock spinLock = new SpinLock();
spinLock.lock();
try {
// critical section
} finally {
spinLock.unlock();
}
AtomicBoolean.compareAndSet()이 C++의 TestAndSet에 해당하는 CPU atomic 명령어야. Java가 내부적으로 하드웨어 atomic 연산을 쓰도록 보장해줘.
면접 포인트 ( •̀ ω •́ )✧
Q. TestAndSet이 왜 필요해?
lock 값을 확인하고 변경하는 두 연산 사이에 다른 스레드가 끼어들 수 있어. TestAndSet은 CPU atomic 명령어라서 이 두 연산이 중간에 간섭받지 않고 원자적으로 실행돼. 스핀락, 뮤텍스, 세마포어 모두 내부적으로 atomic 명령어를 써.
2. 뮤텍스 (Mutex)
Mutual Exclusion의 약자야. 스핀락이 락을 얻을 때까지 계속 루프를 도는 것과 달리, 뮤텍스는 락을 얻을 수 없으면 Block(Sleep) 상태로 들어갔다가 깨어나면 다시 시도해.
mutex -> lock();
// ... critical section
mutex -> unlock();
내부적으로 이렇게 동작해.
class Mutex {
int value = 1; // 1 = 획득 가능, 0 = 누군가 사용 중
int guard = 0;
}
// lock은 critical section에 들어가려는 스레드가 직접 호출
Mutex::lock() {
while (test_and_set(&guard)); // guard도 atomic하게 보호
if (value == 0) {
// 현재 스레드를 큐에 넣고 sleep
guard = 0; & go to sleep
} else {
value = 0; // 획득, 다른 스레드 못 들어오게
guard = 0;
}
}
//unlock은 critical section을 끝낸 그 스레드가 직접 호출
Mutex::unlock() {
while (test_and_set(&guard));
if (큐에 대기 중인 스레드 있으면) {
그 중 하나를 깨운다; // Wakeup
} else {
value = 1; // 다시 획득 가능 상태로
}
guard = 0;
}
// 호출 방법
mutex.lock(); // T1이 직접 호출 — "나 들어갈게"
try {
// critical section
} finally {
mutex.unlock(); // T1이 직접 호출 — "나 나왔어, 다음 들어와도 돼"
}
① T1: mutex.lock() 호출
→ test_and_set(&guard) → guard = 0이었으니 0 반환 → while 탈출
→ value == 1 확인 (획득 가능!)
→ value = 0으로 바꿈 (이제 T2는 못 들어옴)
→ guard = 0으로 풀어줌
→ critical section 진입
② T2: mutex.lock() 호출 (T1 실행 중)
→ test_and_set(&guard) → guard = 0이니 0 반환 → while 탈출
→ value == 0 확인 (누군가 쓰고 있음!)
→ T2를 큐에 넣고 Sleep 상태로 들어감
→ guard = 0으로 풀어줌
③ T1: critical section 끝나고 mutex.unlock() 호출
→ test_and_set(&guard) → 0 반환 → while 탈출
→ 큐에 T2가 있음을 확인
→ T2를 깨움 (Wakeup)
→ guard = 0
④ T2: 깨어나서 critical section 진입
→ 작업 완료 후 mutex.unlock() 호출
→ 큐에 아무도 없음
→ value = 1로 복원 (다시 획득 가능 상태)
→ guard = 0
누가 언제 lock/unlock을 해줘?라는 의문이 들 수 있는데 OS나 다른 누군가가 자동으로 해주는 게 아니야. 프로그래머가 코드에 명시적으로 써줘야 해. 그래서 finally에 unlock()을 넣는 게 중요한 거야 — 예외가 발생해도 반드시 해제되도록.
스핀락이랑 뮤텍스의 차이가 여기서 나와
> 스핀락: lock 못 얻으면 → 루프 돌면서 계속 시도 (CPU 점유)
> 뮤텍스: lock 못 얻으면 → 큐에 들어가서 Sleep → unlock한 스레드가 깨워줌
뮤텍스에서 T2를 깨우는 건 T1이야. T1이 unlock()할 때 "큐에 누구 있어?" 확인하고 있으면 깨워주는 구조거든. T2 스스로 다시 깨어나는 게 아니야.
그래서 뮤텍스에서 unlock()을 빠뜨리면 T2는 큐에서 영원히 잠든 상태가 돼. 아무도 깨워주는 스레드가 없으니까. 이게 교착상태(DeadLock)로 이어지는 이유야.
guard도 뭔지 처음엔 헷갈렸어. value 값 자체도 공유되는 데이터라서 경쟁이 생길 수 있거든. guard가 그 value 변경 로직을 보호하는 장치야. guard도 TestAndSet(CPU atomic 명령어)을 써서 보호해.
스핀락 vs 뮤텍스 — 어느 게 더 나아?
뮤텍스가 항상 나은 건 아니야. 멀티 코어 환경이고 critical section 작업이 컨텍스트 스위칭보다 더 빨리 끝난다면 스핀락이 오히려 더 빨라. 큐에 넣고 깨우는 오버헤드보다 그냥 잠깐 루프 도는 게 더 싸기 때문이야.
그럼 Java에서는 이 Block-Wakeup 방식을 어떻게 쓸까?
synchronized 키워드나 ReentrantLock이 뮤텍스 역할을 해.
import java.util.concurrent.locks.ReentrantLock;
ReentrantLock mutex = new ReentrantLock();
// 사용
mutex.lock(); // 락 획득 못하면 Block(Sleep) → 깨어나면 재시도
try {
// critical section
} finally {
mutex.unlock(); // finally에서 해제 — 예외 나도 반드시 해제되도록
}
finally에서 unlock()하는 게 중요해. 예외가 발생해도 락이 반드시 해제되어야 다른 스레드가 진입할 수 있거든. 이걸 빠뜨리면 락이 영원히 안 풀리는 교착상태(DeadLock)가 생겨 — 이건 다음 파트에서 다룰 거야.
synchronized 버전은 더 간단해.
public synchronized void criticalMethod() {
// critical section
// 메서드 끝나면 자동으로 락 해제
}
// 블록 단위로 쓰고 싶으면
synchronized (this) {
// critical section
}
ReentrantLock은 tryLock(), 타임아웃 설정 등 세밀한 제어가 가능하고, synchronized는 코드가 간단해. 복잡한 락 제어가 필요 없으면 synchronized를 쓰는 게 낫고, 세밀한 제어가 필요하면 ReentrantLock을 써.
3. 세마포어 (Semaphore)
스핀락과 뮤텍스는 둘 다 value가 0 또는 1이야. 세마포어는 달라 — value가 정수형이라서 동시에 여러 개의 프로세스/스레드가 공유 자원에 접근하도록 허용할 수 있어.
목적도 다르게 봐야 해.
- 뮤텍스 → 상호 배제 (한 번에 하나만)
- 세마포어 → 공유 자원 접근 수 제어 + 실행 순서 동기화
semaphore -> wait(); // P 연산 — 임계 구역 들어가기 전
// ... critical section
semaphore -> signal(); // V 연산 — 임계 구역 빠져나올 때
내부 동작
class Semaphore {
int value = 1;
int guard = 0;
}
Semaphore::wait() {
while (test_and_set(&guard));
if (value == 0) {
// 현재 스레드를 큐에 넣고 sleep
guard = 0; & go to sleep
} else {
value -= 1;
guard = 0;
}
}
Semaphore::signal() {
while (test_and_set(&guard));
if (큐에 하나라도 대기 중이라면) {
그 중 하나를 깨운다;
} else {
value += 1;
}
guard = 0;
}
세마포어에는 두 가지 종류가 있어.
- Counting Semaphore: value > 1. 동시 접근 수를 조절할 수 있어
- Binary Semaphore: value = 1. 뮤텍스처럼 동작해
각각 실행 흐름을 보며 이해해보자.
Counting Semaphore (value = 3) 실행 흐름
동시에 3개까지 접근 허용하는 경우야.
① T1, T2, T3: 각각 wait() 호출
value: 3 → 2 → 1 → 0
셋 다 critical section 진입
② T4: wait() 호출
value == 0 → 큐에 들어가서 Sleep
③ T1: critical section 끝나고 signal() 호출
큐에 T4 있음 → T4 깨움
④ T4: 깨어나서 critical section 진입
실행 흐름 — Binary Semaphore (value = 0, 실행 순서 강제)
value를 0으로 시작하면 실행 순서를 강제할 수 있어. 이게 세마포어만의 핵심 사용법이야.
// P1: task1 → signal()
// P2: task2 → wait() → task3
// 목표: task3은 반드시 task1이 끝난 후에 실행
task1이 먼저 끝나면)
① P1, P2 동시 실행
② P1: task1 완료 → signal() 호출
큐에 아무도 없음 → value = 0 + 1 = 1
③ P2: task2 완료 → wait() 호출
value == 1 → 통과! → value = 1 - 1 = 0
④ P2: task3 실행
task2가 먼저 끝나면)
① P1, P2 동시 실행
② P2: task2 완료 → wait() 호출
value == 0 → 큐에 들어가서 Sleep
③ P1: task1 완료 → signal() 호출
큐에 P2 있음 → P2 깨움
← 여기서 P1이 P2의 락을 풀어줘!
④ P2: 깨어나서 task3 실행
어떤 경우든 task3은 반드시 task1 완료 후에 실행돼. 이게 세마포어로 실행 순서를 강제하는 방식이야.
뮤텍스와 결정적 차이 — Signaling 메커니즘
경우 2에서 중요한 게 있어. wait()은 P2가 호출했는데, signal()은 P1이 호출했어.
뮤텍스: lock()한 스레드가 반드시 unlock()도 해야 해
세마포어: wait()한 스레드를 다른 스레드가 signal()로 깨워도 돼
그래서 세마포어를 Signaling 메커니즘이라고 불러. 단순히 자원을 잠그고 푸는 게 아니라 "나 끝났어, 다음 해도 돼"라는 신호를 주고받는 용도로 쓸 수 있어.
처음 봤을 때 "그럼 엉뚱한 스레드가 signal을 마구 보내면 어떡해?" 싶었는데 — 세마포어 자체가 막아주는 건 아니야. 프로그래머가 의도적으로 설계해서 쓰는 도구인 거야. 그래서 잘못 쓰면 뮤텍스보다 버그가 생기기 쉬워.
Java에서는 이렇게 써.
import java.util.concurrent.Semaphore;
// Counting Semaphore — 동시 접근 3개 허용
Semaphore semaphore = new Semaphore(3);
semaphore.acquire(); // wait(P 연산) — value--, 0이면 block
try {
// critical section
} finally {
semaphore.release(); // signal(V 연산) — value++, 대기 스레드 깨움
}
실행 순서 강제할 때는 이렇게 써.
Semaphore semaphore = new Semaphore(0); // 초기값 0
// 스레드 1
task1();
semaphore.release(); // signal → 스레드 2 깨움
// 스레드 2
task2();
semaphore.acquire(); // wait → task1이 끝날 때까지 block
task3(); // task1 완료 후 실행 보장
초기값을 0으로 두면 스레드 2가 acquire()에서 무조건 block돼. 스레드 1이 release()를 호출해야만 진행할 수 있어. 어떤 순서로 실행되든 task3은 반드시 task1 이후에 실행되는 게 보장돼.
뮤텍스와 헷갈리는 경우가 많아서 다시 한 번 정리하자면,
언제 뭘 써?
- 상호 배제만 필요하면 → 뮤텍스
- 동시 접근 수를 제한해야 하면 → Counting Semaphore
- 특정 작업의 실행 순서를 보장해야 하면 → Binary Semaphore (value=0)
식당 비유로 정리
세 개념이 추상적으로 느껴질 때 이 비유가 제일 와닿았어.
🍽️ 식당 = 임계 영역 (Critical Section)
🧍 손님 = 프로세스/스레드
🔒 가게 문 = lock
💤 대기실 = 큐
👩🍳 직원 = CPU 코어
스핀락 — 식당이 꽉 찼는데 손님이 대기실로 안 가고 문 앞에서 계속 두드려. "자리 났어요? 자리 났어요?" 직원(CPU)은 계속 대답해야 해서 다른 업무를 못 해. Busy-Waiting.
근데 이게 유리한 경우가 있어 — 식사 시간(critical section)이 매우 짧거나, 직원이 여럿(멀티 코어)이라면 대기실 갔다 오는 시간보다 그냥 기다리는 게 빠를 수 있어.
뮤텍스 — 식당이 꽉 찼을 때 손님이 대기실에서 기다려. 자리가 나면 직원이 깨워줘. Block-Wakeup. CPU 낭비가 없어.
세마포어 — 식당이 3자리로 확장됐어. 문 앞에 "남은 좌석: 3"이 표시돼. 입장할 때마다 P(감소), 나갈 때마다 V(증가)를 외쳐. 3명이 동시에 앉을 수 있어.
면접 포인트 ( •̀ ω •́ )✧
Q. 스핀락과 뮤텍스의 차이는?
스핀락은 락을 얻을 때까지 루프를 돌며 CPU를 점유(Busy-Waiting). 뮤텍스는 락을 얻지 못하면 Block(Sleep) 상태로 들어갔다가 깨어나면 재시도. 멀티 코어 + critical section이 짧으면 스핀락이 유리할 수 있어.
Q. 뮤텍스와 세마포어의 차이는?
뮤텍스는 락을 건 스레드만 해제 가능하고 상호 배제가 목적. 세마포어는 다른 스레드도 signal로 해제 가능하고 Signaling 메커니즘으로 실행 순서 동기화에 유리해. value가 정수형이라 동시 접근 수 제어도 가능해.
뮤텍스 vs 이진 세마포어 — 뭐가 달라?
| 뮤텍스 | 세마포어 | |
| lock 해제 | 락을 건 스레드만 해제 가능 | 다른 스레드도 signal로 해제 가능 |
| 상태값 | 0, 1 | 0, 1, 2, ... |
| 목적 | 상호 배제 | 자원 접근 수 제어, 실행 순서 동기화 |
| 속성 | priority inheritance 있음 | 없음 |
정리:
- 상호 배제만 필요하면 → 뮤텍스
- 작업 간 실행 순서 동기화가 필요하면 → 세마포어
동기화에서 핵심은 딱 두 가지야. Race Condition이 왜 생기는지 — CPU 연산이 여러 단계로 나뉘기 때문이고. 세 가지 기법의 차이만 기억해. 셋 중에 뭘 쓸지 고민될 때 이 질문 하나만 던져봐 — '동시에 몇 개가 들어가도 돼?' 하나면 뮤텍스, 여럿이면 Counting Semaphore, 순서가 중요하면 Binary Semaphore.
'Study > [ OS ]' 카테고리의 다른 글
| #10 주소 공간과 가상 메모리 (0) | 2026.07.06 |
|---|---|
| #09 교착상태 (DeadLock) (0) | 2026.07.06 |
| #07 PCB & Context Switching (0) | 2026.07.02 |
| #06 시스템콜 System Call (0) | 2026.06.24 |
| #05 인터럽트 Interrupt (0) | 2026.06.23 |
흐이서
Writer공순이 흐이서
