C++ 메모리 순서 예제로 이해하기: relaxed, acquire/release, seq_cst, consume

생산자 스레드가 데이터를 채운 뒤 ready = true를 세우고, 소비자 스레드가 ready를 확인한 다음 데이터를 읽는 구조는 멀티스레드 코드에서 가장 흔한 동기화 형태입니다. 이 구조가 대부분은 잘 돌다가 가끔 초기화되지 않은 값을 읽는다면, 거의 항상 메모리 순서 문제입니다. ready를 memory_order_relaxed로 쓰고 읽었거나, ready가 원자 변수가 아니었거나, 한쪽에만 acquire/release를 붙였을 때 이런 일이 생깁니다.

이 글은 C++11의 std::atomic이 제공하는 메모리 순서(relaxed, acquire, release, acq_rel, seq_cst, consume)가 각각 무엇을 보장하는지, 어떤 패턴에 어떤 순서를 쓰는지, 그리고 자주 하는 실수가 무엇인지를 예제로 설명합니다. 예제는 C++11 이상에서 동작하며, C++20 기능을 쓰는 곳은 따로 표시했습니다.


메모리 순서가 필요한 이유

단일 스레드 안에서는 코드가 적힌 순서대로 실행되는 것처럼 보이지만, 컴파일러와 CPU는 그 착각이 깨지지 않는 범위에서 명령을 재배치합니다. 다른 스레드가 끼어들면 그 재배치가 보이게 됩니다.

// 스레드 A
data = 42;           // (1)
ready = true;        // (2)
// 스레드 B
if (ready)           // (3)
    print(data);     // (4)

컴파일러가 (2)를 (1)보다 먼저 내보내거나, CPU의 쓰기 버퍼 때문에 (2)가 다른 코어에 먼저 보이면, B는 ready == true를 보고도 data의 옛 값을 읽을 수 있습니다. 게다가 ready와 data가 둘 다 일반 변수라면 이 코드는 데이터 레이스이고, C++ 표준상 정의되지 않은 동작입니다. 메모리 순서는 원자 연산에 붙이는 인자로, “이 원자 연산을 기준으로 주변의 다른 메모리 접근이 다른 스레드에 어떤 순서로 보이는가”를 지정합니다.

메모리 순서 종류

flowchart TB
    subgraph strength["강함 → 약함"]
        S1[seq_cst: 전역 순서]
        S2[acquire/release: 쌍 동기화]
        S3[consume: 데이터 의존]
        S4[relaxed: 원자성만]
    end
    S1 --> S2 --> S3 --> S4
memory_order쓸 수 있는 연산보장
relaxed모든 원자 연산그 변수에 대한 원자성과 변수별 수정 순서만 보장하고, 다른 메모리와의 순서는 보장하지 않음
acquireload, RMW이 연산 뒤의 읽기·쓰기가 이 연산보다 앞으로 재배치되지 않음
releasestore, RMW이 연산 앞의 읽기·쓰기가 이 연산보다 뒤로 재배치되지 않음
acq_relRMW(exchange, fetch_add, CAS 등)acquire와 release를 함께 적용
consumeload이 load 결과에 데이터 의존하는 접근만 순서 보장
seq_cst모든 원자 연산acquire/release에 더해, 모든 seq_cst 연산이 하나의 전역 순서를 가짐

store에 acquire를 주거나 load에 release를 주는 것은 잘못된 사용이며, 표준상 정의되지 않은 동작입니다.

synchronizes-with와 happens-before

메모리 순서를 이해하는 핵심 개념은 두 가지입니다. 스레드 A가 원자 변수 M에 release store를 하고, 스레드 B가 M에서 acquire load로 그 값을 읽으면 A의 store가 B의 load와 synchronizes-with 관계에 있다고 합니다. 이 관계가 성립하면 A에서 그 store 이전에 일어난 모든 쓰기가 B에서 그 load 이후의 모든 읽기보다 happens-before가 되고, B는 그 쓰기들을 반드시 볼 수 있습니다.

스레드 A:  data = 42;  ready.store(true, release);
스레드 B:  while (!ready.load(acquire)) {}  x = data;   // x == 42 보장

이때 data는 원자 변수가 아니어도 됩니다. 쓰기와 읽기 사이에 happens-before 관계가 있으므로 데이터 레이스가 아닙니다. 이 점이 release/acquire를 쓰는 가장 큰 이유입니다.

happens-before는 벽시계 기준으로 먼저 실행됐다는 뜻이 아니라 “앞선 쓰기의 결과가 뒤의 읽기에서 반드시 보인다”는 가시성 보장입니다. 시간상 먼저 실행된 쓰기라도 이 관계가 없으면 다른 스레드는 옛 값을 볼 수 있습니다. 또 여기서 말하는 데이터 레이스(동기화 없이 두 스레드가 같은 위치에 접근하고 하나 이상이 쓰기인 경우, 표준상 정의되지 않은 동작)는 레이스 컨디션과 다릅니다. 모든 접근을 원자 연산으로 바꿔 데이터 레이스를 없애도, “값을 확인한 뒤 그 값에 따라 행동하는” 코드는 두 단계 사이에 다른 스레드가 끼어들 수 있어 논리 버그로 남습니다. volatile은 두 문제 어느 쪽도 해결하지 못합니다. C++의 volatile은 메모리 맵 I/O처럼 접근 자체를 생략하지 말라는 표시일 뿐, 원자성도 스레드 간 순서도 보장하지 않습니다.


memory_order_relaxed: 카운터와 통계

relaxed는 원자성만 보장합니다. 여러 스레드가 동시에 fetch_add해도 증가분이 사라지지 않고, 한 변수에 대한 수정들은 모든 스레드가 같은 순서로 관찰합니다. 하지만 그 변수와 다른 메모리 사이의 순서는 아무것도 보장하지 않습니다. 그래서 그 변수 하나의 값만 의미가 있을 때만 써야 합니다.

#include <atomic>
#include <cstdint>
#include <iostream>
#include <thread>
#include <vector>

std::atomic<std::uint64_t> counter{0};

void worker(int n) {
    for (int i = 0; i < n; ++i) {
        // 다른 변수와 동기화할 필요가 없으므로 relaxed로 충분
        counter.fetch_add(1, std::memory_order_relaxed);
    }
}

int main() {
    const int num_threads = 8;
    const int increments_per_thread = 1'000'000;
    std::vector<std::thread> threads;
    for (int i = 0; i < num_threads; ++i) {
        threads.emplace_back(worker, increments_per_thread);
    }
    for (auto& t : threads) t.join();
    // join()이 스레드 종료와 동기화하므로 여기서는 relaxed로 읽어도 최종값 8'000'000이 보장됩니다.
    std::cout << "Counter: " << counter.load(std::memory_order_relaxed) << "\n";
}

relaxed로 플래그를 쓰면 안 되는 이유

// 잘못된 예: relaxed로 데이터 준비 완료를 알림
std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;
    ready.store(true, std::memory_order_relaxed);
}

void consumer() {
    while (!ready.load(std::memory_order_relaxed)) {}
    std::cout << data;   // 42가 보장되지 않고, 표준상 data에 대한 데이터 레이스
}

relaxed store와 relaxed load 사이에는 synchronizes-with 관계가 생기지 않습니다. 그래서 data = 42와 std::cout << data 사이에 happens-before가 없고, 이 코드는 데이터 레이스입니다. x86은 store끼리, load끼리의 재배치를 하지 않으므로 컴파일러가 재배치하지 않는 한 우연히 맞게 동작하는 경우가 많습니다. ARM이나 POWER처럼 약한 메모리 모델에서는 실제로 0을 읽는 일이 생깁니다. x86에서 테스트를 통과했다는 사실이 이 코드가 맞다는 증거가 되지 않는 이유입니다.

스레드별 통계 집계

struct alignas(64) PerThreadStats {
    std::atomic<std::uint64_t> requests{0};
    std::atomic<std::uint64_t> bytes{0};
};
std::vector<PerThreadStats> stats(8);

void handle_request(int thread_id, std::size_t len) {
    stats[thread_id].requests.fetch_add(1, std::memory_order_relaxed);
    stats[thread_id].bytes.fetch_add(len, std::memory_order_relaxed);
}

std::uint64_t total_requests() {
    std::uint64_t sum = 0;
    for (auto& s : stats) {
        sum += s.requests.load(std::memory_order_relaxed);
    }
    return sum;
}

각 스레드는 자기 슬롯만 수정하고, 집계하는 쪽은 그 시점의 근사 스냅샷만 있으면 됩니다. alignas(64)는 슬롯들이 같은 캐시 라인을 공유해 서로의 쓰기가 캐시 라인을 빼앗는 false sharing을 막기 위한 것입니다. 다만 requests와 bytes를 relaxed로 따로 읽으면 두 값이 같은 시점의 값이라는 보장은 없습니다. “요청 수와 바이트 수가 항상 짝이 맞아야 한다”는 요구가 있다면 relaxed 카운터 두 개로는 부족하고, 락이나 한 번에 교체하는 스냅샷 구조가 필요합니다.


acquire/release: 데이터 게시

한 스레드가 데이터를 준비하고 다른 스레드에 “준비됐다”고 알리는 패턴을 게시(publication)라고 합니다. 게시하는 쪽은 release, 확인하는 쪽은 acquire를 씁니다.

#include <atomic>
#include <iostream>
#include <thread>

std::atomic<bool> ready{false};
int data = 0;

void producer() {
    data = 42;
    // release: 이 store 이전의 쓰기(data = 42)가 이 store보다 뒤로 가지 않음
    ready.store(true, std::memory_order_release);
}

void consumer() {
    // acquire: true를 읽는 순간 producer의 release store와 synchronizes-with
    while (!ready.load(std::memory_order_acquire)) {}
    std::cout << "data = " << data << "\n";   // 반드시 42
}

int main() {
    std::thread t1(producer);
    std::thread t2(consumer);
    t1.join();
    t2.join();
}

이 보장은 소비자가 실제로 true를 읽었을 때만 성립합니다. acquire load가 false를 읽었다면 아무 동기화도 일어나지 않았으므로, 그 상태에서 data를 읽으면 여전히 데이터 레이스입니다.

SPSC 링 버퍼

생산자 하나, 소비자 하나인 큐는 인덱스 두 개만으로 락 없이 만들 수 있습니다.

#include <array>
#include <atomic>
#include <cstddef>

template <typename T, std::size_t N>
class SPSCQueue {
public:
    bool push(const T& value) {
        std::size_t w = write_idx_.load(std::memory_order_relaxed);   // 내가 쓰는 변수
        std::size_t next = (w + 1) % N;
        if (next == read_idx_.load(std::memory_order_acquire))
            return false;   // 가득 참 (한 칸은 비워 둠)
        buffer_[w] = value;
        write_idx_.store(next, std::memory_order_release);
        return true;
    }
    bool pop(T& value) {
        std::size_t r = read_idx_.load(std::memory_order_relaxed);    // 내가 쓰는 변수
        if (r == write_idx_.load(std::memory_order_acquire))
            return false;   // 비어 있음
        value = buffer_[r];
        read_idx_.store((r + 1) % N, std::memory_order_release);
        return true;
    }
private:
    std::array<T, N> buffer_{};
    alignas(64) std::atomic<std::size_t> write_idx_{0};
    alignas(64) std::atomic<std::size_t> read_idx_{0};
};

생산자는 buffer_[w]를 쓴 뒤 write_idx_를 release로 올리고, 소비자는 write_idx_를 acquire로 읽은 뒤 buffer_[r]을 읽습니다. 반대 방향도 대칭입니다. 소비자가 buffer_[r]을 다 읽은 뒤 read_idx_를 release로 올려야, 생산자가 그 칸을 덮어쓸 때 소비자의 읽기가 이미 끝났다는 것이 보장됩니다. 자기가 혼자 수정하는 인덱스를 다시 읽을 때는 relaxed로 충분합니다. 이 구현은 칸 하나를 비워 두어 “가득 참”과 “비어 있음”을 구분하므로 실제 용량은 N - 1입니다.

버퍼를 std::vector<T> buffer_{N};처럼 중괄호로 초기화하면 안 됩니다. T가 int나 size_t처럼 정수에서 만들어질 수 있는 타입이면 initializer_list 생성자가 선택되어 크기 N이 아니라 값이 N인 원소 하나짜리 벡터가 됩니다. 위 코드처럼 std::array를 쓰거나 std::vector<T>(N)으로 생성해야 합니다.

지연 초기화 (Double-Checked Locking)

#include <atomic>
#include <mutex>
#include <optional>

template <typename T>
class LazyInit {
public:
    T& get() {
        // 빠른 경로: 이미 초기화됐다면 락 없이 반환
        if (!ready_.load(std::memory_order_acquire)) {
            std::lock_guard<std::mutex> lock(mtx_);
            // 락 안에서는 mutex가 동기화를 제공하므로 relaxed로 다시 확인
            if (!ready_.load(std::memory_order_relaxed)) {
                value_.emplace();
                ready_.store(true, std::memory_order_release);
            }
        }
        return *value_;
    }
private:
    std::mutex mtx_;
    std::atomic<bool> ready_{false};
    std::optional<T> value_;
};

빠른 경로의 acquire load가 true를 읽으면 초기화한 스레드의 release store와 동기화되므로 value_의 내용이 완전히 보입니다. C++11 이전에 이 패턴이 깨졌던 이유가 바로 이 순서 보장이 없었기 때문입니다. 실무에서는 같은 일을 하는 함수 내 static 지역 변수(C++11부터 스레드 안전 초기화 보장)나 std::call_once를 쓰는 편이 더 간단하고 실수할 여지가 없습니다.


memory_order_seq_cst: 하나의 전역 순서

memory_order를 생략하면 기본값은 seq_cst입니다. seq_cst는 acquire/release의 보장을 모두 포함하고, 여기에 더해 프로그램의 모든 seq_cst 연산이 하나의 전역 순서(single total order)에 놓이며 모든 스레드가 그 순서에 동의한다는 보장을 줍니다. acquire/release만으로는 금지되지 않는 결과 중 일부가 이 보장 때문에 금지됩니다.

std::atomic<int> x{0}, y{0};
int a, b;

// 스레드 A
x.store(1);          // seq_cst
a = y.load();        // seq_cst

// 스레드 B
y.store(1);          // seq_cst
b = x.load();        // seq_cst

// 모두 seq_cst라면 (a, b) == (0, 0)은 불가능합니다.

네 연산이 하나의 전역 순서에 놓인다고 생각해 보면 이유가 보입니다. 두 store 중 어느 하나는 순서상 먼저입니다. 예를 들어 x.store(1)이 먼저라면 B의 x.load()는 B의 y.store(1)보다 뒤이고, 그보다 앞선 x.store(1)의 값을 봐야 하므로 b == 1입니다. 반대 경우도 대칭이므로 적어도 하나는 1을 읽습니다. 같은 코드를 release store와 acquire load로 바꾸면 (0, 0)이 허용됩니다. 각 코어의 store가 쓰기 버퍼에 머무는 동안 load가 먼저 실행될 수 있기 때문이고, x86에서도 실제로 관찰됩니다(store buffering).

Peterson 알고리즘

두 스레드의 상호 배제를 락 없이 구현하는 Peterson 알고리즘은 정확히 위의 store buffering 결과에 의존합니다. “내 플래그를 세우고 상대 플래그를 읽는다”는 구조라서, 양쪽이 모두 상대의 플래그를 false로 읽으면 둘 다 임계 구역에 들어갑니다. 이를 막으려면 seq_cst가 필요합니다.

#include <atomic>

std::atomic<bool> flag[2] = {false, false};
std::atomic<int> turn{0};

void lock(int me) {
    int other = 1 - me;
    flag[me].store(true);      // seq_cst
    turn.store(other);         // 양보
    while (flag[other].load() && turn.load() == other) {
        // 상대가 원하고, 차례도 상대라면 대기
    }
}

void unlock(int me) {
    flag[me].store(false, std::memory_order_release);
}

교육용 예제로는 좋지만 실무에서 쓸 이유는 거의 없습니다. 두 스레드에만 동작하고, 바쁜 대기로 CPU를 소모하며, std::mutex가 이미 이 문제를 더 잘 풉니다.

acq_rel: 읽기-수정-쓰기 연산

exchange, fetch_add, compare_exchange_* 같은 RMW 연산은 읽기와 쓰기를 한 번에 하므로 둘 다에 순서를 줄 수 있습니다. acq_rel은 읽는 쪽에 acquire, 쓰는 쪽에 release를 적용합니다. 이전 소유자가 게시한 데이터를 받아 보면서 동시에 자기가 쓴 데이터를 다음 소유자에게 게시해야 할 때 씁니다. 예를 들어 여러 스레드가 체인처럼 이어 받는 참조 카운트 감소에서, 마지막으로 0을 만든 스레드가 객체를 해제한다면 fetch_sub(1, std::memory_order_acq_rel)이 필요합니다. 다른 스레드들이 객체를 쓴 내용이 해제하는 스레드에 보여야 하기 때문입니다(Boost의 참조 카운트 구현처럼 release로 감소한 뒤 0이 됐을 때만 acquire 펜스를 두는 방식도 같은 효과를 냅니다).


memory_order_consume

consume은 “이 load 결과에 데이터 의존하는 접근”, 예를 들어 읽어 온 포인터를 역참조하는 접근에만 순서를 보장하도록 설계됐습니다. ARM과 POWER는 주소 의존성이 있는 load의 순서를 하드웨어가 지켜 주므로, 배리어 없이 acquire보다 싸게 구현할 수 있다는 것이 의도였습니다.

struct Node { int value; Node* next; };
std::atomic<Node*> head{nullptr};
int global_x = 0;

// 읽는 쪽
Node* p = head.load(std::memory_order_consume);
if (p) {
    int v = p->value;   // p에 의존하는 load: 순서 보장
    int g = global_x;   // p와 무관한 load: consume으로는 보장 안 됨 (acquire라면 보장)
}

문제는 컴파일러가 최적화 과정에서 의존성을 없애 버릴 수 있다는 점(예: p - p처럼 결과가 상수가 되는 식)이고, 표준의 의존성 추적 규칙을 그대로 구현하기가 매우 어렵다는 점입니다. 그래서 GCC, Clang, MSVC 모두 consume을 acquire로 강화해서 처리합니다. C++17은 consume의 사양을 개정 중이라며 사용을 잠정적으로 권장하지 않는다고 명시했고, 이후 C++26에서 deprecated로 지정됐습니다. 새 코드에서는 acquire를 쓰면 됩니다. Linux 커널의 RCU처럼 의존성 순서를 실제로 활용하는 코드는 표준 consume이 아니라 컴파일러별 관례와 volatile 접근으로 이를 구현합니다.


자주 하는 실수

한쪽에만 acquire 또는 release를 붙임

synchronizes-with는 release store(또는 그보다 강한 순서)와 acquire load(또는 그보다 강한 순서)가 같은 원자 변수에서 만나야 성립합니다. 생산자만 release로 바꾸고 소비자는 relaxed로 읽으면 동기화가 일어나지 않습니다.

// 잘못된 예
ready.store(true, std::memory_order_release);
if (ready.load(std::memory_order_relaxed))   // acquire가 아님
    use(data);                                 // 데이터 레이스

// 올바른 예
ready.store(true, std::memory_order_release);
if (ready.load(std::memory_order_acquire))
    use(data);

게시 이후에 데이터를 다시 수정함

release/acquire는 “release store 이전의 쓰기”만 넘겨줍니다. 생산자가 ready를 세운 뒤에 data를 다시 쓰면, 그 쓰기와 소비자의 읽기 사이에는 happens-before가 없으므로 데이터 레이스입니다. 게시한 데이터는 읽기 전용으로 취급하거나, 계속 수정해야 한다면 mutex로 보호하거나 새 객체를 만들어 다시 게시해야 합니다.

compare_exchange의 expected가 덮어써진다는 것을 잊음

compare_exchange_weak/strong(expected, desired)는 실패하면 현재 값을 expected에 써 넣습니다. 그래서 “0일 때만 1로 바꾼다”를 루프로 돌릴 때 expected를 다시 0으로 돌려놓지 않으면, 두 번째 시도부터는 “현재 값이 1이면 1로 바꾼다”가 되어 다른 스레드가 이미 잡은 락을 잡았다고 착각합니다.

std::atomic<int> state{0};

// 잘못된 예: 첫 실패 후 expected가 1이 되어 다음 시도에서 바로 성공
int expected = 0;
while (!state.compare_exchange_weak(expected, 1, std::memory_order_acquire)) {}

// 올바른 예
int expected2 = 0;
while (!state.compare_exchange_weak(expected2, 1, std::memory_order_acquire,
                                    std::memory_order_relaxed)) {
    expected2 = 0;
}

반대로 lock-free 스택의 push처럼 “현재 값을 기준으로 새 값을 다시 계산”하는 루프에서는 이 덮어쓰기 덕분에 따로 다시 load할 필요가 없습니다.

CAS의 실패 순서 지정

compare_exchange는 성공했을 때와 실패했을 때의 순서를 따로 받습니다. 실패하면 load만 일어난 것이므로 실패 순서에 release나 acq_rel을 줄 수 없습니다. C++17 이전에는 실패 순서가 성공 순서보다 강하면 안 된다는 제약도 있었습니다. 실패 후 다시 계산할 값이 다른 스레드가 게시한 데이터에 의존하지 않으면 실패 순서는 relaxed로 충분하지만, 실패 시 읽은 포인터를 역참조한다면 실패 순서도 acquire여야 합니다.

여러 변수에 대해 모든 스레드가 같은 순서를 볼 것이라고 가정함

release/acquire는 한 쌍의 스레드 사이의 관계입니다. 서로 다른 스레드 두 개가 각각 x와 y에 release store를 하고, 다른 두 스레드가 x, y를 서로 반대 순서로 acquire load하면, 한 관찰자는 “x가 먼저 바뀌었다”, 다른 관찰자는 “y가 먼저 바뀌었다”고 볼 수 있습니다(IRIW, Independent Reads of Independent Writes). 모든 관찰자가 같은 순서에 동의해야 하는 알고리즘이라면 seq_cst가 필요합니다. 같은 스레드가 flag_a, flag_b 순으로 release store를 하고 소비자가 flag_b를 acquire로 읽어 true를 봤다면, flag_a도 true로 보이는 것은 보장됩니다.

seq_cst를 무조건 비싸다고 가정함

x86에서 fetch_add, exchange, compare_exchange는 순서와 무관하게 lock 접두사 명령 하나로 컴파일되므로, 카운터를 seq_cst에서 relaxed로 바꿔도 x86에서는 생성 코드가 같습니다. 비용 차이는 주로 store에서 납니다. 이 부분은 아래 성능 절에서 다룹니다.

펜스를 혼자 쓰면 동기화된다고 생각함

std::atomic_thread_fence는 주변의 원자 연산과 함께 써야 의미가 있습니다. release 펜스 뒤의 relaxed store와, acquire 펜스 앞의 relaxed load가 같은 값을 주고받을 때 동기화가 성립합니다.

std::atomic<bool> ready{false};
int data = 0;

// 스레드 A
data = 42;
std::atomic_thread_fence(std::memory_order_release);
ready.store(true, std::memory_order_relaxed);

// 스레드 B
while (!ready.load(std::memory_order_relaxed)) {}
std::atomic_thread_fence(std::memory_order_acquire);
int x = data;   // 42 보장

펜스는 여러 원자 변수에 대한 load를 한 번의 acquire로 묶고 싶을 때처럼 쓸 곳이 있지만, 대부분은 원자 연산에 직접 순서를 주는 편이 읽기 쉽고 실수가 적습니다.

메모리 해제를 잊은 lock-free 코드

release/acquire로 포인터 게시를 올바르게 해도, 다른 스레드가 아직 읽고 있을 수 있는 객체를 바로 delete하면 use-after-free가 됩니다. 메모리 순서와는 별개의 문제이며, lock-free 코드에서 가장 자주 놓치는 부분입니다. 아래 설정 캐시와 스택 예제에서 다시 설명합니다.


x86과 ARM에서의 비용

메모리 순서에 따른 비용은 하드웨어의 메모리 모델에 따라 크게 다릅니다. 직접 비교하려면 다음 같은 코드를 대상 하드웨어에서 돌리고, 생성된 어셈블리(g++ -O2 -S)를 함께 보는 것이 가장 확실합니다.

// g++ -std=c++17 -O2 -pthread bench.cpp
#include <atomic>
#include <chrono>
#include <cstdint>
#include <iostream>

constexpr int N = 10'000'000;

template <class F>
void timeit(const char* name, F f) {
    auto start = std::chrono::steady_clock::now();
    f();
    auto end = std::chrono::steady_clock::now();
    std::cout << name << ": "
              << std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count()
              << " ms\n";
}

std::atomic<std::uint64_t> c{0};
std::atomic<int> flag{0};

int main() {
    timeit("fetch_add relaxed", [] { for (int i = 0; i < N; ++i) c.fetch_add(1, std::memory_order_relaxed); });
    timeit("fetch_add seq_cst", [] { for (int i = 0; i < N; ++i) c.fetch_add(1); });
    timeit("store release",     [] { for (int i = 0; i < N; ++i) flag.store(i, std::memory_order_release); });
    timeit("store seq_cst",     [] { for (int i = 0; i < N; ++i) flag.store(i); });
}

x86-64에서 생성되는 명령은 대략 다음과 같습니다.

; fetch_add(1, relaxed)        ; fetch_add(1, seq_cst)
lock addq $1, c(%rip)          lock addq $1, c(%rip)      ; 동일

; store(v, release)            ; store(v, seq_cst)
movl %eax, flag(%rip)          xchgl %eax, flag(%rip)     ; 컴파일러에 따라 mov + mfence

x86은 TSO(Total Store Order) 모델이라 일반 load는 이미 acquire, 일반 store는 이미 release의 성질을 갖습니다. 그래서 acquire load와 release store는 일반 mov로 충분하고, lock이 붙은 RMW 명령은 그 자체로 완전한 배리어라 순서 인자와 관계없이 같은 명령이 나옵니다. x86이 허용하는 유일한 재배치는 “앞선 store가 뒤의 load보다 늦게 보이는 것”이고, seq_cst store는 바로 이것을 막아야 하므로 xchg나 mfence가 필요합니다. 이 명령은 쓰기 버퍼를 비울 때까지 기다리므로 일반 mov보다 확연히 느립니다. 결론적으로 x86에서 메모리 순서를 낮춰서 얻는 이득은 거의 seq_cst store를 release store로 바꾸는 경우에 한정됩니다.

연산x86-64AArch64 (ARMv8)
relaxed load / storemov / movldr / str
acquire loadmovldar (ARMv8.3 이상은 ldapr 가능)
release storemovstlr
seq_cst loadmovldar
seq_cst storexchg 또는 mov + mfencestlr
RMW (fetch_add 등)lock 접두사 명령, 순서와 무관LSE가 있으면 ldadd / ldadda / ldaddl / ldaddal, 없으면 ldxr/stxr 루프

AArch64는 약한 메모리 모델이라 relaxed와 acquire/release가 다른 명령이 되므로, x86과 달리 load와 RMW에서도 순서 선택이 생성 코드에 영향을 줍니다. 반면 seq_cst store가 별도 펜스 없이 stlr 하나로 끝나서, x86에서 가장 비싼 seq_cst store는 오히려 차이가 작습니다. 32비트 ARMv7에서는 acquire/release도 dmb ish 배리어로 구현되어 비용이 더 큽니다.

순서보다 더 큰 비용은 경합입니다. 여러 코어가 같은 원자 변수를 계속 수정하면, 그 캐시 라인이 코어 사이를 오가느라 연산 하나하나가 단일 스레드보다 훨씬 느려지고, 이 비용은 memory_order로 줄일 수 없습니다. 원자 카운터가 병목이라면 순서를 바꾸기보다 앞에서 본 스레드별 카운터처럼 경합 자체를 없애는 편이 효과적입니다.


적용 예

설정 객체 교체 (release/acquire)

#include <atomic>
#include <cstddef>

struct Config {
    int timeout_ms;
    std::size_t buffer_size;
};
std::atomic<Config*> config_ptr{nullptr};

void updater() {
    Config* new_cfg = new Config{100, 4096};
    Config* old = config_ptr.exchange(new_cfg, std::memory_order_acq_rel);
    // 여기서 delete old를 하면 안 됩니다. 다른 스레드가 아직 old를 읽고 있을 수 있습니다.
    retire_later(old);   // 모든 reader가 끝난 뒤 해제 (예: epoch 기반 회수)
}

void reader() {
    Config* cfg = config_ptr.load(std::memory_order_acquire);
    if (cfg) {
        use(cfg->timeout_ms);   // 새 객체의 필드가 완전히 초기화된 상태로 보임
    }
}

release(여기서는 이전 객체도 받아 오므로 acq_rel)와 acquire 덕분에 reader는 항상 완전히 초기화된 Config를 봅니다. 하지만 교체 직후 이전 객체를 바로 지우면, 그 사이에 포인터를 읽어 간 reader가 해제된 메모리를 읽게 됩니다. 해결책으로는 hazard pointer, epoch 기반 회수, RCU 같은 지연 해제 기법이 있고, C++20에서는 std::atomic<std::shared_ptr<Config>>를 쓰면 참조 카운트로 이 문제를 해결할 수 있습니다. 다만 표준 라이브러리 구현이 내부적으로 락을 쓸 수 있으므로 is_lock_free()로 확인해야 합니다.

lock-free 스택 push/pop

struct Node { int value; Node* next; };
std::atomic<Node*> head_{nullptr};

void push(Node* new_node) {
    new_node->next = head_.load(std::memory_order_relaxed);
    // 실패하면 new_node->next에 현재 head가 들어오므로 그대로 재시도
    while (!head_.compare_exchange_weak(new_node->next, new_node,
                                        std::memory_order_release,
                                        std::memory_order_relaxed)) {}
}

bool pop(Node*& out) {
    Node* old = head_.load(std::memory_order_acquire);
    // 실패 시 새 head를 역참조하므로 실패 순서도 acquire
    while (old && !head_.compare_exchange_weak(old, old->next,
                                               std::memory_order_acquire,
                                               std::memory_order_acquire)) {}
    out = old;
    return old != nullptr;
}

push의 release와 pop의 acquire가 짝을 이뤄 value와 next가 pop하는 쪽에 보입니다. 하지만 이 pop은 여러 스레드가 동시에 pop하고 노드를 해제하는 환경에서 안전하지 않습니다. 한 스레드가 old->next를 읽는 사이에 다른 스레드가 같은 노드를 pop해 지울 수 있고(use-after-free), 지워진 주소가 재사용되어 다시 push되면 CAS가 잘못 성공하는 ABA 문제도 생깁니다. 실제로 쓰려면 앞의 설정 교체와 같은 지연 해제 기법이나 태그 포인터가 필요하며, 자세한 내용은 C++ Lock-Free 자료구조에서 다룹니다.

스핀락

#include <atomic>
#include <thread>

class SpinLock {
    std::atomic<bool> locked_{false};
public:
    void lock() {
        while (true) {
            // 잠금 성공 시 임계 구역의 접근이 이 지점 앞으로 올라오지 않도록 acquire
            if (!locked_.exchange(true, std::memory_order_acquire)) return;
            // 실패하면 풀릴 때까지 읽기만 하며 대기 (test-and-test-and-set)
            while (locked_.load(std::memory_order_relaxed)) {
                std::this_thread::yield();   // 또는 x86의 _mm_pause()
            }
        }
    }
    void unlock() {
        // 임계 구역 안의 쓰기가 다음에 lock()하는 스레드에 보이도록 release
        locked_.store(false, std::memory_order_release);
    }
};

lock()의 acquire와 unlock()의 release가 짝을 이뤄 앞 스레드가 임계 구역에서 쓴 값이 다음 스레드에 보입니다. 기다리는 동안 exchange를 계속 호출하면 안 됩니다. exchange는 쓰기라서 매번 캐시 라인의 독점 소유권을 요구하고, 대기 스레드가 여럿이면 락을 쥔 스레드의 캐시 라인까지 계속 빼앗아 해제를 늦춥니다. 위처럼 load로만 기다리면 대기 중에는 캐시 라인을 공유 상태로 둘 수 있습니다. 그래도 사용자 공간 스핀락은 락을 쥔 스레드가 선점당하면 나머지가 CPU만 소모하므로, 임계 구역이 아주 짧고 스레드 수가 코어 수 이하일 때만 쓰고 그 외에는 std::mutex를 쓰는 것이 낫습니다.


메모리 순서를 고르는 방법

flowchart TD
    A[원자 변수 하나] --> B{이 변수로 다른 데이터의 준비 상태를 알리는가?}
    B -->|아니오| C[relaxed]
    B -->|예| D{여러 변수에 대해 모든 스레드가 같은 순서를 봐야 하는가?}
    D -->|예| E[seq_cst]
    D -->|아니오| F[release / acquire]

먼저 그 원자 변수가 자기 값 말고 다른 메모리의 상태를 알리는 역할을 하는지 확인합니다. 그렇지 않다면 relaxed로 충분합니다. 다른 데이터를 게시한다면 쓰는 쪽은 release, 읽는 쪽은 acquire를 쓰고, 둘이 같은 변수에서 짝을 이루는지 확인합니다. store buffering이나 IRIW처럼 여러 변수에 걸친 전역 순서가 필요한 경우에만 seq_cst가 필요합니다.

확신이 서지 않는다면 기본값인 seq_cst를 그대로 두는 것이 맞습니다. seq_cst로 맞게 동작하는 코드를 약한 순서로 바꾸는 것은 성능 측정에서 그 지점이 실제 비용으로 확인됐을 때 해도 늦지 않습니다. 반대 방향, 즉 약한 순서로 버그가 의심될 때 seq_cst로 올려서 증상이 사라지는지 보는 것은 원인 범위를 좁히는 데 유용하지만, 증상이 사라졌다고 원인을 이해한 것은 아닙니다.

검증 도구로는 ThreadSanitizer(-fsanitize=thread)가 가장 쉽게 쓸 수 있습니다. relaxed로 게시한 데이터를 읽는 것 같은 데이터 레이스를 실행 중에 잡아 줍니다. 다만 실제로 실행된 인터리빙만 검사하므로, 재배치가 드물게 일어나는 버그를 놓칠 수 있습니다. x86에서만 테스트하면 약한 메모리 모델에서만 드러나는 버그를 볼 수 없으므로, ARM 서버나 모바일이 배포 대상이라면 해당 하드웨어에서도 스트레스 테스트를 돌려야 합니다.

이전 글: C++ Lock-Free 프로그래밍 다음 글: C++ 네트워크 최적화 #51-7


같이 보면 좋은 글