C++ Memory Order & Happens-Before
멀티코어 CPU는 성능을 위해 명령어 순서를 재배치(reorder)합니다. 컴파일러도 최적화 과정에서 코드 순서를 바꿀 수 있습니다. C++11 메모리 모델은 이러한 재배치를 제어하는 std::memory_order 열거형을 제공합니다. 잘못 사용하면 데이터 레이스나 잘못된 값 관찰이 발생하고, 과도하게 제한하면 성능이 저하됩니다.
1. 여섯 가지 memory_order
섹션 제목: “1. 여섯 가지 memory_order”namespace std { enum memory_order { memory_order_relaxed, // 순서 제약 없음 memory_order_consume, // (사실상 deprecated, acquire로 대체) memory_order_acquire, // 이후 읽기/쓰기가 앞으로 올 수 없음 memory_order_release, // 이전 읽기/쓰기가 뒤로 갈 수 없음 memory_order_acq_rel, // acquire + release memory_order_seq_cst // 전체 순서 보장 (가장 강함, 기본값) };}2. Happens-Before 관계
섹션 제목: “2. Happens-Before 관계”스레드 A의 연산 X가 스레드 B의 연산 Y보다 happens-before라면, B는 A의 결과를 반드시 볼 수 있습니다.
Release store → Acquire load 짝이 형성되면 happens-before 관계 성립
Thread A Thread Bdata = 42; while (!ready.load(acquire)) {}ready.store(true, release); // happens-before assert(data == 42); // 반드시 참3. memory_order_relaxed
섹션 제목: “3. memory_order_relaxed”순서 제약이 전혀 없습니다. 원자적 읽기/쓰기만 보장하고 다른 연산과의 순서는 보장하지 않습니다. 카운터 증가처럼 순서가 중요하지 않을 때 가장 빠릅니다.
#include <atomic>#include <thread>#include <vector>
std::atomic<int> counter{0};
void Increment(int times){ for (int i = 0; i < times; i++) counter.fetch_add(1, std::memory_order_relaxed);}
int main(){ std::vector<std::thread> threads; for (int i = 0; i < 10; i++) threads.emplace_back(Increment, 1000); for (auto& t : threads) t.join();
// 최종값 10000은 보장 (원자성), 중간 순서는 보장 안 함 assert(counter.load() == 10000);}4. Acquire-Release 패턴
섹션 제목: “4. Acquire-Release 패턴”생산자-소비자 패턴의 표준 구현입니다.
#include <atomic>#include <thread>#include <cassert>
std::atomic<bool> ready{false};int data = 0;
void Producer(){ data = 99; // (1) ready.store(true, std::memory_order_release); // (2) Release: (1)이 (2) 이전 보장}
void Consumer(){ while (!ready.load(std::memory_order_acquire)) {} // (3) Acquire // (3)이 성공하면 (1)의 결과가 여기서 반드시 보임 assert(data == 99); // 항상 참}
int main(){ std::thread t1(Producer); std::thread t2(Consumer); t1.join(); t2.join();}잘못된 예 — relaxed로 바꾸면 data == 99가 보장되지 않습니다.
// 위험: 순서 보장 없음ready.store(true, std::memory_order_relaxed); // Producerready.load(std::memory_order_relaxed); // Consumer// data의 값이 0일 수도 있음!5. memory_order_seq_cst (기본값)
섹션 제목: “5. memory_order_seq_cst (기본값)”모든 seq_cst 연산은 단일 전역 순서를 공유합니다. 가장 직관적이지만 x86 이외의 아키텍처(ARM, PowerPC)에서는 메모리 배리어 명령이 추가됩니다.
std::atomic<int> x{0}, y{0};std::atomic<int> r1{0}, r2{0};
// Thread 1void T1() { x.store(1); r1 = y.load(); }// Thread 2void T2() { y.store(1); r2 = x.load(); }
// seq_cst에서: r1==0 && r2==0은 불가능// (두 store 중 하나가 다른 store보다 먼저 일어나야 함)6. acq_rel — Read-Modify-Write 연산
섹션 제목: “6. acq_rel — Read-Modify-Write 연산”fetch_add, exchange, compare_exchange 같은 RMW 연산에 사용합니다.
std::atomic<int> value{0};
// 이전 쓰기가 이 연산 이전에 보이고,// 이 연산 이후 쓰기가 이 연산 이후에 보임int old = value.fetch_add(1, std::memory_order_acq_rel);7. 스핀락 구현 예
섹션 제목: “7. 스핀락 구현 예”class SpinLock{ std::atomic_flag _flag = ATOMIC_FLAG_INIT;
public: void Lock() { // test_and_set: acquire — 이전 읽기/쓰기가 락 획득 이후에 보임 while (_flag.test_and_set(std::memory_order_acquire)) { // CPU 힌트: 스핀 중임을 알림 (x86 PAUSE, ARM YIELD)#if defined(__x86_64__) || defined(_M_X64) __builtin_ia32_pause();#endif } }
void Unlock() { // release — 락 내부 쓰기가 언락 이전에 보임 _flag.clear(std::memory_order_release); }};8. memory_order 선택 가이드
섹션 제목: “8. memory_order 선택 가이드”단순 카운터 (순서 무관) → relaxed생산자-소비자 (데이터 공유) → release (store) + acquire (load)RMW 연산 (fetch_add 등) → acq_rel전체 순서 보장 필요 (안전 우선) → seq_cst (기본값)memory_order_relaxed는 원자성만 보장하며 가장 빠르다. 순서가 중요하지 않은 통계 카운터에 적합하다.- 데이터를 다른 스레드에 넘길 때는 반드시
release(store) +acquire(load) 쌍을 사용해 happens-before를 형성한다. memory_order_seq_cst는 가장 안전하지만 ARM 등 약한 메모리 모델 아키텍처에서 성능 비용이 있다.- 잘못된
memory_order사용은 UB(Undefined Behavior)가 아니라 데이터 레이스를 유발하므로, 결과가 비결정적으로 나타나 디버깅이 극도로 어렵다.