하드웨어 동기화와 뮤텍스
Test-and-Set과 CAS 같은 원자 명령에서 스핀락과 뮤텍스가 구성되는 원리와 각 도구의 비용을 비교합니다.
임계 영역 문제를 해결하려면, 한 번에 하나의 스레드만 진입을 보장하는 메커니즘이 필요합니다.
소프트웨어 알고리즘(Peterson's 등)으로도 가능하지만, 현대 CPU는 메모리 재정렬과 다코어 캐시 일관성 때문에 소프트웨어 방법만으로는 불충분합니다.
그래서 CPU가 원자적(atomic) 명령어를 하드웨어 수준에서 제공하고, 이를 기반으로 뮤텍스, 세마포어 등의 동기화 도구가 구축됩니다.
다중 코어의 상호 배제는 읽기와 쓰기를 한 사건으로 묶어 승자 하나를 정한 뒤, 패자가 기다릴 비용을 스핀과 수면 중에서 선택하는 문제다.
- 1. 경쟁 감지
read + write 여러 코어의 갱신이 겹쳐 공유 값이 깨질 수 있다.
- 2. 원자적 승자 결정
TAS · CAS 비교와 교체를 한 사건으로 묶어 한 스레드만 들인다.
- spinlock · CPU에서 대기
짧음 busy wait 곧 풀릴 락이면 문맥 교환 비용보다 반복 확인이 싸다.
- mutex · 잠들어 대기
김 sleep + wake 대기가 길면 스레드를 재워 CPU를 다른 작업에 넘긴다.
인터럽트 비활성화
가장 원시적인 방법입니다.
임계 영역에 진입하기 전에 인터럽트를 비활성화하면, 타이머 인터럽트가 발생하지 않아 선점되지 않습니다.
disable_interrupts();
// 임계 영역
enable_interrupts();이 방법은 커널 내부의 짧은 임계 영역에서만 사용됩니다.
문제가 많기 때문입니다.
인터럽트가 비활성화된 동안 I/O 완료, 타이머 등 모든 이벤트가 처리되지 않습니다.
멀티코어에서는 효과가 없습니다 — 한 코어의 인터럽트를 비활성화해도 다른 코어는 영향을 받지 않습니다.
그리고 사용자 프로세스에게 인터럽트 비활성화 권한을 주면, 악의적인 프로세스가 CPU를 영원히 독점할 수 있습니다.
하드웨어 원자적 명령어
현대 CPU는 여러 메모리 연산을 단일 하드웨어 명령어로 수행하는 원자적 명령어를 제공합니다.
원자적이란 중간에 끊어지지 않음을 의미합니다.
다른 코어가 끼어들 수 없습니다.
Test-and-Set (TAS)
값을 읽고, 새 값을 쓰는 두 동작을 쪼갤 수 없는 하나의 연산으로 수행합니다.
/* 하드웨어가 원자적으로 수행 */
bool test_and_set(bool *target) {
bool old = *target;
*target = true;
return old;
}반환값이 false이면 이전에 잠겨있지 않았고, 지금 잠겼다 → 락 획득 성공.
반환값이 true이면 이미 잠겨있었다 → 락 획득 실패.
x86에서는 XCHG 명령어가 이 역할을 합니다.
ARM에서는 LDREX/STREX 쌍을 사용합니다.
Compare-and-Swap (CAS)
기대되는 값과 현재 값이 같으면 새 값으로 교체하는 원자적 연산입니다.
TAS보다 범용적입니다.
/* 하드웨어가 원자적으로 수행 */
bool compare_and_swap(int *value, int expected, int new_value) {
if (*value == expected) {
*value = new_value;
return true; /* 성공 */
}
return false; /* 실패: 다른 스레드가 먼저 변경 */
}CAS가 실패하면 다른 스레드가 먼저 값을 변경했다는 의미입니다.
보통 루프를 돌면서 재시도합니다.
이것이 CAS 루프(또는 compare-and-swap loop)패턴입니다.
void atomic_increment(int *counter) {
int old_val, new_val;
do {
old_val = *counter;
new_val = old_val + 1;
} while (!compare_and_swap(counter, old_val, new_val));
}x86에서는 CMPXCHG 명령어가 CAS를 수행합니다.
Java의 AtomicInteger.compareAndSet(), C++의 std::atomic::compare_exchange_weak()가 이 명령어를 사용합니다.
CAS와 Lock-Free 프로그래밍
CAS의 강력함은 락 없이도 스레드 안전한 연산이 가능하다는 것입니다.
위의 atomic_increment는 뮤텍스 없이 안전합니다.
이것이 Lock-Free 프로그래밍의 기반입니다.
Lock-Free가 중요한 이유: 뮤텍스를 사용하면, 락을 잡은 스레드가 선점되거나 지연되면 다른 모든 스레드가 블로킹됩니다.
Lock-Free에서는 최소 하나의 스레드가 항상 진행합니다.
Java의 java.util.concurrent.atomic 패키지, C++의 std::atomic, Rust의 std::sync::atomic이 CAS 기반입니다.
ABA 문제
CAS의 알려진 함정입니다.
값이 A → B → A로 변하면, CAS는 값이 여전히 A이므로 변경 없었다고 판단합니다.
하지만 실제로는 변경이 있었습니다.
연결 리스트에서 이것이 문제가 될 수 있습니다.
해결책: 버전 번호를 함께 비교합니다.
Java의 AtomicStampedReference가 이 방법을 사용합니다.
CAS 루프는 성공과 실패만 보는 코드처럼 보이지만, 실제로는 읽은 값이 언제까지 유효한가를 계속 확인하는 낙관적 동시성 패턴입니다.
특히 ABA 문제는 값만 비교하면 변경 이력을 놓칠 수 있으므로, 포인터나 노드 참조를 다룰 때는 버전 번호까지 함께 보아야 합니다.
락을 잡지 않는 대신 읽은 값이 그대로인지 확인하고, 틀리면 계산을 버린 뒤 새 값으로 다시 시도한다.
- read old현재 값을 읽음
read old 스레드는 공유 변수의 old 값을 복사하고 그 값을 기준으로 계산한다.
- compute new새 값을 준비
compute new 아직 공유 메모리는 바꾸지 않고 지역 변수에서 결과를 만든다.
- compare기대값을 비교
compare 메모리가 old 그대로이면 new로 교체하고, 아니면 실패한다.
- retry실패하면 반복
retry 다른 스레드가 먼저 바꿨다는 뜻이므로 최신 값부터 다시 읽는다.
- 정상 충돌
A:7 → C:8 ≠ fail A를 읽었는데 메모리가 C로 바뀌어 있으면 CAS는 실패하고 재시도한다.
- ABA 함정
A:7 → B:8 → A:9 A가 B를 거쳐 다시 A가 되면 값 비교만으로는 중간 변경을 놓칠 수 있다.
- 방어책
포인터나 노드 참조처럼 변경 이력이 중요한 값은 값과 버전 번호를 함께 비교해서 A:7이 A:9로 돌아온 상황까지 구분한다.
스핀락
스핀락(Spinlock)은 TAS로 구현하는 가장 단순한 동기화 방법입니다.
typedef struct {
int locked;
} spinlock_t;
void spin_lock(spinlock_t *lock) {
while (test_and_set(&lock->locked)) {
/* 락이 풀릴 때까지 CPU를 태우며 반복 (busy waiting) */
}
}
void spin_unlock(spinlock_t *lock) {
lock->locked = 0;
}락을 얻을 때까지 while 루프를 돌며 기다립니다.
이것을 바쁜 대기(Busy Waiting) 또는 스피닝(Spinning)이라고 합니다.
CPU가 아무 유용한 일을 하지 않으면서 계속 명령어를 실행합니다.
스핀락이 효율적인 경우
스핀락은 무조건 나쁘지 않습니다.
멀티코어 환경에서 임계 영역이 매우 짧을 때 오히려 뮤텍스보다 빠릅니다.
뮤텍스는 락 획득 실패 시 스레드를 슬립시키고, 나중에 깨웁니다.
이 과정에 커널 진입(시스템 콜) + 컨텍스트 스위칭이 필요합니다.
임계 영역이 수십 나노초만 지속된다면, 스레드를 재우고 깨우는 비용이 그냥 스피닝하는 비용보다 큽니다.
Linux 커널 내부의 짧은 임계 영역에서 스핀락이 광범위하게 사용됩니다.
사용자 공간에서는 뮤텍스가 일반적입니다.
스핀락이 위험한 경우
싱글코어에서 스핀락은 무의미합니다.
락을 잡고 있는 스레드가 같은 코어에서 실행되고 있으므로, 스피닝하는 스레드가 CPU를 계속 점유하면 락을 잡고 있는 스레드가 실행되지 못합니다.
결국 타이머 인터럽트로 선점될 때까지 스피닝이 계속됩니다.
뮤텍스 (Mutex)
뮤텍스(Mutex, Mutual Exclusion Lock)는 스핀락의 바쁜 대기 문제를 해결합니다.
락을 얻지 못하면, 스레드를 슬립(Sleep) 상태로 전환하여 CPU를 양보합니다.
락이 풀리면 대기 중인 스레드를 깨웁니다.
이것을 Sleep-and-Wake 방식이라고 합니다.
C — pthread_mutex
#include <stdio.h>
#include <stdlib.h>
#include <pthread.h>
int counter = 0;
pthread_mutex_t lock = PTHREAD_MUTEX_INITIALIZER;
void *increment(void *arg) {
for (int i = 0; i < 1000000; i++) {
pthread_mutex_lock(&lock); /* 락 획득 (대기 가능) */
counter++; /* 임계 영역 */
pthread_mutex_unlock(&lock); /* 락 해제 */
}
return NULL;
}
int main() {
pthread_t t1, t2;
pthread_create(&t1, NULL, increment, NULL);
pthread_create(&t2, NULL, increment, NULL);
pthread_join(t1, NULL);
pthread_join(t2, NULL);
printf("Counter: %d\n", counter); /* 항상 2000000 */
return 0;
}pthread_mutex_lock()은 락을 획득합니다.
이미 다른 스레드가 잡고 있으면, 현재 스레드는 블로킹(슬립)됩니다.
pthread_mutex_unlock()은 락을 해제하고, 대기 중인 스레드 중 하나를 깨웁니다.
Python — threading.Lock
import threading
counter = 0
lock = threading.Lock()
def increment():
global counter
for _ in range(1_000_000):
with lock: # acquire() + 자동 release()
counter += 1
t1 = threading.Thread(target=increment)
t2 = threading.Thread(target=increment)
t1.start(); t2.start()
t1.join(); t2.join()
print(f"Counter: {counter}") # 항상 2000000with lock: 구문은 lock.acquire()를 호출하고 블록이 끝나면(예외가 발생해도) lock.release()를 호출합니다.
C에서 lock/unlock을 직접 호출하면 예외 시 unlock을 놓칠 수 있으므로, Python의 with 문이 더 안전합니다.
뮤텍스 사용 주의 사항
이중 잠금 금지: 같은 뮤텍스를 같은 스레드가 두 번 lock()하면 데드락입니다.
재진입이 필요하면 재진입 뮤텍스(Recursive Mutex)를 사용합니다.
pthread_mutexattr_settype(&attr, PTHREAD_MUTEX_RECURSIVE)로 설정합니다.
최소 범위 잠금: 임계 영역은 최대한 짧아야 합니다.
락 안에서 I/O, 네트워크 호출, 대용량 연산을 하면 다른 스레드가 오래 대기합니다.
락 순서 일관성: 여러 뮤텍스를 사용할 때, 항상 같은 순서로 잡아야 합니다.
A → B 순서로 잡는 곳과 B → A 순서로 잡는 곳이 있으면 데드락이 발생합니다.
7장에서 자세히 다룹니다.
뮤텍스 vs 스핀락 비교
| 특성 | 스핀락 | 뮤텍스 |
|---|---|---|
| 대기 방식 | 바쁜 대기 (CPU 소비) | 슬립 (CPU 양보) |
| 적합한 상황 | 매우 짧은 임계 영역, 멀티코어 | 긴 임계 영역, 일반 용도 |
| 오버헤드 | 락 자체는 낮지만 CPU 낭비 | 커널 전환 비용 |
| 싱글코어 | 비효율적 (의미 없음) | 효율적 |
| 사용 공간 | 주로 커널 내부 | 사용자 공간 + 커널 |
Futex — 두 세계의 장점
lock 값이 비어 있으면 atomic 연산으로 바로 진입하고, 이미 잡혀 있으면 futex wait로 커널 대기열에 들어간다.
- user space
atomic try user space 락 값 먼저 확인
- 성공
fast path 성공 커널 진입 없음
- 실패
slow path 실패 futex wait
- unlock
wake unlock 필요한 대기자 깨움
Linux의 Futex(Fast Userspace Mutex)는 스핀락과 뮤텍스의 장점을 결합합니다.
경합이 없을 때(락이 비어있을 때)는 커널 진입 없이 사용자 공간에서 CAS로 락을 획득합니다(스핀락 수준의 속도).
경합이 있을 때만 커널에 진입하여 스레드를 슬립시킵니다(뮤텍스의 CPU 절약).
pthread_mutex가 내부적으로 futex를 사용합니다.
즉, 개발자가 pthread_mutex_lock()을 호출하면, 경합이 없으면 시스템 콜 없이 즉시 반환되고, 경합이 있으면 futex() 시스템 콜로 슬립합니다.
이것이 현대 뮤텍스가 빠른 이유입니다.
아래 다이어그램은 스핀락, 뮤텍스, futex를 임계 영역 길이와 경합 여부에 따라 고르는 기준을 요약합니다.
하드웨어 원자 명령은 재료이고, 스핀락·뮤텍스·futex는 경합 상황에 맞춘 정책입니다.
- 스핀락
Spin 스핀락 멀티코어에서 임계 영역이 매우 짧으면 재우는 비용보다 반복 대기가 작습니다.
- 뮤텍스
Sleep 뮤텍스 임계 영역이 길거나 I/O가 섞이면 CPU를 양보하고 깨우는 편이 안전합니다.
- futex
Hybrid futex 경합이 없을 때는 사용자 공간 CAS, 경합이 생기면 커널 대기로 전환합니다.
현대 뮤텍스의 성능은 락 획득 실패를 만났을 때 어디까지 내려가는가로 읽으면 이해하기 쉽습니다.
경합이 없으면 사용자 공간 CAS로 끝나고, 짧은 경합은 잠깐 스핀하며 버티고, 임계 영역이 길어지면 커널의 대기 큐로 내려가 슬립합니다.
아래 다이어그램은 스핀락, 뮤텍스, futex가 같은 락 문제를 서로 다른 비용 단계로 나누어 처리하는 모습을 압축해서 보여줍니다.
같은 임계 영역이라도 경합이 없을 때, 짧게 겹칠 때, 오래 막힐 때의 비용이 완전히 다르다.
- 사용자 공간 CAS
syscall 락 값이 비어 있으면 원자적 명령어 한 번으로 바로 진입한다.
- 잠깐 스핀
short wait 다른 코어가 곧 unlock할 가능성이 크면 CPU를 쓰며 짧게 반복한다.
- 커널 대기 큐
futex wait 임계 영역이 길면 스레드를 재워 CPU 낭비를 줄인다.
- 깨어난 뒤 재경쟁
wake retry unlock이 대기자를 깨우면 다시 락 값을 확인하고 획득을 시도한다.
- 스핀락
커널 내부의 매우 짧은 구간처럼 잠드는 비용이 더 클 때 적합하다.
- 뮤텍스
일반 사용자 공간 코드처럼 오래 기다릴 수 있으면 슬립이 안전하다.
- futex
평소에는 CAS로 빠르게 끝내고 경합 때만 커널 도움을 받는다.
다음 절에서는 뮤텍스보다 더 유연한 동기화 도구인 세마포어를 다루겠습니다.