C++ 동시성 · 5편

atomic과 메모리 모델

잠금 없이 공유하는 건 어떻게 가능한가? std::atomic과 CAS, 컴파일러·CPU의 재배치, 그리고 seq_cst·acquire/release·relaxed 메모리 오더링과 happens-before.

지금까지는 공유 데이터를 잠금으로 보호했다. 한 번에 한 스레드만 임계 구역에 들이는 방식이다. 이번 편은 방향을 반대로 튼다 — 잠금 없이 공유하는 세계다. std::atomic과 그 뒤의 메모리 모델은 이 시리즈에서 가장 미묘하고 어려운 주제다. 천천히 간다.

왜 잠금 없이 하려는가

mutex는 확실하지만 비용이 있다. lock/unlock 자체의 오버헤드, 경합 시 스레드가 잠들고 깨어나는 비용, 그리고 임계 구역이 실행을 직렬화해 병렬성을 깎는 문제다. 카운터 하나 올리자고 mutex를 잡는 건 과하다.

std::atomic은 잠금 없이도 특정 연산을 원자적(atomic) 으로, 즉 “쪼개지지 않게” 수행한다. 다른 스레드 입장에서 그 연산은 완전히 일어났거나 전혀 일어나지 않은 것으로만 보이고, 중간 상태가 노출되지 않는다.

atomic의 기본

2편에서 ++counter가 “읽고 → 더하고 → 쓰는” 세 단계라 race가 난다고 했다. std::atomic<int>은 이 증가를 하나의 쪼개지지 않는 연산으로 만든다.

#include <atomic>

std::atomic<int> counter{0};

void increment() {
    ++counter;   // 원자적. mutex 없이 안전하다
}

mutex도, 잠금도 없다. 그런데도 여러 스레드가 동시에 ++counter를 해도 값이 새지 않는다. 단순 카운터라면 이게 mutex보다 훨씬 가볍고 빠르다.

기본 연산들은 이렇다.

std::atomic<int> x{0};

x.store(10);            // 원자적 쓰기
int v = x.load();       // 원자적 읽기
int old = x.exchange(5); // 값을 5로 바꾸고 이전 값 반환
x.fetch_add(3);         // 원자적 덧셈 (++와 유사)

compare_exchange — lock-free의 심장

원자적 연산의 핵심은 CAS(Compare-And-Swap), C++에서는 compare_exchange_weak/compare_exchange_strong이다. “현재 값이 내가 예상한 값과 같으면 새 값으로 바꾸고, 아니면 바꾸지 않는다”를 원자적으로 한다.

std::atomic<int> x{0};

int expected = 0;
bool ok = x.compare_exchange_strong(expected, 42);
// x가 0이었으면 → 42로 바꾸고 true 반환
// x가 0이 아니었으면 → 바꾸지 않고, expected에 현재 값을 담아 false 반환

이게 lock-free 알고리즘의 토대다. 전형적인 패턴은 “현재 값을 읽고 → 새 값을 계산하고 → CAS로 바꾸되, 그 사이 다른 스레드가 값을 바꿨으면 실패하니 다시 시도”하는 재시도 루프다.

std::atomic<int> x{0};

void multiplyBy(int factor) {
    int current = x.load();
    while (!x.compare_exchange_weak(current, current * factor)) {
        // 실패하면 current에 최신 값이 담긴 채 루프를 돈다. 그대로 재시도.
    }
}

weak은 실패할 수 있는 하드웨어에서 더 효율적이라 루프 안에서 쓰고, strong은 그런 가짜 실패가 없어 루프 밖 단발 시도에 쓴다.

여기까지는 쉬운 부분이었다

원자성만으로 끝이면 좋겠지만 아니다. 진짜 어려운 건 여러 atomic 변수(또는 atomic과 일반 변수) 사이의 순서다. 이걸 이해하려면 두 가지 불편한 진실을 받아들여야 한다.

컴파일러와 CPU는 명령의 순서를 바꾼다. 단일 스레드에서 결과가 같아 보이기만 하면, 최적화를 위해 읽기·쓰기 순서를 재배치한다. 단일 스레드에서는 아무 문제가 없다. 하지만 다른 스레드가 그 변수들을 지켜보고 있으면, 내가 코드에 쓴 순서와 다른 스레드가 관측하는 순서가 어긋날 수 있다.

고전적인 예를 보자.

int data = 0;
std::atomic<bool> ready{false};

// 스레드 A (생산)
data = 42;              // (1)
ready.store(true);      // (2)

// 스레드 B (소비)
while (!ready.load()) {}  // (3)
std::cout << data;        // (4) — 42가 보장될까?

직관적으로는 B가 ready가 true인 걸 봤으니 data도 42여야 할 것 같다. 하지만 순서 보장이 없다면 (1)과 (2)가 재배치되어, B가 ready == true를 봤는데도 data는 아직 0일 수 있다. 이 어긋남을 통제하는 것이 메모리 오더링(memory ordering) 이다.

메모리 오더링

atomic 연산은 두 번째 인자로 메모리 오더링을 받는다. 이것은 “이 연산 주변의 다른 메모리 접근을 어디까지 재배치할 수 있는가”를 지정한다. 세 단계로 나눠 이해하면 된다.

seq_cst (순차 일관성) — 기본값, 가장 강함. 모든 atomic 연산이 하나의 전역 순서를 따르는 것처럼 동작한다. 가장 직관적이고 실수하기 어렵다. 오더링을 생략하면 이게 적용된다. 대부분의 코드는 이걸로 충분하고, 확신이 없으면 이걸 쓰면 된다.

x.store(1);              // 암묵적으로 seq_cst
x.store(1, std::memory_order_seq_cst);  // 명시적으로 동일

acquire/release — 짝으로 쓰는 중간 강도. release 쓰기 이전의 모든 메모리 쓰기는, 그 값을 acquire로 읽은 스레드에게 반드시 보인다. 위의 data/ready 문제를 정확히 이걸로 푼다.

int data = 0;
std::atomic<bool> ready{false};

// 스레드 A
data = 42;                                   // (1)
ready.store(true, std::memory_order_release); // (2) release

// 스레드 B
while (!ready.load(std::memory_order_acquire)) {}  // (3) acquire
std::cout << data;   // (4) — 이제 42가 보장된다

release 쓰기 (2)는 그 앞의 모든 쓰기(1)를 “봉인”하고, acquire 읽기 (3)가 그 봉인을 열면 (1)까지 함께 보인다. 이 release-acquire 짝이 스레드 간 “일어난 일들”을 동기화하는 핵심 도구다. seq_cst보다 약해서 일부 아키텍처(특히 ARM)에서 더 빠르다.

relaxed — 가장 약함, 순서 보장 없음. 연산의 원자성만 보장하고, 다른 메모리 접근과의 순서는 전혀 보장하지 않는다. 순수한 카운터처럼 “값이 정확히 세어지기만 하면 되고, 다른 데이터와의 순서는 무관한” 경우에만 쓴다.

std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);  // 개수만 맞으면 됨

relaxed는 가장 빠르지만 가장 위험하다. 순서에 조금이라도 의존하면 미묘하게 깨진다. 확신이 서지 않으면 쓰지 않는 게 맞다.

happens-before — 개념의 뼈대

이 모든 것의 밑바탕에는 happens-before 관계가 있다. A가 B보다 happens-before이면, A의 효과는 B에게 반드시 보인다. 같은 스레드 안에서는 코드 순서가 곧 happens-before다. 스레드를 가로지르는 happens-before는 오직 동기화를 통해서만 생긴다 — mutex의 unlock→lock, release→acquire 짝, 스레드 생성/join 등이 그 다리다.

데이터 레이스의 정확한 정의도 여기서 나온다. 서로 happens-before 관계가 없는 두 접근이 같은 위치를 건드리고, 그중 하나라도 쓰기이면 데이터 레이스이며, 이는 정의되지 않은 동작(UB)이다. atomic과 메모리 오더링은 결국 이 happens-before 다리를 놓아 레이스를 없애는 도구다.

atomic::wait — 잠금 없는 대기 (C++20)

3편의 condition_variable을 기억할 것이다. C++20은 mutex도 cv도 없이 atomic 하나로 “기다렸다 깨우기”를 할 수 있게 했다.

std::atomic<bool> ready{false};

// 대기 측
ready.wait(false);        // 값이 false인 동안 잠들어 대기

// 알림 측
ready.store(true);
ready.notify_one();       // 대기 스레드를 깨움

wait(false)는 “값이 false인 동안 기다린다”는 뜻이다. 단순한 플래그 기반 대기에는 cv+mutex 조합보다 이쪽이 훨씬 가볍다.

언제 atomic을 쓰고, 언제 쓰지 말아야 하는가

atomic은 만능이 아니다. 오히려 대부분의 코드는 mutex를 써야 한다.

atomic이 맞는 경우는 단일 변수에 대한 단순한 연산이다. 카운터, 플래그, 단일 포인터 교체 같은 것. 이럴 때 atomic은 mutex보다 빠르고 데드락 위험이 없다.

atomic을 피해야 하는 경우는 여러 변수를 함께 일관되게 바꿔야 할 때다. 예를 들어 자료구조의 여러 필드를 동시에 갱신해야 한다면, 각각을 atomic으로 만드는 것으로는 “한꺼번에 바뀐 것처럼” 만들 수 없다. 이럴 땐 mutex로 묶어야 한다. lock-free 자료구조를 직접 구현하는 것은 CAS 재시도, ABA 문제, 메모리 회수 같은 함정이 가득해 전문가의 영역이다. 대부분의 경우 잘 검증된 라이브러리를 쓰거나 그냥 mutex를 쓰는 편이 옳다.

한 문장으로 요약하면, 의심스러우면 seq_cst atomic이나 mutex를 쓰라. relaxed와 직접 만든 lock-free는 정말로 측정된 병목이 있고, 그것을 감당할 이해가 있을 때만 손대는 도구다.

정리

  • atomic — 잠금 없이 특정 연산을 쪼개지지 않게 수행. 카운터·플래그에 mutex보다 가볍다.
  • compare_exchange (CAS) — “예상값과 같으면 교체”. lock-free 재시도 루프의 토대. 루프엔 weak, 단발엔 strong.
  • 재배치의 진실 — 컴파일러·CPU는 단일 스레드 결과가 같으면 순서를 바꾼다. 다른 스레드가 볼 때 어긋난다.
  • 메모리 오더링seq_cst(기본·최강·안전), acquire/release(짝으로 스레드 간 동기화), relaxed(원자성만, 가장 위험).
  • happens-before — 스레드를 가로지르는 가시성은 동기화(unlock→lock, release→acquire 등)를 통해서만 생긴다. 이게 없는 충돌 접근이 데이터 레이스(UB).
  • atomic::wait (C++20) — 단순 플래그 대기를 cv 없이.
  • 선택 — 단일 변수 단순 연산이면 atomic, 여러 변수 일관 갱신이면 mutex. 의심스러우면 seq_cst나 mutex.

원자성과 순서까지 내려왔으니, 이제 남은 것은 이 모든 도구를 잘못 썼을 때 벌어지는 일이다. 마지막 편에서는 데드락·데이터 레이스·false sharing 같은 동시성 버그와, 그것을 잡아내는 방법을 다룬다.