콘텐츠로 이동

C++ Memory Order & Happens-Before

멀티코어 CPU는 성능을 위해 명령어 순서를 재배치(reorder)합니다. 컴파일러도 최적화 과정에서 코드 순서를 바꿀 수 있습니다. C++11 메모리 모델은 이러한 재배치를 제어하는 std::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 // 전체 순서 보장 (가장 강함, 기본값)
};
}

스레드 A의 연산 X가 스레드 B의 연산 Y보다 happens-before라면, B는 A의 결과를 반드시 볼 수 있습니다.

Release store → Acquire load 짝이 형성되면 happens-before 관계 성립
Thread A Thread B
data = 42; while (!ready.load(acquire)) {}
ready.store(true, release); // happens-before
assert(data == 42); // 반드시 참

순서 제약이 전혀 없습니다. 원자적 읽기/쓰기만 보장하고 다른 연산과의 순서는 보장하지 않습니다. 카운터 증가처럼 순서가 중요하지 않을 때 가장 빠릅니다.

#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);
}

생산자-소비자 패턴의 표준 구현입니다.

#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); // Producer
ready.load(std::memory_order_relaxed); // Consumer
// data의 값이 0일 수도 있음!

모든 seq_cst 연산은 단일 전역 순서를 공유합니다. 가장 직관적이지만 x86 이외의 아키텍처(ARM, PowerPC)에서는 메모리 배리어 명령이 추가됩니다.

std::atomic<int> x{0}, y{0};
std::atomic<int> r1{0}, r2{0};
// Thread 1
void T1() { x.store(1); r1 = y.load(); }
// Thread 2
void T2() { y.store(1); r2 = x.load(); }
// seq_cst에서: r1==0 && r2==0은 불가능
// (두 store 중 하나가 다른 store보다 먼저 일어나야 함)

fetch_add, exchange, compare_exchange 같은 RMW 연산에 사용합니다.

std::atomic<int> value{0};
// 이전 쓰기가 이 연산 이전에 보이고,
// 이 연산 이후 쓰기가 이 연산 이후에 보임
int old = value.fetch_add(1, std::memory_order_acq_rel);

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);
}
};

단순 카운터 (순서 무관) → 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)가 아니라 데이터 레이스를 유발하므로, 결과가 비결정적으로 나타나 디버깅이 극도로 어렵다.