Lock 선택 기준이란 임계 구역의 길이·경합 빈도·읽기 대 쓰기 비율 같은 워크로드 특성을 근거로 mutex, spinlock, shared_mutex, futex 기반 프리미티브 중 무엇을 쓸지 판단하는 체계를 말합니다. 실무에서는 “일단 mutex로 감싸고 느리면 spinlock으로 바꾼다"는 식의 시행착오가 흔한데, 이는 워크로드마다 최적 선택이 달라 매번 재현되는 비효율입니다. 이 장은 이전 장에서 정량화한 동기화 비용을 근거로, 어떤 신호를 보고 어떤 프리미티브를 고를지에 대한 재사용 가능한 기준을 세우는 데 목적이 있습니다.
이 장을 읽기 전에
이 장은 01장: 동기화 비용 정량 분석에서 측정한 “락 획득/해제·경합·컨텍스트 스위치 비용"의 크기를 전제로 합니다. mutex와 atomic 연산의 비용 차이, 그리고 경합이 왜 꼬리 지연(p99)을 악화시키는지를 먼저 이해하고 오면 이 장의 판단 기준이 훨씬 빠르게 와닿습니다. 이 장의 깊이는 기초 수준입니다 — mutex·spinlock·shared_mutex·futex의 동작 원리를 이해하고, 워크로드 신호(임계 구역 길이, 경합도, 읽기/쓰기 비율)로 프리미티브를 고르는 기준을 세우는 것이 목표입니다. 다루지 않는 것: cache line 배치와 false sharing 회피 기법은 03장, acquire/release 등 메모리 순서의 정확한 의미론은 04장, 락 없는 자료구조 설계는 05장, condition_variable의 성능 패턴은 19장, C++20 std::atomic::wait/notify의 futex 기반 구현 세부는 09장에서 각각 다룹니다.
당신의 수준에 맞는 경로
| 수준 | 읽을 부분 | 핵심 목표 |
|---|---|---|
| 초보자 | “락의 계보” ~ “mutex의 내부 동작” | mutex가 futex 위에서 어떻게 동작하는지 이해 |
| 중급자 | “spinlock” ~ “shared_mutex” | 워크로드별로 spinlock·shared_mutex를 언제 쓸지 판단 |
| 실무 적용 | “흔한 오개념” ~ “판단 기준” | 자신의 코드에서 잘못 고른 락을 식별하고 교체 |
락의 계보: mutex에서 futex까지 (역사·배경)
POSIX 스레드 표준(IEEE Std 1003.1c, 1995)은 pthread_mutex_t를 정의했지만 구현 방식은 규정하지 않았습니다. 초기 Linux의 LinuxThreads 구현은 시그널 기반으로 스레드를 깨웠는데, 스레드 수가 늘어날수록 시그널 전달 비용이 커져 확장성이 나빴습니다. 2003년 Ulrich Drepper와 Ingo Molnar가 주도한 **NPTL(Native POSIX Thread Library)**이 리눅스 2.6 커널과 함께 도입되면서 futex(fast userspace mutex) 기반 구현으로 교체되었고, 이때부터 “무경합 상태에서는 커널에 진입하지 않는다"는 지금의 mutex 설계 원칙이 자리 잡았습니다. C++11(2011)은 이 플랫폼별 프리미티브를 std::mutex/std::lock_guard로 표준화해 이식 가능한 인터페이스를 제공했고, C++14는 std::shared_timed_mutex를, C++17은 타임아웃 기능을 뺀 경량 std::shared_mutex를 추가해 읽기 중심 워크로드를 표준 라이브러리만으로 다룰 수 있게 했습니다.
스핀락의 역사에는 반면교사 사례가 있습니다. Apple은 오랫동안 OSSpinLock을 저비용 락으로 제공했지만, 2015년경 커뮤니티에서 “높은 우선순위 스레드가 낮은 우선순위 스레드를 계속 선점해 스핀 중인 스레드가 영원히 락을 못 얻는” 우선순위 역전 문제가 널리 알려졌습니다. iOS의 스케줄러는 우선순위가 낮은 스레드에 실행 시간을 거의 배정하지 않을 수 있어, 스핀 중인 고우선순위 스레드가 그 낮은 스레드를 기다리며 사실상 멈추는 상황이 발생했습니다. 결국 OSSpinLock은 iOS 10(2016)에서 공식적으로 deprecated 처리되었고, 스핀하지 않고 커널에서 우선순위 역전을 방지하며 대기하는 os_unfair_lock으로 대체되었습니다. 이 사례는 “스핀락이 항상 더 빠르다"는 직관이 스케줄러·우선순위 조건에서 깨질 수 있음을 보여줍니다.
mutex의 내부 동작: futex 기반 fast/slow path
리눅스에서 std::mutex는 결국 pthread_mutex_t로, 이는 다시 futex(2) 시스템 콜 위에서 동작합니다. futex의 핵심 아이디어는 “무경합 구간은 전부 사용자 공간에서 원자적 연산만으로 끝내고, 실제로 대기가 필요한 경우에만 커널에 진입한다"는 것입니다. 락 상태를 나타내는 정수 하나를 원자적으로 조작해 성공하면 시스템 콜이 전혀 없고, 이미 잠겨 있어 실패한 스레드만 FUTEX_WAIT로 커널에 재워달라고 요청합니다. 아래는 이 로직을 단순화한 개념 스케치이며, 실제 glibc 구현은 재시도 횟수·스핀 등 추가 상태를 가진 3-상태 머신에 가깝습니다.
| |
이 설계 덕분에 무경합 mutex의 lock/unlock은 원자적 CAS 한두 번에 그치고, 시스템 콜은 실제로 대기자가 있을 때만 발생합니다. 표준 std::mutex 자체는 어떤 재시도·스핀 전략을 쓸지 강제하지 않는 구현 정의(implementation-defined) 동작이며, Windows에서는 futex가 아니라 WaitOnAddress/SRWLOCK 계열 커널 프리미티브 위에서, macOS(libc++)에서는 os_unfair_lock 위에서 유사한 원칙으로 구현됩니다.
| |
std::lock_guard는 생성자에서 잠그고 소멸자에서 해제하므로 예외가 발생해도 락이 풀립니다. 다만 락을 쥔 채로 값비싼 연산(I/O, 다른 락 획득)을 하지 않도록 임계 구역을 최소화하는 것이 우선이며, 그 판단은 01장의 비용 측정과 짝을 이룹니다.
spinlock: 언제 유리한가
spinlock은 락을 못 얻으면 커널에 넘기지 않고 사용자 공간에서 원자적 연산을 반복(busy-wait)하며 재시도하는 방식입니다. 컨텍스트 스위치 비용(커널 진입, 스케줄러 큐 조작, 캐시 워밍업 재현)을 아예 지불하지 않으므로, 임계 구역이 매우 짧고(수십~수백 ns) 다른 코어가 곧 락을 풀어줄 것이 거의 확실할 때 mutex보다 유리할 수 있습니다. 반대로 대기 시간이 길어질 조건(선점, 단일 코어, 코어보다 스레드가 많은 상황)에서는 CPU 사이클만 태우고 아무 진전이 없으므로 손해가 큽니다.
| |
test_and_set 자체가 캐시 라인을 배타적으로 가져오는(RFO) 연산이므로, 안쪽 루프에서 매번 test_and_set을 재시도하면 코어 간 캐시 라인 핑퐁이 심해집니다. 그래서 바깥은 test_and_set, 안쪽 재시도는 읽기 전용 test(또는 C++20 이전이라면 load)로 폴링하는 test-and-test-and-set 패턴을 씁니다 — 캐시 라인 경합과 정렬 문제는 03장에서 더 깊이 다룹니다. glibc는 이 절충을 표준 mutex 안에도 부분적으로 들여왔습니다: PTHREAD_MUTEX_ADAPTIVE_NP 확장으로 만든 mutex는 즉시 잠들지 않고 glibc.pthread.mutex_spin_count 튜너블(기본값 100회)만큼 먼저 스핀한 뒤에야 futex로 블로킹합니다. 단, 이는 GNU 확장이며 표준 std::mutex가 자동으로 적용하는 동작이 아닙니다.
shared_mutex와 읽기 중심 워크로드
std::shared_mutex(C++17)는 읽기(shared) 잠금과 쓰기(exclusive) 잠금을 구분합니다. 여러 리더가 동시에 shared 잠금을 보유할 수 있고, 라이터가 exclusive 잠금을 요청하면 모든 리더가 빠질 때까지 기다립니다. 읽기가 압도적으로 많고 쓰기가 드문 캐시·설정 테이블 같은 자료구조에서, 순수 mutex는 리더끼리도 서로 직렬화시켜 처리량을 깎아 먹지만 shared_mutex는 리더들을 병렬로 흘려보낼 수 있습니다. Windows의 대응물인 SRWLOCK도 같은 shared/exclusive 모드를 제공하며 공식 문서는 “읽기가 쓰기보다 많으면 이 동시성이 critical section 대비 성능·처리량을 높인다"고 설명합니다.
| |
shared_mutex는 리더 수 관리를 위한 추가 상태(리더 카운트, 대기 큐)를 가지므로 무경합 상태에서도 순수 mutex보다 lock/unlock 오버헤드가 크며, 구현에 따라 라이터가 계속 도착하면 리더가 굶거나(reader starvation) 반대로 리더가 몰리면 라이터가 굶는(writer starvation) 정책 차이가 있습니다. 읽기 비율이 낮거나 임계 구역이 매우 짧다면 이 오버헤드가 병렬성 이득을 상쇄해 순수 mutex보다 오히려 느려질 수 있으므로, “읽기/쓰기 비율이 실제로 리더 편중인가"를 먼저 확인해야 합니다.
흔한 오개념
**“spinlock은 항상 mutex보다 빠르다”**는 임계 구역이 짧고 코어가 남아돌며 스레드 수가 코어 수를 넘지 않는다는 전제가 있을 때만 성립합니다. 스레드가 코어보다 많거나 스핀 중에 선점되면, 락을 쥔 스레드가 실행되지 못하는 동안 다른 스레드들은 CPU만 태우며 아무 진전도 만들지 못합니다 — OSSpinLock이 결국 폐기된 이유가 이 실패 모드였습니다.
**"std::mutex는 기본적으로 adaptive spin을 쓴다”**도 흔한 오해입니다. glibc의 adaptive spin은 PTHREAD_MUTEX_ADAPTIVE_NP 타입으로 명시적으로 만든 mutex에만 적용되는 비표준 GNU 확장이며, std::mutex가 내부적으로 사용하는 pthread mutex 타입은 이 확장을 자동으로 켜지 않습니다. 스핀 후 블로킹 전환을 원한다면 라이브러리가 직접 그렇게 구현했는지 표준 문서가 아니라 구현 소스로 확인해야 합니다.
**“shared_mutex로 바꾸면 항상 이득”**이라는 가정도 위험합니다. 앞서 본 것처럼 무경합 lock/unlock 자체의 오버헤드가 순수 mutex보다 크고, 쓰기가 자주 섞이거나 임계 구역이 짧으면 병렬 읽기의 이득보다 관리 비용이 커질 수 있습니다. 바꾸기 전에 실제 읽기/쓰기 비율과 임계 구역 길이를 먼저 측정하는 것이 순서입니다.
판단 기준
아래 표는 워크로드 신호별 1차 권장안입니다. 정확한 임계값(예: “몇 ns 이하면 스핀”)은 코어 수, 스케줄러, 컴파일러·플래그에 따라 달라지므로, 표는 출발점으로 삼고 실제 결정은 자신의 워크로드에서 측정한 뒤에 내려야 합니다.
| 워크로드 신호 | 권장 | 비권장인 이유 |
|---|---|---|
| 임계 구역 매우 짧음(수십~수백 ns) + 코어 여유 있음 + 스레드 수 ≤ 코어 수 | spinlock 또는 adaptive mutex | 순수 mutex는 블로킹 전환(커널 진입) 오버헤드가 상대적으로 큼 |
| 임계 구역 길거나 가변적, 또는 대기 시간이 길어질 수 있음 | std::mutex(futex 기반) | spinlock은 대기 중 CPU만 소비하고 진전이 없음 |
| 읽기 압도적, 쓰기 드묾 | std::shared_mutex/SRWLOCK | 순수 mutex는 리더끼리도 직렬화되어 처리량 손실 |
| 쓰기 비중이 높거나 임계 구역이 매우 짧음 | std::mutex | shared_mutex의 리더 카운트 관리 오버헤드가 병렬 이득을 상쇄 |
| 스레드 수가 코어 수를 초과(오버서브스크립션) | std::mutex(블로킹) | spinlock은 선점된 락 보유자를 기다리며 다른 스레드까지 낭비 |
| 실시간·우선순위 역전에 민감 | 우선순위 상속 지원 mutex(PTHREAD_PRIO_INHERIT) 또는 재설계 | 일반 spinlock·adaptive mutex는 우선순위 역전에 취약 |
경합·대기 시간이 실제로 지배항인지 확신이 서지 않으면, 락 종류를 바꾸기 전에 01장에서 다룬 방식으로 먼저 측정해야 합니다. 아래는 임계 구역 보유 시간을 직접 계측해 후보 락을 좁히는 최소 벤치마크 스켈레톤입니다(x86-64 Linux, GCC 13, g++ -O2 -std=c++20 bench.cpp -lbenchmark -lpthread -o bench로 빌드).
| |
--benchmark_repetitions=5 --benchmark_report_aggregates_only=true 옵션으로 스레드 수를 늘려가며 반복 실행하면, 스레드 수 증가에 따라 처리량이 어디서 꺾이는지가 드러납니다. 같은 스켈레톤에서 std::mutex를 위의 SpinLock으로 교체해 비교하면 자신의 코어 수·워크로드에서 어느 쪽이 유리한지 직접 확인할 수 있습니다.
flowchart TD
csLen{"임계 구역 길이·경합"}
csLen -->|"매우 짧음 + 코어 여유"| contended{"스레드 수가코어 수 이하인가?"}
csLen -->|"길거나 가변적"| rwRatio{"읽기:쓰기 비율은?"}
contended -->|"예"| useSpin["spinlock 또는adaptive mutex"]
contended -->|"아니오(오버서브스크립션)"| useMutex["std::mutex(futex 기반)"]
rwRatio -->|"읽기 압도적"| useShared["std::shared_mutex / SRWLOCK"]
rwRatio -->|"쓰기 비중 높음"| useMutex
비판적 시각: 한계와 트레이드오프
C++ 표준은 std::mutex·std::shared_mutex의 내부 알고리즘(스핀 여부, 페어니스, 우선순위 처리)을 규정하지 않으므로, 같은 코드라도 libstdc++·libc++·MSVC STL에서 서로 다른 성능 특성을 보일 수 있습니다. glibc의 mutex_spin_count 같은 튜너블은 Linux에서만 존재하는 비표준 확장이라 이식성이 없고, 컨테이너·가상화 환경에서는 코어 수 인식 자체가 달라져 스핀 전략의 효과가 베어메탈과 다르게 나타날 수 있습니다. shared_mutex/SRWLOCK의 리더-라이터 공정성 정책도 구현마다 달라, “리더 편중 워크로드에는 무조건 유리하다"는 가정은 라이터 기아 현상이 실제로 문제가 되는 시스템에서는 성립하지 않을 수 있습니다. 이런 이유로 이 장의 판단표는 출발점일 뿐이며, 실제 배포 전에는 대상 플랫폼·컴파일러·코어 구성에서 재현 가능한 벤치마크로 확인하는 절차가 필요합니다.
마무리
- futex 기반 mutex의 fast path(무경합, 시스템 콜 없음)와 slow path(대기,
FUTEX_WAIT/FUTEX_WAKE)를 구분해 설명할 수 있다. - spinlock이 유리한 조건(짧은 임계 구역, 코어 여유, 오버서브스크립션 없음)과 위험한 조건(선점, 단일 코어, 우선순위 역전)을 구분할 수 있다.
-
std::shared_mutex/SRWLOCK이 읽기 중심 워크로드에서 이득을 주는 조건과, 오히려 손해가 되는 조건을 설명할 수 있다. - “spinlock이 항상 빠르다”, “std::mutex는 기본으로 adaptive spin을 쓴다”, “shared_mutex는 항상 이득"이라는 세 가지 오개념을 교정할 수 있다.
- 자신의 워크로드에서 임계 구역 길이·경합도·읽기/쓰기 비율을 근거로 mutex·spinlock·shared_mutex 중 하나를 선택하고, 벤치마크로 그 선택을 검증할 수 있다.
다음 장에서는 이번 장에서 다룬 spinlock의 test-and-test-and-set 패턴이 왜 캐시 라인 배치에 민감한지를 더 깊이 파고듭니다. 여러 스레드가 논리적으로 무관한 변수를 같은 캐시 라인에서 공유할 때 생기는 false sharing을 탐지하는 도구와, 이를 회피하는 정렬·패딩 기법을 다룹니다.
![Featured image of post [Concurrency 07] Lock 선택 기준](/post/concurrency-optimization/lock-selection-criteria-guide/wordcloud_hu_372de4bd47c72017.webp)
![[Performance 07] Introduction: Low-latency 동시성·멀티스레드](/post/concurrency-optimization/getting-started-concurrency-multithreading-performance-tuning/wordcloud_hu_af151f19d558a3a1.webp)
![[Concurrency 07] 동기화 비용 정량 분석](/post/concurrency-optimization/synchronization-primitive-cost-analysis/wordcloud_hu_ecac830646e44354.webp)
![[Concurrency 07] Lock 선택 기준](/post/concurrency-optimization/lock-selection-criteria-guide/wordcloud_hu_e9eb097cef52d930.webp)
![[Concurrency 07] False Sharing 탐지와 회피](/post/concurrency-optimization/false-sharing-detection-avoidance/wordcloud_hu_88b69c82d417b1c9.webp)
![[Concurrency 07] C++ 메모리 모델 실무 해석](/post/concurrency-optimization/cpp-memory-model-acquire-release-seqcst/wordcloud_hu_274c24ffbf3afa86.webp)
![[Concurrency 07] Thread-local Storage 비용과 패턴](/post/concurrency-optimization/thread-local-storage-cost-patterns/wordcloud_hu_f97c5597e81d60b6.webp)
![[Rust] Comprehensive Rust 무료 강의 정리 및 코스 구조](/post/2022-12-30-comprehensive-rust/wordcloud_hu_d1420ff38434cdb6.webp)
![[Concurrency 07] C++20 Atomics 실전](/post/concurrency-optimization/cpp20-atomic-wait-notify/wordcloud_hu_d44048ad7cf6a68a.webp)
![[Hardware] LattePanda Alpha에 Ubuntu 16.04 LTS 설치 가이드](/post/2018-12-06-install-ubuntu-16.04-on-lattepanda/wordcloud_hu_fc536f8de2cbd4bf.webp)