Lock-free Container 구현 — SPSC Queue·Ring Buffer
#한 줄 요약
“SPSC는 atomic 변수 두 개면 끝납니다.” MPMC는 복잡하므로 검증된 라이브러리를 권장합니다.
#어떤 문제를 푸는가
Part 4-03에서 원리를 다뤘다면, 이 글은 실용적인 자료구조 구현을 다룹니다.
임베디드에서 자주 등장하는 패턴은 다음과 같습니다.
- ISR에서 main task로의 통신 (SPSC)
- Worker pool (MPMC queue)
- Free list (lock-free pool)
- Statistics counter (atomic)
각 패턴의 구현과 trade-off를 살펴봅니다.
#SPSC Ring Buffer — 가장 기본
Ring buffer의 구조는 다음과 같습니다. Producer는 head만 전진시키고 Consumer는 tail만 전진시키므로 두 인덱스가 같은 메모리를 동시에 건드릴 일이 없습니다.
Part 4-03에서 본 코드를 다시 옮기면 다음과 같습니다.
template<typename T, size_t N>class SpscQueue { static_assert((N & (N - 1)) == 0, "N must be power of 2");
T buffer_[N]; alignas(64) std::atomic<size_t> head_{0}; // cache line 분리 alignas(64) std::atomic<size_t> tail_{0}; static constexpr size_t kMask = N - 1;
public: bool push(const T& value) { size_t h = head_.load(std::memory_order_relaxed); size_t next = (h + 1) & kMask;
if (next == tail_.load(std::memory_order_acquire)) return false;
buffer_[h] = value; head_.store(next, std::memory_order_release); return true; }
bool pop(T& out) { size_t t = tail_.load(std::memory_order_relaxed); if (t == head_.load(std::memory_order_acquire)) return false;
out = buffer_[t]; tail_.store((t + 1) & kMask, std::memory_order_release); return true; }};핵심은 다음과 같습니다.
- Producer는 head만, Consumer는 tail만 수정합니다.
- 서로의 변수는
acquire로 읽고 자기 것은release로 씁니다. alignas(64)로 head와 tail을 다른 cache line에 두어 false sharing을 회피합니다.
#False Sharing — alignas(64)의 의의
Multi-core에서 같은 cache line의 변수를 다른 core가 동시에 수정하면 cache invalidation이 일어나 성능이 폭락합니다.
struct Bad { std::atomic<size_t> a; // core 1 사용 std::atomic<size_t> b; // core 2 사용}; // 같은 cache line — false sharing
struct Good { alignas(64) std::atomic<size_t> a; alignas(64) std::atomic<size_t> b;}; // 다른 cache line — no false sharingARM Cortex-A multi-core에서는 중요합니다. Cortex-M single core에서는 무관하지만 습관적으로 alignas를 붙입니다.
#Lock-free Stack (Treiber Stack)
MPMC가 가능한 단순한 lock-free stack입니다.
template<typename T>class TreiberStack { struct Node { T value; Node* next; };
std::atomic<Node*> top_{nullptr};
public: void push(Node* node) { Node* old_top = top_.load(std::memory_order_relaxed); do { node->next = old_top; } while (!top_.compare_exchange_weak( old_top, node, std::memory_order_release, std::memory_order_relaxed)); }
Node* pop() { Node* old_top = top_.load(std::memory_order_acquire); while (old_top) { if (top_.compare_exchange_weak( old_top, old_top->next, std::memory_order_acquire, std::memory_order_acquire)) { return old_top; } } return nullptr; }};MPMC이며 모든 thread가 push/pop 모두 수행할 수 있습니다.
문제는 ABA problem입니다. Pop이 old_top->next를 읽는 중에 다른 thread가 old_top을 pop했다가 다른 노드를 push한 뒤 다시 old_top을 push하면, CAS는 성공하지만 next pointer가 잘못된 값을 가리킵니다.
해결책은 다음과 같습니다.
- Tagged pointer — 64-bit으로 ptr과 counter를 묶습니다.
- Hazard pointer를 사용합니다.
#Tagged Pointer
template<typename Node>struct TaggedPtr { Node* ptr; uintptr_t tag;};
std::atomic<TaggedPtr<Node>> top; // 16 byte atomic on 64-bitARM Cortex-M(32-bit)에서는 64-bit atomic이 불가능하므로 32-bit pointer에 작은 counter를 packing합니다.
struct PackedPtr { uint32_t value; // 16-bit ptr (작은 주소 공간) + 16-bit tag};구현이 복잡하므로 작은 임베디드에서는 lock-free MPMC를 보통 회피합니다.
#Lock-free Free List
Part 3-03 Pool Allocator의 lock-free 버전입니다.
template<typename T, size_t N>class LockFreePool { union Slot { alignas(T) std::byte storage[sizeof(T)]; std::atomic<Slot*> next; };
Slot slots_[N]; std::atomic<Slot*> free_head_;
public: LockFreePool() { for (size_t i = 0; i < N - 1; ++i) { slots_[i].next.store(&slots_[i + 1], std::memory_order_relaxed); } slots_[N - 1].next.store(nullptr, std::memory_order_relaxed); free_head_.store(&slots_[0], std::memory_order_release); }
T* allocate() noexcept { Slot* head = free_head_.load(std::memory_order_acquire); while (head) { Slot* next = head->next.load(std::memory_order_relaxed); if (free_head_.compare_exchange_weak( head, next, std::memory_order_release, std::memory_order_acquire)) { return reinterpret_cast<T*>(&head->storage); } } return nullptr; }
void deallocate(T* p) noexcept { if (!p) return; Slot* slot = reinterpret_cast<Slot*>(p); Slot* head = free_head_.load(std::memory_order_relaxed); do { slot->next.store(head, std::memory_order_relaxed); } while (!free_head_.compare_exchange_weak( head, slot, std::memory_order_release, std::memory_order_relaxed)); }};ABA 문제가 발생할 수 있으므로 주의가 필요합니다.
#Boost.Lockfree
Boost가 검증된 lock-free 자료구조를 제공합니다.
#include <boost/lockfree/spsc_queue.hpp>#include <boost/lockfree/queue.hpp>#include <boost/lockfree/stack.hpp>
// SPSC — 가장 빠름boost::lockfree::spsc_queue<int, boost::lockfree::capacity<128>> q;
// MPMC — fixed capacityboost::lockfree::queue<int, boost::lockfree::capacity<128>> mpmc_q;
// Stack — LIFO MPMCboost::lockfree::stack<int, boost::lockfree::capacity<128>> stack;Boost는 내부적으로 tagged pointer와 정교한 알고리즘을 쓰므로 직접 구현하는 것보다 안전합니다.
임베디드에서 Boost가 부담스럽다면 일부 헤더만 포함할 수 있습니다.
#moodycamel::ConcurrentQueue
가장 빠른 MPMC 구현으로, Cameron Desrochers의 고성능 lock-free queue입니다.
#include <concurrentqueue.h>
moodycamel::ConcurrentQueue<int> q;q.enqueue(42);int v;q.try_dequeue(v);수십만 ops/sec를 달성하며 임베디드 multi-core에 적합합니다.
#임베디드 — ISR + 여러 task
// ISR가 producer, 여러 task가 consumerclass EventBus { SpscQueue<Event, 256> queue_; SemaphoreHandle_t event_sem_;
public: // ISR에서 호출 void post_isr(const Event& e) { if (queue_.push(e)) { BaseType_t woken = pdFALSE; xSemaphoreGiveFromISR(event_sem_, &woken); portYIELD_FROM_ISR(woken); } }
// Task에서 호출 bool wait(Event& out, TickType_t timeout) { if (queue_.pop(out)) return true;
if (xSemaphoreTake(event_sem_, timeout) == pdTRUE) { return queue_.pop(out); } return false; }};SPSC queue로 ISR에서 단일 task로 전달합니다. 여러 task가 받아야 한다면 queue를 따로 둡니다.
#임베디드 — Atomic Counter
가장 단순하면서 가장 흔한 패턴입니다.
std::atomic<uint32_t> packets_sent{0};std::atomic<uint32_t> packets_dropped{0};
void send_packet() { if (try_send()) { packets_sent.fetch_add(1, std::memory_order_relaxed); } else { packets_dropped.fetch_add(1, std::memory_order_relaxed); }}
uint32_t get_stats_sent() { return packets_sent.load(std::memory_order_relaxed);}counter 값 자체가 의미를 갖고 순서는 무관하므로 relaxed로 충분합니다.
#측정 — SPSC queue 성능
# Cortex-M4, 1M operations
Mutex-based queue: ~10 M cyclesSPSC lock-free: ~1.2 M cycles (~8x faster)Boost MPMC: ~2.5 M cycles (~4x faster)moodycamel MPMC: ~1.8 M cycles (~5x faster)SPSC가 가장 빠르지만 producer와 consumer가 정확히 하나씩일 때만 쓸 수 있습니다.
#자주 보는 함정과 안티패턴
#1. SPSC를 MPMC처럼 사용
Producer 둘이 push하면 race가 발생합니다. SPSC는 producer가 정확히 하나여야 합니다.
#2. Memory order seq_cst 남용
기본 seq_cst는 가장 느립니다. 필요한 최소 order만 씁니다.
#3. 큰 객체에 atomic 시도
std::atomic<HugeStruct> obj; // hardware atomic 안 됨 → mutex fallbackpointer를 atomic swap하는 방식으로 우회합니다.
#4. ABA problem 무시
Treiber stack 같은 복잡한 lock-free에는 tagged pointer를 적용하거나 전문 라이브러리를 사용합니다.
#5. Cache line alignment 무시
false sharing으로 성능이 폭락합니다. alignas(64)로 분리합니다.
#6. retry loop 무한 반복 가능성
Contention이 높으면 CAS retry가 무한히 반복될 수 있습니다. exponential backoff나 limit을 둡니다.
int retries = 0;while (retries++ < 100) { if (cas(...)) break; // 짧은 wait}#ARM Cortex-M의 LDREX/STREX
std::atomic은 내부적으로 LDREX/STREX를 사용합니다.
# atomic compare_exchange (간소화)loop: LDREX r0, [addr] ; exclusive load CMP r0, expected BNE fail STREX r1, new, [addr] ; exclusive store CMP r1, #0 ; STREX 성공? BNE loop ; 실패시 retryARMv7-M의 exclusive monitor가 atomic을 보장합니다. DMA 같은 다른 master가 같은 주소에 접근하면 STREX가 실패합니다.
중요: 일부 peripheral 주소는 exclusive monitor를 지원하지 않으므로 RAM에서만 안전합니다.
#Lock-free의 실용적 한계
사용 OK:✓ ISR ↔ task 통신 (SPSC)✓ Statistics counter (atomic)✓ Flag/state (atomic)✓ 검증된 라이브러리 사용 (Boost, moodycamel)
피하는 게 좋음:✗ 직접 MPMC 구현 (ABA, hazard pointer 복잡)✗ 복잡한 자료구조 (RB-tree, hash map)✗ Cortex-M0/M0+ (atomic 미지원)
대안:- Critical section (짧음)- RTOS queue (xQueueSend) — 검증됨- Mutex + condition variable대부분의 임베디드 multi-task는 RTOS queue로 충분합니다. Lock-free는 극한 성능이 필요하거나 ISR 통신이 필요할 때만 씁니다.
#정리
- SPSC queue가 임베디드 lock-free의 표준이며, ISR과 task 통신에 적합합니다.
- Producer는 head, Consumer는 tail만 다루므로 서로 무관하며 CAS가 필요 없습니다.
alignas(64)로 cache line을 정렬해 false sharing을 회피합니다.- MPMC는 복잡하므로 검증된 라이브러리(Boost, moodycamel)를 사용합니다.
- ABA problem에는 tagged pointer나 hazard pointer를 쓰거나 MPMC 자체를 회피합니다.
- Cortex-M0/M0+는 atomic을 지원하지 않으므로 critical section을 사용합니다.
#관련 항목
- Part 4-03: Lock-free 기초
- Part 3-03: Pool Allocator — lock-free pool
- Part 4-01: Intrusive Containers
- Practical RTOS Internals
#다음 글
Part 4-05: Type-safe Flags — bit flag를 enum class로 type-safe하게.
Embedded C++ for Real Systems · 33 of 41
- 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
- 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
- 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
- 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
- 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
- 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
- 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
- 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
- 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
- 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
- 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
- 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
- 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
- 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
- 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
- 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
- 17CRTP 패턴 분석 — vtable 없는 정적 다형성
- 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
- 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
- 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
- 21Custom Allocator 기초 — std::allocator 인터페이스 분석
- 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
- 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
- 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
- 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
- 26std::expected 분석 — C++23 결과 타입과 에러 전파
- 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
- 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
- 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
- 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
- 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
- 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
- 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
- 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
- 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
- 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
- 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
- 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
- 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
- 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
- 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
관련 글
임베디드 Lock-free 기초 — atomic·memory ordering·CAS
Atomic, CAS, memory order — mutex 없이 동시성. 임베디드의 ISR-safe 패턴.
임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교
범용 HAL 구조 — 벤더 종속성 격리, 다중 보드/MCU 지원, 시리즈 마무리.
Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
UART, SPI, I2C — peripheral을 type-safe class로. Blocking, interrupt, DMA 패턴.
이 글을 참조하는 글 (5)
- Stream Buffer와 Message Buffer — FreeRTOS 10의 Lock-Free SPSC— Practical RTOS Internals
- 임베디드 Lock-free 기초 — atomic·memory ordering·CAS— Embedded C++ for Real Systems
- MPMC Queue 구현 — Multi-producer Multi-consumer Lock-Free— Modern Embedded Recipes
- ABA 문제 회피 — Tagged Pointer·Hazard·Generation Counter— Modern Embedded Recipes
- Hazard Pointer 분석 — Lock-Free Memory Reclamation— Modern Embedded Recipes