Featured image of post [Concurrency 07] Lock 선택 기준

[Concurrency 07] Lock 선택 기준

mutex·spinlock·shared_mutex·futex 기반 프리미티브 중 워크로드에 맞는 락을 고르는 기준을 다룹니다. 임계 구역 길이·경합도·읽기쓰기 비율별 판단표와 adaptive mutex, SRWLOCK, os_unfair_lock의 구현 차이, 흔한 선택 오류를 정리합니다.

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-상태 머신에 가깝습니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
lock():
  if compare_exchange(state, UNLOCKED, LOCKED):
    return                        // fast path: 원자적 CAS 성공, 시스템 콜 없음
  state = LOCKED_CONTENDED         // 경합 발생을 표시
  while not compare_exchange(state, UNLOCKED, LOCKED_CONTENDED):
    futex_wait(&state, LOCKED_CONTENDED)   // 값이 바뀔 때까지 커널이 재움(slow path)

unlock():
  if fetch_and_set(state, UNLOCKED) == LOCKED_CONTENDED:
    futex_wake(&state, 1)          // 대기자가 있었을 때만 한 명을 깨움(시스템 콜)

이 설계 덕분에 무경합 mutex의 lock/unlock은 원자적 CAS 한두 번에 그치고, 시스템 콜은 실제로 대기자가 있을 때만 발생합니다. 표준 std::mutex 자체는 어떤 재시도·스핀 전략을 쓸지 강제하지 않는 구현 정의(implementation-defined) 동작이며, Windows에서는 futex가 아니라 WaitOnAddress/SRWLOCK 계열 커널 프리미티브 위에서, macOS(libc++)에서는 os_unfair_lock 위에서 유사한 원칙으로 구현됩니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <mutex>

class Counter {
 public:
  void increment() {
    std::lock_guard<std::mutex> lock(mutex_);  // RAII: 예외 발생 시에도 해제 보장
    ++value_;
  }
  long value() const {
    std::lock_guard<std::mutex> lock(mutex_);
    return value_;
  }

 private:
  mutable std::mutex mutex_;
  long value_ = 0;
};

std::lock_guard는 생성자에서 잠그고 소멸자에서 해제하므로 예외가 발생해도 락이 풀립니다. 다만 락을 쥔 채로 값비싼 연산(I/O, 다른 락 획득)을 하지 않도록 임계 구역을 최소화하는 것이 우선이며, 그 판단은 01장의 비용 측정과 짝을 이룹니다.

spinlock: 언제 유리한가

spinlock은 락을 못 얻으면 커널에 넘기지 않고 사용자 공간에서 원자적 연산을 반복(busy-wait)하며 재시도하는 방식입니다. 컨텍스트 스위치 비용(커널 진입, 스케줄러 큐 조작, 캐시 워밍업 재현)을 아예 지불하지 않으므로, 임계 구역이 매우 짧고(수십~수백 ns) 다른 코어가 곧 락을 풀어줄 것이 거의 확실할 때 mutex보다 유리할 수 있습니다. 반대로 대기 시간이 길어질 조건(선점, 단일 코어, 코어보다 스레드가 많은 상황)에서는 CPU 사이클만 태우고 아무 진전이 없으므로 손해가 큽니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
#include <atomic>
#include <thread>

class SpinLock {
 public:
  void lock() noexcept {
    while (flag_.test_and_set(std::memory_order_acquire)) {
      while (flag_.test(std::memory_order_relaxed)) {  // C++20: test로 캐시 라인만 폴링
        std::this_thread::yield();  // 과도한 스핀 시 스케줄러에 양보
      }
    }
  }
  void unlock() noexcept { flag_.clear(std::memory_order_release); }

 private:
  std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};

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 대비 성능·처리량을 높인다"고 설명합니다.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
#include <shared_mutex>
#include <string>
#include <unordered_map>

class ConfigCache {
 public:
  std::string get(const std::string& key) const {
    std::shared_lock lock(mutex_);        // 여러 리더가 동시에 보유 가능
    auto it = map_.find(key);
    return it != map_.end() ? it->second : std::string{};
  }
  void set(std::string key, std::string value) {
    std::unique_lock lock(mutex_);        // 단독 라이터, 모든 리더 배제
    map_[std::move(key)] = std::move(value);
  }

 private:
  mutable std::shared_mutex mutex_;
  std::unordered_map<std::string, std::string> map_;
};

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::mutexshared_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로 빌드).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
#include <benchmark/benchmark.h>
#include <mutex>

std::mutex g_mutex;
long g_counter = 0;

static void BM_MutexIncrement(benchmark::State& state) {
  for (auto _ : state) {
    std::lock_guard<std::mutex> lock(g_mutex);
    ++g_counter;                       // 실제 임계 구역과 비슷한 작업으로 교체해 측정
  }
}
BENCHMARK(BM_MutexIncrement)->Threads(1)->Threads(4)->Threads(16);

BENCHMARK_MAIN();

--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을 탐지하는 도구와, 이를 회피하는 정렬·패딩 기법을 다룹니다.

False Sharing 탐지와 회피