C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)

C++ atomic | Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함)

이 글의 핵심

C++ atomic에 대한 실전 가이드입니다. Mutex 없이 스레드 안전 카운터 만드는 법 (memory_order 포함) 등을 예제와 함께 상세히 설명합니다.

들어가며: 뮤텍스가 너무 느려요

”counter만 보호하려고 mutex를 쓰기엔 부담이에요”

여러 스레드가 단순히 카운터 하나만 증가시키는 경우, mutex를 쓰면 오버헤드와 경합이 부담이었습니다. 반면 atomic(원자 변수—한 번에 하나의 연산만 완료되도록 보장되어, 여러 스레드가 동시에 접근해도 data race가 나지 않음)으로 바꾸면 락 없이도 증가가 한 번에 이루어져 data race가 사라지고, 실제로도 지연이 줄었습니다.

mutex로 보호한 경우에서는 counter++를 할 때마다 락을 잡고 풀어야 해서, 스레드 수가 많을수록 락 경합이 심해집니다. counter처럼 단일 변수만 보호할 때는 오버헤드가 아깝습니다.

atomic으로 바꾼 경우에서는 counter 자체가 std::atomic<int>이므로 counter++가 CPU에서 원자적으로 실행됩니다. 락을 쓰지 않아도 한 스레드가 증가시키는 동안 다른 스레드가 중간 값을 읽지 않고, data race가 사라집니다. 실제로 고빈도 카운터를 mutex에서 atomic으로 바꾼 뒤 지연이 눈에 띄게 줄었던 경험이 있습니다.

flowchart LR
  subgraph mutex["Mutex 방식"]
    M1[스레드1] --> M2[락 획득 대기]
    M2 --> M3[counter++]
    M3 --> M4[락 해제]
    M4 --> M5[다음 스레드 대기]
  end
  subgraph atomic["Atomic 방식"]
    A1[스레드1] --> A2[원자적 증가]
    A2 --> A3[완료]
    A4[스레드2] --> A5[원자적 증가]
    A5 --> A6[완료]
  end

mutex로 보호한 경우:

std::mutex mtx;
int counter = 0;

void inc() {
    std::lock_guard<std::mutex> lock(mtx);
    counter++;
}

위 코드 설명: counter++마다 lock_guard로 락을 잡고 풀어야 해서, 스레드가 많을수록 락 경합이 커집니다. 단일 변수만 보호할 때는 mutex 오버헤드가 부담이 되므로, 이런 경우에는 atomic이 더 적합합니다.

atomic으로 바꾼 경우:

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

void inc() {
    counter++;  // 원자적 연산, data race 없음
}

위 코드 설명: std::atomic<int>operator++는 CPU에서 원자적으로 실행되어, 여러 스레드가 동시에 증가시켜도 data race가 발생하지 않습니다. 락을 쓰지 않아 경합이 줄고, 단일 변수에 대한 읽기·쓰기·증가만 필요할 때 mutex보다 가볍습니다.

언제 atomic을 쓰면 좋은가: 한 변수에 대한 읽기·쓰기·증가·감소만 필요할 때는 mutex보다 atomic이 부담이 적고, 캐시 라인만 겨냥해 동기화하므로 경합이 적습니다. 반면 “여러 변수를 한 번에 바꿔야 일관성이 유지되는” 경우에는 mutex로 한 블록을 통째로 보호하는 편이 맞습니다.

이번 글에서는 std::atomic의 기본 사용법과, 메모리 순서(memory_order—멀티스레드에서 읽기·쓰기가 다른 스레드에 어떤 순서로 보일지 지정하는 것. 기본값만 써도 대부분 안전)가 무엇인지, 언제 atomic을 쓰고 언제 mutex를 쓸지 실전 관점에서 정리합니다. 카운터나 플래그처럼 “변수 하나”만 보호할 때는 mutex보다 atomic이 가볍고, 경합이 적을수록 유리합니다. 반대로 여러 변수를 한 번에 바꿔야 하거나 복잡한 조건이 있으면 mutex가 맞고, atomic은 그런 경우에 보조적으로만 쓰는 것이 안전합니다.

추가 문제 시나리오

시나리오 1: 고부하 API 서버의 요청 카운터
초당 10만 QPS를 처리하는 API 서버에서 std::mutex로 요청 수를 보호하면, 락 경합으로 인해 응답 지연이 급증합니다. std::atomic<uint64_t>로 바꾸면 락 오버헤드가 사라져 P99 지연이 수십 ms 줄어든 사례가 있습니다.

시나리오 2: 레이스 컨디션으로 인한 잘못된 카운트
여러 스레드가 counter++를 수행할 때, mutex 없이 일반 int를 쓰면 읽기-수정-쓰기가 원자적이지 않아 data race가 발생합니다. 8스레드가 각 100만 번 증가시켜도 최종 값이 800만이 아닌 200만~300만처럼 나오는 현상을 겪을 수 있습니다.

시나리오 3: 플래그와 데이터 동기화 오류
생산자 스레드가 데이터를 채운 뒤 ready = true를 설정하고, 소비자가 ready를 확인한 후 데이터를 읽는 패턴에서, 메모리 재배치 때문에 소비자가 ready는 true인데 데이터는 아직 초기화되지 않은 상태를 볼 수 있습니다. memory_order_release/acquire 쌍이 없으면 이런 버그가 발생합니다.

시나리오 4: Shutdown 신호가 늦게 전달됨
워커 스레드가 while (!shutdown_flag) 루프를 돌 때, shutdown_flag를 일반 변수로 두면 메인 스레드가 true로 설정해도 워커가 캐시된 false를 계속 읽어 무한 루프에 빠질 수 있습니다. std::atomic<bool>과 적절한 memory_order가 필요합니다.

시나리오 5: Lock-free 큐에서 ABA 문제
멀티스레드 환경에서 노드 포인터를 CAS로 교체할 때, A→B→A처럼 같은 주소가 재사용되면 CAS가 “값이 같다”고 잘못 판단해 중간에 발생한 변경을 무시할 수 있습니다. 버전 카운터나 hazard pointer로 완화해야 합니다.

이 글을 읽으면:

  • std::atomic으로 정수·포인터 등을 data race 없이 읽고 쓸 수 있습니다.
  • load(), store(), exchange(), compare_exchange_* 등 전체 연산의 의미를 알 수 있습니다.
  • memory_order가 무엇을 제어하는지(특히 seq_cst, acquire/release, relaxed) 상세히 이해할 수 있습니다.
  • lock-free 큐·스택 등 자료 구조를 구현할 수 있습니다.
  • ABA 문제, 메모리 순서 오류 등 흔한 실수를 피할 수 있습니다.
  • atomic과 mutex의 성능 차이를 벤치마크로 확인할 수 있습니다.
  • 프로덕션에서 카운터, 플래그, lock-free 알고리즘 패턴을 적용할 수 있습니다.

목차

  1. std::atomic이란
  2. 원자 연산 전체: load, store, exchange, CAS
  3. 메모리 순서(memory_order) 상세
  4. Lock-free 자료 구조: 큐와 스택
  5. 흔한 실수: ABA 문제, 메모리 순서
  6. 성능 비교: atomic vs mutex
  7. 프로덕션 패턴: 카운터, 플래그, lock-free
  8. atomic vs mutex: 언제 무엇을 쓸까
  9. 실전 주의사항

1. std::atomic이란

Atomic이란 “나눌 수 없는” 연산이라는 뜻입니다. 여러 스레드가 같은 변수에 대해 읽기-수정-쓰기를 해도, 그 연산이 한 덩어리처럼 동작해 중간 상태가 다른 스레드에 보이지 않습니다. 따라서 그 변수에 대한 data race가 없고, C++ 표준상 정의된 동작을 합니다.

std::atomic<T>는 보통 다음에 사용합니다.

  • 정수형: atomic<int>, atomic<unsigned>, atomic<size_t> 등 — 카운터, 플래그
  • 포인터: atomic<T*> — lock-free 큐 등에서 다음 노드 포인터
  • bool: atomic<bool> — 준비 완료 플래그

일부 타입만 지원합니다. 정수·포인터·bool 등이 대표적이고, 임의의 구조체는 atomic으로 쓸 수 없으며, 그런 경우에는 mutex로 보호해야 합니다.


2. 원자 연산 전체: load, store, exchange, CAS

2.1 읽기와 쓰기: load, store

  • load(): 현재 값을 읽음. a.load() 또는 (int)a처럼 변환으로도 쓸 수 있음.
  • store(val): 값을 씀. a.store(1) 또는 a = 1.
// 복사해 붙여넣은 뒤: g++ -std=c++17 -o atomic_ops atomic_ops.cpp && ./atomic_ops
#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> value{0};
    value.store(42);
    value = 42;  // 동일
    int x = value.load();
    int y = value;  // 동일
    std::cout << x << " " << y << "\n";
    return 0;
}

위 코드 설명: value.store(42)value = 42는 동일하고, value.load()(int)value도 동일합니다. atomic에 대한 읽기·쓰기는 모두 원자적으로 수행되어, 다른 스레드가 중간 상태를 보지 않습니다.

실행 결과: 42 42 가 한 줄 출력됩니다.

2.2 교환: exchange

  • exchange(val): 값을 val로 바꾸고, 바꾸기 전 값을 반환. “원자적 swap”이라고 생각하면 됨.
#include <atomic>
#include <iostream>

int main() {
    std::atomic<int> flag{0};
    int old = flag.exchange(1);  // 0 → 1로 바꾸고, 이전 값 0 반환
    std::cout << "old=" << old << " new=" << flag.load() << "\n";
    return 0;
}

위 코드 설명: exchange(1)은 값을 1로 설정하고 이전 값을 반환합니다. “한 번만 실행”하는 초기화, “이전 값 확인 후 교체” 패턴에 유용합니다.

실행 결과: old=0 new=1

2.3 읽기-수정-쓰기 (RMW): fetch_add, fetch_sub

  • fetch_add(n): 값을 n만큼 더하고, 더하기 전 값을 반환.
  • fetch_sub(n): 빼기.
  • ++/—, +=/-=: fetch_add/fetch_sub를 사용하는 것과 동일한 동작.
std::atomic<int> counter{0};

counter++;           // counter.fetch_add(1)
counter += 10;       // counter.fetch_add(10)
int prev = counter.fetch_add(1);  // 이전 값 반환

위 코드 설명: counter++counter += 10은 내부적으로 fetch_add를 사용하는 원자 연산입니다. fetch_add(1)은 값을 1 증가시키고 증가 전 값을 반환하므로, “한 번만 증가시키고 그 전 값을 쓰는” 패턴(예: 고유 ID 발급)에 쓸 수 있습니다.

2.4 Compare-And-Swap (CAS)

  • compare_exchange_strong(expected, desired): *thisexpected와 같으면 desired로 바꾸고 true 반환. 다르면 expected를 현재 값으로 업데이트하고 false 반환.
  • compare_exchange_weak(expected, desired): strong과 비슷하지만, 일부 아키텍처에서 “spurious failure”(실제로 같아도 실패로 보고할 수 있음)가 있을 수 있음. 루프 안에서 쓸 때는 weak가 더 효율적일 수 있음.
#include <atomic>

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

// "0이면 1로 바꾸기" — 한 스레드만 성공
bool try_claim() {
    int expected = 0;
    return value.compare_exchange_strong(expected, 1);
}

// lock-free 증가 (실제로는 fetch_add가 더 적합하지만 CAS 패턴 예시)
void cas_increment() {
    int old_val = value.load();
    while (!value.compare_exchange_weak(old_val, old_val + 1)) {
        // old_val이 현재 value로 자동 업데이트됨
    }
}

위 코드 설명: CAS는 “예상 값과 같을 때만 새 값으로 교체”하는 원자 연산입니다. lock-free 자료 구조의 핵심 도구이며, compare_exchange_weak는 spurious failure 때문에 루프 안에서 사용합니다. expected는 참조로 전달되어 실패 시 현재 값으로 갱신됩니다.

2.5 CAS 완전 예제: 고유 ID 발급, 최댓값 갱신, 조건부 업데이트

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

// 예제 1: CAS로 고유 ID 발급 (한 번만 실행되는 패턴)
std::atomic<int> next_id{0};

int allocate_id() {
    int expected = next_id.load();
    int desired;
    do {
        desired = expected + 1;
    } while (!next_id.compare_exchange_weak(expected, desired));
    return desired;
}

// 예제 2: CAS로 최댓값 갱신 (여러 스레드가 경쟁)
std::atomic<int> max_value{0};

void update_max(int candidate) {
    int old_max = max_value.load();
    while (candidate > old_max &&
           !max_value.compare_exchange_weak(old_max, candidate)) {
        // CAS 실패 시 old_max가 현재 값으로 갱신됨
    }
}

// 예제 3: 조건부 업데이트 (0이면 1로, 1이면 2로)
std::atomic<int> state{0};

bool try_transition(int from, int to) {
    int expected = from;
    return state.compare_exchange_strong(expected, to);
}

int main() {
    std::vector<std::thread> threads;
    for (int i = 0; i < 4; ++i) {
        threads.emplace_back([&]() {
            for (int j = 0; j < 1000; ++j) {
                allocate_id();
                update_max(j);
            }
        });
    }
    for (auto& t : threads) t.join();
    std::cout << "next_id=" << next_id.load() << " max=" << max_value.load() << "\n";
    return 0;
}

위 코드 설명: allocate_id는 CAS 루프로 경쟁 없이 고유 ID를 발급합니다. update_max는 후보자가 현재 최댓값보다 클 때만 갱신합니다. try_transition은 상태 머신에서 “특정 상태에서만 전이”할 때 사용합니다. 실행 결과: next_id=4001 등 (스레드 수×1000+1), max=999.

2.6 compare_exchange_strong vs weak

구분strongweak
spurious failure없음있을 수 있음 (ARM, PowerPC 등)
루프 필요보통 1회 시도로 충분실패 시 재시도 루프 필수
성능weak보다 약간 무거울 수 있음일부 CPU에서 더 빠름

권장: 루프 안에서 CAS를 쓸 때는 compare_exchange_weak를 사용하고, 실패 시 expected가 갱신되므로 다시 시도하면 됩니다. “한 번만 시도하고 실패하면 포기”하는 패턴에서는 compare_exchange_strong가 적합합니다.

// weak: 루프에서 사용 (spurious failure 허용)
while (!atomic_val.compare_exchange_weak(expected, desired)) {}

// strong: 한 번만 시도
if (atomic_val.compare_exchange_strong(expected, desired)) {
    // 성공
} else {
    // 실패, expected에 현재 값이 들어감
}

3. 메모리 순서(memory_order) 상세

여러 스레드가 메모리를 읽고 쓸 때, 실제 실행 순서는 코드 순서와 다르게 바뀔 수 있습니다(컴파일러·CPU 재배치). memory_order는 “다른 스레드에게 얼마나 순서를 보장할지”를 지정합니다.

3.1 왜 메모리 순서가 필요한가?

flowchart TB
  subgraph CPU["CPU 관점"]
    A[코드 순서] --> B[실제 실행 순서]
    B --> C[재배치 가능]
  end
  subgraph Thread["스레드 A"]
    T1[data = 1]
    T2[ready = true]
  end
  subgraph Thread2["스레드 B"]
    T3[if ready]
    T4[use data]
  end

스레드 A가 data = 1ready = true를 썼다고 해도, CPU/컴파일러가 ready = true를 먼저 실행할 수 있습니다. 그러면 스레드 B는 ready가 true인데 data는 아직 0인 상태를 볼 수 있어, 버그가 됩니다. memory_order로 “이 쓰기 이전의 모든 쓰기는 이 쓰기 이후로 재배치되지 않는다”를 보장할 수 있습니다.

3.2 메모리 순서 종류

순서의미사용처
seq_cst단일 전체 순서. 가장 강한 보장.기본값, 디버깅
acquire이 로드 이후의 읽기/쓰기는 이 로드 이전으로 재배치 안 됨락 획득, 데이터 로드 후
release이 스토어 이전의 읽기/쓰기는 이 스토어 이후로 재배치 안 됨락 해제, 데이터 저장 후
acq_relacquire + releaseCAS 등 RMW
relaxed순서 보장 없음. 원자성만 보장카운터 등 단순 연산

3.3 seq_cst (Sequentially Consistent)

  • 기본값. 모든 스레드가 “하나의 전체 순서”처럼 보이게 함.
  • 가장 강한 보장이지만, 일부 아키텍처에서 가장 비쌈.
  • 실전 조언: 처음에는 기본값만 사용. 성능이 중요해진 뒤에 acquire/release를 고려.
std::atomic<bool> ready{false};
std::atomic<int> data{0};

// 스레드 A
void producer() {
    data.store(42);                           // seq_cst (기본)
    ready.store(true);                        // seq_cst (기본)
}

// 스레드 B
void consumer() {
    while (!ready.load()) {}                  // seq_cst (기본)
    assert(data.load() == 42);                // 항상 42
}

3.4 acquire / release

  • release: “이 스토어 이전”의 모든 메모리 연산은 이 스토어 “이후”로 재배치되지 않음. → 데이터를 다 쓴 뒤 플래그를 올리는 패턴.
  • acquire: “이 로드 이후”의 모든 메모리 연산은 이 로드 “이전”으로 재배치되지 않음. → 플래그를 확인한 뒤 데이터를 읽는 패턴.
// producer: 데이터 쓴 뒤 플래그
void producer() {
    data = 42;  // 일반 변수 (또는 atomic)
    ready.store(true, std::memory_order_release);  // 이전 쓰기 완료 보장
}

// consumer: 플래그 확인 후 데이터
void consumer() {
    while (!ready.load(std::memory_order_acquire)) {}
    int x = data;  // release와 쌍을 이루므로 42 보장
}

위 코드 설명: releaseacquire가 쌍을 이루면, release 스토어 “이전”의 쓰기는 acquire 로드 “이후”에 보입니다. 즉, consumer가 ready를 true로 보면 data는 반드시 42입니다.

3.5 relaxed

  • 순서 보장 없음. 원자성만 보장.
  • 카운터처럼 “값만 맞으면 되고, 다른 변수와의 순서는 상관없을 때” 사용.
std::atomic<int> counter{0};

void inc() {
    counter.fetch_add(1, std::memory_order_relaxed);
}

주의: relaxed는 다른 변수와의 happens-before 관계를 만들지 않습니다. “플래그 체크 후 데이터 읽기” 같은 패턴에는 acquire/release가 필요합니다.

3.6 메모리 장벽 (Memory Barrier)

memory_order는 내부적으로 CPU의 메모리 장벽(barrier) 명령과 연결됩니다.

  • seq_cst: full barrier — 모든 로드/스토어가 이 지점에서 정렬됨
  • acquire: load barrier — 이 로드 이전의 로드가 이 로드 이후로 넘어가지 않음
  • release: store barrier — 이 스토어 이후의 스토어가 이 스토어 이전으로 넘어가지 않음
  • relaxed: barrier 없음 — 원자성만 보장

x86/AMD64에서는 대부분의 로드/스토어가 이미 순서를 유지하므로, acquire/release가 추가 비용 없이 동작하는 경우가 많습니다. ARM 등 weak memory 모델에서는 차이가 더 큽니다.

3.7 memory_order 완전 예제: Producer-Consumer 동기화

int shared_data[1024];  // 일반 변수
std::atomic<bool> data_ready{false};

void producer() {
    for (int i = 0; i < 1024; ++i) shared_data[i] = i * 2;
    data_ready.store(true, std::memory_order_release);  // 이전 쓰기 완료 보장
}

void consumer() {
    while (!data_ready.load(std::memory_order_acquire)) {}
    int sum = 0;
    for (int i = 0; i < 1024; ++i) sum += shared_data[i];
    // sum = 1023*1024 = 1047552 보장
}

위 코드 설명: release로 저장하면 producer의 모든 쓰기가 이 스토어 이후로 재배치되지 않습니다. acquire로 로드하면 consumer가 이 로드 이후의 읽기가 이 로드 이전으로 재배치되지 않습니다. 따라서 consumer가 data_ready를 true로 보면 shared_data는 반드시 채워진 상태입니다. relaxed를 쓰면 이 보장이 없어 잘못된 값을 읽을 수 있습니다.

3.8 선택 가이드

// 대부분 이렇게만 써도 충분 (seq_cst)
counter++;
flag.store(true);

// 성능 최적화가 필요할 때만 order 지정
flag.store(true, std::memory_order_release);
if (flag.load(std::memory_order_acquire)) { /* ... */ }

// 순수 카운터 (다른 변수와 무관)
counter.fetch_add(1, std::memory_order_relaxed);

4. Lock-free 자료 구조: 큐와 스택

4.1 Lock-free 스택 (단순 버전)

CAS로 head 포인터만 원자적으로 바꾸는 방식입니다.

#include <atomic>

template<typename T>
struct LockFreeStack {
    struct Node {
        T data;
        Node* next;
    };
    std::atomic<Node*> head{nullptr};

    void push(const T& value) {
        Node* new_node = new Node{value, head.load()};
        while (!head.compare_exchange_weak(new_node->next, new_node)) {
            // CAS 실패 시 new_node->next가 현재 head로 갱신됨
        }
    }

    bool pop(T& result) {
        Node* old_head = head.load();
        while (old_head && !head.compare_exchange_weak(old_head, old_head->next)) {
            // CAS 실패 시 old_head가 현재 head로 갱신됨
        }
        if (!old_head) return false;
        result = old_head->data;
        delete old_head;  // ABA 문제 가능 — 아래 흔한 실수 참고
        return true;
    }
};

위 코드 설명: push는 새 노드를 head 앞에 붙이고, CAS로 head를 새 노드로 교체합니다. pop은 head를 다음 노드로 바꾸고, 이전 head의 데이터를 반환합니다. 실제 프로덕션에서는 ABA 문제 해결(예: 버전 카운터)과 메모리 재사용 정책이 필요합니다.

4.2 Lock-free 큐 (단일 생산자·단일 소비자, SPSC)

가장 단순한 형태는 한 스레드만 push, 한 스레드만 pop하는 경우입니다.

#include <array>
#include <atomic>

template<typename T, size_t N>
struct SPSCQueue {
    std::array<T, N> buffer;
    std::atomic<size_t> write_idx{0};
    std::atomic<size_t> read_idx{0};

    bool push(const T& value) {
        size_t w = write_idx.load(std::memory_order_relaxed);
        if ((w + 1) % N == read_idx.load(std::memory_order_acquire))
            return false;  // 꽉 참
        buffer[w] = value;
        write_idx.store((w + 1) % N, std::memory_order_release);
        return true;
    }

    bool pop(T& value) {
        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;
    }
};

위 코드 설명: SPSC에서는 생산자와 소비자가 각각 하나씩이므로, write_idxread_idx만 acquire/release로 동기화하면 됩니다. MPMC(다중 생산자·다중 소비자)는 더 복잡하며, 보통 std::atomic만으로는 한계가 있어 hazard pointer나 RCU 같은 기법이 필요합니다.

4.3 Lock-free vs Wait-free

  • Lock-free: 어떤 스레드도 무한히 블록되지 않음. 최소 한 스레드는 진행.
  • Wait-free: 모든 스레드가 유한 단계 내에 완료.

fetch_add, exchange는 wait-free에 가깝습니다. CAS 루프 기반 구조는 lock-free이지만, 경합이 심하면 재시도가 많아져 wait-free는 아닙니다. 실무에서는 lock-free만으로도 mutex보다 나은 경우가 많습니다.


5. 흔한 실수: ABA 문제, 메모리 순서

5.1 ABA 문제

상황: 스레드 A가 head를 읽고 (값 A), CAS로 A→B로 바꾸려 함. 그 사이 스레드 B가 A를 pop하고, 새 노드 C를 push한 뒤, C가 A와 같은 주소를 재사용 (또는 A가 다시 push됨). 스레드 A의 CAS는 “값이 A로 같다”고 판단해 성공하지만, 실제로는 중간에 A→X→A처럼 바뀐 상태.

해결: 버전/카운터를 함께 CAS에 포함시키거나, hazard pointer로 노드가 안전하게 재사용될 때까지 보호.

// ABA 완화 예: 포인터 + 버전을 묶어서 CAS
struct NodePtr {
    Node* ptr;
    uintptr_t version;
};
std::atomic<NodePtr> head;

bool pop(T& result) {
    NodePtr old_head = head.load();
    while (old_head.ptr) {
        NodePtr new_head{old_head.ptr->next, old_head.version + 1};
        if (head.compare_exchange_weak(old_head, new_head)) {
            result = old_head.ptr->data;
            delete old_head.ptr;
            return true;
        }
    }
    return false;
}

위 코드 설명: version을 매 CAS마다 증가시키면, 같은 포인터라도 버전이 다르면 CAS가 실패합니다. 이렇게 ABA를 완화할 수 있습니다. (완전한 해결은 hazard pointer 등이 필요)

5.2 메모리 순서 오류

잘못된 예: relaxed로 플래그만 바꾸고, 데이터는 일반 변수로 쓸 때. consumer가 플래그를 보고 데이터를 읽어도, 재배치 때문에 이전 값을 볼 수 있음.

// ❌ 잘못된 예
int data = 0;
std::atomic<bool> ready{false};

void producer_bad() {
    data = 42;
    ready.store(true, std::memory_order_relaxed);  // 순서 보장 없음!
}

void consumer_bad() {
    while (!ready.load(std::memory_order_relaxed)) {}
    int x = data;  // 42가 아닐 수 있음 (재배치)
}
// ✅ 올바른 예: release/acquire 쌍
void producer_ok() {
    data = 42;
    ready.store(true, std::memory_order_release);
}

void consumer_ok() {
    while (!ready.load(std::memory_order_acquire)) {}
    int x = data;  // 42 보장
}

5.3 volatile과 혼동

volatile은 컴파일러 최적화만 제한할 뿐, 스레드 간 동기화를 보장하지 않습니다. atomic이나 mutex를 써야 합니다.

// ❌ volatile — 스레드 안전 아님
volatile int counter = 0;
counter++;  // data race!

// ✅ atomic
std::atomic<int> counter{0};
counter++;

5.4 CAS 루프에서 expected 갱신 누락

compare_exchange_weak/strong 실패 시 expected가 현재 값으로 갱신됩니다. 루프에서 이 갱신을 활용하지 않으면 무한 루프에 빠집니다.

// ❌ 잘못된 예: expected를 고정값으로 유지 — value가 2,3,4...이면 영원히 실패
void wrong_cas() {
    int expected = 0;
    while (!value.compare_exchange_weak(expected, 1)) {}
}

// ✅ 올바른 예: expected가 자동 갱신됨
void correct_cas() {
    int expected = value.load();
    while (!value.compare_exchange_weak(expected, expected + 1)) {}
}

5.5 여러 변수의 일관성 — atomic만으로 부족

atomic은 해당 변수 하나에 대한 원자성만 보장합니다. 여러 변수를 “한 번에” 바꿔야 할 때는 mutex가 필요합니다.

// ❌ atomic 두 개 — a와 b가 "같은 시점"에 바뀌는 것이 아님
std::atomic<int> a{0}, b{0};
void bad_update() { a.store(1); b.store(1); }

// ✅ mutex로 한 블록 보호
std::mutex mtx;
int x = 0, y = 0;
void good_update() {
    std::lock_guard<std::mutex> lock(mtx);
    x = 1; y = 1;
}

5.6 Data Race 감지: ThreadSanitizer 활용

실수로 atomic을 빼먹었을 때 ThreadSanitizer(TSan)로 data race를 감지할 수 있습니다.

g++ -std=c++17 -fsanitize=thread -g -O1 -o race_test race_test.cpp && ./race_test
// race_test.cpp — TSan이 감지하는 예
#include <thread>
int counter = 0;  // ❌ atomic 아님 → data race
void inc() { for (int i = 0; i < 1000000; ++i) counter++; }
int main() {
    std::thread t1(inc), t2(inc);
    t1.join(); t2.join();
    return 0;
}

위 코드 설명: TSan을 사용하면 “WARNING: ThreadSanitizer: data race” 메시지와 함께 문제 위치를 알려줍니다. std::atomic<int> counter{0}으로 바꾸면 경고가 사라집니다.

5.7 atomic 포인터와 포인터가 가리키는 데이터

std::atomic<T*>포인터 값만 원자적으로 만듭니다. 포인터가 가리키는 객체의 내용은 별도 동기화가 필요합니다.

// atomic은 ptr 자체만 보호
std::atomic<Node*> ptr{nullptr};

void bad_use() {
    Node* p = ptr.load();
    if (p) {
        p->data = 42;  // ❌ ptr는 원자적이지만, p->data 접근은 data race 가능
    }
}

// ✅ 포인터 로드 후 객체 사용 시 별도 동기화 필요 (hazard pointer, RCU 등)

6. 성능 비교: atomic vs mutex

6.1 벤치마크 시나리오

여러 스레드가 공유 카운터를 100만 번씩 증가시키는 경우를 가정합니다.

#include <atomic>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>

constexpr int NUM_THREADS = 8;
constexpr int OPS_PER_THREAD = 1'000'000;

void benchmark_mutex() {
    std::mutex mtx;
    int counter = 0;
    auto start = std::chrono::high_resolution_clock::now();
    std::vector<std::thread> threads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back([&]() {
            for (int j = 0; j < OPS_PER_THREAD; ++j) {
                std::lock_guard<std::mutex> lock(mtx);
                counter++;
            }
        });
    }
    for (auto& t : threads) t.join();
    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Mutex: " << ms << " ms, counter=" << counter << "\n";
}

void benchmark_atomic() {
    std::atomic<int> counter{0};
    auto start = std::chrono::high_resolution_clock::now();
    std::vector<std::thread> threads;
    for (int i = 0; i < NUM_THREADS; ++i) {
        threads.emplace_back([&]() {
            for (int j = 0; j < OPS_PER_THREAD; ++j) {
                counter++;
            }
        });
    }
    for (auto& t : threads) t.join();
    auto end = std::chrono::high_resolution_clock::now();
    auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
    std::cout << "Atomic: " << ms << " ms, counter=" << counter << "\n";
}

int main() {
    benchmark_mutex();
    benchmark_atomic();
}

6.2 예상 결과 (참고)

스레드 수Mutex (대략)Atomic (대략)비고
1~50ms~20msatomic이 약 2배 빠름
4~200ms~80ms경합 시 차이 확대
8~400ms~150msmutex 경합 심화

실제 수치는 CPU·OS에 따라 다릅니다.

6.3 왜 atomic이 더 빠른가?

  1. 락 오버헤드 없음: mutex는 lock/unlock 시 시스템 콜, 캐시 동기화 등이 발생할 수 있음.
  2. 캐시 라인 수준 동기화: atomic은 해당 변수의 캐시 라인만 invalidate하고, mutex는 락 변수 + 보호 데이터 전체에 영향을 줄 수 있음.
  3. 경합 시 동작: mutex는 한 스레드만 진행하고 나머지는 대기. atomic은 CAS 등으로 여러 스레드가 “재시도”하며 진행할 수 있어, 경합이 적을 때 유리.

6.4 memory_order별 성능 차이

일반적으로 seq_cstacq_relacquire/releaserelaxed 순으로 비용이 낮아집니다. x86에서는 차이가 작지만, ARM에서는 relaxed가 눈에 띄게 빠를 수 있습니다. 정확한 측정은 대상 플랫폼에서 벤치마크하는 것이 좋습니다.

정리: 단일 변수에 대한 고빈도 연산에서는 atomic이 mutex보다 보통 2~5배 정도 빠릅니다. 여러 변수를 한 번에 보호해야 하면 mutex가 맞고, atomic은 “변수 하나”에 집중할 때 유리합니다.

6.5 성능 최적화 팁

팁 1: 캐시 라인 분리 (False Sharing 방지)

여러 스레드가 서로 다른 atomic 변수를 자주 갱신할 때, 같은 캐시 라인에 있으면 한 스레드의 쓰기가 다른 스레드의 캐시를 invalidate해 성능이 떨어집니다. 캐시 라인(보통 64바이트) 경계에 맞춰 패딩을 넣어 분리합니다.

// ❌ 같은 캐시 라인 — false sharing
struct Bad { std::atomic<int> c1{0}; std::atomic<int> c2{0}; };

// ✅ alignas(64)로 캐시 라인 분리
struct alignas(64) Good {
    std::atomic<int> c1{0};
    char pad[64 - sizeof(std::atomic<int>)];
    std::atomic<int> c2{0};
};

팁 2: relaxed 사용 (순서가 필요 없을 때)

카운터, 통계, 모니터링용 변수처럼 “다른 변수와의 순서”가 필요 없으면 memory_order_relaxed를 사용합니다. ARM 등 weak memory 모델에서는 seq_cst 대비 눈에 띄게 빠를 수 있습니다.

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

void on_request() {
    request_count.fetch_add(1, std::memory_order_relaxed);
}

팁 3: 로컬 캐싱으로 atomic 접근 감소

여러 번 증가시킬 때 로컬 변수에 모았다가 주기적으로 atomic에 반영합니다.

thread_local int local_count = 0;
constexpr int BATCH = 100;

void process_requests() {
    for (auto& req : requests) {
        process(req);
        if (++local_count >= BATCH) {
            total_count.fetch_add(local_count, std::memory_order_relaxed);
            local_count = 0;
        }
    }
    total_count.fetch_add(local_count, std::memory_order_relaxed);
}

팁 4: fetch_add vs CAS 루프

단순 증가/감소는 fetch_add가 CAS 루프보다 효율적입니다. CAS는 “조건부 업데이트”가 필요할 때만 사용합니다.

// ✅ 권장: fetch_add
counter.fetch_add(1, std::memory_order_relaxed);

// ❌ 비권장: CAS로 증가 (fetch_add가 더 나음)
int old = counter.load();
while (!counter.compare_exchange_weak(old, old + 1)) {}

팁 5: is_lock_free 확인

std::atomic이 내부적으로 락을 사용하는지 확인합니다. is_lock_free()가 false이면 mutex 기반 fallback이므로, 해당 타입은 atomic 대신 mutex를 고려하는 것이 좋습니다.

std::atomic<MyStruct> s;
if (!s.is_lock_free()) {
    // MyStruct는 락으로 보호됨 — mutex 직접 사용이 나을 수 있음
}

7. 프로덕션 패턴: 카운터, 플래그, lock-free

7.1 고빈도 카운터

// 요청 수, 에러 수 등
std::atomic<uint64_t> request_count{0};
std::atomic<uint64_t> error_count{0};

void on_request() {
    request_count.fetch_add(1, std::memory_order_relaxed);
}

void on_error() {
    error_count.fetch_add(1, std::memory_order_relaxed);
}

위 코드 설명: 다른 변수와의 순서가 필요 없으면 relaxed로 충분합니다. 모니터링·통계용 카운터에 적합합니다.

7.2 준비 완료 플래그 (Producer-Consumer)

std::atomic<bool> data_ready{false};
std::vector<int> shared_data;

void producer() {
    shared_data = compute_data();
    data_ready.store(true, std::memory_order_release);
}

void consumer() {
    while (!data_ready.load(std::memory_order_acquire)) {
        std::this_thread::yield();
    }
    process(shared_data);
}

7.3 한 번만 실행 (Initialization)

std::atomic<bool> initialized{false};

void init_once() {
    if (!initialized.exchange(true)) {
        do_heavy_init();
    }
}

위 코드 설명: exchange(true)는 “false였으면 true로 바꾸고 false 반환, 이미 true였으면 true 반환”입니다. 따라서 한 스레드만 do_heavy_init()을 실행하고, 나머지는 스킵합니다.

7.4 Lock-free 알고리즘 선택 가이드

상황권장
단일 변수 (카운터, 플래그)std::atomic
SPSC 큐원형 버퍼 + atomic 인덱스
MPMC 큐boost::lockfree::queue 또는 표준 라이브러리
복잡한 불변식mutex 우선, 필요 시 lock-free 검토

7.5 Shutdown 플래그

std::atomic<bool> shutdown_requested{false};

void worker_thread() {
    while (!shutdown_requested.load(std::memory_order_acquire)) {
        do_work();
    }
}

void request_shutdown() {
    shutdown_requested.store(true, std::memory_order_release);
}

위 코드 설명: acquire/release를 쓰면, worker가 shutdown_requested를 true로 보기 전에 수행한 do_work()의 모든 부수 효과가 request_shutdown() 호출 이전에 완료된 것처럼 보입니다. seq_cst보다 가볍고, 이 패턴에는 충분합니다.

7.6 Double-Checked Locking (초기화)

std::atomic<MyClass*> instance{nullptr};
std::mutex init_mutex;

MyClass* get_instance() {
    MyClass* tmp = instance.load(std::memory_order_acquire);
    if (tmp == nullptr) {
        std::lock_guard<std::mutex> lock(init_mutex);
        tmp = instance.load(std::memory_order_relaxed);
        if (tmp == nullptr) {
            tmp = new MyClass();
            instance.store(tmp, std::memory_order_release);
        }
    }
    return tmp;
}

위 코드 설명: 첫 로드에서 nullptr가 아니면 락 없이 반환. nullptr이면 락을 잡고 다시 확인 후 초기화. release로 저장하면 다른 스레드의 acquire 로드가 완전히 초기화된 객체를 보게 됩니다. C++11 이후 std::call_once를 쓰는 편이 더 단순합니다.

7.7 프로덕션 패턴: 모니터링·메트릭 수집

struct Metrics {
    std::atomic<uint64_t> requests{0};
    std::atomic<uint64_t> errors{0};

    void record_request() {
        requests.fetch_add(1, std::memory_order_relaxed);
    }
    void record_error() {
        errors.fetch_add(1, std::memory_order_relaxed);
    }
};

위 코드 설명: API 서버에서 요청 수, 에러 수를 atomic으로 수집합니다. relaxed로 충분하며, 모니터링 목적에 적합합니다.

7.8 프로덕션 패턴: Rate Limiter (토큰 버킷)

#include <atomic>

class TokenBucket {
    std::atomic<int64_t> tokens;
    int64_t max_tokens;

public:
    TokenBucket(int64_t max) : tokens(max), max_tokens(max) {}

    bool try_consume() {
        int64_t current = tokens.load(std::memory_order_relaxed);
        while (current > 0) {
            if (tokens.compare_exchange_weak(current, current - 1,
                                            std::memory_order_relaxed))
                return true;
        }
        return false;
    }

    void refill(int64_t count) {
        int64_t old = tokens.load(std::memory_order_relaxed);
        int64_t desired;
        do {
            desired = std::min(old + count, max_tokens);
        } while (!tokens.compare_exchange_weak(old, desired,
                                               std::memory_order_relaxed));
    }
};

위 코드 설명: 토큰 버킷의 핵심만 추린 예제입니다. try_consume은 CAS로 토큰을 1 감소시키고, refill은 주기적으로 호출해 토큰을 보충합니다. 실제 프로덕션에서는 refill 타이밍을 별도 스레드나 타이머로 관리합니다.

7.9 프로덕션 패턴: 단계별 상태 머신

enum class State { Idle, Running, Paused, Stopped };
std::atomic<State> current_state{State::Idle};

bool try_transition(State from, State to) {
    State expected = from;
    return current_state.compare_exchange_strong(expected, to);
}
// try_transition(State::Idle, State::Running) — 여러 스레드가 동시에 호출해도 한 스레드만 성공

위 코드 설명: 상태 머신에서 “특정 상태에서만 전이”할 때 CAS를 사용합니다.


8. atomic vs mutex: 언제 무엇을 쓸까

  • 한두 개의 단순 변수(카운터, bool 플래그 등)만 보호할 때 → atomic이 적합. 락 없이 원자적 연산만으로 data race를 없앨 수 있고, 경합이 적을 때 유리합니다.
  • 여러 변수를 한 번에, 또는 복잡한 조건/구조를 일관되게 유지해야 할 때 → mutex가 적합. atomic만으로는 “A를 바꾼 다음 B를 바꾼다”를 다른 스레드에 하나의 단위로 보장하기 어렵습니다.
  • 큐, 맵, 리스트 같은 자료 구조 전체를 보호할 때 → mutex(또는 락 프리 구조 + atomic 조합). 단일 atomic 변수만으로는 표현이 불가능합니다.

정리하면: 단일 변수·단순 연산은 atomic, 그 이상은 mutex를 먼저 고려하면 됩니다.


9. 실전 주의사항

atomic은 “그 변수만” 보호한다

std::atomic<int> aa에 대한 접근만 원자적으로 만듭니다. 다른 변수 b, c와의 “같은 시점에 같이 바뀌는” 관계는 atomic만으로는 보장되지 않습니다. 여러 변수의 일관성이 필요하면 mutex를 쓰세요.

volatile과 혼동하지 말 것

volatile은 컴파일러의 최적화(제거·재배치)만 제한할 뿐, 스레드 간 동기화를 보장하지 않습니다. 멀티스레드에서 공유 변수를 보호하려면 atomic 또는 mutex를 사용해야 합니다.

생성·복사·대입

std::atomic은 복사 생성·복사 대입이 삭제되어 있습니다. 값을 넘기려면 load()로 읽어서 복사하거나, store()로 써야 합니다.

atomic 타입별 지원 연산

타입load/storefetch_add/subCASexchange
atomic<int>
atomic<bool>
atomic<T*>fetch_add (offset)
atomic<struct>제한적 (is_lock_free 확인)

atomic<bool>에는 fetch_add가 없습니다. atomic<T*>fetch_add는 포인터 오프셋(요소 크기 단위)을 더합니다. 구조체는 std::atomic이 lock-free로 지원하는 경우에만 사용 가능하며, is_lock_free()로 확인해야 합니다.

구현 체크리스트

  • 단일 변수만 보호하는지 확인 (여러 변수면 mutex)
  • 플래그+데이터 패턴에서는 release/acquire 쌍 사용
  • 순수 카운터는 relaxed 고려
  • lock-free 구조 구현 시 ABA 문제 대비
  • volatile 대신 atomic 사용

같이 보면 좋은 글 (내부 링크)

이 주제와 연결되는 다른 글입니다.

  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ 실전 가이드 시리즈 전체 목차 | #0~#49 기초·메모리·네트워크·면접

이 글에서 다루는 키워드 (관련 검색어)

C++ atomic, std::atomic, 원자적 연산, lock-free, 메모리 순서, data race 방지, compare_exchange, ABA 문제 등으로 검색하시면 이 글이 도움이 됩니다.

정리

  • std::atomic은 단일 변수에 대한 읽기/쓰기·RMW를 data race 없이 수행하게 합니다.
  • load/store, exchange, fetch_add/fetch_sub, compare_exchange_strong/weak로 다루며, 기본값인 seq_cst만 써도 대부분 충분합니다.
  • memory_order는 “다른 스레드에 보이는 순서”를 제어하며, relaxed(카운터), acquire/release(플래그+데이터)를 상황에 맞게 선택합니다.
  • lock-free 스택·큐는 CAS 기반으로 구현 가능하지만, ABA 문제와 메모리 재사용에 주의합니다.
  • 흔한 실수: ABA, 메모리 순서 오류, volatile 혼동을 피합니다.
  • 한두 변수·단순 연산은 atomic, 여러 변수·복잡한 불변식은 mutex를 선택하면 됩니다.

시리즈 마무리

#7-1에서 스레드 생성과 join/detach, #7-2에서 mutex와 락, #7-3에서 condition_variable과 producer-consumer, #7-4에서 atomic까지 다뤘습니다. 이 네 편이면 실무에서 자주 쓰는 멀티스레딩 패턴의 기초를 갖출 수 있습니다.

다음 권장: 동시성 유틸을 더 쓰고 싶다면 std::async, std::future, 스레드 풀 등을 다루는 글로 이어가면 좋습니다. 현재 시리즈에서는 여기까지가 “멀티스레딩 완벽 가이드”의 마지막 편입니다.


자주 묻는 질문 (FAQ)

Q. 이 내용을 실무에서 언제 쓰나요?

A. C++ atomic 변수 완벽 가이드. std::atomic으로 락 없이 스레드 안전하게 카운터·플래그 관리, memory_order(relaxed·acquire·release·seq_cst) 의미, atomic v… 실무에서는 위 본문의 예제와 선택 가이드를 참고해 적용하면 됩니다.

Q. 선행으로 읽으면 좋은 글은?

A. 각 글 하단의 이전 글 링크를 따라가면 순서대로 배울 수 있습니다. C++ 시리즈 목차에서 전체 흐름을 확인할 수 있습니다.

Q. 더 깊이 공부하려면?

A. cppreference와 해당 라이브러리 공식 문서를 참고하세요. 글 말미의 참고 자료 링크도 활용하면 좋습니다.

한 줄 요약: std::atomic으로 단순 변수는 lock 없이 스레드 안전하게 쓸 수 있습니다. 다음으로 예외 처리 기초(#8-1)를 읽어보면 좋습니다.

다음 글: C++ 실전 가이드 #8-1: 예외 처리 기초 - try-catch와 예외 처리 기초를 다룹니다.

참고 자료


관련 글

  • C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
  • C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
  • C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
  • C++ condition_variable 실무 패턴 |
  • C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례