C++ 메모리 모델: happens-before, memory_order 선택 기준, 데이터 레이스가 UB인 이유
이 글의 핵심
C++11 이전에는 스레드가 언어 표준에 존재하지 않아, 멀티스레드 코드가 실제로 안전한지는 컴파일러와 플랫폼의 관례에 의존했습니다. C++11의 메모리 모델은 '어떤 스레드의 메모리 쓰기가 언제 다른 스레드에 보이는가'를 happens-before/synchronizes-with 관계로 정확히 정의하고, memory_order로 그 관계를 얼마나 엄격하게 요구할지 선택할 수 있게 합니다. 이 글은 이 개념들을 실전 코드와 함께 정리합니다.
메모리 모델이란?
메모리 모델은 멀티스레드 환경에서 한 스레드의 메모리 접근이 다른 스레드에게 언제, 어떤 순서로 보이는지를 규정하는 규칙입니다.
// 데이터 레이스 예시
int x = 0;
// 스레드 1
x = 1;
// 스레드 2
cout << x << endl; // 0? 1? 미정의!
이 코드가 “0 또는 1 중 하나가 출력된다”처럼 예측 가능한 결과를 내는 것도 아니라는 점이 중요합니다. C++ 표준은 동기화 없이 한 스레드가 쓰고 다른 스레드가 읽는 이런 상황을 데이터 레이스로 규정하고, 데이터 레이스가 있는 프로그램의 동작 전체를 정의되지 않은 동작(UB)으로 취급합니다. 실무에서는 이게 “운 좋으면 항상 예상대로 동작하다가, 컴파일러 버전이나 최적화 옵션을 바꾸는 순간 갑자기 깨지는” 버그로 나타나는 경우가 많습니다.
happens-before
#include <atomic>
#include <thread>
atomic<bool> ready(false);
int data = 0;
void producer() {
data = 42; // A
ready.store(true, memory_order_release); // B
}
void consumer() {
while (!ready.load(memory_order_acquire)); // C
cout << data << endl; // D: 42 보장
}
// A happens-before B
// B synchronizes-with C (C가 B가 저장한 true를 읽었을 때)
// 따라서 B happens-before C
// A happens-before D
happens-before는 “A가 happens-before B이면, A의 결과가 B를 실행하는 스레드에게 반드시 보인다”는 관계입니다. 이 예제가 왜 data가 항상 42로 보이는지 설명하는 방식은 연쇄적입니다: 같은 스레드 안에서는 순서대로 실행되므로 A→B가 성립하고, C가 B가 저장한 값을 읽으면 release 저장 B가 acquire 로드 C와 synchronizes-with 관계를 맺어 B happens-before C가 성립하며, 그 결과 A→D까지 이어집니다. 이 “연쇄”를 눈으로 따라갈 수 있어야 memory_order를 정확히 쓸 수 있습니다 — 중간에 한 링크라도 relaxed로 약해지면 이 연쇄가 끊어집니다.
synchronizes-with
atomic<int> x(0);
atomic<int> y(0);
// 스레드 1
void thread1() {
x.store(1, memory_order_release); // A
}
// 스레드 2
void thread2() {
while (x.load(memory_order_acquire) == 0); // B
y.store(1, memory_order_release); // C
}
// A synchronizes-with B
// B happens-before C
synchronizes-with는 서로 다른 스레드 사이에서 성립하는 관계로, release 쓰기와 그 값을 관찰하는 acquire 읽기 사이에서만 발생합니다. 여기서 눈여겨볼 점은 synchronizes-with(스레드 간)와 happens-before(같은 스레드 내 순서 포함)가 서로 다른 개념이지만 이렇게 연결되어 “A가 결국 C보다 먼저 일어났다”는 더 큰 보장을 만들어낸다는 것입니다.
실전 예시
예시 1: 스핀락
class SpinLock {
private:
atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(memory_order_acquire)) {
// 스핀
}
}
void unlock() {
flag.clear(memory_order_release);
}
};
int counter = 0;
SpinLock lock; // 모든 스레드가 같은 락 객체를 공유해야 함
void increment() {
for (int i = 0; i < 1000; i++) {
lock.lock();
counter++;
lock.unlock();
}
}
int main() {
thread t1(increment);
thread t2(increment);
t1.join();
t2.join();
cout << counter << endl; // 2000
}
lock()의 acquire와 unlock()의 release가 정확히 위에서 다룬 synchronizes-with 관계를 만들어, 한 스레드가 락을 풀기 전에 쓴 counter 값이 다음에 락을 잡는 스레드에게 반드시 보이도록 보장합니다. 이 메모리 순서 없이 순수하게 relaxed로만 구현했다면, 락이 상호 배제는 지키더라도 counter 값의 가시성은 보장하지 못해 결과가 2000이 아닐 수 있습니다.
이런 코드에서 흔한 실수는 SpinLock lock;을 increment() 함수 안에 선언하는 것입니다. 그러면 스레드마다 자기만의 락 객체를 갖게 되어, 각자 아무도 경쟁하지 않는 락을 잡고 풀 뿐 counter는 전혀 보호되지 않습니다. 메모리 순서를 아무리 정확하게 골라도 “같은 원자 변수”를 두고 동기화하지 않으면 synchronizes-with 관계 자체가 생기지 않는다는 것을 잘 보여 주는 실수입니다. 이런 코드는 반복 횟수가 적으면 우연히 2000이 나오기도 해서, 반복 횟수를 백만 단위로 늘리거나 ThreadSanitizer로 돌려 봐야 문제가 드러납니다.
스핀락은 교육용으로는 좋지만 실무에서 직접 쓸 일은 드뭅니다. 락을 잡은 스레드가 선점되면 다른 스레드들은 CPU를 100% 쓰면서 헛돌고, test_and_set이 매번 캐시 라인을 쓰기 상태로 가져오므로 코어가 많을수록 캐시 라인이 코어 사이를 오가는 비용이 커집니다. 굳이 쓴다면 C++20의 flag.test(memory_order_relaxed)로 먼저 읽기만 하며 기다리다가 풀렸을 때만 test_and_set을 시도하는 방식(test-and-test-and-set)이 경합을 줄여 줍니다. 대부분의 경우에는 운영체제가 대기 중인 스레드를 재워 주는 std::mutex가 더 낫습니다.
예시 2: 더블 체크 락킹
class Singleton {
private:
static atomic<Singleton*> instance;
static mutex mtx;
Singleton() {}
public:
static Singleton* getInstance() {
Singleton* tmp = instance.load(memory_order_acquire);
if (tmp == nullptr) {
lock_guard<mutex> lock(mtx);
tmp = instance.load(memory_order_relaxed);
if (tmp == nullptr) {
tmp = new Singleton();
instance.store(tmp, memory_order_release);
}
}
return tmp;
}
};
atomic<Singleton*> Singleton::instance(nullptr);
mutex Singleton::mtx;
C++11 이전에는 이 패턴이 유명한 함정이었습니다 — 원시 포인터로 짜면 new Singleton()이 “메모리 할당 → 생성자 실행 → 포인터 대입” 세 단계로 재배치될 수 있어, 다른 스레드가 아직 생성자가 끝나지 않은 반쯤 만들어진 객체의 포인터를 읽어버리는 사고가 날 수 있었습니다. atomic<Singleton*>과 release/acquire를 쓰면 “포인터가 non-null로 보인다”는 것이 곧 “생성자가 끝난 객체”라는 것까지 함께 보장되므로, 이 재배치 문제가 사라집니다. 바깥쪽의 첫 load가 락 없이 먼저 확인하는 이유는, 인스턴스가 이미 만들어진 뒤의 압도적으로 많은 호출에서 뮤텍스를 아예 건드리지 않게 하기 위한 최적화입니다.
락 안의 두 번째 load가 relaxed여도 되는 이유는, 뮤텍스가 이미 순서를 보장하기 때문입니다. 다른 스레드가 같은 뮤텍스 안에서 store를 했다면 뮤텍스의 잠금·해제가 그 쓰기를 보이게 해 줍니다. 다만 실무에서 이 패턴을 직접 쓸 필요는 거의 없습니다. C++11부터 함수 안의 static 지역 변수 초기화는 스레드 안전하게 한 번만 일어나도록 표준이 보장하므로(흔히 “Meyers 싱글턴”이라고 부릅니다) static Singleton& get() { static Singleton s; return s; } 한 줄이 같은 일을 더 안전하게 합니다. 컴파일러는 내부적으로 거의 같은 더블 체크를 생성해 줍니다. 직접 구현한 더블 체크 락킹이 필요한 경우는 지연 초기화 대상이 static이 아니거나 초기화 실패 후 재시도 같은 특별한 요구가 있을 때 정도입니다.
예시 3: 생산자-소비자
#include <queue>
class SafeQueue {
private:
queue<int> q;
mutex mtx;
condition_variable cv;
public:
void push(int value) {
{
lock_guard<mutex> lock(mtx);
q.push(value);
}
cv.notify_one();
}
int pop() {
unique_lock<mutex> lock(mtx);
cv.wait(lock, [this] { return !q.empty(); });
int value = q.front();
q.pop();
return value;
}
};
void producer(SafeQueue& queue) {
for (int i = 0; i < 10; i++) {
queue.push(i);
cout << "생산: " << i << endl;
this_thread::sleep_for(chrono::milliseconds(100));
}
}
void consumer(SafeQueue& queue) {
for (int i = 0; i < 10; i++) {
int value = queue.pop();
cout << "소비: " << value << endl;
}
}
int main() {
SafeQueue queue;
thread t1(producer, ref(queue));
thread t2(consumer, ref(queue));
t1.join();
t2.join();
}
이 예제는 앞서 다룬 atomic/memory_order 대신 뮤텍스와 조건 변수로 동기화합니다. 뮤텍스 자체가 락을 걸고 풀 때마다 필요한 메모리 순서 보장을 내부적으로 이미 해주기 때문에, 이 코드 어디에도 명시적인 memory_order가 등장하지 않습니다. 실무에서는 이 예제처럼 “굳이 원자적 연산의 세부 메모리 순서를 직접 다룰 필요 없이 뮤텍스/조건 변수로 충분한 경우”가 훨씬 많고, atomic과 memory_order를 직접 조작하는 건 뮤텍스 오버헤드가 프로파일링으로 병목임이 확인된 뒤에 고려하는 것이 순서입니다.
메모리 순서
Relaxed
atomic<int> counter(0);
void increment() {
counter.fetch_add(1, memory_order_relaxed);
}
// 순서 보장 없음, 가장 빠름
relaxed는 오직 그 연산 자체의 원자성(중간 상태 없이 통째로 일어남)만 보장하고, 다른 메모리 접근과의 순서 관계는 전혀 보장하지 않습니다. 이 카운터 값 자체 외에는 다른 어떤 데이터의 가시성도 이 연산에 의존하지 않을 때만 안전한 선택입니다.
Acquire-Release
atomic<bool> ready(false);
int data = 0;
void producer() {
data = 42;
ready.store(true, memory_order_release);
}
void consumer() {
while (!ready.load(memory_order_acquire));
cout << data << endl; // 42 보장
}
acquire/release는 세 가지 순서 중 가장 실용적인 중간 지점입니다. seq_cst보다 하드웨어에 따라 더 저렴하면서도, “이 플래그가 true로 보였다면 그 전에 쓴 데이터도 함께 보인다”는 실무에서 가장 자주 필요한 보장(생산자-소비자 패턴)을 정확히 제공합니다.
“하드웨어에 따라”라는 단서가 중요합니다. x86은 원래 메모리 순서가 강한 편이라 acquire 로드와 release 저장이 일반 mov 명령으로 컴파일되고, 비용 차이는 주로 seq_cst 저장(xchg나 mfence가 붙음)에서 생깁니다. 반면 ARM이나 POWER처럼 약한 메모리 모델의 CPU에서는 relaxed와 acquire/release 사이에도 명령어 차이가 생깁니다. 그래서 x86 개발 PC에서 relaxed로 잘못 짠 코드가 수년간 문제없이 돌다가, ARM 서버나 Apple Silicon으로 옮기는 순간 간헐적으로 깨지는 일이 실제로 일어납니다. 테스트가 x86에서만 돌고 있다면 메모리 순서 버그는 사실상 검증되지 않은 상태라고 보는 것이 맞습니다.
Sequential Consistency
atomic<int> x(0);
atomic<int> y(0);
// 모든 스레드가 같은 순서로 봄
x.store(1, memory_order_seq_cst);
y.store(1, memory_order_seq_cst);
seq_cst(기본값)는 모든 스레드가 모든 원자적 연산을 정확히 같은 하나의 전역 순서로 관찰하도록 보장하는 가장 강력하고 가장 이해하기 쉬운 순서입니다.
acquire/release로는 부족하고 seq_cst가 꼭 필요한 대표적인 경우는 “내가 쓴 뒤 상대가 쓴 것을 읽는” 대칭 패턴입니다.
atomic<int> x(0), y(0);
int r1, r2;
void t1() { x.store(1, memory_order_release); r1 = y.load(memory_order_acquire); }
void t2() { y.store(1, memory_order_release); r2 = x.load(memory_order_acquire); }
// acquire/release: r1 == 0 && r2 == 0 이 가능
// seq_cst로 바꾸면: 둘 다 0인 결과는 불가능
직관적으로는 두 스레드 중 하나는 먼저 저장했을 테니 적어도 한쪽은 1을 읽어야 할 것 같습니다. 하지만 release 저장과 그 뒤의 acquire 로드는 서로 순서가 보장되지 않아, CPU가 저장을 스토어 버퍼에 넣어 둔 채 로드를 먼저 실행할 수 있습니다. 이 현상은 x86에서도 실제로 관찰됩니다. 두 스레드가 “상대가 깃발을 들었는지” 확인하는 Dekker·Peterson 알고리즘이나, 두 플래그를 동시에 보는 종료 조건 같은 코드가 이 패턴에 해당합니다. acquire/release만으로는 설명하기 어려운 복잡한 다대다 동기화 패턴에서 “일단 seq_cst로 짜서 정확성부터 확보하고, 프로파일링으로 병목이 확인된 부분만 완화한다”는 접근이 실무에서 안전한 기본 전략입니다.
데이터 레이스
// ❌ 데이터 레이스
int counter = 0;
void increment() {
counter++; // 여러 스레드에서 (UB)
}
// ✅ atomic 사용
atomic<int> counter(0);
void increment() {
counter++; // 안전
}
// ✅ mutex 사용
int counter = 0;
mutex mtx;
void increment() {
lock_guard<mutex> lock(mtx);
counter++;
}
counter++가 위험한 이유는 이 한 줄이 실제로는 “읽기 → 더하기 → 쓰기” 세 단계이기 때문입니다. 두 스레드가 동시에 이 세 단계를 실행하면 한쪽의 증가분이 사라지는 전형적인 레이스 컨디션이 발생합니다. atomic<int>로 바꾸면 ++ 연산자가 자체적으로 이 세 단계를 하나의 원자적 연산으로 묶어주고, mutex로 감싸면 애초에 두 스레드가 이 세 단계를 동시에 실행하지 못하도록 막습니다 — 접근 방식은 다르지만 목표는 같습니다.
자주 발생하는 문제
문제 1: 잘못된 메모리 순서
// ❌ relaxed (데이터 레이스)
atomic<bool> ready(false);
int data = 0;
void producer() {
data = 42;
ready.store(true, memory_order_relaxed); // 잘못됨!
}
void consumer() {
while (!ready.load(memory_order_relaxed));
cout << data << endl; // 42가 아닐 수 있음!
}
// ✅ acquire-release
void producer() {
data = 42;
ready.store(true, memory_order_release);
}
void consumer() {
while (!ready.load(memory_order_acquire));
cout << data << endl; // 42 보장
}
ready라는 원자적 변수를 썼다는 사실만으로 안전해졌다고 착각하기 쉬운 대표적인 함정입니다. atomic은 ready 자체에 대한 접근만 데이터 레이스에서 보호할 뿐, relaxed를 쓰면 data라는 일반 변수와의 순서 관계는 전혀 보장하지 않습니다. “원자적 타입을 썼으니 안전하다”가 아니라 “정확한 memory_order까지 함께 골랐을 때만 안전하다”는 것이 이 예제의 핵심 교훈입니다.
문제 2: 가시성 문제
// ❌ 가시성 없음
bool flag = false;
void thread1() {
flag = true;
}
void thread2() {
while (!flag); // 무한 루프 가능!
}
// ✅ atomic 사용
atomic<bool> flag(false);
void thread1() {
flag.store(true);
}
void thread2() {
while (!flag.load());
}
이 예제의 “무한 루프 가능”이라는 표현은 과장이 아닙니다. 컴파일러가 while (!flag)를 최적화하면서 flag가 루프 안에서 절대 바뀌지 않는다고(다른 스레드가 바꾼다는 걸 모르므로) 가정하고 루프 조건 검사 자체를 통째로 제거하거나 레지스터에 캐싱해버릴 수 있어, 실제로 다른 스레드가 flag를 true로 바꿔도 이 스레드는 영원히 그 변화를 보지 못할 수 있습니다. atomic<bool>은 컴파일러에게 “이 변수는 다른 스레드가 바꿀 수 있으니 이런 최적화를 하지 말라”는 신호를 정확히 전달합니다.
문제 3: 순서 재배치
// 컴파일러/CPU가 재배치 가능
int x = 0;
int y = 0;
// 스레드 1
x = 1; // A
y = 1; // B
// 재배치: B가 A보다 먼저 실행될 수 있음!
// ✅ memory_order로 순서 보장
atomic<int> x(0);
atomic<int> y(0);
x.store(1, memory_order_release);
y.store(1, memory_order_release);
단일 스레드 관점에서는 A와 B의 순서가 결과에 영향을 주지 않으므로, 컴파일러도 CPU도 이 둘을 재배치할 자유가 있습니다(“as-if 규칙” — 관찰 가능한 결과가 같다면 순서를 바꿔도 된다는 원칙). 일반 int로 둔 첫 버전은 순서 이전에 그 자체가 데이터 레이스라서 다른 스레드가 읽는 순간 UB입니다.
atomic으로 바꾼 버전에서 순서 보장이 어떻게 성립하는지 정확히 짚어 보면, 핵심은 y의 release 저장입니다. release 저장은 “이 저장보다 앞선 모든 쓰기”를 함께 넘기므로, 다른 스레드가 y.load(memory_order_acquire)로 1을 읽었다면 그보다 앞선 x = 1도 반드시 보입니다. 따라서 이 방향의 순서(x가 먼저, y가 나중)만 필요하다면 x의 저장은 relaxed여도 충분합니다. 반대로 읽는 쪽이 acquire가 아니라 relaxed로 y를 읽으면 이 보장은 성립하지 않습니다. release와 acquire는 항상 짝으로 동작한다는 점, 그리고 서로 다른 변수에 대한 저장과 로드 사이의 순서(앞 절의 r1 == r2 == 0 예제)가 필요할 때만 seq_cst가 필요하다는 점을 구분해 두면 됩니다.
FAQ
Q1: 메모리 순서는 어떻게 선택하나요?
A: 확실하지 않으면 기본값인 seq_cst로 둡니다. “플래그가 보이면 그 전에 쓴 데이터도 보여야 한다”는 한 방향 전달이면 release 저장과 acquire 로드 쌍으로 충분하고, 통계 카운터처럼 다른 데이터의 가시성이 그 값에 의존하지 않을 때만 relaxed를 씁니다. 완화는 프로파일러로 병목이 확인된 곳에서만 하는 것이 안전합니다.
Q2: happens-before와 “먼저 실행됐다”는 같은 뜻인가요?
A: 아닙니다. happens-before는 “A의 결과가 B에서 반드시 보인다”는 가시성 보장이지, 벽시계 기준 실행 시각이 아닙니다. 시간상 먼저 실행된 쓰기라도 happens-before 관계가 없으면 다른 스레드가 옛 값을 볼 수 있습니다.
Q3: 데이터 레이스와 레이스 컨디션은 같은 말인가요?
A: 다릅니다. 데이터 레이스는 동기화 없이 두 스레드가 같은 메모리 위치에 접근하고 그중 하나 이상이 쓰기인 경우로, C++에서는 UB입니다. 레이스 컨디션은 실행 순서에 따라 결과가 달라지는 논리 버그로, 모든 접근을 atomic으로 바꿔 데이터 레이스를 없애도 “확인 후 행동(check-then-act)” 같은 레이스 컨디션은 남을 수 있습니다.
Q4: 메모리 모델 버그는 어떻게 찾나요?
A: -fsanitize=thread(ThreadSanitizer)가 데이터 레이스를 실행 중에 잡아 줍니다. 단, 실제로 실행된 경로의 레이스만 보고하므로 테스트가 동시 실행 경로를 충분히 밟아야 하고, atomic_thread_fence처럼 TSan이 완전히 모델링하지 못하는 구성도 있습니다. x86에서 드러나지 않는 순서 버그는 ARM 기기에서 부하 테스트를 돌려 봐야 재현되는 경우가 많습니다.