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::mutex | C++11 | 비재귀, 기본 | 일반 상호배제 |
std::recursive_mutex | C++11 | 동일 스레드 재획득 가능 | 재귀 알고리즘 |
std::timed_mutex | C++11 | 시도 제한 시간 | 데드락 회피 |
std::shared_mutex | C++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(®ion->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 또는 읽기 복사-업데이트 패턴