C++ 동시성 핵심: 스레드 동기화와 프로세스 간 통신

CAS 하드웨어 원리와 ABA 문제

원자적 연산의 근간은 CPU의 CAS(Compare-And-Swap) 명령어입니다. 이 명령어는 메모리 주소의 현재값이 예상값과 일치할 때만 새값으로 교체하며, 전 과정이 하드웨어 수준에서 원자적으로 실행됩니다.

// CAS의 개념적 동작 (x86 CMPXCHG 명령어)
bool atomic_cas(int* target, int expect, int swap) {
    // 하드웨어가 원자적으로 보장
    if (*target == expect) {
        *target = swap;
        return true;  // 교체 성공
    }
    return false;     // 실패, expect에 현재값 반영
}

스핀락 구현은 CAS의 가장 직관적인 활용입니다:

#include <atomic>

class SpinMutex {
    std::atomic_flag occupied = ATOMIC_FLAG_INIT;
public:
    void acquire() {
        while (occupied.test_and_set(std::memory_order_acquire)) {
            // x86에서 PAUSE 명령으로 스핀 대기 최적화
            __asm__ __volatile__("pause");
        }
    }
    void release() {
        occupied.clear(std::memory_order_release);
    }
};

ABA 문제와 해결책

CAS의 고질적 문제는 ABA 문제입니다. 값이 A→B→A로 변경되면 변경 이력을 감지하지 못합니다. 무잠금 스택에서 특히 치명적입니다:

// 위험한 상황: 스레드A가 pop 중일 때
// Top → Node1 → Node2 → Node3
// 스레드A: old_top=Node1, new_top=Node2 예상
// 스레드B: pop×2 후 Node1 재삽입
// 결과: Node2는 이미 해제됐지만 CAS 성공 → 댕글링 포인터!

해결책으로는 버전 태그(128비트 DCAS), Hazard Pointer, 또는 C++20의 std::atomic<std::shared_ptr<T>>를 활용한 안전한 메모리 회수가 있습니다.

x86 LOCK 프리픽스와 캐시 일관성

x86에서 CAS는 LOCK 프리픽스와 결합됩니다. 과거에는 시스템 버스를 완전히 점유하는 버스 락이었으나, 현대 프로세서는 캐시 락으로 진화했습니다.

[캐시 락 동작 원리]

CPU Core 0          CPU Core 1
┌──────────┐       ┌──────────┐
│ L1 캐시  │←MESI→│ L1 캐시  │  ← 대상 캐시 라인만 잠금
│ [라인X]  │       │ [라인X]  │    (무효화 후 독점)
└────┬─────┘       └────┬─────┘
     │                  │
     └──────┬───────────┘
            ▼
      L3/링 버스 (다른 메모리 접근 허용)

MESI 프로토콜의 네 가지 상태는 캐시 락의 핵심입니다:

  • Modified: 현재 코어 독점, 메모리와 불일치
  • Exclusive: 현재 코어 독점, 메모리와 일치
  • Shared: 다중 코어 공유, 메모리와 일치
  • Invalid: 무효 상태
// 캐시 라인 정렬으로 거짓 공유 방지
struct alignas(64) PaddedCounter {
    std::atomic<int> value{0};
    // 64바이트 캐시 라인 채우기
    char padding[60];
};

std::atomic 메모리 순서

C++11은 6가지 메모리 순서를 제공합니다. memory_order_relaxed는 순서 보장 없이 원자성만 제공하며, memory_order_seq_cst는 가장 강력한 전역 순서를 보장합니다.

// Release-Acquire 동기화 패턴
std::atomic<bool> flag{false};
std::string payload;

void producer_thread() {
    payload = "중요 데이터";           // (1) 일반 쓰기
    flag.store(true, std::memory_order_release);  // (2) 릴리스
    // (1)은 (2) 이전에 완료됨 (재배치 금지)
}

void consumer_thread() {
    while (!flag.load(std::memory_order_acquire)) {  // (3) 어콰이어
        // 대기
    }
    // (3)은 (4) 이전에 완료됨
    // (2)→(3) 동기화로 (1)→(4) happens-before 보장
    assert(payload == "중요 데이터");  // (4) 항상 성공
}

compare_exchange_weak는 의사 실패(spurious failure)가 있지만 LL/SC 아키텍처(ARM 등)에서 루프 내에서 더 효율적입니다. compare_exchange_strong은 의사 실패가 없어 단일 시도에 적합합니다.

Mutex 계열과 RAII 래퍼

타입버전특징용도
std::mutexC++11비재귀, 기본일반 상호배제
std::recursive_mutexC++11동일 스레드 재획득 가능재귀 알고리즘
std::timed_mutexC++11시도 제한 시간데드락 회피
std::shared_mutexC++17읽기 공유, 쓰기 독점읽기 중심 워크로드

std::lock_guard는 단순한 RAII 래퍼이며, std::unique_lock은 더 유연합니다:

// defer_lock: 지연 획득으로 다중 락 안전 확보
std::unique_lock<std::mutex> lk_a(mtx_a, std::defer_lock);
std::unique_lock<std::mutex> lk_b(mtx_b, std::defer_lock);
std::lock(lk_a, lk_b);  // 데드락 방지 알고리즘으로 동시 획득

// C++17 scoped_lock: 가장 권장되는 방식
std::scoped_lock both(mtx_a, mtx_b);  // 가변 인자, 예외 안전

데드락 방지 전략

데드락의 4가지 필요조건(상호배제, 점유대기, 비선점, 순환대기) 중 하나를 깨면 예방됩니다.

// 계층 락: 락에 레벨 부여로 순환대기 방지
class HierarchicalMutex {
    const unsigned level_;
    static thread_local unsigned thread_level_;
    
    void check_violation() {
        if (thread_level_ <= level_)
            throw std::logic_error("계층 위반!");
    }
public:
    void lock() {
        check_violation();
        internal_.lock();
        thread_level_ = level_;  // 현재 레벨 기록
    }
    void unlock() {
        thread_level_ = previous_level_;
        internal_.unlock();
    }
    std::mutex internal_;
};

타임아웃 기반 try_lock_for는 점유대기 조건을 깨는 대안입니다:

bool try_transfer(Account& to, double amount, milliseconds timeout) {
    unique_lock<mutex> lk_from(mtx_, defer_lock);
    unique_lock<mutex> lk_to(to.mtx_, defer_lock);
    
    if (!lk_from.try_lock_for(timeout)) return false;
    if (!lk_to.try_lock_for(timeout)) return false;  // 실패 시 첫 락 자동 해제
    
    // 이체 로직...
    return true;
}

조건 변수 사용법

조건 변수는 반드시 while 루프와 조건자와 함께 사용해야 합니다:

template<typename T>
class BlockingQueue {
    queue<T> buf_;
    mutex mtx_;
    condition_variable cv_;
    
public:
    void enqueue(T val) {
        {
            lock_guard<mutex> lk(mtx_);
            buf_.push(move(val));
        }
        cv_.notify_one();  // 락 해제 후 알림
    }
    
    T dequeue() {
        unique_lock<mutex> lk(mtx_);
        // 반드시 while: 의사 깨우기(spurious wakeup) 처리
        cv_.wait(lk, [this]{ return !buf_.empty(); });
        
        T val = move(buf_.front());
        buf_.pop();
        return val;
    }
};

알림 방식 선택:

  • notify_one: 단일 소비자, 경쟁 최소화
  • notify_all: 다중 소비자, 조건자 재검사 필수

락 없는 동시성 기법

thread_local 저장소

// 스레드별 독립 인스턴스로 동기화 불필요
class PerThreadCache {
    static thread_local vector<int> local_buffer_;
public:
    void accumulate(int val) {
        local_buffer_.push_back(val);  // 락 없음
    }
    static void merge_to_global();
};

불변 객체

class ImmutableConfig {
    const int timeout_;
    const string endpoint_;
public:
    ImmutableConfig with_timeout(int t) const {
        return ImmutableConfig(t, endpoint_);  // 새 객체 반환
    }
    // 모든 필드 const → 완전한 스레드 안전
};

Actor 모델

class Actor {
    queue<function<void()>> mailbox_;
    mutex mtx_;
    condition_variable cv_;
    thread worker_;
    
    void run_loop() {
        while (running_) {
            function<void()> msg;
            {
                unique_lock<mutex> lk(mtx_);
                cv_.wait(lk, [this]{ return !mailbox_.empty(); });
                msg = move(mailbox_.front());
                mailbox_.pop();
            }
            msg();  // 직렬 처리로 내부 락 불필요
        }
    }
public:
    void send(function<void()> task) {
        lock_guard<mutex> lk(mtx_);
        mailbox_.push(move(task));
        cv_.notify_one();
    }
};

프로세스 간 통신(IPC)

방식방향속도특징
익명 파이프단방향중간부모-자식 전용
명명 파이프(FIFO)단/양방향중간파일 시스템 기반
메시지 큐양방향빠름커널 내 경계 보존
공유 메모리양방향최고별도 동기화 필요
소켓양방향변동네트워크 가능

POSIX 공유 메모리

#include <sys/mman.h>
#include <fcntl.h>

void posix_shm_demo() {
    // 공유 메모리 객체 생성
    int fd = shm_open("/my_shm", O_CREAT | O_RDWR, 0666);
    ftruncate(fd, 4096);  // 크기 설정
    
    // 프로세스 주소 공간에 매핑
    void* ptr = mmap(nullptr, 4096, 
                     PROT_READ | PROT_WRITE,
                     MAP_SHARED,  // 핵심: 수정이 다른 프로세스에 보임
                     fd, 0);
    close(fd);  // 매핑 후 파일 디스크립터 불필요
    
    // 사용 후 정리
    munmap(ptr, 4096);
    shm_unlink("/my_shm");
}

공유 메모리는 제로 카피로 가장 빠르지만, 별도의 동기화 메커니즘(프로세스 공유 뮤텍스, 세마포어)이 필수입니다:

struct SharedRegion {
    pthread_mutex_t mtx;  // PTHREAD_PROCESS_SHARED 속성 필요
    int counter;
    char data[1024];
};

// 프로세스 공유 뮤텍스 초기화
pthread_mutexattr_t attr;
pthread_mutexattr_init(&attr);
pthread_mutexattr_setpshared(&attr, PTHREAD_PROCESS_SHARED);
pthread_mutex_init(&region->mtx, &attr);

fork()와 프로세스 생명주기

쓰기 시 복사(Copy-On-Write)

fork()는 페이지 테이블만 복사하고 물리 페이지는 공유합니다. 첫 쓰기 시 페이지 폴트가 발생해 별도 물리 페이지가 할당됩니다.

int global = 42;

void cow_demo() {
    pid_t pid = fork();
    if (pid == 0) {
        // 자식: 쓰기 전까지 부모와 물리 페이지 공유
        global = 100;  // 여기서 COW 트리거, 새 페이지 할당
        // 가상 주소는 동일(&global)하지만 물리 주소 분리
    } else {
        wait(nullptr);
        // 부모의 global은 여전히 42
    }
}

좀비 프로세스 처리

자식이 종료되면 SIGCHLD 신호가 발생합니다. 부모가 wait()를 호출하지 않으면 PCB가 남아 좀비 상태가 됩니다.

// 비동기 좀비 회수 (권장 방식)
void sigchld_handler(int) {
    // 저장된 errno 보존
    int saved = errno;
    
    // WNOHANG으로 블록 없이 모든 종료 자식 회수
    while (waitpid(-1, nullptr, WNOHANG) > 0) {}
    
    errno = saved;
}

// 메인에서 설정
struct sigaction sa{};
sa.sa_handler = sigchld_handler;
sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
sigaction(SIGCHLD, &sa, nullptr);

성능 비교 요약

// 단순 카운터 벤치마크 (8 스레드, 1000만 회)
// atomic relaxed:  ~200ms  (락 프리, 캐시 일관성 트래픽)
// mutex:          ~3000ms (커널 진입, 문맥 전환)

선택 가이드:

  • 단순 카운터/플래그: std::atomic (relaxed)
  • 복잡한 임계구역: std::mutex 또는 std::shared_mutex
  • 지연에 민감한 실시간: 락 프리 구조
  • 읽기 압도적: RCU 또는 읽기 복사-업데이트 패턴

태그: C++ std::atomic memory_order Mutex deadlock

10월 8일 23:58에 게시됨