8a.2 동기화와 메모리 모델 — 실행을 제한하는 계약과 비용
동기화 프리미티브는 가능한 실행의 집합을 좁히는 도구이고, 언어 메모리 모델은 그 도구가 실제로 무엇을 약속하는지 정한 계약서다. 이 문서는 mutex·조건 변수·atomic이 각각 제한하는 것과 청구하는 비용을 세우고, 동기화 없는 플래그 신호가 컴파일러 최적화만으로 무한 대기가 되는 것을 관찰하며, 비경합·경합 상황의 동기화 비용을 실측해 락 범위와 granularity 판단의 근거를 만든다. 관찰 예제는 macOS 26.5.2(Apple M5 Pro 18코어, 캐시 라인 128B), Apple clang 21에서 실행해 확인했다.
학습 목표
- mutex·조건 변수·atomic이 각각 실행 공간을 어떻게 제한하는지 설명하고, 지켜야 할 불변식에 맞는 프리미티브를 선택한다.
- happens-before 관계를 근거로 동기화 유무에 따라 허용되는 실행 결과를 판단한다.
- 비경합 락과 경합 락의 비용 구조를 커널 대기 모델로 설명하고 측정으로 확인한다.
- 락 순서 규율로 데드락을 예방하고, 락 granularity의 트레이드오프를 측정 근거로 판단한다.
배경: 왜 이것이 존재하는가
8a.1은 문제를 세웠다 — 코드 하나가 실행들의 집합을 정의하고, 그중 일부가 불변식을 깬다. 해법의 방향은 하나뿐이다. 불변식을 깨는 실행이 집합에서 배제되도록, 프로그램이 실행 공간을 제한한다.
제한의 수단이 동기화 프리미티브다. 상호 배제는 Dijkstra의 세마포어(1965)에서 출발해 모니터를 거쳐 오늘의 mutex·조건 변수로 이어졌고, 하드웨어의 원자적 명령(compare-and-swap 계열)이 atomic 연산으로 노출됐다. 그런데 프리미티브만으로는 반쪽이다. "락 안에서 쓴 값을 락 밖에서 읽으면 어떻게 되는가", "동기화 없이 쓴 값은 언제 다른 스레드에 보이는가" — 이 질문에 답하려면 언어가 프리미티브의 의미를 명세해야 한다. 그 명세가 메모리 모델이고, C++11이 언어 차원에서 정식화한 뒤(C11, Java는 앞서 JSR-133에서) 주류 언어의 공통 기반이 됐다.
관찰부터 시작하자. 동기화가 "예의상 권장"이 아니라 계약임을 보여 주는 실험이다.
핵심 개념
관찰 — 동기화 없는 신호는 계약 밖이다
한 스레드가 플래그를 기다리고, 메인 스레드가 100ms 뒤에 플래그를 세운다. 동기화는 없다.
// flag_spin.c — 동기화 없는 플래그 폴링 (핵심부)
int ready = 0;
void *waiter(void *arg) {
while (!ready)
; // ready가 1이 되기를 기다린다
printf("루프 탈출: ready = %d, 시작 후 %.1f ms\n", ready, elapsed_ms());
return NULL;
}
int main(void) {
pthread_t t;
pthread_create(&t, NULL, waiter, NULL);
usleep(100 * 1000); // 100ms 뒤에 신호를 보낸다
ready = 1;
pthread_join(t, NULL);
}빌드 방법에 따라 세 가지 다른 프로그램이 나온다.
| 빌드 | 관찰 결과 |
|---|---|
clang -O0 | 루프 탈출: ready = 1, 시작 후 104.2 ms — 기대대로 동작한다 |
clang -O2 | 영원히 종료하지 않는다 |
clang -O2 + ready를 atomic_int로 | 루프 탈출: ready = 1, 시작 후 102.7 ms |
-O2의 어셈블리를 보면 이유가 드러난다.
_waiter: ; clang -O2 출력 (arm64, 일부)
ldr w19, [x8, _ready@PAGEOFF] ; ready를 루프 밖에서 한 번만 읽는다
cbz w19, LBB0_2 ; 0이면 →
...
LBB0_2:
b LBB0_2 ; 무한 루프컴파일러는 ready를 바꿀 수 있는 것이 이 루프 안에 없다고 — data race가 없다는 전제 아래 — 판단하고, 읽기를 루프 밖으로 끌어올렸다. 8a.1에서 세운 계약의 반대면이다: race가 없다는 전제로 최적화할 권리를 받은 컴파일러에게, 동기화 없는 스레드 간 신호는 존재하지 않는 통신이다. 하드웨어 재정렬(챕터 7이 다룬 층)까지 갈 것도 없이 컴파일러 단계에서 이미 깨졌다는 점에 주목하자. "내 CPU는 캐시 일관성이 있으니 결국 보이겠지"라는 추론은 컴파일러라는 층을 건너뛴 것이다.
atomic_int로 바꾸면 왜 되는가. atomic 접근은 컴파일러에게 "이 위치는 다른 스레드와의 통신 채널"이라 선언하는 것이고, 컴파일러는 읽기를 없애거나 끌어올릴 수 없게 되며, 하드웨어에도 필요한 순서 보장을 지시한다. 이 "선언과 보장"의 일반형이 다음 절의 happens-before다.
happens-before — 동기화가 만드는 순서의 그물
메모리 모델의 핵심 개념은 하나다. happens-before는 두 연산 사이의 순서 관계로, 다음 규칙으로 만들어진다.
- 같은 스레드 안에서 프로그램 순서상 앞선 연산은 뒤 연산보다 happens-before다.
- 동기화 연산이 스레드 사이를 잇는다 — mutex의 unlock은 그 뒤를 잇는 lock보다, atomic 쓰기는 그 값을 읽는 atomic 읽기보다,
pthread_create호출은 새 스레드의 시작보다, 스레드의 종료는 그것을 join한 지점보다 happens-before다. - 관계는 이행적이다. A→B이고 B→C이면 A→C다.
이 그물이 보장하는 것: A가 B보다 happens-before이면, A의 쓰기는 B에서 보인다. 그리고 8a.1의 data race 정의가 이 관계로 완성된다 — 같은 위치에 대한 두 접근(하나 이상이 쓰기)이 happens-before로 이어져 있지 않으면 data race다.
위의 플래그 예제를 이 언어로 다시 읽으면: ready = 1(쓰기)과 while (!ready)(읽기) 사이에 어떤 동기화 연산도 없으므로 happens-before가 없고, 따라서 data race이며, 쓰기가 읽기에 보인다는 보장 자체가 없다. atomic 버전은 규칙 2로 관계가 생겨 보장이 성립한다. mutex로 감싼 공유 변수 접근이 안전한 이유도 같다 — 앞선 임계 구역의 쓰기는 unlock→lock 연결을 타고 다음 임계 구역에서 보인다.
언어들의 공통 계약은 8a.1에서 언급한 DRF-SC다: 프로그램에 data race가 없으면, 실행은 순차적 일관성(모든 연산이 하나의 전역 순서로 섞인 것)으로 보인다. 즉 동기화를 올바르게 쓰는 한 interleaving 모델로 추론해도 되고, 재정렬·가시성의 늪은 계약 위반자에게만 열린다. 주요 언어의 대응은 이렇다.
| 언어 | 동기화 도구 | 비고 |
|---|---|---|
| C/C++ | std::atomic/_Atomic, mutex 계열 | data race는 미정의 동작. volatile은 동시성 도구가 아니다 |
| Java | synchronized, volatile, java.util.concurrent | data race여도 미정의는 아니지만 반직관적 값 허용 (JLS 17) |
| Go | channel, sync, sync/atomic | 명세가 "race 있는 프로그램을 추론하려 하지 말라"고 명시 |
| JS | Atomics + SharedArrayBuffer | worker 사이만 해당. 단일 루프 안은 data race 없음 (8a.1) |
mutex — 상호 배제라는 가장 넓은 보장
mutex는 실행 공간을 가장 직접적으로 제한한다. lock과 unlock 사이의 임계 구역은 한 번에 한 스레드만 실행하므로, 임계 구역들끼리는 interleaving이 아예 없다 — 원자적 블록의 순차 실행만 남는다. read-modify-write든 check-then-act든 복합 불변식이든, 불변식을 깨뜨렸다 복원하는 전 구간을 하나의 임계 구역에 넣으면 race condition이 구조적으로 사라진다.
범위 결정이 곧 설계다. 락의 보호 대상은 코드가 아니라 불변식이라는 8a.1의 관점이 여기서 실행 규칙이 된다.
- 너무 좁으면 틀린다. 검사만 잠그고 행동을 밖에 두면 data race는 없어도 race window는 그대로다(8a.1의 쿠폰 사례).
- 너무 넓으면 느리다. 임계 구역은 정의상 직렬 구간이므로, 병렬 실행의 상한을 직접 깎는다. 임계 구역에서 I/O나 외부 호출을 하는 것은 그 지연 전체를 직렬화하는 결정이다.
조건 변수 — 상태를 기다리는 표준 형태
mutex가 "동시에 하지 마라"라면, 조건 변수(condition variable)는 "상태가 될 때까지 기다려라"를 담당한다. 큐가 비어 있으면 소비자는 기다려야 한다 — 락을 쥔 채 폴링하면 생산자가 락을 얻지 못하고, 락을 놓고 sleep 폴링하면 지연과 CPU 낭비를 맞바꾸는 조율 문제가 생긴다. 조건 변수는 "락을 원자적으로 놓고 잠들었다가, 신호를 받으면 락을 다시 쥐고 깨어난다"로 이 문제를 푼다.
// 소비자 — 구조만 보인 조각이다
pthread_mutex_lock(&m);
while (queue_empty(&q)) // if가 아니라 while
pthread_cond_wait(&cv, &m); // 원자적으로: unlock → 대기 → 깨어나며 lock
item = dequeue(&q);
pthread_mutex_unlock(&m);while은 스타일이 아니라 계약이다. POSIX는 pthread_cond_wait가 신호 없이 깨어날 수 있음(spurious wakeup)을 명시하고, 깨어난 시점과 락을 다시 쥔 시점 사이에 다른 스레드가 상태를 바꿨을 수도 있다. 두 경우 모두 "깨어났다"는 "조건이 참이다"를 함의하지 않으므로, 조건은 락을 쥔 채 재검사해야 한다. if로 쓴 조건 변수 대기는 그 자체로 check-then-act 버그다.
세마포어는 "n개까지 동시 허용"이라는 계수형 제한(연결 풀, 동시 요청 상한)에, rwlock은 읽기가 압도적일 때 읽기끼리의 병렬성을 남기는 데 쓰인다. 어느 것이든 판단 기준은 같다 — 지켜야 할 불변식이 무엇이고, 어떤 병렬성까지 허용해도 그것이 유지되는가.
atomic — 한 변수 크기의 임계 구역
atomic 연산은 하드웨어의 원자적 명령(compare-and-swap, fetch-and-add 계열)을 언어 차원으로 노출한 것이다. 단일 변수에 대한 read-modify-write를 락 없이 원자적으로 수행하고, happens-before 관계도 만든다. 8a.1의 JS 카운터를 고친 Atomics.add, 위 플래그를 고친 atomic_int가 이것이다.
한계는 정확히 "단일 변수"에 있다. atomic 두 개를 각각 갱신하는 것은 두 개의 원자적 연산이지 하나의 원자적 묶음이 아니다 — 출금과 입금, 컬렉션과 크기 카운터처럼 여러 위치에 걸친 불변식은 atomic으로 지킬 수 없고 락(또는 트랜잭션)이 필요하다. "atomic으로 바꿨으니 스레드 안전"이라는 문장은 불변식이 한 변수 안에 닫혀 있을 때만 참이다.
락의 비용 — 비경합과 경합은 다른 세계다
프리미티브 선택의 나머지 반은 비용이다. 현대 mutex 구현(Linux futex, macOS os_unfair_lock 계열)의 구조는 두 세계로 나뉜다.
- 비경합(uncontended): lock은 유저 공간의 원자적 CAS 한 번이다. 커널 진입이 없다. 수 ns~수십 ns.
- 경합(contended): CAS가 실패하면 잠시 스핀하다가, 커널에 "이 주소가 바뀌면 깨워 달라"고 등록하고 잠든다. 이것이 챕터 8에서 본 자발적 컨텍스트 스위치다 — 직접 비용(커널 왕복, 마이크로초 단위)에 더해 캐시·TLB 워밍업 상실이라는 간접 비용을 치르고, 깨어날 때 다시 경합할 수도 있다.
즉 "락이 비싼가"는 질문이 성립하지 않고, "이 락이 얼마나 경합하는가"가 올바른 질문이다. 측정하자. 스레드들이 카운터를 각자 1000만 번 증가시키는 세 구현 — mutex 보호, relaxed atomic, per-thread 샤딩(스레드마다 캐시 라인 크기로 정렬된 자기 슬롯만 갱신하고 마지막에 합산) — 을 스레드 수를 바꿔 실행했다. 값은 증가 1회당 평균 비용이다.
| 스레드 수 | mutex | atomic | sharded |
|---|---|---|---|
| 1 (비경합) | 6.5 ns | 1.5 ns | 0.5 ns |
| 4 | 17.7 ns | 5.9 ns | 0.3 ns |
| 16 | 23.3 ns | 5.5 ns | 0.1 ns |
읽는 법이 결과 자체보다 중요하다.
- 비경합 mutex는 6.5ns다. "락은 느리다"는 통념이 상정하는 비용은 대부분 여기 없다. 경합 없는 경로에 있는 락을 제거하는 최적화는 수 ns를 아끼고 정확성 근거를 버리는 거래다.
- 경합이 비용을 만든다. 같은 mutex가 16스레드 경합에서 3.6배가 됐고, 이 워크로드처럼 임계 구역이 극단적으로 짧으면 스핀이 흡수하지만 임계 구역이 길어지면 잠들기 시작하며 컨텍스트 스위치 비용(마이크로초 단위)으로 도약한다. atomic도 경합을 피하지 못한다 — 락은 없지만 같은 캐시 라인을 두고 코어들이 경쟁하므로 라인 핑퐁 비용(1.5→5.9ns)을 치른다.
- 샤딩만 평평하다. 스레드별 슬롯은 공유가 없어 경합 자체가 사라진다. 단, 슬롯들을 캐시 라인 경계로 패딩하지 않으면 논리적으로 분리된 슬롯이 물리적으로 같은 라인에 놓여 false sharing으로 되돌아간다 — 이 함정과 라인 크기 확인 방법은 챕터 7의 실험이 다뤘다.
이 표가 granularity 판단의 원형이다. 경합하는 하나의 락은 쪼개서(fine-grained, 예: 해시 버킷별 락) 경합을 나누거나, 공유 자체를 샤딩으로 없애고 읽기 시점에 합치거나, 설계를 바꿔 공유를 제거한다. 방향마다 복잡도 비용이 붙으므로, 출발점은 언제나 "이 락이 실제로 경합하는가"의 측정이다.
데드락 — 제한이 만드는 새로운 실패
실행 공간을 제한하는 도구는 새로운 실패 모드를 들여온다. 락이 둘 이상이면, 서로가 쥔 락을 기다리는 순환이 가능해진다.
// deadlock.c — 두 스레드가 두 락을 반대 순서로 잡는다 (핵심부)
void *t1(void *_) { void *t2(void *_) {
pthread_mutex_lock(&a); pthread_mutex_lock(&b);
pthread_mutex_lock(&b); pthread_mutex_lock(&a);
... ...이 프로그램을 반복 실행 루프로 돌리면 몇 초 안에 멈춘다(실측: 3초 내 hang). T1이 a를 쥐고 b를 기다리는 순간 T2가 b를 쥐고 a를 기다리면, 어느 쪽도 진행할 수 없다 — 대기 그래프(누가 누구를 기다리는가)에 순환이 생긴 것이다. race와 마찬가지로 타이밍 의존이라 "지금까지 안 났다"는 부재의 증거가 아니다.
실무 대응은 예방이 기본이다. 모든 코드 경로가 락을 같은 전역 순서로 획득하면 대기 순환은 구조적으로 불가능하다(순서상 뒤의 락을 쥔 채 앞의 락을 기다리는 상황이 생기지 않는다). 락 순서를 문서화하고 리뷰에서 강제하는 것이 규율의 실체다. 진단 쪽은 단순한 편이다 — 데드락은 race와 달리 증상이 멈춘 채 지속되므로, hang 상태의 프로세스에서 스레드 덤프를 뜨면(디버거, jstack 등) 서로를 기다리는 락 대기가 그대로 보인다. 참고로 DB는 같은 문제를 다르게 푼다 — 트랜잭션의 락 획득 순서를 통제할 수 없으므로 대기 그래프의 순환을 탐지해 한쪽을 죽이는 쪽을 선택했다(챕터 11의 deadlock 처리).
실무 관점
프리미티브 선택의 순서
선택지를 성능 순으로 훑는 것이 아니라 불변식에서 출발한다.
- 공유를 없앨 수 있는가. 불변 데이터, 메시지로 소유권 이전, per-thread 상태 후 합산. race도 락도 없는 구간이 가장 좋은 구간이다.
- 불변식이 한 변수에 닫히는가. 그렇다면 atomic이 후보다. 카운터·플래그·참조 교체가 전형이다.
- 여러 위치에 걸치는가. 락으로 묶는다. 임계 구역은 불변식 복원까지, 그러나 I/O는 밖으로.
- 상태를 기다려야 하는가. 조건 변수(또는 언어의 상응물)를 쓰고, 조건 재검사는
while로 한다.
이 순서에서 lock-free 자료구조가 없다는 점이 의도다. lock-free는 "락 없이 빠르게"가 아니라 특정 진행 보장(어떤 스레드가 멈춰도 전체는 전진한다)을 위한 기술이고, 그 대가로 ABA 문제·메모리 회수 같은 미정의 동작 지뢰밭 위의 설계를 요구한다. 필요한 상황(신호 핸들러, 극단적 꼬리 지연 요구)은 드물고, 그때도 직접 구현이 아니라 검증된 라이브러리(언어 표준의 concurrent 컬렉션 등)를 쓰는 것이 기본값이다.
통념 검증: "double-checked locking으로 락 비용을 아끼자"
지연 초기화에서 락 비용을 아끼려는 고전 패턴이다.
if (instance == null) { // 1차 검사 — 락 없이
synchronized (lock) {
if (instance == null) // 2차 검사 — 락 안에서
instance = new Instance();
}
}이 패턴은 두 층의 함정을 한 번에 보여 준다. 첫째, 1차 검사의 읽기와 초기화 쓰기 사이에 happens-before가 없으면 data race다 — Java에서는 instance가 volatile이 아니면 부분 생성된 객체가 보일 수 있고, C++에서는 미정의 동작이다(현대 C++의 정답은 이 패턴 자체를 static 지역 초기화나 std::call_once로 대체하는 것이다). 둘째, 아끼려던 비용의 크기를 위 표가 말해 준다 — 비경합 락은 수 ns다. 초기화 이후 경로가 정말 뜨거워 측정으로 정당화되는 경우가 아니라면, 이 패턴은 수 ns를 아끼려 메모리 모델의 가장 미묘한 지점에 코드를 세우는 거래다. 언어가 제공하는 검증된 형태(volatile 필드, call_once, 언어 런타임의 지연 초기화)를 쓰는 것이 기본값이어야 한다.
통념 검증: "경합이 심하니 스레드를 늘려 처리량을 올리자"
경합하는 락 앞에서 스레드 추가는 거꾸로 작동한다. 임계 구역은 직렬 구간이므로 처리량의 상한은 "임계 구역 실행 시간의 역수"로 이미 정해져 있고(Amdahl의 법칙의 국소판), 스레드를 늘리면 그 상한은 그대로인 채 경합 대기와 컨텍스트 스위치만 늘어난다. 챕터 8의 자발적 스위치 급증이 이 상황의 커널 지표다. 올바른 지렛대는 스레드 수가 아니라 임계 구역 축소·락 분할·공유 제거다.
락 경합의 진단 경로
경합은 추측하지 말고 관찰한다. 신호는 계층마다 있다 — 애플리케이션 프로파일에서 lock wait 시간, JVM이라면 스레드 덤프의 BLOCKED 상태 누적, 커널 지표로는 CPU 사용률이 낮은데 자발적 컨텍스트 스위치가 높은 조합(챕터 8의 치트시트)이 "락(또는 I/O)을 기다리며 잠드는 스레드"의 시그니처다. 경합이 확인된 뒤에야 granularity 조정이 의미를 가진다.
더 깊이
acquire/release — DRF-SC 아래의 층
이 문서는 atomic을 기본 순서 보장(C++ 용어로 seq_cst)으로 다뤘지만, 명세는 더 약한 순서를 허용한다. acquire/release는 "이 쓰기 이전의 모든 쓰기가, 이 값을 읽은 스레드에 보인다"는 쌍방향 최소 계약으로 happens-before를 만들고, relaxed는 원자성만 남기고 순서 보장을 제거한다(위 벤치마크의 카운터가 relaxed다 — 합계만 맞으면 되고 순서는 무관하므로). 약한 순서는 아키텍처에 따라 실제 비용 차이를 만들지만, 잘못 쓰면 DRF-SC의 보호 밖으로 나간다. 실무 기본값은 명확하다 — 기본 순서로 시작하고, 약한 순서는 측정이 정당화하고 리뷰가 감당할 수 있을 때만 내려간다. 정밀한 정의는 언어 명세(C++ [atomics.order], JLS 17)가 1차 자료다.
같은 문제, 다른 층의 답들
이 챕터가 세운 "불변식을 지키도록 동시 실행을 제한한다"는 문제는 스택의 다른 층에서 반복된다. 커널은 자신의 자료구조를 스핀락과 락 없는 기법으로 지키고, DB는 락과 MVCC로 트랜잭션 격리라는 계약을 판다(챕터 11) — 격리 수준은 정확히 "어떤 interleaving까지 허용할 것인가"의 눈금이다. 분산 시스템은 공유 메모리조차 없는 곳에서 순서와 합의를 만들어야 하며(챕터 10), happens-before의 원형인 Lamport의 논리 시계가 그 출발점이다. 층마다 도구는 다르지만 판단 구조 — 허용할 실행, 배제할 실행, 그 비용 — 는 같다.
정리
- 동기화는 가능한 실행의 집합을 좁히는 일이고, 메모리 모델은 동기화 연산이 만드는 happens-before 관계로 그 보장을 명세한다. 동기화 없는 스레드 간 신호는 계약 밖이다 — 컴파일러 최적화만으로 무한 대기가 되는 것을 실측했다.
- mutex는 임계 구역들의 interleaving을 제거하고, 조건 변수는 상태 대기를 담당하며(
while재검사는 계약이다), atomic은 한 변수에 닫힌 불변식까지만 지킨다. - 락 비용은 경합이 정한다. 비경합 lock은 CAS 한 번(실측 6.5ns), 경합은 캐시 라인 핑퐁을 거쳐 커널 대기(자발적 컨텍스트 스위치)로 도약한다. "락이 느리다"가 아니라 "경합이 느리다"가 정확한 문장이다.
- 데드락은 락 획득 순서의 전역 규율로 예방하고, 스레드 덤프의 대기 순환으로 진단한다.
- granularity·샤딩·lock-free는 경합 측정이 정당화할 때만 의미 있는 선택지이며, 공유 제거가 언제나 첫 번째 후보다.
확인 문제
1. 한 스레드가 큰 구조체를 채운 뒤 done = 1(일반 변수)을 쓰고, 다른 스레드가 while (!done); 후 그 구조체를 읽는 코드가 "지금까지 잘 동작해 왔다". 이 코드의 두 가지 독립적인 위험을 happens-before로 설명하고, 최소 수정을 제시하라.
정답과 해설
첫째, done 자체가 data race라 읽기 스레드가 갱신을 영원히 보지 못할 수 있다 — 본문의 관찰처럼 컴파일러가 읽기를 루프 밖으로 끌어올리면 무한 대기가 된다. 둘째, 설령 done = 1이 보여도 구조체 쓰기들과 읽기 사이에 happens-before가 없으므로 부분적으로만 채워진 구조체를 읽을 수 있다(컴파일러·하드웨어 재정렬 모두 가능). "지금까지 잘 동작"은 특정 컴파일러·최적화 수준·아키텍처의 우연이다. 최소 수정은 done을 atomic으로 바꾸는 것이다 — atomic 쓰기→읽기가 happens-before를 만들고, 그 이행성으로 구조체 쓰기 전부가 읽기에 보이게 된다. 스핀 대신 잠들어야 한다면 mutex+조건 변수로 바꾼다.
2. 16코어 서버의 요청 처리기가 전역 mutex로 보호되는 통계 맵을 요청마다 갱신한다. 프로파일에서 lock wait가 상위에 잡혔다. 후보는 (a) 스레드 수 축소 (b) 맵을 버킷별 락으로 분할 (c) per-thread 맵 후 주기적 병합이다. 각 후보가 본문의 비용 모델에서 무엇을 바꾸는지 설명하고 선택 기준을 제시하라.
정답과 해설
(a)는 경합자 수를 줄여 대기와 컨텍스트 스위치를 완화하지만 직렬 구간(임계 구역) 자체는 그대로라 근본 상한이 남고, 요청 처리 전체의 병렬성도 함께 줄인다. (b)는 하나의 락을 여러 락으로 나눠 경합 확률을 버킷 수만큼 낮춘다 — 키 분포가 고르면 효과적이지만 핫 키가 있으면 그 버킷이 다시 전역 락이 된다. (c)는 공유 자체를 제거해 갱신 경로의 경합을 0으로 만든다(본문 벤치마크의 sharded가 평평했던 이유). 대가는 병합 지연만큼 통계가 늦고, per-thread 슬롯의 캐시 라인 패딩과 병합 로직이라는 복잡도가 붙는다. 선택 기준: 통계의 실시간성 요구가 느슨하면 (c)가 구조적으로 우월하고, 즉시 일관된 조회가 필요하면 (b)를 키 분포 측정과 함께 적용한다. (a)는 경합 완화가 아니라 용량 조정 수단으로 봐야 한다.
3. 서비스가 간헐적으로 완전히 멈춘다. CPU 사용률은 거의 0이고, 재시작하면 한동안 정상이다. race와 데드락 중 어느 쪽 가설이 유력한가? 근거와 확인 절차를 제시하라.
정답과 해설
데드락(또는 락 대기 순환에 준하는 자원 고갈) 가설이 유력하다. race는 잘못된 값·깨진 불변식으로 나타나고 프로그램은 계속 달리지만, 데드락은 관련 스레드들이 영구 대기에 들어가므로 "CPU가 놀면서 멈춰 있는" 상태가 지속된다. 재시작으로 회복되는 것도 대기 순환이 초기화되기 때문이다. 확인 절차: 멈춘 상태에서 재시작하지 말고 스레드 덤프를 확보한다 — 서로 다른 락을 쥔 채 상대의 락을 기다리는 스레드 쌍(순환)이 보이면 확정이다. 이후 두 락의 획득 순서가 갈리는 코드 경로를 찾아 전역 순서로 통일한다. 멈춤이 완전하지 않고 일부 요청만 느리다면 경합·스레드 풀 고갈 쪽 가설도 함께 조사한다.
참고 자료
- Sarita Adve, Hans-J. Boehm, "Memory Models: A Case for Rethinking Parallel Languages and Hardware" (CACM 2010) — DRF-SC 계약이 언어·하드웨어 공통의 기반이 된 경위. 본문 메모리 모델 절의 뼈대다.
- Ulrich Drepper, "Futexes Are Tricky" — 유저 공간 CAS + 커널 대기라는 현대 mutex 구조의 1차 해설. 비경합/경합 이원 구조의 출처다.
- POSIX,
pthread_cond_wait— spurious wakeup과 재검사 요구를 명시한 명세.while재검사가 계약인 근거다. - Java Language Specification, Chapter 17: Threads and Locks — happens-before의 언어 명세 정식화. volatile·synchronized의 정확한 보장을 확인할 때 읽는다.
- The Go Memory Model — "race 있는 프로그램을 추론하지 말라"는 조언과 함께 동기화 도구별 happens-before 규칙을 짧게 정리한 문서. 실무자용 메모리 모델 입문으로도 유용하다.
- Maurice Herlihy, Nir Shavit, The Art of Multiprocessor Programming 2nd ed. — 진행 보장(lock-free/wait-free)의 정확한 정의와 lock-free 자료구조의 난이도를 확인하는 표준 교재.