C++ Memory Order | '메모리 순서' 가이드
이 글의 핵심
C++ Memory Order - "메모리 순서" 가이드. C++ Memory Order의 메모리 순서란?, 메모리 순서 종류, 실전 예시를 실전 코드와 함께 설명합니다.
메모리 순서란?
메모리 순서(memory order)는 여러 스레드가 동시에 원자적 변수에 접근할 때, 각 스레드의 읽기·쓰기가 다른 스레드의 눈에 어떤 순서로 보일지를 규정하는 규칙입니다. 컴파일러와 CPU는 성능을 위해 프로그램에 작성된 순서와 실제 실행 순서를 자유롭게 재정렬할 수 있는데, 단일 스레드 안에서는 이런 재정렬이 결과에 영향을 주지 않지만 여러 스레드가 같은 데이터를 공유하는 순간 문제가 됩니다. 아래 예시에서 Thread 1은 x를 먼저 쓰고 y를 나중에 쓰지만, memory_order_relaxed는 이 순서를 다른 스레드에 보이는 순서로 보장하지 않으므로 Thread 2는 y가 이미 갱신된 것을 봤는데도 x는 아직 갱신되지 않은 것처럼 관측할 수 있습니다. 이런 직관에 반하는 결과가 실제로 발생할 수 있다는 것이 메모리 순서를 정확히 이해해야 하는 이유입니다.
#include <atomic>
std::atomic<int> x{0};
std::atomic<int> y{0};
// Thread 1
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
// Thread 2
int r1 = y.load(std::memory_order_relaxed);
int r2 = x.load(std::memory_order_relaxed);
// r1=1, r2=0 가능 (재정렬)
메모리 순서 종류
C++11 메모리 모델은 순서 보장 강도에 따라 크게 세 단계의 메모리 순서를 제공하며, 강도가 높을수록 안전하지만 성능은 낮아지는 트레이드오프가 있습니다. memory_order_relaxed는 해당 원자적 연산의 원자성만 보장할 뿐 다른 메모리 연산과의 순서는 전혀 보장하지 않아 가장 빠르지만, 여러 변수 사이의 인과 관계를 표현할 수 없습니다. memory_order_acquire/memory_order_release는 한 스레드의 쓰기(release)와 다른 스레드의 읽기(acquire)를 짝지어, release 이전의 모든 쓰기가 짝이 되는 acquire 이후에는 반드시 보이도록 보장하는 실용적인 절충안입니다. 마지막으로 memory_order_seq_cst는 모든 스레드가 마치 하나의 전역 순서로 실행된 것처럼 보이도록 가장 강하게 보장하며, 별도로 순서를 지정하지 않았을 때의 기본값으로 채택되어 있습니다.
// 1. memory_order_relaxed
// - 순서 보장 없음
// - 가장 빠름
// 2. memory_order_acquire/release
// - 획득-해제 의미론
// - 일반적 사용
// 3. memory_order_seq_cst (기본)
// - 순차 일관성
// - 가장 강함
실전 예시
세 가지 메모리 순서가 실제로 어떤 상황에 적합한지는 구체적인 사용 사례를 통해 보는 것이 가장 명확합니다. 아래 예시들은 순서가 필요 없는 단순 카운터, 생산자-소비자 동기화, 순차 일관성이 필요한 경우, 그리고 실전에서 자주 쓰이는 스핀락 구현을 차례로 다룹니다.
예시 1: relaxed
단순히 값을 누적하기만 하고 다른 변수와의 순서 관계가 전혀 필요 없는 카운터는 memory_order_relaxed를 쓰기에 가장 적합한 경우입니다. 아래 increment 함수는 여러 스레드에서 동시에 호출되어도 fetch_add의 원자성 덕분에 값이 유실되거나 잘못 누적되는 일은 없지만, 각 스레드의 증가 연산이 서로 어떤 순서로 보이는지는 전혀 보장하지 않습니다. 이런 특성 때문에 relaxed는 통계 카운터나 참조 카운트처럼 “정확한 최종 값”만 중요하고 “중간 관측 순서”는 상관없는 경우에 가장 낮은 오버헤드로 원자성을 확보하는 수단이 됩니다.
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1, std::memory_order_relaxed);
}
// 여러 스레드에서 호출
// 순서 보장 없지만 원자성 보장
예시 2: acquire-release
생산자-소비자 패턴은 acquire-release 의미론이 가장 빛을 발하는 대표적인 상황입니다. 아래 예시에서 Producer는 data에 실제 값을 먼저 쓴 뒤 ready를 memory_order_release로 true로 설정하는데, release는 “이 store 이전의 모든 쓰기가 완료되었음을 보장한 뒤에 이 값을 공개한다”는 의미를 가집니다. Consumer는 ready를 memory_order_acquire로 읽어 true를 확인하는 순간, release 이전에 있었던 data = 42라는 쓰기까지 반드시 보인다는 것이 acquire-release 쌍의 핵심 보장입니다. 이 덕분에 data 자체는 원자적 변수가 아니어도 되고(다만 별도의 데이터 레이스 방지가 필요), ready라는 하나의 플래그만으로 두 스레드 사이의 동기화 지점을 만들 수 있습니다.
std::atomic<bool> ready{false};
int data = 0;
// Thread 1 (Producer)
void produce() {
data = 42; // 1
ready.store(true, std::memory_order_release); // 2
// 1이 2보다 먼저 실행 보장
}
// Thread 2 (Consumer)
void consume() {
while (!ready.load(std::memory_order_acquire)) {}
// ready가 true면 data = 42 보장
std::cout << data << std::endl; // 42
}
예시 3: seq_cst
memory_order_seq_cst는 관련된 모든 원자적 연산이 프로그램 전체에서 단 하나의 전역 순서에 맞춰 실행된 것처럼 보이도록 보장하는 가장 강력한 순서입니다. 아래 예시에서 두 스레드가 각각 x와 y를 순서대로 쓰고 읽는데, acquire-release만으로는 두 스레드가 서로 “상대방이 아직 아무것도 안 쓴 것처럼” 보는 r1=0, r2=0이라는 결과가 이론적으로 가능하지만, seq_cst를 쓰면 이런 모순된 관측이 원천적으로 배제됩니다. 이런 강한 보장은 여러 원자적 변수 사이의 복잡한 상호작용을 다룰 때 안심하고 쓸 수 있게 해주지만, 그만큼 CPU에게 더 많은 동기화 명령(메모리 펜스)을 요구하므로 relaxed나 acquire-release보다 성능 비용이 더 큽니다.
std::atomic<int> x{0};
std::atomic<int> y{0};
// Thread 1
x.store(1, std::memory_order_seq_cst);
int r1 = y.load(std::memory_order_seq_cst);
// Thread 2
y.store(1, std::memory_order_seq_cst);
int r2 = x.load(std::memory_order_seq_cst);
// r1=0, r2=0 불가능 (순차 일관성)
예시 4: 스핀락
스핀락은 acquire-release 의미론을 활용하는 가장 실용적인 예시 중 하나로, 뮤텍스처럼 스레드를 잠재우는 대신 락이 풀릴 때까지 반복문으로 계속 확인(spin)하는 락 구현입니다. 아래 SpinLock 클래스의 lock()은 test_and_set을 memory_order_acquire로 호출해 이전에 락을 잡았던 스레드가 임계 구역에서 수행한 모든 쓰기가 보이도록 보장하고, unlock()은 memory_order_release로 플래그를 해제해 자신이 임계 구역에서 한 쓰기가 다음 락 획득자에게 보이도록 보장합니다. 이 구조는 뮤텍스보다 락 대기 시간이 아주 짧을 것으로 예상되는 상황(예: 임계 구역이 몇 개의 명령어에 불과한 경우)에서 컨텍스트 스위칭 비용을 피하기 위해 사용되지만, 대기 시간이 길어지면 CPU를 낭비하며 계속 도는 바쁜 대기(busy-wait)가 되므로 신중하게 적용해야 합니다.
class SpinLock {
std::atomic_flag flag = ATOMIC_FLAG_INIT;
public:
void lock() {
while (flag.test_and_set(std::memory_order_acquire)) {
// 스핀
}
}
void unlock() {
flag.clear(std::memory_order_release);
}
};
acquire-release 의미론
앞선 예시를 조금 더 일반화하면, acquire-release 패턴의 핵심은 “release 이전의 쓰기는 짝이 되는 acquire 이후에 반드시 보인다”는 한 문장으로 요약할 수 있습니다. 아래 코드에서 data에 대한 쓰기는 memory_order_relaxed로 이루어지지만, 바로 뒤이은 ready.store(true, memory_order_release)가 그 이전의 모든 쓰기를 “release 이전”으로 묶어주기 때문에 결과적으로 data의 갱신도 함께 동기화됩니다. Consumer 쪽에서도 ready를 memory_order_acquire로 확인한 뒤에야 data를 읽으므로, data 자체를 무거운 순서로 감쌀 필요 없이 ready라는 단일한 동기화 지점을 통해 전체 데이터 교환을 안전하게 만들 수 있습니다.
std::atomic<int> data{0};
std::atomic<bool> ready{false};
// Producer
void produce() {
data.store(42, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
// release 전 모든 쓰기 완료
}
// Consumer
void consume() {
while (!ready.load(std::memory_order_acquire)) {}
// acquire 후 모든 읽기 시작
int value = data.load(std::memory_order_relaxed);
}
자주 발생하는 문제
메모리 순서는 컴파일도 되고 대부분의 경우 잘 동작하는 것처럼 보이다가, 특정 하드웨어나 특정 타이밍에서만 드물게 실패하는 버그를 만들기 쉬운 영역입니다. 아래 네 가지는 실무에서 특히 자주 발견되는 실수입니다.
문제 1: relaxed 오용
memory_order_relaxed로 여러 변수를 쓰면서 그 변수들 사이에 순서 관계가 있다고 암묵적으로 가정하는 것은 가장 흔하면서도 위험한 실수입니다. 아래 잘못된 예시에서 Thread 1은 x를 쓴 뒤 y를 쓰지만, y와 x 모두 relaxed이므로 Thread 2가 y == 1을 확인했다고 해서 x == 1이라는 보장은 전혀 없습니다. 이 문제를 해결하려면 두 변수 중 나중에 쓰는 쪽(y)을 release로, 그것을 확인하는 읽기를 acquire로 바꿔 명시적인 동기화 지점을 만들어야 하며, 그래야만 y == 1을 관측한 시점에 x == 1도 함께 보장됩니다.
// ❌ 순서 의존
std::atomic<int> x{0}, y{0};
// Thread 1
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_relaxed);
// Thread 2
if (y.load(std::memory_order_relaxed) == 1) {
// x == 1 보장 안 됨
}
// ✅ acquire-release
x.store(1, std::memory_order_relaxed);
y.store(1, std::memory_order_release);
if (y.load(std::memory_order_acquire) == 1) {
// x == 1 보장
}
문제 2: 비원자적 접근
std::atomic<int> x를 일반 int처럼 int value = x;로 대입하면 암묵적으로 x.load()가 호출되지만, 이때 사용되는 메모리 순서는 기본값인 memory_order_seq_cst로 고정되어 있어서 개발자가 원하는 순서를 선택할 수 없습니다. 대부분의 상황에서는 이 기본값이 안전하게 동작하지만, 성능이 중요한 경로에서 relaxed나 acquire를 명시적으로 쓰고 싶은데도 암묵적 변환 문법을 사용하면 항상 가장 무거운 seq_cst가 적용되어 불필요한 성능 손해를 보게 됩니다. 그래서 원자적 변수를 다룰 때는 암묵적 변환에 의존하기보다 x.load(std::memory_order_acquire)처럼 원하는 순서를 항상 명시적으로 지정하는 습관을 들이는 것이 좋습니다.
std::atomic<int> x{0};
// ❌ 비원자적 접근
int value = x; // 암시적 load (seq_cst)
// ✅ 명시적 순서
int value = x.load(std::memory_order_acquire);
문제 3: 성능
fetch_add처럼 메모리 순서를 명시하지 않고 호출하면 기본값인 memory_order_seq_cst가 적용되는데, 이는 다른 변수와의 동기화가 필요 없는 단순 카운터에서조차 불필요하게 강한 동기화 명령을 CPU에 요구하게 만듭니다. 특히 여러 코어가 같은 카운터를 동시에 갱신하는 고빈도 연산에서는 seq_cst와 relaxed 사이의 성능 차이가 눈에 띄게 벌어질 수 있으며, 이는 캐시 라인 동기화와 메모리 펜스 비용의 차이에서 비롯됩니다. 그래서 다른 메모리 연산과의 순서 관계가 실제로 필요하지 않다는 것이 명확하다면, 기본값을 그대로 두기보다 memory_order_relaxed를 명시적으로 지정해 불필요한 동기화 비용을 없애는 것이 유리합니다.
// ❌ seq_cst (느림)
counter.fetch_add(1); // 기본 seq_cst
// ✅ relaxed (빠름)
counter.fetch_add(1, std::memory_order_relaxed);
문제 4: 복잡한 동기화
여러 개의 변수를 하나의 신호(flag)로 함께 동기화해야 할 때, 각 변수마다 개별적으로 acquire-release를 적용하려 하면 코드가 복잡해지고 실수하기도 쉽습니다. 아래 예시처럼 x와 y를 모두 memory_order_relaxed로 쓰고, 마지막에 ready만 memory_order_release로 설정하면 그 이전의 모든 relaxed 쓰기(x, y 둘 다)가 함께 release 의미론에 묶여, Consumer가 ready를 acquire로 확인한 뒤에는 x와 y 모두 안전하게 보입니다. 이렇게 “묶어서 한 번에 동기화”하는 패턴을 이해하면, 변수 개수가 늘어나도 매번 별도의 동기화 지점을 만들 필요 없이 하나의 플래그로 깔끔하게 여러 데이터의 가시성을 보장할 수 있습니다.
// 여러 변수 동기화
std::atomic<int> x{0}, y{0};
std::atomic<bool> ready{false};
// Producer
x.store(1, std::memory_order_relaxed);
y.store(2, std::memory_order_relaxed);
ready.store(true, std::memory_order_release);
// Consumer
while (!ready.load(std::memory_order_acquire)) {}
// x, y 모두 보장
성능 비교
지금까지 다룬 세 가지 메모리 순서는 안전성과 성능이 정확히 반비례하는 관계에 있습니다. memory_order_relaxed는 원자성만 보장하고 순서에 대한 별도 동기화 명령이 필요 없어 가장 빠르지만 스레드 간 인과 관계를 표현할 수 없고, memory_order_acquire/memory_order_release는 짝을 이루는 지점에서만 동기화 비용이 발생해 relaxed와 seq_cst의 중간 정도 성능을 내며, memory_order_seq_cst는 모든 스레드에 걸친 전역 순서를 보장하기 위해 매번 가장 강한 메모리 펜스를 필요로 해 가장 느립니다. 실무에서는 먼저 seq_cst(기본값)로 정확성을 확보한 뒤, 프로파일링으로 실제 병목이 확인된 원자적 연산에 한해서만 relaxed나 acquire-release로 단계적으로 완화하는 접근이 안전합니다.
// relaxed: 가장 빠름
counter.fetch_add(1, std::memory_order_relaxed);
// acquire/release: 중간
flag.store(true, std::memory_order_release);
// seq_cst: 가장 느림
x.store(1, std::memory_order_seq_cst);
FAQ
Q1: 메모리 순서는?
A: 멀티스레드 메모리 연산 순서 지정.
Q2: 종류는?
A:
- relaxed: 순서 없음
- acquire/release: 획득-해제
- seq_cst: 순차 일관성
Q3: 언제 사용?
A:
- relaxed: 카운터
- acquire/release: 동기화
- seq_cst: 복잡한 동기화
Q4: 성능?
A: relaxed > acquire/release > seq_cst.
Q5: 기본값?
A: seq_cst (가장 안전).
Q6: 메모리 순서 학습 리소스는?
A:
- “C++ Concurrency in Action”
- “Memory Ordering at Compile Time”
- cppreference.com
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ Atomic Operations | “원자적 연산” 가이드
- C++ Atomic | “메모리 순서” 완벽 가이드
- C++ Lock-Free Programming | “락 프리 프로그래밍” 가이드
관련 글
- C++ Atomic Operations
- C++ Atomic
- C++ Lock-Free Programming
- C++ shared_future | 여러 스레드에서 future 결과 공유
- C++ async & launch
이 글에서 다루는 키워드 (관련 검색어)
C++, memory-order, atomic, C++11, concurrency 등으로 검색하시면 이 글이 도움이 됩니다.