본문으로 건너뛰기
Embedded C++ for Real Systems · 31/41

임베디드 Lock-free 기초 — atomic·memory ordering·CAS

· Hawk · 6분 읽기

#한 줄 요약

“Lock-free는 mutex 없이 atomic operation으로 동기화하는 방식입니다.” 짧고 deterministic하며 ISR에서도 안전합니다.

#어떤 문제를 푸는가

Mutex는 다음과 같은 비용을 동반합니다.

  • context switch가 발생해 RTOS task가 block됩니다.
  • Priority inversion이 일어납니다. 낮은 priority가 lock을 잡으면 높은 priority가 막힙니다.
  • Deadlock 가능성이 있습니다. 두 개 이상의 mutex를 잘못 잡으면 발생합니다.
  • 대부분의 RTOS에서 ISR은 mutex를 쓸 수 없습니다.

Lock-free는 mutex 없이 동시에 접근하면서 atomic 명령으로 consistency를 보장합니다.

// Mutex 기반
std::mutex m;
int counter = 0;
void increment() {
std::lock_guard lock(m);
counter++;
}
// Lock-free
std::atomic<int> counter{0};
void increment() {
counter.fetch_add(1); // atomic
}

ARM Cortex-M의 atomic instruction(LDREX/STREX)이 하드웨어 레벨에서 이를 보장합니다.

#std::atomic — 기본

#include <atomic>
std::atomic<int> counter{0};
counter.store(42);
int v = counter.load();
counter.fetch_add(1); // counter++ (atomic)
int old = counter.exchange(0); // 교체
// Compare-and-swap
int expected = 5;
bool success = counter.compare_exchange_weak(expected, 10);
// counter == 5면 10으로 변경 + true
// 아니면 expected = current value + false

ARM Cortex-M에서 4-byte atomic은 single instruction이며 load/store가 자연스럽게 atomic입니다.

fetch_addcompare_exchange는 LDREX/STREX를 사용합니다.

#CAS — Compare-And-Swap

Lock-free의 핵심 도구입니다. 값을 비교해서 일치하면 새 값으로 교체하는 동작 전체가 하나의 atomic 명령으로 실행됩니다. 다른 스레드가 끼어들면 CAS가 실패하고, 최신 값을 다시 읽어 재시도합니다.

CAS retry loop — 실패 후 reload-and-retry 흐름

std::atomic<Node*> head{nullptr};
void push(Node* n) {
Node* old_head = head.load();
do {
n->next = old_head;
} while (!head.compare_exchange_weak(old_head, n));
// CAS 성공할 때까지 retry
}

흐름은 다음과 같습니다.

  1. head의 현재 값을 old_head로 읽습니다.
  2. n->next = old_head로 설정합니다.
  3. CAS로 head == old_headn으로 교체합니다.
  4. 다른 thread가 끼어들어 head가 변경되었으면 CAS가 실패하므로 retry합니다.

retry loop이 lock-free의 특징이며 deadlock이 없습니다. 다만 contention이 높으면 starvation이 발생할 수 있습니다.

#ABA Problem

CAS의 함정입니다. 값이 A → B → A로 바뀌어도 CAS는 성공합니다.

// Thread 1: pop 시도
Node* old_top = top.load(); // = A
// (이때 Thread 2가 A pop, B push, A push)
// 이제 top = A지만 *A->next는 변경*
top.compare_exchange_weak(old_top, old_top->next);
// CAS 성공 — 그러나 old_top->next는 잘못된 값

해결책은 다음과 같습니다.

  • Tagged pointer — pointer + counter를 묶어 64-bit으로 다룹니다.
  • Hazard pointer — 다른 thread가 현재 사용 중인 pointer를 추적합니다.
  • Epoch-based reclamation — gc 비슷한 방식입니다.

임베디드에서는 간단한 경우에만 lock-free를 씁니다. ABA 회피가 복잡해진다면 mutex가 나을 때도 많습니다.

#Memory Order

std::atomic 연산은 memory ordering 인자를 받습니다.

counter.store(1, std::memory_order_relaxed);
counter.load(std::memory_order_acquire);
counter.fetch_add(1, std::memory_order_release);
counter.compare_exchange_weak(expected, new_value,
std::memory_order_seq_cst,
std::memory_order_acquire);
Order의미사용
relaxed순서 보장 없음counter만
acquireload — 이후 memory 작업이 이전으로 옮겨가지 않음reader
releasestore — 이전 memory 작업이 이후로 옮겨가지 않음writer
acq_relacquire + releaseRMW
seq_cst모든 thread가 같은 순서 (기본)강한 보장

대부분의 임베디드 코드는 acquire/release를 활용해 seq_cst보다 빠르게 만듭니다.

#임베디드 — ISR-safe Counter

std::atomic<uint32_t> tick_count{0};
extern "C" void SysTick_Handler() {
tick_count.fetch_add(1, std::memory_order_relaxed);
}
uint32_t get_uptime_ms() {
return tick_count.load(std::memory_order_relaxed);
}

ISR과 main에서 동시에 접근해도 atomic이라 안전하고 lock도 필요 없습니다.

#임베디드 — Lock-free SPSC Queue

Single Producer Single Consumer 패턴이며, 가장 단순한 lock-free queue입니다.

template<typename T, size_t N>
class SpscQueue {
static_assert((N & (N - 1)) == 0, "N must be power of 2");
T buffer_[N];
std::atomic<size_t> head_{0}; // producer
std::atomic<size_t> tail_{0}; // consumer
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; // full
}
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; // empty
}
out = buffer_[t];
tail_.store((t + 1) & kMask, std::memory_order_release);
return true;
}
};

Producer는 head만, Consumer는 tail만 수정합니다. 서로 다른 변수를 다루므로 CAS가 필요 없습니다.

acquire/release로 한쪽의 write가 다른 쪽에서 visible하게 만듭니다.

#ISR + main 사용

SpscQueue<Event, 64> event_queue;
extern "C" void UART_IRQHandler() {
Event e = read_uart();
event_queue.push(e); // ISR가 producer
}
void main_loop() {
Event e;
while (event_queue.pop(e)) {
process(e); // main이 consumer
}
}

mutex 없이 ISR-main 통신이 가능하며 deterministic하게 동작합니다.

#MPMC Queue — 복잡

Multi-Producer Multi-Consumer는 훨씬 복잡합니다. Boost.Lockfree, Folly, Concurrent Data Structures 같은 검증된 라이브러리를 활용합니다.

// 직접 구현 어렵다 — 검증된 라이브러리 사용
#include <boost/lockfree/queue.hpp>
boost::lockfree::queue<int, boost::lockfree::capacity<128>> q;

임베디드에서는 task마다 producer와 consumer가 하나씩인 경우가 대부분이라 SPSC로 충분합니다.

#자료 정합성 — Critical Section vs Lock-free

// V1 — Critical section
void update_shared() {
__disable_irq();
counter++;
if (counter > MAX) counter = 0;
__enable_irq();
}
// V2 — Lock-free (단순 카운터만)
std::atomic<int> counter{0};
void update_shared() {
int v;
int next;
do {
v = counter.load();
next = (v + 1) > MAX ? 0 : v + 1;
} while (!counter.compare_exchange_weak(v, next));
}

V1은 모든 ISR을 차단하지만, V2는 해당 변수에만 영향을 줍니다. V1이 단순하지만 V2가 더 deterministic합니다.

#ARM Cortex-M의 한계

Cortex-M0/M0+는 LDREX/STREX를 지원하지 않으므로 atomic operation을 쓸 수 없습니다.

  • Cortex-M3, M4, M7은 LDREX/STREX가 있어 atomic을 쓸 수 있습니다.
  • Cortex-M0, M0+는 atomic이 없으므로 critical section만 사용합니다.
// Cortex-M0+
void increment() {
__disable_irq();
++counter;
__enable_irq();
}

Cortex-M0+에서는 interrupt disable이 가장 저렴한 동기화입니다.

#자주 보는 함정과 안티패턴

#1. Memory order 무시

counter.store(1); // 기본 seq_cst — 가장 느림

필요한 최소 order만 사용합니다. relaxed/acquire/release 중 적절한 것을 고릅니다.

#2. ABA problem 무시

복잡한 lock-free에서는 tagged pointer나 hazard pointer를 씁니다. 아니면 간단한 경우에만 lock-free를 적용합니다.

#3. load 후 사용하고 store

int v = counter.load();
process(v);
counter.store(v + 1); // 다른 thread가 끼어들면 race

fetch_add 같은 atomic operation을 씁니다.

#4. 큰 객체에 atomic 적용

std::atomic<HugeStruct> obj; // hardware atomic 불가 — lock 사용

4 byte 이하로 만들거나 별도 동기화를 사용합니다.

#5. Cortex-M0에 atomic 가정

LDREX/STREX가 없으므로 runtime fallback이나 컴파일 에러가 발생합니다. target을 확인합니다.

#6. Lock-free라고 빠르다고 가정

contention이 높으면 CAS retry loop가 길어져 mutex보다 느릴 수도 있습니다. 반드시 측정합니다.

#측정 — atomic vs critical section

# Cortex-M4, simple counter increment
1. Mutex (FreeRTOS): ~600 cycles
2. Critical section: ~30 cycles
3. Atomic fetch_add: ~15 cycles
4. Plain ++ (no sync): ~5 cycles (but unsafe)

atomic이 가장 빠르면서도 안전합니다. critical section은 모든 ISR을 차단하므로 latency에 영향을 줍니다.

#정리

  • Lock-free는 atomic operation으로 mutex 없이 동시성을 다룹니다.
  • 핵심은 std::atomic과 CAS(compare_exchange)입니다.
  • Memory order는 relaxed/acquire/release/seq_cst 중 필요한 최소만 선택합니다.
  • SPSC queue가 임베디드 lock-free의 표준이며 ISR과 main 통신에 적합합니다.
  • Cortex-M0/M0+는 atomic을 지원하지 않으므로 critical section을 씁니다.
  • ABA problem에 주의하고 복잡한 lock-free 자료구조는 전문 라이브러리에 맡깁니다.

#관련 항목

#다음 글

Part 4-04: Lock-free Container — queue와 stack의 lock-free 구현. SPSC, MPMC 차이.

Embedded C++ for Real Systems · 32 of 41

  1. 1Embedded C++ for Real Systems — 임베디드 모던 C++ 시리즈 소개
  2. 2임베디드 C++ vs C — 런타임·코드 크기·ABI 관점 비교
  3. 3임베디드 C++ 컴파일러 플래그 분석 — -fno-rtti·-fno-exceptions·-Os
  4. 4임베디드 C++ 런타임 요구사항 — libstdc++·newlib·crt0 분석
  5. 5C++ 코드 크기 분석 — 가상 함수·템플릿·예외 비용 추적
  6. 6C++ ABI 호환성 — Itanium ABI·name mangling·vtable 레이아웃
  7. 7C++ 스타트업 코드 분석 — .init_array·전역 생성자 호출 순서
  8. 8임베디드 C++ 링커 스크립트 — vtable·정적 객체 배치 추적
  9. 9임베디드 C++ 표준 선택 가이드 — C++11/14/17/20/23 트레이드오프
  10. 10임베디드 RAII 기초 — 리소스 안전성과 결정적 소멸 보장
  11. 11임베디드 RAII 실전 패턴 — Lock·Pin·DMA·Power 관리
  12. 12constexpr 기초와 임베디드 적용 — 컴파일 타임 계산 활용
  13. 13constexpr 고급 활용 — 룩업 테이블·CRC·해시 컴파일 타임 생성
  14. 14consteval과 constinit 분석 — C++20 컴파일 타임 강제 메커니즘
  15. 15임베디드 Templates 기초 — 타입 안전과 코드 재사용 분석
  16. 16Template 비용 분석 — 코드 폭증·인스턴스화·디버그 정보 측정
  17. 17CRTP 패턴 분석 — vtable 없는 정적 다형성
  18. 18Type Traits 임베디드 활용 — SFINAE·is_pod·컴파일 타임 검사
  19. 19C++20 Concepts 활용 — 템플릿 제약과 가독성 개선
  20. 20동적 할당 없는 임베디드 C++ — placement new·정적 객체·풀
  21. 21Custom Allocator 기초 — std::allocator 인터페이스 분석
  22. 22Pool Allocator 구현 — Fixed-Size Block과 O(1) 보장
  23. 23std::pmr 임베디드 활용 — Polymorphic Memory Resource 분석
  24. 24No-Exception C++ 설계 — 코드 크기·결정성 트레이드오프
  25. 25임베디드 에러 처리 패턴 — Result·errno·optional 비교
  26. 26std::expected 분석 — C++23 결과 타입과 에러 전파
  27. 27No-RTTI C++ 설계 — dynamic_cast 제거와 정적 타입 분기
  28. 28임베디드 스마트 포인터 선택 — unique·shared·custom 비교
  29. 29임베디드 C++ 소유권 모델 — single·shared·borrow 패턴
  30. 30Intrusive Containers 분석 — 동적 할당 없는 컨테이너 설계
  31. 31ETL 라이브러리 분석 — Embedded Template Library의 STL 대체
  32. 32임베디드 Lock-free 기초 — atomic·memory ordering·CAS
  33. 33Lock-free Container 구현 — SPSC Queue·Ring Buffer
  34. 34Type-safe Flags 패턴 — Enum Class·Strong Typedef·Tag
  35. 35임베디드 State Machine 패턴 — Variant·Visitor·Table-driven 비교
  36. 36Compile-time FSM 구현 — 템플릿으로 상태 전이 검증
  37. 37Singleton 대안 패턴 — Service Locator·Static Init·Phantom
  38. 38MMIO Register 추상화 — 타입 안전한 비트 필드 접근
  39. 39GPIO 추상화 패턴 — Template·Concept으로 보드 독립성
  40. 40Peripheral 추상화 — UART·SPI·I2C 공통 인터페이스 설계
  41. 41임베디드 HAL 설계 패턴 — Static·Dynamic·Hybrid 비교