C++ atomic: 락 없는 카운터, 메모리 순서, 언제 mutex 대신 쓰나
💡 핵심 개념: 카운터·플래그 한 개처럼 “한 덩어리”만 여러 스레드가 건드리면
std::atomic을 먼저 고려하라. 두 개 이상의 변수가 동시에 맞아야 하는 불변식이면 mutex가 맞다.memory_order는 처음엔 기본(memory_order_seq_cst)만 써도 되고, 병목이 보일 때만relaxed/acquire·release를 골라도 늦지 않습니다. 선행: mutex(#7-2), condition_variable(#7-3).
들어가며: 뮤텍스가 너무 느려요
여러 스레드가 단순히 카운터 하나만 증가시키는 경우, mutex를 쓰면 오버헤드와 경합이 부담이었다. 반면 atomic(원자 변수—한 번에 하나의 연산만 완료되도록 보장되어, 여러 스레드가 동시에 접근해도 data race가 나지 않음)으로 바꾸면 락 없이도 증가가 한 번에 이루어져 data race가 사라지고, 실제로도 지연이 줄었다. mutex로 보호한 경우에서는 counter++를 할 때마다 락을 잡고 풀어야 해서, 스레드 수가 많을수록 락 경합이 심해집니다. counter처럼 단일 변수만 보호할 때는 오버헤드가 아깝다. atomic으로 바꾼 경우에서는 counter 자체가 std::atomic<int>이므로 counter++가 CPU에서 원자적으로 실행됩니다. 락을 쓰지 않아도 한 스레드가 증가시키는 동안 다른 스레드가 중간 값을 읽지 않으며, data race가 사라집니다. 실제로 고빈도 카운터를 mutex에서 atomic으로 바꾼 뒤 지연이 눈에 띄게 줄었던 경험이 있습니다.
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++;
}
counter++마다 lock_guard로 락을 잡고 풀어야 해서, 스레드가 많을수록 락 경합이 커집니다. 단일 변수만 보호할 때는 mutex 오버헤드가 부담이 되므로, 이런 경우에는 atomic이 더 적합합니다.
atomic으로 바꾼 경우:
std::atomic<int> counter{0};
void inc() {
counter++; // 원자적 연산, data race 없음
}
std::atomic<int>의 operator++는 CPU에서 원자적으로 실행되어, 여러 스레드가 동시에 증가시켜도 data race가 발생하지 않습니다. 락을 쓰지 않아 경합이 줄고, 단일 변수에 대한 읽기·쓰기·증가만 필요할 때 mutex보다 가볍습니다.
언제 atomic을 쓰면 좋은가: 한 변수에 대한 읽기·쓰기·증가·감소만 필요할 때는 mutex보다 atomic이 부담이 적고, 캐시 라인만 겨냥해 동기화하므로 경합이 적습니다. 반면 “여러 변수를 한 번에 바꿔야 일관성이 유지되는” 경우에는 mutex로 한 블록을 통째로 보호하는 편이 맞습니다.
이번 글에서는 std::atomic의 기본 사용법과, 메모리 순서(memory_order—멀티스레드에서 읽기·쓰기가 다른 스레드에 어떤 순서로 보일지 지정하는 것. 기본값만 써도 대부분 안전)가 무엇인지, 언제 atomic을 쓰고 언제 mutex를 쓸지 실전 관점에서 정리합니다.
카운터나 플래그처럼 “변수 하나”만 보호할 때는 mutex보다 atomic이 가볍으며, 경합이 적을수록 유리합니다. 반대로 여러 변수를 한 번에 바꿔야 하거나 복잡한 조건이 있으면 mutex가 맞으며, atomic은 그런 경우에 보조적으로만 쓰는 것이 안전합니다.
초당 10만 QPS 서버의 요청 카운터
고부하 API 서버의 요청 카운터
초당 10만 QPS를 처리하는 API 서버에서 std::mutex로 요청 수를 보호하면, 락 경합으로 인해 응답 지연이 급증합니다. std::atomic<uint64_t>로 바꾸면 요청마다 락을 잡고 기다리는 구간이 사라져, 경합이 심할수록 꼬리 지연이 줄어듭니다.
레이스 컨디션으로 인한 잘못된 카운트
여러 스레드가 counter++를 수행할 때, mutex 없이 일반 int를 쓰면 읽기-수정-쓰기가 원자적이지 않아 data race가 발생합니다. 8스레드가 각 100만 번 증가시켜도 최종 값이 800만보다 작게, 그것도 실행할 때마다 다르게 나옵니다.
플래그와 데이터 동기화 오류
생산자 스레드가 데이터를 채운 뒤 ready = true를 설정하며, 소비자가 ready를 확인한 후 데이터를 읽는 패턴에서, 메모리 재배치 때문에 소비자가 ready는 true인데 데이터는 아직 초기화되지 않은 상태를 볼 수 있습니다. memory_order_release/acquire 쌍이 없으면 이런 버그가 발생합니다.
Shutdown 신호가 늦게 전달됨
워커 스레드가 while (!shutdown_flag) 루프를 돌 때, shutdown_flag를 일반 변수로 두면 메인 스레드가 true로 설정해도 워커가 캐시된 false를 계속 읽어 무한 루프에 빠질 수 있습니다. std::atomic<bool>과 적절한 memory_order가 필요합니다.
Lock-free 큐에서 ABA 문제
멀티스레드 환경에서 노드 포인터를 CAS로 교체할 때, A→B→A처럼 같은 주소가 재사용되면 CAS가 “값이 같다”고 잘못 판단해 중간에 발생한 변경을 무시할 수 있습니다. 버전 카운터나 hazard pointer로 완화해야 합니다.
이 글을 읽으면:
std::atomic으로 정수·포인터 등을 data race 없이 읽고 쓸 수 있습니다.load(),store(),exchange(),compare_exchange_*등 전체 연산의 의미를 알 수 있습니다.memory_order가 무엇을 제어하는지(특히 seq_cst, acquire/release, relaxed) 상세히 이해할 수 있습니다.- lock-free 큐·스택 등 자료 구조를 구현할 수 있습니다.
- ABA 문제, 메모리 순서 오류 등 흔한 실수를 피할 수 있습니다.
- atomic과 mutex의 성능 차이를 벤치마크로 확인할 수 있습니다.
- 프로덕션에서 카운터, 플래그, lock-free 알고리즘 패턴을 적용할 수 있습니다.
나눌 수 없는 연산: std::atomic의 의미
Atomic이란 “나눌 수 없는” 연산이라는 뜻입니다.
여러 스레드가 같은 변수에 대해 읽기-수정-쓰기를 해도, 그 연산이 한 덩어리처럼 동작해 중간 상태가 다른 스레드에 보이지 않습니다.
따라서 그 변수에 대한 data race가 없으며, C++ 표준상 정의된 동작을 합니다.
std::atomic<T>는 보통 다음에 사용합니다.
- 정수형:
atomic<int>,atomic<unsigned>,atomic<size_t>등 — 카운터, 플래그 - 포인터:
atomic<T*>— lock-free 큐 등에서 다음 노드 포인터 - bool:
atomic<bool>— 준비 완료 플래그T는 trivially copyable해야 합니다. 즉memcpy로 복사해도 되는 타입만 담을 수 있습니다. 정수·포인터·bool, 그리고 멤버가 모두 그런 타입인 단순 구조체는 가능하지만,std::string이나std::vector처럼 복사 생성자가 따로 있는 타입은 컴파일 에러가 납니다. 구조체를 담을 수는 있어도 크기에 따라 내부적으로 락을 쓸 수 있다는 점은 아래 “atomic이 보호하지 않는 것” 절의 “lock-free가 아닌 atomic” 항목에서 다룹니다.std::atomic<double>은 C++11부터 load/store/exchange/CAS는 되지만fetch_add는 C++20부터 지원되어, 그 이전에는 CAS 루프로 직접 구현해야 했습니다.
원자 연산 전체: load, store, exchange, CAS
읽기와 쓰기: load, store
- load(): 현재 값을 읽음.
a.load()또는(int)a처럼 변환으로도 쓸 수 있음. - store(val): 값을 씀.
a.store(1)또는a = 1.
// 복사해 붙여넣은 뒤: 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";
return 0;
}
value.store(42)와 value = 42는 동일하며, value.load()와 (int)value도 동일합니다. atomic에 대한 읽기·쓰기는 모두 원자적으로 수행되어, 다른 스레드가 중간 상태를 보지 않습니다.
실행 결과: 42 42 가 한 줄 출력됩니다.
교환: 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";
return 0;
}
exchange(1)은 값을 1로 설정하고 이전 값을 반환합니다. “한 번만 실행”하는 초기화, “이전 값 확인 후 교체” 패턴에 유용합니다.
실행 결과: old=0 new=1
읽기-수정-쓰기 (RMW): fetch_add, fetch_sub
- fetch_add(n): 값을 n만큼 더하며, 더하기 전 값을 반환.
- fetch_sub(n): 빼기.
- ++/—, +=/-=: fetch_add/fetch_sub를 사용하는 것과 동일한 동작.
std::atomic<int> counter{0};
counter++; // counter.fetch_add(1)
counter += 10; // counter.fetch_add(10)
int prev = counter.fetch_add(1); // 이전 값 반환
counter++와 counter += 10은 내부적으로 fetch_add를 사용하는 원자 연산입니다. fetch_add(1)은 값을 1 증가시키고 증가 전 값을 반환하므로, “한 번만 증가시키고 그 전 값을 쓰는” 패턴(예: 고유 ID 발급)에 쓸 수 있습니다.
Compare-And-Swap (CAS)
- compare_exchange_strong(expected, desired):
*this가expected와 같으면desired로 바꾸고true반환. 다르면expected를 현재 값으로 업데이트하고false반환. - compare_exchange_weak(expected, desired): strong과 비슷하지만, 일부 아키텍처에서 “spurious failure”(실제로 같아도 실패로 보고할 수 있음)가 있을 수 있음. 루프 안에서 쓸 때는 weak가 더 효율적일 수 있음.
#include <atomic>
std::atomic<int> value{0};
// "0이면 1로 바꾸기" — 한 스레드만 성공
bool try_claim() {
int expected = 0;
return value.compare_exchange_strong(expected, 1);
}
// lock-free 증가 (실제로는 fetch_add가 더 적합하지만 CAS 패턴 예시)
void cas_increment() {
int old_val = value.load();
while (!value.compare_exchange_weak(old_val, old_val + 1)) {
// old_val이 현재 value로 자동 업데이트됨
}
}
CAS는 “예상 값과 같을 때만 새 값으로 교체”하는 원자 연산입니다. lock-free 자료 구조의 핵심 도구이며, compare_exchange_weak는 spurious failure 때문에 루프 안에서 사용합니다. expected는 참조로 전달되어 실패 시 현재 값으로 갱신됩니다.
CAS 완전 예제: 고유 ID 발급, 최댓값 갱신, 조건부 업데이트
#include <atomic>
#include <iostream>
#include <thread>
#include <vector>
// 예제 1: CAS로 고유 ID 발급 (한 번만 실행되는 패턴)
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: CAS로 최댓값 갱신 (여러 스레드가 경쟁)
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)) {
// CAS 실패 시 old_max가 현재 값으로 갱신됨
}
}
// 예제 3: 조건부 업데이트 (0이면 1로, 1이면 2로)
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는 CAS 루프로 경쟁 없이 고유 ID를 발급합니다. update_max는 후보자가 현재 최댓값보다 클 때만 갱신합니다. try_transition은 상태 머신에서 “특정 상태에서만 전이”할 때 사용합니다. 실행 결과: next_id=4001 등 (스레드 수×1000+1), max=999.
compare_exchange_strong vs weak
| 구분 | strong | weak |
|---|---|---|
| spurious failure | 없음 | 있을 수 있음 (ARM, PowerPC 등) |
| 루프 필요 | 보통 1회 시도로 충분 | 실패 시 재시도 루프 필수 |
| 성능 | weak보다 약간 무거울 수 있음 | 일부 CPU에서 더 빠름 |
권장: 루프 안에서 CAS를 쓸 때는 compare_exchange_weak를 사용하며, 실패 시 expected가 갱신되므로 다시 시도하면 됩니다. “한 번만 시도하고 실패하면 포기”하는 패턴에서는 compare_exchange_strong가 적합합니다.
// weak: 루프에서 사용 (spurious failure 허용)
while (!atomic_val.compare_exchange_weak(expected, desired)) {}
// strong: 한 번만 시도
if (atomic_val.compare_exchange_strong(expected, desired)) {
// 성공
} else {
// 실패, expected에 현재 값이 들어감
}
seq_cst, acquire/release, relaxed: memory_order 고르기
여러 스레드가 메모리를 읽고 쓸 때, 실제 실행 순서는 코드 순서와 다르게 바뀔 수 있습니다(컴파일러·CPU 재배치). memory_order는 “다른 스레드에게 얼마나 순서를 보장할지”를 지정합니다.
왜 메모리 순서가 필요한가?
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를 썼다고 해도, CPU/컴파일러가 ready = true를 먼저 실행할 수 있습니다. 그러면 스레드 B는 ready가 true인데 data는 아직 0인 상태를 볼 수 있어, 버그가 됩니다. memory_order로 “이 쓰기 이전의 모든 쓰기는 이 쓰기 이후로 재배치되지 않는다”를 보장할 수 있습니다.
메모리 순서 종류
| 순서 | 의미 | 사용처 |
|---|---|---|
| seq_cst | 단일 전체 순서. 가장 강한 보장. | 기본값, 디버깅 |
| acquire | 이 로드 이후의 읽기/쓰기는 이 로드 이전으로 재배치 안 됨 | 락 획득, 데이터 로드 후 |
| release | 이 스토어 이전의 읽기/쓰기는 이 스토어 이후로 재배치 안 됨 | 락 해제, 데이터 저장 후 |
| acq_rel | acquire + release | CAS 등 RMW |
| relaxed | 순서 보장 없음. 원자성만 보장 | 카운터 등 단순 연산 |
seq_cst (Sequentially Consistent)
- 기본값. 모든 스레드가 “하나의 전체 순서”처럼 보이게 함.
- 가장 강한 보장이지만, 일부 아키텍처에서 가장 비쌈.
- 실전 조언: 처음에는 기본값만 사용. 성능이 중요해진 뒤에 acquire/release를 고려.
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: “이 로드 이후”의 모든 메모리 연산은 이 로드 “이전”으로 재배치되지 않음. → 플래그를 확인한 뒤 데이터를 읽는 패턴.
// producer: 데이터 쓴 뒤 플래그
void producer() {
data = 42; // 일반 변수 (또는 atomic)
ready.store(true, std::memory_order_release); // 이전 쓰기 완료 보장
}
// consumer: 플래그 확인 후 데이터
void consumer() {
while (!ready.load(std::memory_order_acquire)) {}
int x = data; // release와 쌍을 이루므로 42 보장
}
release와 acquire가 쌍을 이루면, release 스토어 “이전”의 쓰기는 acquire 로드 “이후”에 보입니다. 즉, consumer가 ready를 true로 보면 data는 반드시 42입니다.
relaxed
- 순서 보장 없음. 원자성만 보장.
- 카운터처럼 “값만 맞으면 되고, 다른 변수와의 순서는 상관없을 때” 사용.
std::atomic<int> counter{0};
void inc() {
counter.fetch_add(1, std::memory_order_relaxed);
}
주의: relaxed는 다른 변수와의 happens-before 관계를 만들지 않습니다. “플래그 체크 후 데이터 읽기” 같은 패턴에는 acquire/release가 필요합니다.
메모리 장벽 (Memory Barrier)
memory_order는 내부적으로 CPU의 메모리 장벽(barrier) 명령과 연결됩니다.
- seq_cst: full barrier — 모든 로드/스토어가 이 지점에서 정렬됨
- acquire: 이 로드 이후의 읽기·쓰기가 이 로드 앞으로 올라오지 않음 (단방향 장벽)
- release: 이 스토어 이전의 읽기·쓰기가 이 스토어 뒤로 내려가지 않음 (단방향 장벽)
- relaxed: barrier 없음 — 원자성만 보장 x86/AMD64에서는 대부분의 로드/스토어가 이미 순서를 유지하므로, acquire/release가 추가 비용 없이 동작하는 경우가 많습니다. ARM 등 weak memory 모델에서는 차이가 더 큽니다.
memory_order 완전 예제: 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 = 1023*1024 = 1047552 보장
}
release로 저장하면 producer의 모든 쓰기가 이 스토어 이후로 재배치되지 않습니다. acquire로 로드하면 consumer가 이 로드 이후의 읽기가 이 로드 이전으로 재배치되지 않습니다. 따라서 consumer가 data_ready를 true로 보면 shared_data는 반드시 채워진 상태입니다. relaxed를 쓰면 이 보장이 없어 잘못된 값을 읽을 수 있습니다.
memory_order 선택 가이드
// 대부분 이렇게만 써도 충분 (seq_cst)
counter++;
flag.store(true);
// 성능 최적화가 필요할 때만 order 지정
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 문제(5.1)가 겹칩니다.
즉 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)로 충분하고, 직접 작성해도 안전합니다(아래 atomic 활용 패턴 절). - 스레드 하나가 쓰고 하나가 읽는 버퍼라면 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를 읽고 (값 A), CAS로 A→B로 바꾸려 함. 그 사이 스레드 B가 A를 pop하며, 새 노드 C를 push한 뒤, C가 A와 같은 주소를 재사용 (또는 A가 다시 push됨). 스레드 A의 CAS는 “값이 A로 같다”고 판단해 성공하지만, 실제로는 중간에 A→X→A처럼 바뀐 상태. 해결: 버전/카운터를 함께 CAS에 포함시키거나, hazard pointer로 노드가 안전하게 재사용될 때까지 보호.
// ABA 완화 예: 포인터 + 버전을 묶어서 CAS
struct NodePtr {
Node* ptr;
uintptr_t version;
};
std::atomic<NodePtr> head;
bool pop(T& result) {
NodePtr old_head = head.load();
while (old_head.ptr) {
NodePtr 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;
}
version을 매 CAS마다 증가시키면, 같은 포인터라도 버전이 다르면 CAS가 실패합니다. 이렇게 ABA를 완화할 수 있습니다. 다만 old_head.ptr->next를 읽는 순간 다른 스레드가 그 노드를 이미 delete했을 수 있다는 use-after-free 문제는 버전으로 막을 수 없으므로, 완전한 해결에는 hazard pointer 같은 메모리 회수 기법이 필요합니다.
64비트 시스템에서 NodePtr는 16바이트이므로 16바이트 CAS 명령어(x86-64의 cmpxchg16b)가 있어야 lock-free로 동작합니다. GCC에서는 -mcx16 옵션이 필요하고, 조건이 맞지 않으면 libatomic의 락 기반 구현으로 바뀌어 is_lock_free()가 false를 돌려주거나 undefined reference to '__atomic_compare_exchange_16' 링크 에러가 납니다. 이때는 -latomic을 추가해야 합니다.
메모리 순서 오류
잘못된 예: relaxed로 플래그만 바꾸고, 데이터는 일반 변수로 쓸 때. consumer가 플래그를 보고 데이터를 읽어도, 재배치 때문에 이전 값을 볼 수 있음.
// ❌ 잘못된 예
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 보장
}
volatile과 혼동
volatile은 컴파일러 최적화만 제한할 뿐, 스레드 간 동기화를 보장하지 않습니다. atomic이나 mutex를 써야 합니다.
// ❌ volatile — 스레드 안전 아님
volatile int counter = 0;
counter++; // data race!
// ✅ atomic
std::atomic<int> counter{0};
counter++;
CAS 루프에서 expected 갱신 누락
compare_exchange_weak/strong 실패 시 expected가 현재 값으로 갱신됩니다. 루프에서 이 갱신을 활용하지 않으면 무한 루프에 빠집니다.
// ❌ 잘못된 예: expected를 고정값으로 유지 — value가 2,3,4...이면 영원히 실패
void wrong_cas() {
int expected = 0;
while (!value.compare_exchange_weak(expected, 1)) {}
}
// ✅ 올바른 예: expected가 자동 갱신됨
void correct_cas() {
int expected = value.load();
while (!value.compare_exchange_weak(expected, expected + 1)) {}
}
여러 변수의 일관성 — atomic만으로 부족
atomic은 해당 변수 하나에 대한 원자성만 보장합니다. 여러 변수를 “한 번에” 바꿔야 할 때는 mutex가 필요합니다.
// ❌ 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;
}
Data Race 감지: ThreadSanitizer 활용
실수로 atomic을 빼먹었을 때 ThreadSanitizer(TSan)로 data race를 감지할 수 있습니다.
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;
}
TSan을 사용하면 “WARNING: ThreadSanitizer: data race” 메시지와 함께 문제 위치를 알려줍니다. std::atomic<int> counter{0}으로 바꾸면 경고가 사라집니다.
atomic 포인터와 포인터가 가리키는 데이터
std::atomic<T*>는 포인터 값만 원자적으로 만듭니다. 포인터가 가리키는 객체의 내용은 별도 동기화가 필요합니다.
// atomic은 ptr 자체만 보호
std::atomic<Node*> ptr{nullptr};
void bad_use() {
Node* p = ptr.load();
if (p) {
p->data = 42; // ❌ ptr는 원자적이지만, p->data 접근은 data race 가능
}
}
// ✅ 포인터 로드 후 객체 사용 시 별도 동기화 필요 (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::high_resolution_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::high_resolution_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::high_resolution_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::high_resolution_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) 스레드를 늘려도 전체 처리량이 거의 오르지 않습니다. 이런 고빈도 카운터라면 6.5의 팁 3처럼 스레드별로 세고 나중에 합치는 방식이 atomic 하나를 공유하는 것보다 훨씬 빠른 경우가 많습니다.
왜 atomic이 더 빠른가?
- 락 대기 비용 없음: 경쟁이 없을 때는 mutex도 커널에 들어가지 않습니다(Linux의 futex 기반 구현). 시스템 콜과 컨텍스트 스위치가 생기는 것은 락을 기다리는 스레드를 재우고 깨울 때입니다. atomic은 이 대기 경로 자체가 없습니다.
- 캐시 라인 수준 동기화: atomic은 해당 변수의 캐시 라인만 invalidate하며, mutex는 락 변수 + 보호 데이터 전체에 영향을 줄 수 있음.
- 경합 시 동작: mutex는 한 스레드만 진행하고 나머지는 대기. atomic은 CAS 등으로 여러 스레드가 “재시도”하며 진행할 수 있어, 경합이 적을 때 유리.
memory_order별 성능 차이
일반적으로 seq_cst ≥ acq_rel ≥ acquire/release ≥ relaxed 순으로 비용이 낮아집니다. x86에서는 차이가 작지만, ARM에서는 relaxed가 눈에 띄게 빠를 수 있습니다. 정확한 측정은 대상 플랫폼에서 벤치마크하는 것이 좋습니다.
정리: 단일 변수에 대한 고빈도 연산, 특히 경쟁이 있는 상황에서는 atomic이 mutex보다 유리한 경우가 많지만 배수는 환경마다 다릅니다. 여러 변수를 한 번에 보호해야 하면 mutex가 맞으며, atomic은 “변수 하나”에 집중할 때 유리합니다.
성능 최적화 팁
팁 1: 캐시 라인 분리 (False Sharing 방지)
여러 스레드가 서로 다른 atomic 변수를 자주 갱신할 때, 같은 캐시 라인에 있으면 한 스레드의 쓰기가 다른 스레드의 캐시를 invalidate해 성능이 떨어집니다. 캐시 라인(보통 64바이트) 경계에 맞춰 패딩을 넣어 분리합니다.
// ❌ 같은 캐시 라인 — false sharing
struct Bad { std::atomic<int> c1{0}; std::atomic<int> c2{0}; };
// ✅ alignas(64)로 캐시 라인 분리
struct alignas(64) Good {
std::atomic<int> c1{0};
char pad[64 - sizeof(std::atomic<int>)];
std::atomic<int> c2{0};
};
팁 2: relaxed 사용 (순서가 필요 없을 때)
카운터, 통계, 모니터링용 변수처럼 “다른 변수와의 순서”가 필요 없으면 memory_order_relaxed를 사용합니다. ARM 등 weak memory 모델에서는 seq_cst 대비 눈에 띄게 빠를 수 있습니다.
std::atomic<uint64_t> request_count{0};
void on_request() {
request_count.fetch_add(1, std::memory_order_relaxed);
}
팁 3: 로컬 캐싱으로 atomic 접근 감소
여러 번 증가시킬 때 로컬 변수에 모았다가 주기적으로 atomic에 반영합니다.
thread_local int local_count = 0;
constexpr int BATCH = 100;
void process_requests() {
for (auto& req : requests) {
process(req);
if (++local_count >= BATCH) {
total_count.fetch_add(local_count, std::memory_order_relaxed);
local_count = 0;
}
}
total_count.fetch_add(local_count, std::memory_order_relaxed);
}
팁 4: fetch_add vs CAS 루프
단순 증가/감소는 fetch_add가 CAS 루프보다 효율적입니다. CAS는 “조건부 업데이트”가 필요할 때만 사용합니다.
// ✅ 권장: fetch_add
counter.fetch_add(1, std::memory_order_relaxed);
// ❌ 비권장: CAS로 증가 (fetch_add가 더 나음)
int old = counter.load();
while (!counter.compare_exchange_weak(old, old + 1)) {}
팁 5: is_lock_free 확인
std::atomic이 내부적으로 락을 사용하는지 확인합니다. is_lock_free()가 false이면 mutex 기반 fallback이므로, 해당 타입은 atomic 대신 mutex를 고려하는 것이 좋습니다.
std::atomic<MyStruct> s;
if (!s.is_lock_free()) {
// MyStruct는 락으로 보호됨 — mutex 직접 사용이 나을 수 있음
}
고빈도 카운터, 준비 플래그, 스핀락: atomic 활용 패턴
고빈도 카운터
// 요청 수, 에러 수 등
std::atomic<uint64_t> request_count{0};
std::atomic<uint64_t> error_count{0};
void on_request() {
request_count.fetch_add(1, std::memory_order_relaxed);
}
void on_error() {
error_count.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);
}
한 번만 실행 (Initialization)
std::atomic<bool> initialized{false};
void init_once() {
if (!initialized.exchange(true)) {
do_heavy_init();
}
}
exchange(true)는 “false였으면 true로 바꾸고 false 반환, 이미 true였으면 true 반환”입니다. 따라서 한 스레드만 do_heavy_init()을 실행하며, 나머지는 스킵합니다.
Lock-free 알고리즘 선택 가이드
| 상황 | 권장 |
|---|---|
| 단일 변수 (카운터, 플래그) | std::atomic |
| SPSC 큐 | 원형 버퍼 + atomic 인덱스 |
| MPMC 큐 | boost::lockfree::queue 또는 표준 라이브러리 |
| 복잡한 불변식 | mutex 우선, 필요 시 lock-free 검토 |
Shutdown 플래그
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);
}
acquire/release를 쓰면, worker가 shutdown_requested를 true로 보기 전에 수행한 do_work()의 모든 부수 효과가 request_shutdown() 호출 이전에 완료된 것처럼 보입니다. seq_cst보다 가볍으며, 이 패턴에는 충분합니다.
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 로드가 완전히 초기화된 객체를 보게 됩니다. 락 안의 두 번째 load가 relaxed여도 되는 것은 mutex가 이미 동기화를 제공하기 때문입니다.
C++11 이후에는 이 코드를 직접 쓸 이유가 거의 없습니다. 표준이 함수 지역 static 변수의 초기화를 스레드 안전하게 보장하므로 static MyClass& get_instance() { static MyClass instance; return instance; }만으로 같은 효과를 얻고, new로 만든 객체가 해제되지 않는 문제도 없습니다. 초기화 시점을 따로 제어해야 한다면 std::call_once를 씁니다.
모니터링과 메트릭 수집
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);
}
};
API 서버에서 요청 수, 에러 수를 atomic으로 수집합니다. relaxed로 충분하며, 모니터링 목적에 적합합니다.
토큰 버킷 Rate Limiter
#include <atomic>
class TokenBucket {
std::atomic<int64_t> tokens;
int64_t max_tokens;
public:
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은 CAS로 토큰을 1 감소시키고, 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) — 여러 스레드가 동시에 호출해도 한 스레드만 성공
상태 머신에서 “특정 상태에서만 전이”할 때 CAS를 사용합니다.
스핀락: 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 카운터로 바쁜 대기(busy wait)를 하지 말고 condition_variable(#7-3)로 소비자 스레드를 재워야 작업이 없는 동안 CPU를 쓰지 않습니다.
atomic vs mutex: 언제 무엇을 쓸까
- 한두 개의 단순 변수(카운터, bool 플래그 등)만 보호할 때 → atomic이 적합. 락 없이 원자적 연산만으로 data race를 없앨 수 있으며, 경합이 적을 때 유리합니다.
- 여러 변수를 한 번에, 또는 복잡한 조건/구조를 일관되게 유지해야 할 때 → mutex가 적합. atomic만으로는 “A를 바꾼 다음 B를 바꾼다”를 다른 스레드에 하나의 단위로 보장하기 어렵습니다.
- 큐, 맵, 리스트 같은 자료 구조 전체를 보호할 때 → mutex(또는 락 프리 구조 + atomic 조합). 단일 atomic 변수만으로는 표현이 불가능합니다. 정리하면: 단일 변수·단순 연산은 atomic, 그 이상은 mutex를 먼저 고려하면 됩니다.
atomic이 보호하지 않는 것: 다른 변수, 복사, 큰 타입
atomic은 “그 변수만” 보호한다
std::atomic<int> a는 a에 대한 접근만 원자적으로 만듭니다.
다른 변수 b, c와의 “같은 시점에 같이 바뀌는” 관계는 atomic만으로는 보장되지 않습니다.
여러 변수의 일관성이 필요하면 mutex를 쓰세요.
volatile과 혼동하지 말 것
volatile은 컴파일러의 최적화(제거·재배치)만 제한할 뿐, 스레드 간 동기화를 보장하지 않습니다.
멀티스레드에서 공유 변수를 보호하려면 atomic 또는 mutex를 사용해야 합니다.
생성·복사·대입
std::atomic은 복사 생성·복사 대입이 삭제되어 있습니다.
값을 넘기려면 load()로 읽어서 복사하거나, store()로 써야 합니다.
atomic 타입별 지원 연산
| 타입 | load/store | fetch_add/sub | CAS | exchange |
|---|---|---|---|---|
atomic<int> | ✅ | ✅ | ✅ | ✅ |
atomic<bool> | ✅ | ❌ | ✅ | ✅ |
atomic<T*> | ✅ | fetch_add (offset) | ✅ | ✅ |
atomic<struct> (trivially copyable) | ✅ | ❌ | ✅ | ✅ |
atomic<bool>에는 fetch_add가 없습니다. atomic<T*>의 fetch_add는 포인터 오프셋(요소 크기 단위)을 더합니다. trivially copyable한 구조체는 크기와 상관없이 std::atomic으로 쓸 수 있고 load/store/exchange/CAS를 지원하지만, 아래처럼 lock-free가 아닐 수 있습니다.
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); // 플랫폼·옵션(-mcx16)에 따라 실패
}
400바이트짜리 Large는 한 번에 원자적으로 바꿀 CPU 명령어가 없으므로, 표준 라이브러리가 락으로 원자성을 흉내 냅니다. is_lock_free()는 실행 시점에, C++17의 is_always_lock_free는 컴파일 시점에 이를 알려 줍니다. GCC에서는 이런 타입을 쓰려면 -latomic 링크가 필요할 수 있습니다.
lock-free가 아닌 atomic은 구현 내부에서 주소를 해시해 전역 락 테이블의 락을 잡는 방식으로 동작합니다. 여전히 원자적이고 올바르지만 “락 없이 빠르게”라는 목적은 사라지고, 서로 무관한 두 atomic 객체가 같은 락을 공유해 불필요하게 경쟁할 수도 있어서 성능이 직접 쓴 mutex와 비슷하거나 더 나쁠 수 있습니다. 또 시그널 핸들러 안에서는 lock-free atomic만 안전하게 쓸 수 있습니다. lock-free 구조를 설계한다면 핵심 타입에 static_assert(std::atomic<T>::is_always_lock_free);를 걸어 두면, 다른 플랫폼으로 옮겼을 때 조용히 락 기반으로 바뀌는 일을 빌드 단계에서 잡을 수 있습니다.
atomic 도입 전 체크리스트
- 단일 변수만 보호하는지 확인 (여러 변수면 mutex)
- 플래그+데이터 패턴에서는 release/acquire 쌍 사용
- 순수 카운터는 relaxed 고려
- lock-free 구조 구현 시 ABA 문제 대비
- volatile 대신 atomic 사용
같이 보면 좋은 글
- C++ std::thread 입문 | join 누락·디태치 남용 등 자주 하는 실수 3가지와 해결법
- C++ mutex로 race condition 해결하기 | 주문 카운터 버그부터 lock_guard까지
- C++ 실전 가이드 시리즈 전체 목차 | #0~#49 기초·메모리·네트워크·면접
- C++ 고급 멀티스레딩 | 스레드 풀·Work Stealing
- C++ condition_variable 실무 패턴
- C++ 스택 vs 힙 | 재귀에서 프로그램이 죽는 이유와 스택 오버플로우 사례
시리즈 마무리
#7-1에서 스레드 생성과 join/detach, #7-2에서 mutex와 락, #7-3에서 condition_variable과 producer-consumer, #7-4에서 atomic까지 다뤘습니다. 이 네 편이면 실무에서 자주 쓰는 멀티스레딩 패턴의 기초를 갖출 수 있습니다.
다음 권장: 동시성 유틸을 더 쓰고 싶다면 std::async, std::future, 스레드 풀 등을 다루는 글로 이어가면 좋습니다. 현재 시리즈에서는 여기까지가 멀티스레딩 기초 묶음의 마지막 편입니다.
자주 묻는 질문 (FAQ)
Q. atomic 변수 두 개를 각각 갱신하면 왜 일관성이 깨질 수 있나요?
A. 각 atomic 연산은 그 변수 하나에 대해서만 원자적이므로, 예를 들어 head와 size를 따로 갱신하면 다른 스레드가 두 연산 사이에 끼어들어 한쪽만 바뀐 중간 상태를 볼 수 있습니다. 두 변수 사이의 불변식은 atomic만으로 보호되지 않는다는 것이 본문 5.5의 핵심입니다. 여러 값을 함께 바꿔야 한다면 mutex로 묶거나, 값을 담은 불변 객체를 새로 만들어 포인터 하나만 atomic하게 교체하거나, 작은 값이라면 하나의 정수에 비트로 묶어 atomic 하나로 다루는 방법이 있습니다.