TL;DR — Private Server의 actor mailbox에 사용할 bounded MPSC queue를 구현하면서 Atomic 기반 구현이 MutexLock 기반 구현보다 빠를 것이라고 예상했습니다. 하지만 즉시 재시도하는 CAS 구현은 MutexLock보다 느렸고, CAS 실패 뒤 pause를 적용하자 결과가 다시 역전되었습니다.
Table of contents
Open Table of contents
들어가며
Private Server에서는 여러 IO worker가 만든 event를 하나의 actor owner에게 전달해야 합니다. 이 경계에 여러 producer와 단일 consumer가 사용하는 bounded MPSC queue를 구현했습니다.
처음에는 MutexLock을 제거하고 Atomic 연산만 사용하면 queue가 더 빠를 것이라고 예상했습니다. 그러나 초기 성능 실험에서는 producer가 하나일 때와 여러 개일 때의 결과가 크게 달랐습니다. 이 글에서는 구현 자체보다 예상이 어긋난 이유를 추적하고, 동시성 자료구조를 어떤 기준으로 선택해야 하는지 정리합니다.
이 글에서 다루는 내용:
- MPSC topology와 bounded queue가 필요한 이유
- MutexLock 기반 queue와 Atomic 기반 queue의 동기화 방식
- producer 증가에 따른 CAS 경합과 처리량 변화
- false sharing과 실제 공유 지점의 구분
- MSVC
std::mutex와 exclusiveSRWLOCK의 관계 - CAS 재시도에
_mm_pause기반 exponential backoff를 적용한 실험 - 동일한 임계 구역을 보호하는 CAS SpinLock 후속 실험
- mutex-free와 lock-free progress guarantee의 차이
- Private Server에서 queue를 선택하는 기준
1. Private Server에 MPSC Queue가 필요한 이유
여러 IO worker는 같은 Runtime Session을 대상으로 event를 만들 수 있지만, session의 mutable state는 한 actor owner만 변경할 수 있게 설계했습니다. 따라서 actor mailbox는 여러 producer가 enqueue하고 단일 drain owner(= actor)가 dequeue하는 MPSC 경계가 됩니다.
bounded capacity(= ring buffer) 를 가진 queue 를 통해 생산되는 event에 따라 동적으로 메모리 할당을 하는 오버헤드를 피하고자 하였고, queue의 사이즈는 producer/consumer가 event 를 생산/소모하는 횟수를 관측하여 조절했습니다.
2. 두 가지 Queue와 처음 세운 가설
비교 대상은 하나의 MutexLock으로 ring buffer의 push와 pop을 보호하는 queue와, producer가 CAS로 위치를 예약하고 slot별 sequence로 message를 공개하는 queue입니다.
처음 세운 가설은 다음과 같습니다.
MutexLock 획득과 대기를 제거하면 Atomic 기반 queue의 처리량이 더 높을 것이다.
MutexLock 구현은 push와 pop의 queue 상태 변경을 같은 std::mutex로 보호합니다. capacity 검사도 lock 안에서 수행해야 여러 producer가 동시에 남은 공간이 있다고 판단하는 경쟁을 막을 수 있습니다.
bool LockedBoundedMpscQueue::TryPush(const Message& value)
{
std::lock_guard<std::mutex> lock{mutex_};
if (size_ == capacity_)
return false;
storage_[tail_] = value;
tail_ = (tail_ + 1) % capacity_;
++size_;
return true;
}LockedBoundedMpscQueue.cpp
3. Atomic Queue는 Message를 어떻게 전달하는가
Atomic 기반 queue의 동작은 slot 예약과 message publication으로 나누어 설명합니다.
- 여러 producer가 공유 enqueue position을 CAS로 경쟁합니다.
- CAS에 성공한 producer가 해당 slot을 독점합니다.
- producer가 일반 메모리인 message를 작성합니다.
- release store로 slot이 준비되었다는 사실을 공개합니다.
- consumer는 acquire load로 준비 상태와 message 작성을 함께 관찰합니다.
핵심 코드는 다음과 같습니다. CAS에 성공한 producer만 slot에 message를 쓰고, 작성이 끝난 뒤 release store로 consumer에게 공개합니다.
if (enqueuePos_.compare_exchange_weak(
pos,
pos + 1,
std::memory_order_relaxed,
std::memory_order_relaxed))
{
slot.message = value;
// slot의 message를 이 thread에서 수정
// 수정 내용을 release
slot.seq.store(pos + 1, std::memory_order_release);
return true;
}
// 초기 구현은 실패 직후 다음 CAS를 시도합니다.
continue;MutexFreeBoundedMpscQueue.cpp
enqueuePos_는 slot의 소유권만 결정하므로 CAS에는 relaxed를 사용합니다. 실제 message publication은 slot별 sequence의 release/acquire 관계가 담당합니다.
4. 예상과 달랐던 초기 실험
MutexLock baseline의 capacity 검사를 lock 안으로 옮긴 뒤 Windows Release x64 환경에서 다시 측정했습니다. workload는 다음과 같습니다.
- Windows 11, Intel Core Ultra 5 226V, MSVC 19.51, Release x64
- producer 8개와 consumer 1개
- producer당 1,000,000개, 총 8,000,000개 message
- 16-byte message와 capacity 65,536의 bounded ring
- queue full이면
std::this_thread::yield()후 같은 message 재시도 - 조건별 10회 실행 후 median과 min/max 기록
전체 테스트 흐름은 다음과 같습니다.
queue를 생성한다
consumer 1개를 시작한다
시작 barrier에서 대기한다
총 8,000,000개를 소비할 때까지 TryPop을 반복한다
producer 8개를 시작한다
시작 barrier에서 대기한다
각 producer가 1,000,000개 message를 생성한다
queue가 full이면 yield 후 같은 message를 다시 시도한다
모든 thread가 barrier에 도착하면 시간을 측정한다
producer와 consumer가 끝날 때까지 기다린다
elapsed time과 throughput을 계산한다
조건별로 10회 반복하고 median과 min/max를 기록한다
첫 baseline에서는 pause 없이 즉시 재시도하는 CAS queue와 MutexLock queue를 각각 10회 측정했습니다.
| 구현 | Min/Max elapsed | Throughput |
|---|---|---|
| MutexLock | 495.376 / 1,497.160 ms | 12.499 Mmsg/s |
| CAS | 866.816 / 1,726.800 ms | 5.015 Mmsg/s |
처리량은 총 8,000,000개 message를 median elapsed로 나누어 환산했습니다. 이번 workload에서 MutexLock queue의 처리량은 CAS queue의 약 2.49배였습니다. Atomic 연산만 사용하면 MutexLock보다 빠를 것이라는 처음 가설과 반대되는 결과였습니다.
4-1. Producer가 늘어나면 무엇을 경쟁하는가
producer가 늘어나면 모든 producer가 같은 Atomic 위치를 수정하려고 시도하고, CAS 실패와 재시도가 증가합니다.
때문에 producer가 하나인 상태에서 비교를 해보면 enqueue position에 대한 CAS 경합이 줄기 때문에 더 좋은 결과를 얻을 수 있었습니다.
4-2. False Sharing과 True Sharing 구분하기
서로 다른 스레드가 서로 다른 변수를 수정하지만 두 변수가 같은 cache line에 배치된 경우를 false sharing으로 구분합니다. enqueue position과 dequeue position을 서로 다른 cache line에 배치했습니다.
반면 여러 producer가 동일한 enqueue position을 CAS하는 것은 실제로 같은 값을 공유하는 true sharing입니다.
5. std::mutex는 경합을 어떻게 처리하는가
왜 초기 예상과 다른 결과가 발생했는지 확인하기 위해서는 std::mutex가 어떤 방식으로 작동하는지 확인이 필요합니다.
MSVC 환경에서의 std::mutex는 실제로 다음과 같은 코드를 내부적으로 호출하고 있습니다.
std::lock_guard<std::mutex>
-> std::mutex::lock()
-> _Mtx_lock()
-> AcquireSRWLockExclusive()
std::mutex::unlock()
-> _Mtx_unlock()
-> ReleaseSRWLockExclusive()
내부 동작 흐름에서 SRWLOCK 를 호출하고 있는 것을 확인할 수 있습니다. SRWLOCK은 다음과 같은 특징을 가지고 있는 경량 lock 입니다.
CreateMutex()로 생성되는 WinAPI 의 handle 기반 Mutex Object와 다름- 단일 process 내부에서만 사용 가능한 경량 lock
- lock 은 pointer 크기의 작은(slim) 방식으로 구현되어 비교적 빠름
- 여러 reader 가 읽을 땐 shared mode 지원
- writer 가 작업할 땐 단독으로 작업할 수 있는 exclusive mode 지원
- MSVC 내부에서 사용하는 모드
exclusive lock을 즉시 얻지 못한 thread는 소유권을 받을 때까지 기다립니다. 대기하던 thread가 다시 실행되는 과정에서는 scheduler에 의한 context switch 비용이 발생할 수 있습니다. 대신 기다리는 producer가 공유 cache line에 CAS를 계속 요청하지 않으므로 runnable contender와 cache coherence traffic을 줄일 수 있습니다.
CAS queue는 반대 특성을 보입니다. 모든 producer가 계속 runnable한 상태에서 같은 enqueuePos_에 CAS를 반복하므로 wait와 wake-up 비용은 피하지만, 실패한 CAS와 cache line ownership 경쟁에 CPU 시간을 사용할 수 있습니다.
따라서 최초 결과는 다음 가설로 이어졌습니다.
현재처럼 producer가 많고 각 queue operation이 짧은 workload에서는, SRWLOCK의 대기·재실행 비용보다 CAS의 과도한 재시도 비용이 더 클 수 있다. 그러므로 CAS 경합 비용을 줄여보자.
6. CAS 재시도에 Exponential Pause를 적용
가설에 따라 CAS 실패 직후 모든 producer 가 다시 CAS를 수행하면서 경합을 하는 횟수를 줄이기 위한 방법을 찾아보았습니다.
Win32 API 에서는 YieldProcessor라는 매크로를 제공하고 있습니다. x64 기반의 환경에서는 _mm_pause를 호출하게 되어 있는데, 이는 다음과 같은 동작을 가능하게 합니다.
- thread를 wait 으로 전환하거나 cpu time slice 를 반납하지 않음
- CPU 명령 수준의 짧은 pause 실행
- context switch 가 없음
따라서 SRWLOCK에서 발생하는 경합 과정 중의 wait 후 context 복구 과정 비용이 CAS 경합으로 인해 발생하는 비용보다 크다고 하면, pause를 통해 비용을 줄일 수 있다고 생각해볼 수 있겠습니다.
if (!enqueuePos_.compare_exchange_weak(
pos,
pos + 1,
std::memory_order_relaxed,
std::memory_order_relaxed))
{
for (std::size_t count = 0; count < pauseCount; ++count)
YieldProcessor(); // Windows x64에서는 _mm_pause
pauseCount *= 2;
if (pauseCount > 1024)
pauseCount = 1024;
continue;
}MutexFreeBoundedMpscQueue.cpp
processor에 spin-wait 중임을 알리고 다음 CAS까지의 active spin을 완화합니다. queue가 full일 때 호출하는 std::this_thread::yield()와도 역할이 다릅니다.
std::this_thread::yield()는 이름에 Yield 가 포함되어 있어YieldProcessor와 비슷하게 동작할 것이라 생각하기 쉽지만 실제로 그렇게 동작하지 않음- 실제 MSVC 에서는
SwitchToThread를 호출하고, 이는 현재 time slice를 다른 runnable thread에 양보하여 context switch가 발생할 수 있음
같은 Windows 환경과 workload에서 MutexLock을 먼저, CAS+Pause를 두 번째로 실행하는 묶음을 10회 반복했습니다.
| 구현 | Min/Max elapsed | Throughput |
|---|---|---|
| MutexLock | 539.096 / 1,084.180 ms | 10.894 Mmsg/s |
| CAS + exponential pause | 159.037 / 235.814 ms | 41.857 Mmsg/s |
이번 묶음에서는 CAS+Pause가 10회 모두 MutexLock보다 짧았고, 처리량은 약 3.84배였습니다. 이전 별도 세션의 CAS 처리량은 5.015 Mmsg/s였으므로 exponential pause가 현재 high-contention workload를 크게 개선했을 가능성을 확인했습니다.
7. Atomic을 사용하면 진짜 Lock-Free인가
Atomic 연산만 사용하고 MutexLock이 없다는 사실만으로 자료구조 전체가 lock-free가 되지는 않습니다.
현재 구조에서는 한 producer가 enqueue position을 예약한 뒤 message를 공개하기 전에 멈추면, consumer가 해당 slot을 지나갈 수 없습니다. 뒤의 producer가 다음 slot 작성을 끝내더라도 앞 slot이 준비되지 않았다면 FIFO 진행이 막힙니다.
이 실행 순서를 통해 다음 용어를 구분합니다.
- mutex-free: MutexLock 없이 Atomic 연산으로 구현된 구조
- lock-free atomic: 특정 Atomic 타입과 연산이 내부 lock 없이 수행된다는 속성
- lock-free algorithm: 경쟁 중에도 시스템 전체에서 일부 operation의 완료가 보장되는 progress 속성
- wait-free algorithm: 각 operation이 유한 단계 안에 완료되는 더 강한 progress 속성
따라서 최종 글에서는 현재 구현을 공식적인 의미의 lock-free queue로 단정하지 않고, Vyukov bounded queue 계열의 mutex-free bounded MPSC 변형으로 설명합니다.
8. 고성능 Queue는 무엇이 다른가
하나의 공유 cursor가 병목이라면 memory order나 padding만 조정하는 것으로는 확장성에 한계가 있습니다. 더 높은 처리량을 목표로 하는 queue는 공유 지점과 동기화 횟수 자체를 줄이는 방향을 사용합니다.
후보 구조는 다음과 같습니다.
- producer별 sub-queue로 enqueue 경합 분산
- worker 또는 CPU별 queue sharding
- 여러 message를 한 번에 예약하는 batch operation
- topology가 허용하는 경로의 전용 SPSC ring
- 드문 경로에만 MutexLock을 사용하는 block 기반 hybrid queue
각 구조는 ordering, memory usage, bounded capacity, NUMA locality와 구현 복잡도에서 서로 다른 계약을 가집니다. 따라서 “가장 빠른 MPMC queue”를 찾기보다 실제 producer/consumer 수와 필요한 ordering을 먼저 정해야 합니다.
참고할 고성능 MPSC Queue 구현
- Ubisoft TaskScheduler의 intrusive MPSC queue는 producer가 CAS로 node를 연결하고 single consumer가 한 번에 전체 목록을 가져오는 구조입니다. 한 건씩 dequeue하는 일반 FIFO API를 포기하고 batch drain에 맞춰 atomic 연산 횟수를 줄인 사례입니다.
- moodycamel::ConcurrentQueue는 MPMC queue지만 MPSC topology에서도 사용할 수 있습니다. producer별 sub-queue, producer token과 bulk API로 하나의 전역 enqueue cursor에 경합이 집중되는 것을 피합니다. 대신 서로 다른 producer 사이의 global ordering은 보장하지 않습니다.
- rigtorp::MPMCQueue는 고정 capacity의 bounded ring과 slot별 turn counter를 사용하는 구현입니다. 현재 queue와 가까운 bounded storage 계약을 비교할 수 있지만, 여러 producer가 공유 enqueue position을 경쟁하는 구조적 비용도 함께 확인해야 합니다.
이 구현들은 현재 queue를 바로 교체하기 위한 정답 목록이 아닙니다. 공유 cursor, batch 처리, ordering과 bounded capacity 중 어떤 계약을 바꾸어 처리량을 얻는지 비교하기 위한 참고 자료입니다.
핵심 요약:
- MPSC와 MPMC는 동기화 방식이 아니라 producer/consumer topology를 나타냅니다.
- MutexLock을 제거해도 공유 cursor에 대한 경합은 사라지지 않습니다.
- false sharing과 동일 Atomic에 대한 true sharing은 다른 문제입니다.
- CAS 실패 뒤의 pause는 scheduler yield가 아니라 잠시 CPU 명령을 쉬는 (time slice 반환 X) 것입니다.
- 짧은 대기가 예상되는 환경에서는 spin이 유리할 수 있지만 실제 선택은 workload 측정으로 결정해야 합니다.
- Atomic primitive가 lock-free인 것과 자료구조 전체가 lock-free인 것은 다릅니다.
참고 자료
- Dmitry Vyukov — Bounded MPMC Queue — 현재 MPSC 구현이 참고한 per-slot sequence 기반 bounded queue 원본입니다.
- C++ Working Draft — Atomic memory ordering, data race와 forward progress 용어를 확인하기 위한 기준입니다.
- Microsoft STL —
mutex.cpp— 현재 MSVCstd::mutex에서 exclusiveSRWLOCK을 사용하는 구현 경로입니다. - Microsoft — Slim Reader/Writer Locks — process-local
SRWLOCK의 shared/exclusive 계약입니다. - Microsoft — Mutex Objects — handle 기반 WinAPI Mutex object와 process 간 사용 계약입니다.
- Microsoft — YieldProcessor — Windows별 processor yield hint와 x64
_mm_pause매핑입니다.
이 게시물은 학습한 내용을 바탕으로 초안을 작성한 뒤, LLM의 도움을 받아 내용을 검수하고 다듬어 완성되었습니다.