C++ Memory Order 완벽 가이드: 멀티스레드 프로그래밍의 핵심
멀티스레드 프로그래밍에서 std::atomic을 사용해본 적이 있다면, memory_order라는 옵션을 본 적이 있을 것이다. 대부분 기본값으로 두거나, 무슨 의미인지 모른 채 복사해서 사용하는 경우가 많다. 이 글에서는 C++의 memory order가 왜 필요하고, 각 옵션이 어떤 의미를 갖는지 명확하게 정리한다.
원자성(Atomicity)과 순서(Ordering)는 다르다
먼저 중요한 개념을 짚고 가자. std::atomic이 보장하는 것은 두 가지다.
- 원자성: 연산이 중간에 끊기지 않고 완전히 실행됨
- 순서: 다른 메모리 연산과의 실행 순서
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 세 가지뿐이다.
정리
- memory_order는 원자성이 아닌 순서를 제어한다
- 재배치는 컴파일러와 CPU 양쪽에서 일어난다
- relaxed는 빠르지만 순서 보장이 없다
- acquire/release는 스레드 간 동기화의 핵심이다
- seq_cst는 안전하지만 가장 느리다
- consume은 무시하라
- ARM에서는 memory order 선택이 성능에 실질적 영향을 준다
멀티스레드 코드에서 버그는 재현하기 어렵고, 디버깅은 더 어렵다. memory order를 정확히 이해하고 사용하면 이런 미묘한 버그를 예방할 수 있다. 확실하지 않으면 seq_cst로 시작하고, 프로파일링 후 필요한 곳만 최적화하는 것이 현명한 접근이다.