C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나
이 글은 mutex(#7-2)와 condition_variable(#7-3)에 이어지는 내용입니다.
들어가며: 카운터 하나에 mutex가 필요할까
여러 스레드가 카운터 하나만 증가시키는 경우, mutex로 보호하면 증가할 때마다 락을 잡고 풀어야 하고 스레드가 많을수록 락 경합이 커집니다. 변수 하나를 지키기에는 무거운 도구입니다. std::atomic(원자 변수)은 그 변수에 대한 연산이 나눌 수 없는 한 단위로 실행되도록 보장해, 락 없이도 여러 스레드가 동시에 접근할 때 data race가 생기지 않게 합니다.
flowchart LR
subgraph mutex[Mutex 방식]
M1[스레드1] --> M2[락 획득 대기]
M2 --> M3[counter++]
M3 --> M4[락 해제]
M4 --> M5[다음 스레드 대기]
end
subgraph atomic[Atomic 방식]
A1[스레드1] --> A2[원자적 증가]
A2 --> A3[완료]
A4[스레드2] --> A5[원자적 증가]
A5 --> A6[완료]
end
mutex로 보호한 경우입니다.
std::mutex mtx;
int counter = 0;
void inc() {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}
atomic으로 바꾼 경우입니다.
std::atomic<int> counter{0};
void inc() {
counter++; // 원자적 연산, data race 없음
}
std::atomic<int>의 operator++는 x86의 lock xadd처럼 CPU의 원자적 명령으로 실행되어, 여러 스레드가 동시에 증가시켜도 값이 유실되지 않습니다. 반면 여러 변수를 한 번에 바꿔야 일관성이 유지되는 경우에는 mutex로 한 블록을 통째로 보호하는 편이 맞습니다. 이 글은 std::atomic의 연산, 메모리 순서(memory_order: 멀티스레드에서 읽기·쓰기가 다른 스레드에 어떤 순서로 보일지 지정하는 것), atomic과 mutex를 고르는 기준을 정리합니다.
atomic이 필요한 대표적인 상황
고부하 서버의 요청 카운터는 요청마다 증가하므로, mutex로 보호하면 요청을 처리하는 모든 스레드가 같은 락을 두고 경쟁합니다. std::atomic<uint64_t>로 바꾸면 락을 기다리는 구간이 사라집니다.
일반 int를 여러 스레드가 counter++하면 읽기-수정-쓰기가 원자적이지 않아 data race가 발생합니다. 8스레드가 각 100만 번 증가시켜도 최종 값이 800만보다 작게, 그것도 실행할 때마다 다르게 나올 수 있습니다.
생산자가 데이터를 채운 뒤 ready = true를 설정하고 소비자가 ready를 확인한 후 데이터를 읽는 패턴에서는, 재배치 때문에 소비자가 ready는 true인데 데이터는 아직 채워지지 않은 상태를 볼 수 있습니다. memory_order_release/acquire 쌍(또는 기본값 seq_cst)이 이 문제를 막습니다.
워커가 while (!shutdown_flag) 루프를 도는데 shutdown_flag가 일반 bool이면, 컴파일러는 다른 스레드가 이 값을 바꾸지 않는다고 가정하고 읽기를 루프 밖으로 끌어내 무한 루프를 만들 수 있습니다. 동기화 없는 동시 접근은 data race라서 미정의 동작이기 때문입니다. std::atomic<bool>로 선언해야 매 반복마다 실제 값을 읽습니다.
나눌 수 없는 연산: std::atomic의 의미
atomic은 “나눌 수 없다”는 뜻입니다. 여러 스레드가 같은 atomic 변수에 읽기-수정-쓰기를 해도 그 연산이 한 덩어리처럼 동작해 중간 상태가 다른 스레드에 보이지 않으므로, 그 변수에 대한 data race가 없고 표준상 정의된 동작을 합니다.
std::atomic<T>는 주로 카운터와 플래그용 정수형(atomic<int>, atomic<size_t> 등), lock-free 자료구조의 노드를 가리키는 포인터(atomic<T*>), 준비 완료 플래그(atomic<bool>)에 씁니다.
T는 trivially copyable해야 합니다. 즉 memcpy로 복사해도 되는 타입만 담을 수 있습니다. 정수, 포인터, bool, 그리고 멤버가 모두 그런 타입인 단순 구조체는 가능하지만, std::string이나 std::vector처럼 복사 생성자가 따로 있는 타입은 컴파일 에러가 납니다. 구조체를 담을 수는 있어도 크기에 따라 내부적으로 락을 쓸 수 있다는 점은 뒤의 “lock-free가 아닌 atomic”에서 다룹니다. std::atomic<double>은 C++11부터 load/store/exchange/CAS가 되지만 fetch_add는 C++20부터 지원되어, 그 이전에는 CAS 루프로 직접 구현해야 했습니다.
원자 연산: load, store, exchange, CAS
읽기와 쓰기: load, store
load()는 현재 값을 읽고, store(val)은 값을 씁니다. 변환 연산자와 대입 연산자로도 같은 일을 할 수 있습니다.
// g++ -std=c++17 -o atomic_ops atomic_ops.cpp && ./atomic_ops
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> value{0};
value.store(42);
value = 42; // 동일
int x = value.load();
int y = value; // 동일
std::cout << x << " " << y << "\n"; // 42 42
return 0;
}
교환: exchange
exchange(val)은 값을 val로 바꾸고 바꾸기 전 값을 반환하는 원자적 swap입니다.
#include <atomic>
#include <iostream>
int main() {
std::atomic<int> flag{0};
int old = flag.exchange(1); // 0 → 1로 바꾸고, 이전 값 0 반환
std::cout << "old=" << old << " new=" << flag.load() << "\n"; // old=0 new=1
return 0;
}
이전 값을 확인하면서 교체하는 동작이 한 연산으로 일어나므로, 여러 스레드 중 정확히 하나만 “처음 바꾼 스레드”가 되는 패턴에 쓸 수 있습니다.
읽기-수정-쓰기 (RMW): fetch_add, fetch_sub
fetch_add(n)은 n을 더하고 더하기 전 값을 반환하며, fetch_sub(n)은 뺍니다. ++, --, +=, -= 연산자도 같은 원자 연산으로 동작합니다(전위 ++와 +=는 연산 후 값을 반환한다는 점만 다릅니다).
std::atomic<int> counter{0};
counter++; // counter.fetch_add(1)
counter += 10; // counter.fetch_add(10) + 10을 반환
int prev = counter.fetch_add(1); // 증가 전 값 반환
증가 전 값을 돌려주므로 고유 ID 발급처럼 “내 몫의 번호”를 받는 패턴에 바로 쓸 수 있습니다.
Compare-And-Swap (CAS)
compare_exchange_strong(expected, desired)는 현재 값이 expected와 같으면 desired로 바꾸고 true를 반환합니다. 다르면 expected를 현재 값으로 갱신하고 false를 반환합니다. compare_exchange_weak는 같은 일을 하지만 LL/SC 명령을 쓰는 ARM이나 PowerPC 같은 아키텍처에서 값이 같아도 실패를 보고하는 spurious failure가 있을 수 있어, 재시도 루프 안에서 씁니다.
#include <atomic>
std::atomic<int> value{0};
// "0이면 1로 바꾸기": 여러 스레드가 호출해도 한 스레드만 성공
bool try_claim() {
int expected = 0;
return value.compare_exchange_strong(expected, 1);
}
// CAS 루프로 증가 (실제로는 fetch_add가 적합하지만 패턴 예시)
void cas_increment() {
int old_val = value.load();
while (!value.compare_exchange_weak(old_val, old_val + 1)) {
// 실패하면 old_val이 현재 값으로 갱신되고, 그 값으로 다시 시도
}
}
CAS는 lock-free 자료구조의 핵심 도구입니다. expected가 참조로 전달되어 실패 시 현재 값으로 갱신되므로, 루프에서는 갱신된 값을 바탕으로 새 desired를 계산해 다시 시도합니다.
CAS 예제: ID 발급, 최댓값 갱신, 조건부 전이
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
// 예제 1: CAS 루프로 ID 발급 (fetch_add(1) + 1과 같은 결과)
std::atomic<int> next_id{0};
int allocate_id() {
int expected = next_id.load();
int desired;
do {
desired = expected + 1;
} while (!next_id.compare_exchange_weak(expected, desired));
return desired;
}
// 예제 2: 최댓값 갱신 (여러 스레드가 경쟁)
std::atomic<int> max_value{0};
void update_max(int candidate) {
int old_max = max_value.load();
while (candidate > old_max &&
!max_value.compare_exchange_weak(old_max, candidate)) {
// 실패 시 old_max가 현재 값으로 갱신되고, 조건을 다시 검사
}
}
// 예제 3: 조건부 전이 (from 상태일 때만 to로)
std::atomic<int> state{0};
bool try_transition(int from, int to) {
int expected = from;
return state.compare_exchange_strong(expected, to);
}
int main() {
std::vector<std::thread> threads;
for (int i = 0; i < 4; ++i) {
threads.emplace_back([] {
for (int j = 0; j < 1000; ++j) {
allocate_id();
update_max(j);
}
});
}
for (auto& t : threads) t.join();
std::cout << "next_id=" << next_id.load() << " max=" << max_value.load() << "\n";
return 0;
}
allocate_id는 락 없이 중복 없는 ID를 발급합니다. 4개 스레드가 1000번씩 호출하므로 실행 결과는 next_id=4000 max=999입니다. update_max는 후보가 현재 최댓값보다 클 때만 갱신하고, 다른 스레드가 더 큰 값을 먼저 넣었으면 조건이 거짓이 되어 루프를 빠져나옵니다. try_transition은 상태 머신에서 특정 상태에서만 전이를 허용할 때 씁니다.
compare_exchange_strong vs weak
| 구분 | strong | weak |
|---|---|---|
| spurious failure | 없음 | 있을 수 있음 (ARM, PowerPC 등 LL/SC 기반) |
| 쓰는 곳 | 한 번만 시도하는 코드 | 재시도 루프 |
| 비용 | LL/SC 아키텍처에서는 내부에 루프가 들어감 | 루프 안에서는 더 가벼울 수 있음 |
x86에서는 lock cmpxchg 한 명령으로 둘 다 구현되어 차이가 없습니다.
// weak: 루프에서 사용 (spurious failure 허용)
while (!atomic_val.compare_exchange_weak(expected, desired)) {
desired = compute(expected); // 갱신된 expected로 다시 계산
}
// strong: 한 번만 시도
if (atomic_val.compare_exchange_strong(expected, desired)) {
// 성공
} else {
// 실패, expected에 현재 값이 들어감
}
seq_cst, acquire/release, relaxed: memory_order 고르기
여러 스레드가 메모리를 읽고 쓸 때, 실제 실행 순서는 컴파일러 최적화와 CPU의 재배치 때문에 코드 순서와 달라질 수 있습니다. memory_order는 atomic 연산 주변에서 이런 재배치를 얼마나 제한할지, 다른 스레드에 무엇이 보이도록 보장할지를 지정합니다.
왜 메모리 순서가 필요한가?
flowchart TB
subgraph CPU[CPU 관점]
A[코드 순서] --> B[실제 실행 순서]
B --> C[재배치 가능]
end
subgraph Thread[스레드 A]
T1[data = 1]
T2[ready = true]
end
subgraph Thread2[스레드 B]
T3[if ready]
T4[use data]
end
스레드 A가 data = 1 다음에 ready = true를 썼더라도, 동기화가 없다면 다른 스레드에는 ready = true가 먼저 보일 수 있습니다. 그러면 스레드 B는 ready가 true인데 data는 아직 0인 상태를 보게 됩니다. ready에 release 스토어를 쓰고 B가 acquire 로드로 읽으면, “release 이전의 모든 쓰기는 acquire로 그 값을 읽은 스레드에 보인다”는 보장이 생깁니다.
메모리 순서 종류
| 순서 | 의미 | 사용처 |
|---|---|---|
| seq_cst | acquire/release 보장에 더해, 모든 seq_cst 연산이 하나의 전체 순서를 가짐 | 기본값 |
| acquire | 이 로드 이후의 읽기·쓰기가 이 로드 앞으로 재배치되지 않음 | 락 획득, 플래그 확인 후 데이터 읽기 |
| release | 이 스토어 이전의 읽기·쓰기가 이 스토어 뒤로 재배치되지 않음 | 락 해제, 데이터를 쓴 뒤 플래그 설정 |
| acq_rel | acquire + release | CAS 등 RMW 연산 |
| relaxed | 원자성만 보장, 다른 메모리 연산과의 순서는 보장 안 함 | 독립적인 카운터 |
seq_cst (Sequentially Consistent)
기본값입니다. 모든 스레드가 seq_cst 연산들을 하나의 전체 순서로 관찰하므로 추론하기 가장 쉽고, 일부 아키텍처에서는 가장 비쌉니다. 처음에는 기본값으로 정확하게 동작하게 만들고, 측정으로 병목이 확인된 뒤에 약한 순서를 검토하는 순서가 안전합니다.
std::atomic<bool> ready{false};
std::atomic<int> data{0};
// 스레드 A
void producer() {
data.store(42); // seq_cst (기본)
ready.store(true); // seq_cst (기본)
}
// 스레드 B
void consumer() {
while (!ready.load()) {} // seq_cst (기본)
assert(data.load() == 42); // 항상 42
}
acquire / release
release 스토어는 그 이전의 메모리 연산이 스토어 뒤로 내려가지 않게 하므로, 데이터를 다 쓴 뒤 플래그를 올리는 쪽에 씁니다. acquire 로드는 그 이후의 메모리 연산이 로드 앞으로 올라오지 않게 하므로, 플래그를 확인한 뒤 데이터를 읽는 쪽에 씁니다.
int data = 0; // 일반 변수
std::atomic<bool> ready{false};
// producer: 데이터를 쓴 뒤 플래그
void producer() {
data = 42;
ready.store(true, std::memory_order_release);
}
// consumer: 플래그 확인 후 데이터
void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
int x = data; // release와 짝을 이루므로 42 보장
}
acquire 로드가 release 스토어가 쓴 값을 읽으면 두 연산 사이에 동기화(synchronizes-with) 관계가 생기고, release 이전의 쓰기는 acquire 이후에 모두 보입니다. 그래서 data가 일반 변수여도 data race가 아닙니다.
relaxed
relaxed는 그 변수 자체에 대한 원자성과, 같은 변수에 대한 수정 순서가 모든 스레드에서 일관된다는 것만 보장합니다. 다른 변수와의 순서는 보장하지 않으므로, 값만 맞으면 되는 독립적인 카운터에 씁니다.
std::atomic<int> counter{0};
void inc() {
counter.fetch_add(1, std::memory_order_relaxed);
}
relaxed는 다른 변수와의 happens-before 관계를 만들지 않습니다. “플래그 확인 후 데이터 읽기” 같은 패턴에는 반드시 acquire/release가 필요합니다.
하드웨어에서는 어떻게 구현되나
memory_order는 컴파일러의 재배치 제한과 CPU의 메모리 장벽(fence) 명령으로 구현됩니다. x86/AMD64는 TSO라는 비교적 강한 메모리 모델을 써서 일반 로드와 스토어만으로 acquire/release 의미가 성립하므로, acquire 로드와 release 스토어는 보통의 mov 명령으로 컴파일됩니다. seq_cst 스토어에는 xchg나 mfence 같은 전체 장벽이 붙습니다. ARM이나 POWER처럼 약한 메모리 모델의 CPU에서는 acquire/release에도 별도 명령(ARMv8의 ldar/stlr 등)이 필요하고, relaxed와의 비용 차이가 x86보다 큽니다.
Producer-Consumer 예제
int shared_data[1024]; // 일반 변수
std::atomic<bool> data_ready{false};
void producer() {
for (int i = 0; i < 1024; ++i) shared_data[i] = i * 2;
data_ready.store(true, std::memory_order_release);
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {}
int sum = 0;
for (int i = 0; i < 1024; ++i) sum += shared_data[i];
// sum = 2 * (0 + 1 + ... + 1023) = 1047552 보장
}
consumer가 data_ready를 true로 보면 producer가 그 전에 쓴 배열 전체가 보입니다. 두 연산을 모두 relaxed로 바꾸면 이 보장이 사라져 일부 요소가 0으로 보일 수 있습니다.
memory_order 선택 기준
// 대부분 이렇게만 써도 충분 (seq_cst)
counter++;
flag.store(true);
// 데이터 게시처럼 짝이 분명할 때 acquire/release
flag.store(true, std::memory_order_release);
if (flag.load(std::memory_order_acquire)) { /* ... */ }
// 다른 변수와 무관한 순수 카운터
counter.fetch_add(1, std::memory_order_relaxed);
Lock-free 자료 구조로 가기 전에
CAS를 배우면 스택이나 큐를 직접 lock-free로 만들어 보고 싶어집니다. 스택의 push는 “새 노드의 next를 현재 head로 두고, head를 새 노드로 CAS”하는 몇 줄이면 되고, 실제로 올바르게 동작합니다. 문제는 pop입니다. 스레드 A가 head 노드를 읽고 그 next를 읽으려는 사이에 스레드 B가 같은 노드를 꺼내 delete하면, A는 해제된 메모리를 읽습니다. 그 위에 해제된 주소가 재사용되어 CAS가 잘못 성공하는 ABA 문제(아래)가 겹칩니다.
즉 lock-free 자료구조의 어려움은 CAS 자체가 아니라 언제 노드를 해제해도 안전한가(safe memory reclamation)에 있습니다. 이를 해결하는 hazard pointer, epoch 기반 회수(EBR), 다중 생산자·다중 소비자 큐의 표준 해법인 Michael-Scott 큐, 가장 단순하고 실용적인 단일 생산자·단일 소비자(SPSC) 링 버퍼는 Lock-Free 자료구조 #34-3에서 코드와 함께 다룹니다. C++26에는 hazard pointer(std::hazard_pointer)와 RCU(std::rcu_domain)가 표준 라이브러리에 들어갑니다.
실무 관점에서는 이렇게 나눌 수 있습니다. 카운터, 플래그, 한 번만 쓰는 포인터 게시처럼 원자 변수 하나로 끝나는 문제는 이 글의 도구(fetch_add, exchange, acquire/release)로 충분하고 직접 작성해도 안전합니다. 스레드 하나가 쓰고 하나가 읽는 버퍼라면 SPSC 링 버퍼가 lock-free 구조 중 가장 검증하기 쉽습니다. 여러 스레드가 동시에 넣고 빼는 컨테이너는 직접 작성하기보다 Boost.Lockfree, folly, moodycamel::ConcurrentQueue 같은 검증된 구현을 쓰거나, 먼저 mutex로 만들어 측정해 보는 편이 낫습니다. 경합이 적으면 mutex가 충분히 빠르고, 경합이 심하면 lock-free 구조도 CAS 재시도와 캐시 라인 경합 때문에 기대만큼 빨라지지 않는 경우가 많습니다.
용어도 하나 정리해 둡니다. lock-free는 “어떤 스레드가 멈춰도 시스템 전체로는 누군가 진행한다”, wait-free는 “모든 스레드가 유한한 단계 안에 끝난다”는 보장입니다. fetch_add는 하드웨어가 한 명령으로 처리하는 플랫폼(x86의 lock xadd)에서 wait-free이고, CAS 재시도 루프는 lock-free이지만 wait-free는 아닙니다.
ABA 문제, 잘못된 memory_order, volatile 혼동
ABA 문제
스레드 A가 스택의 head를 읽고(노드 X), CAS로 head를 X에서 X->next로 바꾸려 한다고 해 보겠습니다. 그 사이 스레드 B가 X를 pop해 해제하고, 다른 노드를 push했는데 할당자가 X와 같은 주소를 재사용했습니다. 스레드 A의 CAS는 head가 여전히 X와 같은 주소라서 성공하지만, A가 미리 읽어 둔 X->next는 이미 스택에 없는 노드일 수 있습니다. 값은 같아 보여도 그 사이에 상태가 바뀐 것입니다.
해결책은 포인터와 함께 버전 카운터를 CAS에 포함하거나, hazard pointer로 다른 스레드가 보고 있는 노드는 재사용되지 않게 보호하는 것입니다.
// ABA 완화 예: 포인터 + 버전을 묶어서 CAS (개념 스케치)
struct Node { int data; Node* next; };
struct TaggedPtr {
Node* ptr;
uintptr_t version;
};
std::atomic<TaggedPtr> head;
bool pop(int& result) {
TaggedPtr old_head = head.load();
while (old_head.ptr) {
TaggedPtr new_head{old_head.ptr->next, old_head.version + 1};
if (head.compare_exchange_weak(old_head, new_head)) {
result = old_head.ptr->data;
delete old_head.ptr; // 다른 스레드가 아직 읽고 있을 수 있음 (아래 설명)
return true;
}
}
return false;
}
버전을 매 CAS마다 증가시키면 같은 주소라도 버전이 다르므로 CAS가 실패해 ABA를 막을 수 있습니다. 다만 old_head.ptr->next를 읽는 순간 다른 스레드가 그 노드를 이미 delete했을 수 있다는 use-after-free 문제는 버전으로 막을 수 없으므로, 완전한 해결에는 hazard pointer 같은 메모리 회수 기법이 필요합니다.
64비트 시스템에서 TaggedPtr는 16바이트이므로 16바이트 CAS 명령(x86-64의 cmpxchg16b)이 있어야 lock-free로 동작할 수 있습니다. GCC는 이런 16바이트 atomic을 libatomic 호출로 처리하는 경우가 많아서, -mcx16을 주더라도 is_lock_free()가 false를 돌려줄 수 있고, -latomic을 빼먹으면 undefined reference to '__atomic_compare_exchange_16' 링크 에러가 납니다. 이 때문에 실무에서는 포인터 상위 비트에 태그를 넣거나, 노드를 배열 인덱스로 표현해 8바이트 안에 인덱스와 버전을 함께 담는 방법도 많이 씁니다.
메모리 순서 오류
플래그를 relaxed로 바꾸고 데이터는 일반 변수로 쓰면, consumer가 플래그를 보고 데이터를 읽어도 이전 값을 볼 수 있습니다. 게다가 이 경우 data에 대한 접근은 동기화되지 않은 동시 접근이라 data race, 즉 미정의 동작입니다.
// 잘못된 예
int data = 0;
std::atomic<bool> ready{false};
void producer_bad() {
data = 42;
ready.store(true, std::memory_order_relaxed); // 순서 보장 없음
}
void consumer_bad() {
while (!ready.load(std::memory_order_relaxed)) {}
int x = data; // 42가 아닐 수 있음
}
// 올바른 예: release/acquire 쌍
void producer_ok() {
data = 42;
ready.store(true, std::memory_order_release);
}
void consumer_ok() {
while (!ready.load(std::memory_order_acquire)) {}
int x = data; // 42 보장
}
x86에서는 relaxed 버전도 대개 우연히 동작하기 때문에, 이런 버그는 ARM 서버나 모바일로 옮긴 뒤에야 드러나는 경우가 많습니다.
volatile과 혼동
volatile은 컴파일러가 그 변수에 대한 접근을 생략하거나 합치지 못하게 할 뿐, 원자성도 스레드 간 순서도 보장하지 않습니다. C++ 표준에서 volatile 변수에 대한 동시 접근도 data race입니다. 하드웨어 레지스터(MMIO)처럼 원래 용도 외에 스레드 동기화에는 atomic이나 mutex를 써야 합니다.
// volatile: 스레드 안전 아님
volatile int counter = 0;
counter++; // 읽기-수정-쓰기가 나뉘어 data race
// atomic
std::atomic<int> counter{0};
counter++;
CAS 루프에서 expected를 잘못 다루기
compare_exchange_*가 실패하면 expected가 현재 값으로 갱신됩니다. 루프에서 이 동작을 고려하지 않으면 두 종류의 버그가 생깁니다.
// 잘못된 예 1: 루프 안에서 expected를 매번 고정값으로 되돌림
// value가 0이 아닌 동안 영원히 실패 (바쁜 대기)
void wrong_spin() {
int expected;
do {
expected = 0;
} while (!value.compare_exchange_weak(expected, 1));
}
// 잘못된 예 2: desired를 루프 밖에서 한 번만 계산
// 첫 실패 후 expected는 갱신되지만 desired는 옛 값 기준이라, 다른 스레드의 증가를 덮어씀
void wrong_increment() {
int expected = value.load();
int desired = expected + 1;
while (!value.compare_exchange_weak(expected, desired)) {}
}
// 올바른 예: 갱신된 expected로 desired를 매번 다시 계산
void correct_increment() {
int expected = value.load();
while (!value.compare_exchange_weak(expected, expected + 1)) {}
}
첫 번째는 “0일 때만 바꾸겠다”는 의도라면 루프 대신 compare_exchange_strong 한 번으로 충분하고, 기다려야 한다면 C++20의 wait()/notify_one()이나 condition_variable을 쓰는 편이 낫습니다.
여러 변수의 일관성은 atomic만으로 부족하다
atomic은 해당 변수 하나에 대한 원자성만 보장합니다. 예를 들어 head와 size를 각각 atomic으로 두고 따로 갱신하면, 다른 스레드가 두 연산 사이에 끼어들어 한쪽만 바뀐 중간 상태를 볼 수 있습니다.
// atomic 두 개: a와 b가 "같은 시점"에 바뀌는 것이 아님
std::atomic<int> a{0}, b{0};
void bad_update() { a.store(1); b.store(1); }
// mutex로 한 블록 보호
std::mutex mtx;
int x = 0, y = 0;
void good_update() {
std::lock_guard<std::mutex> lock(mtx);
x = 1; y = 1;
}
여러 값을 함께 바꿔야 한다면 mutex로 묶거나, 값을 담은 불변 객체를 새로 만들어 포인터 하나만 atomic하게 교체하거나(C++20의 std::atomic<std::shared_ptr<T>>가 이 용도에 맞습니다), 작은 값이라면 하나의 정수에 비트로 묶어 atomic 하나로 다루는 방법이 있습니다.
ThreadSanitizer로 data race 찾기
atomic을 빼먹은 곳은 ThreadSanitizer(TSan)로 찾을 수 있습니다.
g++ -std=c++17 -fsanitize=thread -g -O1 -o race_test race_test.cpp && ./race_test
// race_test.cpp: TSan이 감지하는 예
#include <thread>
int counter = 0; // atomic이 아님 → data race
void inc() { for (int i = 0; i < 1000000; ++i) counter++; }
int main() {
std::thread t1(inc), t2(inc);
t1.join(); t2.join();
return 0;
}
실행하면 WARNING: ThreadSanitizer: data race 메시지와 함께 경쟁하는 두 접근의 위치가 출력됩니다. std::atomic<int> counter{0};으로 바꾸면 경고가 사라집니다.
atomic 포인터와 가리키는 데이터
std::atomic<T*>는 포인터 값만 원자적으로 만듭니다. 포인터가 가리키는 객체의 내용은 별도로 동기화해야 합니다.
std::atomic<Node*> ptr{nullptr};
void bad_use() {
Node* p = ptr.load();
if (p) {
p->data = 42; // ptr 로드는 원자적이지만, p->data 접근은 다른 스레드와 경쟁할 수 있음
}
}
게시하는 쪽이 객체를 완성한 뒤 release로 포인터를 저장하고 읽는 쪽이 acquire로 읽으면 “완성된 객체를 읽기”까지는 안전합니다. 하지만 게시 후에도 여러 스레드가 객체를 수정한다면 그 수정은 mutex나 객체 내부의 atomic으로 따로 보호해야 하고, 객체를 언제 해제할지도 hazard pointer나 RCU 같은 기법으로 관리해야 합니다.
atomic 카운터와 mutex 카운터 벤치마크
벤치마크 코드
여러 스레드가 공유 카운터를 100만 번씩 증가시키는 경우입니다.
#include <atomic>
#include <chrono>
#include <iostream>
#include <mutex>
#include <thread>
#include <vector>
constexpr int NUM_THREADS = 8;
constexpr int OPS_PER_THREAD = 1'000'000;
void benchmark_mutex() {
std::mutex mtx;
int counter = 0;
auto start = std::chrono::steady_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < NUM_THREADS; ++i) {
threads.emplace_back([&]() {
for (int j = 0; j < OPS_PER_THREAD; ++j) {
std::lock_guard<std::mutex> lock(mtx);
counter++;
}
});
}
for (auto& t : threads) t.join();
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Mutex: " << ms << " ms, counter=" << counter << "\n";
}
void benchmark_atomic() {
std::atomic<int> counter{0};
auto start = std::chrono::steady_clock::now();
std::vector<std::thread> threads;
for (int i = 0; i < NUM_THREADS; ++i) {
threads.emplace_back([&]() {
for (int j = 0; j < OPS_PER_THREAD; ++j) {
counter++;
}
});
}
for (auto& t : threads) t.join();
auto end = std::chrono::steady_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(end - start).count();
std::cout << "Atomic: " << ms << " ms, counter=" << counter << "\n";
}
int main() {
benchmark_mutex();
benchmark_atomic();
}
결과를 읽는 법
이 벤치마크의 절대 수치는 CPU, 코어 수, OS, 표준 라이브러리 구현에 따라 크게 달라지므로 직접 실행해 확인해야 합니다. 경향만 말하면, 스레드가 하나일 때는 경쟁이 없어 mutex의 lock/unlock도 원자적 연산 한두 번으로 끝나기 때문에 차이가 작고, 스레드 수를 늘려 경쟁이 심해질수록 mutex는 대기 스레드가 잠들고 깨어나는 비용이 더해져 차이가 벌어집니다. 반대로 atomic도 여러 코어가 같은 변수를 계속 수정하면 캐시 라인이 코어 사이를 오가며(cache line bouncing) 스레드를 늘려도 전체 처리량이 거의 오르지 않습니다. 이런 고빈도 카운터라면 아래 “스레드별로 모았다가 반영하기”처럼 스레드별로 세고 나중에 합치는 방식이 atomic 하나를 공유하는 것보다 빠른 경우가 많습니다.
경합 상황에서 atomic이 유리한 이유
경쟁이 없을 때는 Linux의 futex 기반 구현처럼 mutex도 커널에 들어가지 않습니다. 시스템 콜과 컨텍스트 스위치가 생기는 것은 락을 기다리는 스레드를 재우고 깨울 때이고, atomic 증가에는 이 대기 경로 자체가 없습니다. 또 mutex로 카운터를 보호하면 락 변수의 캐시 라인과 카운터의 캐시 라인을 모두 오가야 하지만, atomic은 카운터가 있는 캐시 라인 하나만 오갑니다. 락을 잡은 스레드가 임계 영역 도중 선점되면 나머지 스레드가 모두 멈추는 문제도 atomic에는 없습니다.
memory_order별로는 일반적으로 seq_cst, acquire/release, relaxed 순으로 비용이 낮아집니다. x86에서는 RMW 연산이 모두 lock 접두 명령으로 컴파일되어 순서 지정에 따른 차이가 거의 없고, ARM에서는 relaxed가 눈에 띄게 빠를 수 있습니다.
성능 최적화 기법
캐시 라인 분리 (False Sharing 방지)
여러 스레드가 서로 다른 atomic 변수를 자주 갱신하더라도, 두 변수가 같은 캐시 라인에 있으면 한 스레드의 쓰기가 다른 스레드의 캐시 라인을 무효화해 성능이 떨어집니다. 변수를 캐시 라인(x86에서 보통 64바이트) 경계에 맞춰 분리합니다.
// 같은 캐시 라인: false sharing
struct Bad { std::atomic<int> c1{0}; std::atomic<int> c2{0}; };
// 멤버마다 alignas(64)로 캐시 라인 분리
struct Good {
alignas(64) std::atomic<int> c1{0};
alignas(64) std::atomic<int> c2{0};
};
C++17의 std::hardware_destructive_interference_size를 쓰면 64를 직접 적지 않아도 되지만, 컴파일러에 따라 지원 여부와 경고가 다르므로 확인하고 씁니다.
순서가 필요 없으면 relaxed
카운터, 통계, 모니터링용 변수처럼 다른 변수와의 순서가 필요 없으면 memory_order_relaxed를 씁니다.
std::atomic<uint64_t> request_count{0};
void on_request() {
request_count.fetch_add(1, std::memory_order_relaxed);
}
스레드별로 모았다가 반영하기
여러 번 증가시킬 때 스레드 지역 변수에 모았다가 주기적으로 atomic에 반영하면 공유 캐시 라인을 건드리는 횟수가 줄어듭니다.
std::atomic<long> total_count{0};
void process_requests(const std::vector<Request>& requests) {
long local_count = 0;
for (const auto& req : requests) {
process(req);
if (++local_count >= 100) {
total_count.fetch_add(local_count, std::memory_order_relaxed);
local_count = 0;
}
}
total_count.fetch_add(local_count, std::memory_order_relaxed); // 남은 몫 반영
}
대신 total_count를 읽는 쪽은 최대 (스레드 수 × 99)만큼 덜 반영된 값을 볼 수 있으므로, 정확한 실시간 값이 필요한 곳에는 맞지 않습니다.
단순 증가는 fetch_add
단순 증가와 감소는 fetch_add가 CAS 루프보다 효율적입니다. CAS 루프는 경합이 있으면 실패와 재시도를 반복하지만, fetch_add는 하드웨어가 한 번에 처리합니다. CAS는 조건부 업데이트가 필요할 때만 씁니다.
// 권장
counter.fetch_add(1, std::memory_order_relaxed);
// 비권장: CAS로 증가
int old = counter.load();
while (!counter.compare_exchange_weak(old, old + 1)) {}
is_lock_free 확인
std::atomic이 내부적으로 락을 쓰는지 확인할 수 있습니다. is_lock_free()가 false이면 락 기반 구현이므로, 그 타입은 mutex를 직접 쓰는 편이 의도가 분명할 수 있습니다.
std::atomic<MyStruct> s;
if (!s.is_lock_free()) {
// MyStruct는 내부적으로 락으로 보호됨
}
카운터, 플래그, 스핀락: atomic 활용 패턴
모니터링 카운터
struct Metrics {
std::atomic<uint64_t> requests{0};
std::atomic<uint64_t> errors{0};
void record_request() {
requests.fetch_add(1, std::memory_order_relaxed);
}
void record_error() {
errors.fetch_add(1, std::memory_order_relaxed);
}
};
요청 수와 에러 수는 서로 독립된 통계이므로 relaxed로 충분합니다. 다만 두 값을 따로 읽으면 같은 시점의 스냅샷이 아니므로, 에러율처럼 두 값의 비율을 계산할 때는 약간 어긋난 값이 나올 수 있다는 점을 감안합니다.
준비 완료 플래그 (Producer-Consumer)
std::atomic<bool> data_ready{false};
std::vector<int> shared_data;
void producer() {
shared_data = compute_data();
data_ready.store(true, std::memory_order_release);
}
void consumer() {
while (!data_ready.load(std::memory_order_acquire)) {
std::this_thread::yield();
}
process(shared_data);
}
기다리는 시간이 길 수 있다면 바쁜 대기 대신 C++20의 data_ready.wait(false)와 notify_all(), 또는 condition_variable을 씁니다.
한 번만 실행
std::atomic<bool> initialized{false};
void init_once() {
if (!initialized.exchange(true)) {
do_heavy_init();
}
}
exchange(true)는 이전 값이 false였던 스레드 하나에만 false를 돌려주므로, do_heavy_init()은 정확히 한 번 실행됩니다. 그러나 나머지 스레드는 초기화가 끝나기를 기다리지 않고 바로 지나가므로, 초기화된 자원을 곧바로 사용하는 코드라면 아직 준비되지 않은 자원을 쓰게 됩니다. 다른 스레드가 초기화 완료를 기다려야 한다면 std::call_once나 함수 지역 static 변수를 쓰는 것이 맞습니다.
Lock-free 도구 선택
| 상황 | 선택지 |
|---|---|
| 단일 변수 (카운터, 플래그) | std::atomic |
| SPSC 큐 | 원형 버퍼 + atomic 인덱스 |
| MPMC 큐 | Boost.Lockfree, moodycamel::ConcurrentQueue 등 검증된 라이브러리 (표준 라이브러리에는 동시성 큐가 없음) |
| 복잡한 불변식 | mutex 우선, 측정 후 필요하면 lock-free 검토 |
종료 플래그
std::atomic<bool> shutdown_requested{false};
void worker_thread() {
while (!shutdown_requested.load(std::memory_order_acquire)) {
do_work();
}
}
void request_shutdown() {
shutdown_requested.store(true, std::memory_order_release);
}
release/acquire 쌍은 request_shutdown()을 호출한 스레드가 스토어 이전에 쓴 내용(예: 종료 사유)이, 플래그를 true로 읽은 워커에게 보인다는 것을 보장합니다. 플래그 외에 함께 전달할 데이터가 없다면 relaxed로도 충분합니다. atomic 로드는 컴파일러가 루프 밖으로 끌어내지 못하므로, 순서와 상관없이 워커는 결국 true를 보게 됩니다.
Double-Checked Locking
std::atomic<MyClass*> instance{nullptr};
std::mutex init_mutex;
MyClass* get_instance() {
MyClass* tmp = instance.load(std::memory_order_acquire);
if (tmp == nullptr) {
std::lock_guard<std::mutex> lock(init_mutex);
tmp = instance.load(std::memory_order_relaxed);
if (tmp == nullptr) {
tmp = new MyClass();
instance.store(tmp, std::memory_order_release);
}
}
return tmp;
}
첫 로드에서 nullptr가 아니면 락 없이 반환하고, nullptr이면 락을 잡고 다시 확인한 뒤 초기화합니다. release 스토어와 acquire 로드가 짝을 이뤄, 포인터를 본 스레드는 완전히 생성된 객체를 봅니다. 락 안의 두 번째 로드가 relaxed여도 되는 것은 mutex가 이미 동기화를 제공하기 때문입니다.
C++11 이후에는 이 코드를 직접 쓸 이유가 거의 없습니다. 표준이 함수 지역 static 변수의 초기화를 스레드 안전하게 보장하므로 static MyClass& get_instance() { static MyClass instance; return instance; }만으로 같은 효과를 얻고, new로 만든 객체가 해제되지 않는 문제도 없습니다. 초기화 시점을 따로 제어해야 한다면 std::call_once를 씁니다.
토큰 버킷 Rate Limiter
#include <algorithm>
#include <atomic>
#include <cstdint>
class TokenBucket {
std::atomic<int64_t> tokens;
int64_t max_tokens;
public:
explicit TokenBucket(int64_t max) : tokens(max), max_tokens(max) {}
bool try_consume() {
int64_t current = tokens.load(std::memory_order_relaxed);
while (current > 0) {
if (tokens.compare_exchange_weak(current, current - 1,
std::memory_order_relaxed))
return true;
}
return false;
}
void refill(int64_t count) {
int64_t old = tokens.load(std::memory_order_relaxed);
int64_t desired;
do {
desired = std::min(old + count, max_tokens);
} while (!tokens.compare_exchange_weak(old, desired,
std::memory_order_relaxed));
}
};
토큰 버킷의 핵심만 추린 예제입니다. try_consume은 토큰이 남아 있을 때만 1 감소시키고, 토큰이 0이 되면 fetch_sub와 달리 음수로 내려가지 않습니다. refill은 상한을 넘지 않게 보충합니다. 실제로는 refill을 별도 타이머나 마지막 보충 시각을 기준으로 계산하는 방식으로 호출합니다.
상태 머신 전이
enum class State { Idle, Running, Paused, Stopped };
std::atomic<State> current_state{State::Idle};
bool try_transition(State from, State to) {
State expected = from;
return current_state.compare_exchange_strong(expected, to);
}
// try_transition(State::Idle, State::Running): 여러 스레드가 동시에 호출해도 한 스레드만 성공
스핀락: atomic_flag와 test-and-test-and-set
std::atomic_flag는 표준이 반드시 lock-free라고 보장하는 유일한 타입입니다(std::atomic<bool>도 대부분의 플랫폼에서 lock-free지만 보장은 아닙니다). 그래서 가장 기본적인 스핀락은 atomic_flag로 만듭니다.
#include <atomic>
#include <mutex>
#include <thread>
class SpinLock {
std::atomic_flag flag_ = ATOMIC_FLAG_INIT; // C++20부터는 기본 생성만으로 clear 상태
public:
void lock() {
while (flag_.test_and_set(std::memory_order_acquire)) {
// C++20: 값이 풀릴 때까지 읽기만 하며 대기 (test-and-test-and-set)
while (flag_.test(std::memory_order_relaxed)) {
std::this_thread::yield();
}
}
}
void unlock() {
flag_.clear(std::memory_order_release);
}
};
SpinLock spin;
void critical_section() {
std::lock_guard<SpinLock> guard(spin); // lock/unlock이 있으므로 lock_guard 사용 가능
// 임계 영역
}
test_and_set은 플래그를 세우고 이전 값을 돌려주므로, false를 받은 스레드 하나만 락을 얻습니다. acquire/release 쌍이 임계 영역 안의 읽기·쓰기가 락 밖으로 새지 않게 합니다. lock()/unlock()을 직접 부르는 대신 std::lock_guard를 쓰면 예외가 나도 락이 풀립니다.
안쪽 루프가 있는 이유는 캐시 때문입니다. test_and_set이나 exchange는 매번 캐시 라인을 쓰기 모드로 가져와 다른 코어의 캐시를 무효화하지만, test()나 load()는 공유 상태로 읽기만 하므로 기다리는 스레드끼리 캐시를 덜 흔듭니다. C++20 이전이라 atomic_flag::test()가 없다면 std::atomic<bool>에 exchange(true)와 load(relaxed)를 조합해 같은 구조를 만듭니다.
스핀락은 기다리는 동안 스레드를 재우지 않고 계속 확인하므로, 임계 영역이 매우 짧고 스레드 수가 코어 수보다 적을 때만 mutex보다 유리합니다. 락을 가진 스레드가 운영체제에 의해 선점되면 나머지 스레드는 의미 없이 CPU를 태우고, 우선순위가 다르면 낮은 우선순위의 락 소유자가 실행되지 못하는 상황도 생깁니다. 표준 mutex 구현들도 잠들기 전에 잠깐 스핀하는 최적화를 이미 하고 있어서, 사용자 공간 애플리케이션에서 직접 만든 스핀락이 std::mutex보다 빠른 경우는 생각보다 드뭅니다. 도입한다면 실제 부하에서 측정한 뒤에 결정하는 편이 안전합니다.
작업 큐: mutex와 atomic 크기 카운터의 조합
자료구조 전체는 mutex로 보호하되, “비어 있는지”만 락 없이 빠르게 확인하고 싶을 때 atomic 카운터를 곁들이는 패턴입니다.
#include <atomic>
#include <mutex>
#include <queue>
template<typename T>
class WorkQueue {
std::queue<T> queue_;
std::mutex mutex_;
std::atomic<size_t> size_{0};
public:
void push(T item) {
{
std::lock_guard<std::mutex> lock(mutex_);
queue_.push(std::move(item));
}
size_.fetch_add(1, std::memory_order_release);
}
bool try_pop(T& item) {
if (size_.load(std::memory_order_acquire) == 0) {
return false; // 비어 보이면 락을 잡지 않고 바로 반환
}
std::lock_guard<std::mutex> lock(mutex_);
if (queue_.empty()) {
return false; // 그 사이 다른 소비자가 가져갔을 수 있음
}
item = std::move(queue_.front());
queue_.pop();
size_.fetch_sub(1, std::memory_order_relaxed);
return true;
}
size_t approx_size() const {
return size_.load(std::memory_order_relaxed);
}
};
큐 자체의 일관성은 mutex가 책임지고, size_는 “락을 잡아 볼 가치가 있는지”를 알려 주는 힌트일 뿐입니다. 그래서 try_pop은 락을 잡은 뒤 queue_.empty()를 다시 확인합니다. push 직후 카운터가 오르기 전 잠깐 동안은 큐에 원소가 있는데도 size_가 0으로 보일 수 있는데, 이 경우 소비자가 한 번 헛걸음할 뿐 데이터가 사라지지는 않습니다.
approx_size()라는 이름을 붙인 것도 같은 이유입니다. 돌려준 값은 호출자가 쓰는 순간 이미 바뀌어 있을 수 있으므로, if (q.approx_size() > 0) q.try_pop(item); 같은 코드도 여전히 실패를 처리해야 합니다. 동시성 자료구조에서 “확인 후 행동(check-then-act)“은 원자적이지 않다는 점이 핵심이고, 그래서 try_pop이 성공 여부를 반환값으로 돌려주도록 설계합니다.
실무의 작업 큐는 대부분 “비어 있으면 바로 false”가 아니라 “작업이 들어올 때까지 기다리기”가 필요합니다. 그때는 atomic 카운터로 바쁜 대기를 하지 말고 condition_variable(#7-3)로 소비자 스레드를 재워야 작업이 없는 동안 CPU를 쓰지 않습니다.
atomic vs mutex: 언제 무엇을 쓸까
카운터나 bool 플래그처럼 한두 개의 단순 변수만 보호할 때는 atomic이 적합합니다. 락 없이 원자적 연산만으로 data race를 없앨 수 있고, 경합이 있을 때 특히 유리합니다. 여러 변수를 한 번에 바꾸거나 복잡한 조건을 일관되게 유지해야 할 때는 mutex가 적합합니다. atomic만으로는 “A를 바꾼 다음 B를 바꾼다”를 다른 스레드에 하나의 단위로 보이게 할 수 없습니다. 큐, 맵, 리스트 같은 자료구조 전체를 보호할 때도 기본은 mutex이고, lock-free 구조는 측정으로 필요성이 확인된 뒤 검증된 구현을 쓰는 편이 안전합니다.
atomic의 제약: 복사, 지원 연산, 큰 타입
복사와 대입
std::atomic은 복사 생성과 복사 대입이 삭제되어 있습니다. 두 atomic 사이의 복사를 원자적으로 할 방법이 없기 때문입니다. 값을 옮기려면 b.store(a.load())처럼 명시적으로 읽고 씁니다. 같은 이유로 std::atomic을 멤버로 가진 클래스는 기본 복사 생성자가 삭제되므로, 필요하면 직접 정의해야 합니다.
타입별 지원 연산
| 타입 | load/store | fetch_add/sub | CAS | exchange |
|---|---|---|---|---|
atomic<int> 등 정수 | 지원 | 지원 (and/or/xor도) | 지원 | 지원 |
atomic<bool> | 지원 | 없음 | 지원 | 지원 |
atomic<T*> | 지원 | 지원 (요소 단위 오프셋) | 지원 | 지원 |
atomic<double> | 지원 | C++20부터 | 지원 | 지원 |
atomic<구조체> (trivially copyable) | 지원 | 없음 | 지원 | 지원 |
atomic<T*>의 fetch_add는 일반 포인터 산술처럼 요소 크기 단위로 더합니다. trivially copyable한 구조체는 크기와 상관없이 std::atomic으로 쓸 수 있지만, 아래처럼 lock-free가 아닐 수 있습니다. 구조체를 CAS로 비교할 때는 값이 아니라 객체 표현(바이트)을 비교하므로, 패딩 바이트가 있는 구조체는 값이 같아도 CAS가 실패할 수 있다는 점도 주의합니다(C++20부터는 패딩을 비교에서 제외하도록 규정이 바뀌었습니다).
lock-free가 아닌 atomic: 크기 제한과 is_lock_free
#include <atomic>
#include <iostream>
struct Large { int data[100]; };
struct Pair { void* ptr; unsigned long long version; }; // 64비트에서 16바이트
int main() {
std::atomic<int> x{0};
std::atomic<Large> large{};
std::cout << x.is_lock_free() << " " << large.is_lock_free() << "\n"; // 보통 1 0
// C++17: 컴파일 시점 상수로 빌드 단계에서 확인
static_assert(std::atomic<int>::is_always_lock_free);
// static_assert(std::atomic<Pair>::is_always_lock_free); // 플랫폼과 컴파일러에 따라 실패
}
400바이트짜리 Large는 한 번에 원자적으로 바꿀 CPU 명령어가 없으므로, 표준 라이브러리가 락으로 원자성을 흉내 냅니다. is_lock_free()는 실행 시점에, C++17의 is_always_lock_free는 컴파일 시점에 이를 알려 줍니다. GCC에서는 이런 타입을 쓰려면 -latomic 링크가 필요할 수 있습니다.
lock-free가 아닌 atomic은 구현에 따라 동작이 다릅니다. GCC의 libatomic은 객체 주소를 해시해 전역 락 테이블의 락을 잡고, MSVC는 객체 안에 작은 스핀락을 둡니다. 어느 쪽이든 원자적이고 올바르지만 “락 없이 빠르게”라는 목적은 사라지고, 전역 락 테이블 방식에서는 서로 무관한 두 atomic 객체가 같은 락을 공유해 불필요하게 경쟁할 수도 있어서 직접 쓴 mutex와 비슷하거나 더 느릴 수 있습니다. 또 시그널 핸들러 안에서는 lock-free atomic만 안전하게 쓸 수 있습니다. lock-free 구조를 설계한다면 핵심 타입에 static_assert(std::atomic<T>::is_always_lock_free);를 걸어 두면, 다른 플랫폼으로 옮겼을 때 조용히 락 기반으로 바뀌는 일을 빌드 단계에서 잡을 수 있습니다.
같이 보면 좋은 글
- C++ std::thread와 jthread 기초: 생성, join·detach, 공유 데이터 보호
- C++ mutex: 경쟁 상태, 데드락, lock_guard·scoped_lock 사용 원칙
- C++ condition_variable로 작업 큐 만들기: 허위 깨움 처리와 스레드 풀
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing