레이스 컨디션과 std::atomic

Jaemyeong Lee·2024년 12월 25일

게임 서버1

목록 보기
113/220

레이스 컨디션을 정확히 이해하기

재현 예제

#include <iostream>
#include <thread>

int sum = 0;

void Inc() {
    for (int i = 0; i < 1'000'000; ++i) {
        ++sum;
    }
}

void Dec() {
    for (int i = 0; i < 1'000'000; ++i) {
        --sum;
    }
}

int main() {
    std::thread t1(Inc), t2(Dec);
    t1.join();
    t2.join();
    std::cout << sum << '\n';  // 기대: 0, 실제: 실행마다 달라질 수 있음
}

왜 깨지는가

  • ++sum/--sum은 읽기-수정-쓰기(Read-Modify-Write) 복합 연산입니다.
  • 두 스레드가 같은 값을 읽고 각자 저장하면 업데이트가 유실됩니다.
  • 비원자 공유 변수의 동시 읽기/쓰기는 C++에서 데이터 레이스 -> UB(정의되지 않은 동작) 입니다.

핵심 결론

  • "결과가 이상할 수 있다" 수준이 아니라, 프로그램 의미 자체가 깨질 수 있습니다.

std::atomic 핵심

기본 사용

#include <atomic>

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

void Inc() {
    for (int i = 0; i < 1'000'000; ++i) {
        sum.fetch_add(1, std::memory_order_relaxed);
    }
}

atomic이 보장하는 것

  • 단일 원자 변수에 대한 연산을 끊기지 않게 수행(원자성)
  • 같은 원자 변수에 대해 일관된 수정 순서를 형성
  • 적절한 memory order와 함께 쓰면 스레드 간 가시성/순서 동기화 가능

atomic이 보장하지 않는 것

  • 여러 변수에 걸친 불변식(예: HP와 상태를 동시에 맞추기)
  • 컨테이너 내부 구조의 동시 수정 안전성
  • 자동으로 "항상 lock-free, 항상 빠름"을 보장하지 않음

자주 쓰는 atomic 연산

연산의미사용 예
load()원자적 읽기플래그/카운터 조회
store(v)원자적 쓰기상태 갱신
fetch_add(n)이전 값 반환 후 더함카운터 증가
exchange(v)값 교체 + 이전 값 반환토글/소유권 넘김
compare_exchange_*예상값 비교 후 조건부 교체(CAS)락프리 자료구조 핵심
is_lock_free()해당 타입이 락프리인지 힌트성능 조사 참고

compare_exchange 주의점

  • 실패 시 expected 인자가 현재 값으로 덮어써집니다.
  • 그래서 CAS 루프에서는 expected를 매 반복마다 올바르게 유지해야 합니다.
  • 일반적으로 반복 루프에서는 weak, 단발 비교에서는 strong을 자주 사용합니다.

memory_order 빠른 가이드

오더의미권장 사용
relaxed원자성만 보장, 순서 보장 약함단순 통계 카운터
acquire이후 읽기/쓰기 재배치 방지소비자 측 load
release이전 읽기/쓰기 재배치 방지생산자 측 store
acq_relacquire + releaseread-modify-write
seq_cst가장 직관적 전역 순서기본값으로 시작할 때

실무 원칙

  • 처음에는 seq_cst로 정확성을 확보합니다.
  • 병목이 확인될 때만 acquire/releaserelaxed로 낮추고, 근거를 문서화합니다.

CAS 패턴 예시

최대값 갱신 (lock-free 스타일)

void UpdateMax(std::atomic<int>& mx, int v) {
    int cur = mx.load(std::memory_order_relaxed);
    while (cur < v &&
           !mx.compare_exchange_weak(cur, v,
                                     std::memory_order_release,
                                     std::memory_order_relaxed)) {
        // 실패하면 cur가 최신값으로 갱신됨
    }
}

배울 포인트

  • CAS는 "현재 값이 내가 예상한 값일 때만" 갱신합니다.
  • 락 없이 경쟁을 제어할 수 있지만, 루프/재시도 비용이 생길 수 있습니다.

atomic으로 해결되지 않는 문제

vector::push_back 동시 호출

  • 여러 스레드가 같은 vectorpush_back하면 내부 size/capacity/포인터 갱신이 충돌합니다.
  • reserve()는 재할당 횟수만 줄일 뿐, 동시 수정 안전성을 보장하지 않습니다.

안전한 대안

상황권장 방법
공용 컨테이너에 쓰기std::mutex로 임계영역 보호
고성능 수집스레드 로컬 버퍼에 모은 뒤 단일 스레드 merge
생산자-소비자 구조동시성 큐 또는 락+큐
std::mutex m;
std::vector<int> v;

void SafePush(int x) {
    std::lock_guard<std::mutex> lock(m);
    v.push_back(x);
}

강의 시 유의사항

강조 포인트

  • atomic은 강력하지만 "단일 상태"에 특히 강합니다.
  • 복합 상태/컨테이너 불변식은 mutex가 더 적합한 경우가 많습니다.
  • memory_order를 모르면 "가끔만 재현되는 버그"를 설명하기 어렵습니다.

자주 하는 오해

오해바로잡기
atomic이면 모든 동기화 문제 해결여러 변수의 일관성은 별도 설계 필요
reserve()하면 vector 동시 push가 안전동시 수정 자체가 여전히 위험
relaxed가 항상 더 좋다순서 보장이 필요하면 오동작 가능

체크 질문 (스스로 답해보기)

  • 왜 비원자 공유 변수의 동시 접근은 UB인가?
  • compare_exchange_weak에서 expected가 왜 바뀌는가?
  • atomicmutex 중 무엇을 선택할지 어떤 기준으로 판단할 것인가?

profile
李家네_공부방

0개의 댓글