CAS 기반 락프리 링 버퍼와 Mutex Lock 비교
다중 스레드 환경에서 가장 흔한 문제는 여러 스레드가 같은 데이터를 동시에 수정하는 상황이다.
예를 들어 여러 입금 스레드가 하나의 balance 값을 동시에 갱신한다고 생각해보자.
단순히 balance = balance + amount를 실행하면 읽기, 계산, 쓰기 사이에 다른 스레드가 끼어들 수 있다.
그 결과 일부 입금이 사라지는 갱신 손실(Lost Update)이 발생한다.
이 문제를 해결하는 대표적인 방식은 두 가지다.
Mutex Lock = 공유 데이터를 직접 보호한다.
Lock-Free Ring Buffer = 공유 데이터를 직접 건드리지 않고 이벤트를 전달한다.락프리 링 버퍼는 보통 CAS(Compare-And-Swap) 같은 원자적 연산으로 구현된다. 그래서 이 글에서는 링 버퍼, 락프리, CAS, 뮤텍스가 각각 어떤 역할을 하는지 구분해서 정리한다.
먼저 결론
락프리 링 버퍼와 뮤텍스는 서로 대체재처럼 보이지만, 실제로는 해결하는 방식이 다르다.
뮤텍스는 여러 스레드가 같은 코드 구간에 동시에 들어오지 못하게 막는다. 즉 공유 변수 자체를 보호한다.
락프리 링 버퍼는 공유 변수 수정을 한 스레드에게 몰아준다. 여러 생산자 스레드는 이벤트만 큐에 넣고, 단일 소비자 스레드가 그 이벤트를 순서대로 처리한다.
Mutex 방식
여러 스레드 -> lock 획득 -> balance 직접 수정 -> unlock
락프리 링 버퍼 방식
여러 스레드 -> 입금 이벤트 push -> 단일 소비자 -> balance 수정핵심은 이것이다.
공유 데이터를 모두가 직접 수정해야 한다면 Mutex가 단순하다.
공유 데이터 수정을 한 곳으로 모을 수 있다면 락프리 링 버퍼가 유리할 수 있다.용어부터 분리해서 보기
락프리 링 버퍼라는 이름에는 세 가지 개념이 섞여 있다.
Ring Buffer = 고정 크기 배열을 원형으로 재사용하는 자료구조
Lock-Free = Mutex 없이 진행을 보장하려는 동시성 방식
CAS = Lock-Free 구현에 자주 쓰이는 원자적 연산이 세 개를 분리해서 보면 구조가 훨씬 이해하기 쉽다.
Ring Buffer
링 버퍼(Ring Buffer)는 고정 크기 배열을 원형으로 사용하는 큐다.
배열의 마지막 인덱스까지 사용하면 다시 0번 인덱스로 돌아간다. 이 동작을 Wrap-around라고 부른다.
0 -> 1 -> 2 -> 3 -> 0 -> 1 -> ...링 버퍼를 쓰는 이유는 메모리를 계속 새로 할당하지 않기 위해서다. 처음에 정해진 크기의 배열을 만들고, 그 공간을 반복해서 재사용한다.
그래서 로그, 이벤트, 네트워크 패킷, 입금 요청처럼 짧은 메시지를 빠르게 전달하는 곳에 잘 맞는다.
Lock-Free
락프리(Lock-Free)는 운영체제의 Mutex나 Semaphore 같은 잠금 객체에 의존하지 않는 동시성 방식이다.
뮤텍스 기반 코드는 락을 얻지 못하면 스레드가 대기 상태로 들어간다. 이때 커널 개입, 컨텍스트 스위칭, 스케줄링 비용이 발생한다.
락프리 알고리즘은 이런 잠금 대기 대신 CPU가 제공하는 원자적 명령을 사용한다. 여러 스레드가 충돌하더라도 전체 시스템 관점에서 누군가는 계속 진행할 수 있도록 설계한다.
주의할 점이 있다. 락프리는 "모든 스레드가 항상 빨리 끝난다"는 뜻이 아니다. 경합이 심하면 어떤 스레드는 CAS에 계속 실패하고 재시도할 수 있다.
CAS
CAS(Compare-And-Swap)는 메모리의 현재 값이 내가 예상한 값과 같을 때만 새 값으로 바꾸는 원자적 연산이다.
CAS(메모리 주소, 예상 값, 새 값)
if 현재 메모리 값 == 예상 값:
현재 메모리 값을 새 값으로 변경
성공 반환
else:
실패 반환예를 들어 생산자 스레드가 tail 값을 10으로 읽었다고 하자.
이 스레드는 CPU에 이렇게 요청한다.
tail이 아직도 10이면 11로 바꿔줘.그 사이 다른 스레드가 이미 tail을 11로 바꿨다면 CAS는 실패한다.
실패한 스레드는 최신 값을 다시 읽고 다른 슬롯 예약을 시도한다.
CAS가 중요한 이유는 비교와 변경이 하나의 원자적 작업으로 처리되기 때문이다.
일반적인 tail = tail + 1은 읽기, 계산, 쓰기가 분리되어 있다.
여러 생산자가 동시에 실행하면 같은 슬롯을 중복으로 예약할 수 있다.
반면 CAS를 사용하면 특정 tail 값을 차지하는 생산자는 하나뿐이다.
나머지 생산자는 실패를 감지하고 다시 시도한다.
Mutex Lock
뮤텍스(Mutex)는 임계 구역(Critical Section)을 한 번에 하나의 스레드만 실행하게 만드는 잠금이다.
가장 큰 장점은 단순함이다. 공유 데이터를 수정하는 구간 전체를 락으로 감싸면 된다.
lock 획득 -> 공유 데이터 수정 -> lock 해제대신 락을 오래 잡거나 경합이 심하면 성능이 급격히 떨어질 수 있다. 락을 얻지 못한 스레드는 대기하고, 운영체제가 스레드를 재우고 깨우는 비용을 부담한다.
왜 링 버퍼가 balance를 직접 고치면 안 될까
초보자가 자주 헷갈리는 지점이 있다.
락프리 링 버퍼의 tail 인덱스를 CAS로 안전하게 증가시켰다고 해서, 큐 밖의 공유 변수까지 자동으로 안전해지는 것은 아니다.
CAS로 보호한 것 = 링 버퍼 슬롯 예약
보호되지 않은 것 = balance 같은 외부 공유 변수생산자 스레드가 슬롯을 안전하게 예약한 뒤 balance를 직접 수정하면 여전히 데이터 경합이 생긴다.
따라서 락프리 링 버퍼는 공유 변수를 직접 수정하는 도구라기보다, 스레드 사이에서 메시지나 이벤트를 전달하는 도구로 보는 것이 맞다.
MPSC 구조로 문제를 바꾸기
입금 예시는 MPSC(Multiple Producers, Single Consumer) 구조로 바꾸면 이해하기 쉽다.
여러 생산자 = 입금 요청을 만드는 스레드
단일 소비자 = 입금 요청을 순서대로 처리하는 스레드생산자는 balance를 직접 수정하지 않는다.
대신 "500원 입금" 같은 이벤트를 링 버퍼에 넣는다.
소비자는 링 버퍼에서 이벤트를 꺼내고, 혼자서 balance를 수정한다.
이 구조에서는 balance에 접근하는 스레드가 하나뿐이다.
그래서 balance 자체에는 Mutex나 atomic이 필요하지 않다.
Producer A -> DepositEvent(500) -> Ring Buffer
Producer B -> DepositEvent(1000) -> Ring Buffer
Ring Buffer -> Consumer 하나 -> balance += amount락프리 링 버퍼 예시 코드
아래 코드는 여러 생산자 스레드가 DepositEvent를 링 버퍼에 넣고, 단일 소비자 스레드만 balance를 갱신하는 예시다.
핵심은 생산자가 잔액을 직접 수정하지 않는다는 점이다. 생산자는 CAS로 링 버퍼의 슬롯을 예약하고 이벤트만 기록한다. 소비자 하나가 이벤트를 순서대로 꺼내 잔액을 더한다.
#include <array>
#include <atomic>
#include <cstddef>
#include <iostream>
#include <thread>
template <typename T, std::size_t Capacity>
class MpscRingBuffer {
static_assert((Capacity & (Capacity - 1)) == 0,
"Capacity must be a power of two");
struct alignas(64) Slot {
std::atomic<std::size_t> sequence;
T value;
};
std::array<Slot, Capacity> buffer_;
std::atomic<std::size_t> tail_{0}; // producer들이 CAS로 증가시킴
std::size_t head_{0}; // consumer 하나만 접근
public:
MpscRingBuffer() {
for (std::size_t i = 0; i < Capacity; ++i) {
buffer_[i].sequence.store(i, std::memory_order_relaxed);
}
}
bool try_push(const T& item) {
std::size_t pos = tail_.load(std::memory_order_relaxed);
for (;;) {
Slot& slot = buffer_[pos & (Capacity - 1)];
std::size_t seq = slot.sequence.load(std::memory_order_acquire);
std::ptrdiff_t diff =
static_cast<std::ptrdiff_t>(seq) -
static_cast<std::ptrdiff_t>(pos);
if (diff == 0) {
if (tail_.compare_exchange_weak(
pos,
pos + 1,
std::memory_order_relaxed,
std::memory_order_relaxed)) {
slot.value = item;
slot.sequence.store(pos + 1, std::memory_order_release);
return true;
}
} else if (diff < 0) {
// consumer가 아직 이 슬롯을 비우지 않았으므로 링 버퍼가 가득 찬 상태
return false;
} else {
pos = tail_.load(std::memory_order_relaxed);
}
}
}
bool try_pop(T& out) {
Slot& slot = buffer_[head_ & (Capacity - 1)];
std::size_t seq = slot.sequence.load(std::memory_order_acquire);
std::ptrdiff_t diff =
static_cast<std::ptrdiff_t>(seq) -
static_cast<std::ptrdiff_t>(head_ + 1);
if (diff != 0) {
return false;
}
out = slot.value;
slot.sequence.store(head_ + Capacity, std::memory_order_release);
++head_;
return true;
}
};
struct DepositEvent {
int amount;
};
int main() {
constexpr int producer_count = 2;
constexpr int repeat_count = 100000;
MpscRingBuffer<DepositEvent, 1024> queue;
std::atomic<int> producers_done{0};
int balance = 0; // consumer 스레드만 수정하므로 atomic이나 mutex가 필요 없음
auto producer = [&](int amount) {
for (int i = 0; i < repeat_count; ++i) {
DepositEvent event{amount};
while (!queue.try_push(event)) {
std::this_thread::yield();
}
}
producers_done.fetch_add(1, std::memory_order_release);
};
auto consumer = [&] {
DepositEvent event{};
while (producers_done.load(std::memory_order_acquire) < producer_count) {
while (queue.try_pop(event)) {
balance += event.amount;
}
std::this_thread::yield();
}
while (queue.try_pop(event)) {
balance += event.amount;
}
};
std::thread consumer_thread(consumer);
std::thread p1(producer, 500);
std::thread p2(producer, 1000);
p1.join();
p2.join();
consumer_thread.join();
std::cout << "최종 잔액: " << balance << "원\n";
return 0;
}예시 코드에서 봐야 할 부분
위 코드의 핵심은 tail_, head_, sequence의 역할을 구분하는 것이다.
tail_은 여러 생산자가 동시에 증가시킨다.
그래서 compare_exchange_weak를 사용한다.
tail_.compare_exchange_weak(pos, pos + 1, ...);이 CAS가 성공한 생산자만 해당 슬롯에 이벤트를 쓸 수 있다. 실패한 생산자는 다른 생산자가 먼저 슬롯을 예약했다는 뜻이므로 다시 시도한다.
head_는 소비자 하나만 증가시킨다.
그래서 atomic이 아니라 일반 정수로 충분하다.
std::size_t head_{0};sequence는 슬롯의 현재 상태를 나타낸다.
링 버퍼는 배열 인덱스를 계속 재사용하므로, 같은 인덱스라도 이전 라운드의 데이터인지 현재 라운드의 빈 슬롯인지 구분해야 한다.
sequence == producer가 기대한 값 -> 쓸 수 있는 슬롯
sequence < producer가 기대한 값 -> 아직 소비되지 않은 슬롯
sequence == consumer가 기대한 값 + 1 -> 읽을 수 있는 슬롯memory_order_release와 memory_order_acquire는 값 기록 순서를 맞추는 역할을 한다.
생산자가 slot.value를 쓴 뒤 sequence를 release로 갱신한다.
소비자는 sequence를 acquire로 읽는다.
이렇게 해야 소비자가 커밋된 슬롯을 봤을 때 slot.value도 올바르게 관측할 수 있다.
락프리 링 버퍼의 부작용
락프리 링 버퍼가 항상 더 좋은 것은 아니다.
가장 큰 부작용은 바쁜 대기(Busy Waiting)다. 버퍼가 가득 찼거나 비어 있으면 스레드는 실패하고 다시 시도한다.
while (!queue.try_push(event)) {
std::this_thread::yield();
}재시도가 짧게 끝나면 빠르다. 하지만 소비자가 느리거나 생산자가 너무 많으면 CPU를 계속 소모한다.
또 하나의 제약은 고정 용량이다.
링 버퍼는 정해진 배열을 재사용하므로, 처리량이 생산량을 따라가지 못하면 try_push가 실패한다.
실제 서비스에서는 다음 정책을 함께 정해야 한다.
이벤트를 버릴 수 있는가?
생산자를 잠시 늦출 것인가?
버퍼 크기를 얼마로 잡을 것인가?
재시도 횟수나 대기 시간을 어떻게 둘 것인가?이런 설계를 보통 백프레셔(Backpressure)라고 부른다.
Mutex Lock 방식
같은 문제를 뮤텍스로 풀면 구조는 훨씬 단순해진다.
공유 변수 balance를 수정하는 구간을 std::lock_guard로 감싸면 된다.
#include <iostream>
#include <mutex>
#include <thread>
int balance = 0;
std::mutex mtx;
void deposit(int amount) {
// 임계 구역 시작: 스코프 생성 시 자동 락 획득
std::lock_guard<std::mutex> lock(mtx);
balance = balance + amount;
// 임계 구역 끝: 스코프 종료 시 자동 락 해제
}현대 C++에서는 lock()과 unlock()을 직접 호출하기보다 std::lock_guard나 std::scoped_lock 같은 RAII 도구를 사용하는 편이 안전하다.
스코프를 벗어나면 자동으로 락이 풀린다. 중간에 예외가 발생해도 락 해제를 놓칠 가능성이 줄어든다.
Mutex의 비용
뮤텍스의 비용은 락을 잡는 명령 자체보다 경합이 생겼을 때 커진다.
락을 얻지 못한 스레드는 계속 실행되지 못한다. 운영체제는 그 스레드의 상태를 저장하고 대기 상태로 전환한다.
이 과정에서 다음 비용이 생긴다.
컨텍스트 스위칭 = 실행 중이던 스레드를 멈추고 다른 스레드로 전환하는 비용
커널 모드 전환 = 운영체제 스케줄러가 개입하는 비용
캐시 미스 = 다시 깨어난 스레드가 이전 캐시 상태를 잃어서 생기는 비용
호송 현상 = 여러 스레드가 하나의 락 앞에서 줄을 서는 현상그래서 짧은 작업에 락 경합이 아주 심하면 뮤텍스 오버헤드가 크게 보일 수 있다.
반대로 작업이 DB 조회, 파일 I/O, 네트워크 호출처럼 오래 걸린다면 이야기가 다르다. 이 경우 스레드를 계속 돌리며 재시도하는 것보다 뮤텍스로 재우는 편이 CPU를 덜 낭비할 수 있다.
비교 정리
| 비교 항목 | CAS 기반 락프리 링 버퍼 | Mutex Lock |
|---|---|---|
| 보호 방식 | 공유 데이터를 직접 보호하지 않고 이벤트 전달 경로를 보호한다 | 공유 데이터를 수정하는 임계 구역을 보호한다 |
| 주요 도구 | CAS, atomic, sequence, memory order | mutex, lock_guard, scoped_lock |
| 스레드 대기 | 실패하면 재시도하거나 yield한다 | 락을 얻지 못하면 대기 상태로 들어갈 수 있다 |
| 장점 | 짧은 이벤트 전달에서 컨텍스트 스위칭을 줄일 수 있다 | 구현과 추론이 단순하다 |
| 단점 | 구현 난도가 높고 바쁜 대기가 생길 수 있다 | 경합이 심하면 컨텍스트 스위칭 비용이 커진다 |
| 잘 맞는 경우 | 로그, 이벤트, 입금 요청처럼 짧은 메시지를 빠르게 전달하는 경우 | DB, 파일, 네트워크처럼 오래 걸리거나 복잡한 작업을 보호하는 경우 |
언제 무엇을 선택할까
락프리 링 버퍼를 선택해도 되는 경우는 비교적 명확하다.
작업을 이벤트로 표현할 수 있다.
공유 상태를 단일 소비자에게 모을 수 있다.
이벤트 처리 시간이 짧고 예측 가능하다.
버퍼가 가득 찰 때의 정책을 정할 수 있다.이 조건을 만족하면 락프리 링 버퍼가 좋은 선택이 될 수 있다. 특히 생산자가 많고 소비자가 하나인 MPSC 구조에서는 설계가 단순해진다.
반대로 다음 상황에서는 Mutex가 더 현실적이다.
공유 객체를 여러 스레드가 직접 읽고 써야 한다.
임계 구역 안의 작업이 복잡하다.
DB, 파일, 네트워크처럼 대기 시간이 긴 작업이 섞여 있다.
락프리 알고리즘의 정확성을 검증할 여력이 부족하다.락프리는 성능을 얻을 수 있지만 구현 난도가 높다. 특히 메모리 순서, ABA 문제, 캐시 라인 경합, 바쁜 대기 정책을 잘못 다루면 뮤텍스보다 더 위험한 코드가 된다.
정리
CAS 기반 락프리 링 버퍼는 공유 데이터를 직접 수정하지 않고 이벤트 전달 구조로 문제를 바꾸는 방식이다.
여러 생산자는 CAS로 슬롯을 예약하고 이벤트를 넣는다. 단일 소비자는 이벤트를 순서대로 꺼내 공유 상태를 갱신한다.
CAS는 슬롯 예약을 안전하게 만든다.
Ring Buffer는 고정 메모리를 반복해서 재사용한다.
Single Consumer는 balance 같은 공유 상태의 데이터 경합을 없앤다.Mutex는 더 단순하다. 공유 데이터를 수정하는 구간을 락으로 감싸면 된다.
짧은 이벤트를 매우 자주 전달하고, 공유 상태를 한 스레드에 모을 수 있다면 락프리 링 버퍼를 고려할 수 있다. 반대로 작업이 길거나 구조가 복잡하다면 Mutex를 사용하는 편이 더 명확하고 안전하다.