C++ Memory Order 완벽 가이드: 멀티스레드 프로그래밍의 핵심

멀티스레드 프로그래밍에서 std::atomic을 사용해본 적이 있다면, memory_order라는 옵션을 본 적이 있을 것이다. 대부분 기본값으로 두거나, 무슨 의미인지 모른 채 복사해서 사용하는 경우가 많다. 이 글에서는 C++의 memory order가 왜 필요하고, 각 옵션이 어떤 의미를 갖는지 명확하게 정리한다.

원자성(Atomicity)과 순서(Ordering)는 다르다

먼저 중요한 개념을 짚고 가자. std::atomic이 보장하는 것은 두 가지다.

  1. 원자성: 연산이 중간에 끊기지 않고 완전히 실행됨
  2. 순서: 다른 메모리 연산과의 실행 순서

memory_order는 이 중 두 번째, 순서에 대한 보장 수준을 지정한다. 원자성은 어떤 memory_order를 사용하든 항상 보장된다.

왜 순서가 문제가 되는가?

우리가 작성한 코드가 그대로 실행된다고 생각하면 오산이다. 실제로는 두 단계에서 재배치가 일어난다.

1. 컴파일 타임: 컴파일러 최적화

int a = 1;
int b = 2;
int c = 3;

컴파일러는 성능 최적화를 위해 이 순서를 자유롭게 바꿀 수 있다. 싱글스레드에서는 결과가 동일하기 때문이다.

2. 런타임: CPU의 Out-of-Order Execution

현대 CPU는 파이프라인 효율을 위해 명령어를 순서대로 실행하지 않는다. 메모리 읽기가 오래 걸리면 다음 명령어를 먼저 실행하는 식이다.

싱글스레드에서는 이 모든 최적화가 투명하게 동작한다. 하지만 멀티스레드에서는 다른 스레드가 이 “중간 상태”를 관찰할 수 있다.

// 스레드 A
data = 42;          // (1)
ready = true;       // (2)
 
// 스레드 B
if (ready) {        // (3)
    use(data);      // (4) - data가 42라고 보장할 수 있는가?
}

(1)과 (2)의 순서가 바뀌면, 스레드 B는 ready == true인데 data는 아직 42가 아닌 상황을 볼 수 있다.

6가지 Memory Order

C++11은 6가지 memory order를 제공한다.

std::memory_order_relaxed;
std::memory_order_consume;
std::memory_order_acquire;
std::memory_order_release;
std::memory_order_acq_rel;
std::memory_order_seq_cst;

약한 보장부터 강한 보장 순으로 살펴보자.

memory_order_relaxed

가장 약한 보장이다. 원자성만 보장하고, 순서는 전혀 보장하지 않는다.

std::atomic<int> counter{0};
 
void increment() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

언제 사용하는가? 다른 데이터와 동기화가 필요 없는 독립적인 카운터에 적합하다.

// 요청 수 집계 - 총합만 중요하고 순서는 상관없음
std::atomic<uint64_t> request_count{0};
 
void handle_request() {
    request_count.fetch_add(1, std::memory_order_relaxed);
    // 실제 요청 처리...
}

memory_order_acquire / memory_order_release

이 둘은 쌍으로 사용되어 스레드 간 동기화를 구현한다.

  • release: 이 연산 이전의 모든 쓰기가 이 연산 뒤로 재배치되지 않음
  • acquire: 이 연산 이후의 모든 읽기가 이 연산 앞으로 재배치되지 않음
std::atomic<bool> ready{false};
int data = 0;
 
// Producer 스레드
void producer() {
    data = 42;                                      // (1)
    ready.store(true, std::memory_order_release);   // (2) - (1)이 반드시 (2) 전에 완료
}
 
// Consumer 스레드
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}  // (3)
    std::cout << data << std::endl;                    // (4) - 반드시 42 출력
}

release가 “이전 쓰기를 밀어낸다”고 생각하고, acquire가 “이후 읽기를 당겨온다”고 생각하면 이해하기 쉽다.

이 패턴은 lock-free 자료구조의 핵심이다.

class Spinlock {
    std::atomic<bool> locked{false};
public:
    void lock() {
        while (locked.exchange(true, std::memory_order_acquire)) {
            // 스핀 대기
        }
    }
    
    void unlock() {
        locked.store(false, std::memory_order_release);
    }
};

memory_order_acq_rel

acquire와 release를 동시에 적용한다. read-modify-write 연산(예: fetch_add, compare_exchange)에서 사용한다.

std::atomic<int> sync_point{0};
 
void thread_func() {
    int old = sync_point.fetch_add(1, std::memory_order_acq_rel);
    // 이전 스레드의 쓰기를 관찰하고 (acquire)
    // 내 쓰기를 다음 스레드에게 공개함 (release)
}

memory_order_seq_cst

가장 강한 보장이며, 기본값이다. Sequential Consistency의 약자로, 모든 스레드가 동일한 전역 순서로 연산을 관찰한다.

std::atomic<int> x{0};
x.store(1);  // memory_order_seq_cst가 기본값

직관적으로 이해하기 쉽고 안전하지만, 성능 비용이 가장 크다. “잘 모르겠으면 seq_cst”가 안전한 선택이다.

memory_order_consume (사용 금지)

이론적으로는 acquire보다 약한 보장으로, data dependency가 있는 연산에만 순서를 보장한다.

std::atomic<Data*> ptr{nullptr};
 
// Consumer
Data* d = ptr.load(std::memory_order_consume);
int x = d->value;   // d에 의존하므로 순서 보장

그러나 현실에서는 사용하면 안 된다.

  • 대부분의 컴파일러가 consume을 acquire로 승격시킴
  • C++17에서 공식적으로 사용 자제 권고
  • 의미가 모호하고 구현이 어려움

그냥 memory_order_acquire를 사용하자.

아키텍처별 차이

memory order의 실제 비용은 CPU 아키텍처에 따라 다르다.

x86/x64 (Strong Memory Model)

x86은 기본적으로 강한 순서 보장을 제공한다. relaxed와 acquire/release의 성능 차이가 크지 않다.

ARM, PowerPC (Weak Memory Model)

ARM 기반 프로세서는 적극적으로 재배치를 수행한다. memory_order에 따라 다른 barrier 명령어가 삽입되며, 성능 차이가 유의미하다.

// ARM에서
x.store(1, std::memory_order_relaxed);   // 단순 store
x.store(1, std::memory_order_release);   // store + DMB 명령어
x.store(1, std::memory_order_seq_cst);   // store + 더 강한 barrier

임베디드 시스템이나 ARM 기반 SoC(예: NXP S32G)를 다룬다면 적절한 memory order 선택이 성능에 실질적인 영향을 준다.

선택 가이드

성능     ←―――――――――――――――――――――――――――――→     안전성
relaxed      acquire/release      seq_cst
상황권장 memory_order
단순 카운터, 통계relaxed
Producer-Consumer 패턴release/acquire 쌍
Lock-free 자료구조acquire/release 또는 acq_rel
잘 모르겠을 때seq_cst (기본값)
consume사용하지 말 것

쉽게 암기하는 방법

6가지나 되는 옵션을 모두 외울 필요는 없다. 실무에서는 3가지만 기억하면 된다.

핵심 3가지

relaxed         →  "나 혼자 쓸 거야"
release/acquire →  "넘겨줄게 / 받을게"
seq_cst         →  "잘 모르겠어"

택배 시스템으로 비유하기

memory_order비유
relaxed그냥 물건만 보냄 (포장도 순서도 신경 안 씀)
release”여기까지 다 포장했어” → 박스 봉인
acquire”박스 열었어” → 이제부터 내용물 확인
seq_cst등기우편 (추적번호, 순서 모두 보장)

더 단순하게

relaxed   = 카운터
release   = store (내보내기)
acquire   = load (가져오기)
seq_cst   = 기본값 (안전)

release는 store, acquire는 load와 짝지어 외우면 된다.

ptr.store(data, memory_order_release);  // release로 내보내고
ptr.load(memory_order_acquire);         // acquire로 받는다

버릴 것

  • consume: 존재를 잊어라
  • acq_rel: acquire + release 합친 것 (read-modify-write 연산용)

결국 일상적으로 쓰는 건 relaxed, release/acquire 쌍, seq_cst 세 가지뿐이다.

정리

  1. memory_order는 원자성이 아닌 순서를 제어한다
  2. 재배치는 컴파일러와 CPU 양쪽에서 일어난다
  3. relaxed는 빠르지만 순서 보장이 없다
  4. acquire/release는 스레드 간 동기화의 핵심이다
  5. seq_cst는 안전하지만 가장 느리다
  6. consume은 무시하라
  7. ARM에서는 memory order 선택이 성능에 실질적 영향을 준다

멀티스레드 코드에서 버그는 재현하기 어렵고, 디버깅은 더 어렵다. memory order를 정확히 이해하고 사용하면 이런 미묘한 버그를 예방할 수 있다. 확실하지 않으면 seq_cst로 시작하고, 프로파일링 후 필요한 곳만 최적화하는 것이 현명한 접근이다.