80 쪼개지지 않는 연산 — <stdatomic.h>
먼저 알아야 할 것
돌아보기
12장에서 CPU가 한 박자에 여러 일을 하고 순서까지 바꿔 실행한다고 했고, 11장에서는 코어마다 자기 캐시를 들고 있다고 했다. 그러면 두 갈래가 같은 변수를 동시에 1씩 늘리면 — 결과는 2인가?
답. 아닐 수 있다. x = x + 1은 기계에게 한 걸음이 아니라 세 걸음(읽고, 더하고, 쓰고)이고, 두 갈래가 이 세 걸음을 서로 끼워 넣으면 둘이 같은 값을 읽어 같은 값을 쓴다 — 하나가 통째로 사라진다. 게다가 코어마다 캐시가 달라서 “썼는데 상대에게 안 보이는” 시차까지 겹친다. 이 장의 첫 예제가 그 손실을 실제로 세어 보인다.
이 장의 필요성과 맥락
이 장이 끝나면
volatile이 왜 답이 아닌지, 그리고 메모리 순서(memory order)라는 어려운 손잡이를 언제 건드리고 언제 두어야 하는지까지.이 장에서 답할 질문
compare_exchange의 strong과 weak은 무엇이 다른가? 왜 “헛되이 실패” 하는 판이 따로 있는가?
80.1 잃어버린 갱신 — 눈으로 확인한다
examples/ch80/atomic.c
/* 경쟁 상태와 원자적 연산 — 같은 프로그램을 두 번 돌린다. */
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>
#define THREADS 4
#define BUMPS 200000
static long plain; /* 그냥 long — 보호 없음 */
static atomic_long safe; /* 원자적 long */
static int worker(void *unused)
{
(void)unused;
for (int i = 0; i < BUMPS; i++) {
plain = plain + 1; /* 읽고-더하고-쓰기: 쪼개진다 */
atomic_fetch_add(&safe, 1); /* 한 덩어리로 일어난다 */
}
return 0;
}
int main(void)
{
thrd_t t[THREADS];
for (int i = 0; i < THREADS; i++)
thrd_create(&t[i], worker, NULL);
for (int i = 0; i < THREADS; i++)
thrd_join(t[i], NULL);
long expected = (long)THREADS * BUMPS;
printf("expected : %ld\n", expected);
printf("unprotected: %ld%s\n", plain, plain == expected ? "" : " <- we lost some");
printf("atomic : %ld\n", (long)safe);
printf("\nis atomic_long lock-free? %s\n",
atomic_is_lock_free(&safe) ? "yes" : "no");
return 0;
}
실행 결과
expected : 800000
unprotected: 288229 <- we lost some
atomic : 800000
is atomic_long lock-free? yes
같은 루프를 네 갈래가 20만 번씩 돌았다. 기대한 값은 80만인데, 보호 없는 long은 그 근처에도 못 갔다. 사라진 몫은 실행할 때마다 다르다 — 이 비결정성이야말로 이 부류 버그의 성격이다. 재현되지 않는 손실.
원자적(atomic) 변수 쪽은 언제나 정확히 80만이다. atomic_fetch_add가 “읽고-더하고-쓰기”를 쪼개질 수 없는 한 덩어리로 수행하기 때문이다. 원자(原子)라는 이름이 그 뜻이다 — 더 이상 쪼개지지 않는 것.
흔한 오해. “경쟁 상태는 값이 가끔 어긋나는, 확률적인 성능 문제다”
두 겹으로 틀렸다. 첫째, 값이 어긋나는 것이 아니라 계약이 깨진다. C11 이후의 표준은 이렇게 정한다 — 두 갈래가 같은 기억을 동시에 만지고 그중 하나라도 쓰기이며 원자적이지도 순서 지어지지도 않았다면, 그것은 데이터 경쟁이고 프로그램 전체가 정의되지 않은 동작이다(52장). “값이 하나 틀리는” 정도로 끝난다는 보장이 없다는 뜻이다.
둘째, 컴파일러가 이 전제 위에서 최적화한다. 경쟁이 없다고 가정하고 변수를 레지스터에 올려 두면, 상대가 아무리 값을 바꿔도 이쪽 루프는 영원히 옛 값만 본다. 실제로 “디버그 빌드에서는 되는데 릴리스에서 멈추는” 무한 루프의 흔한 정체가 이것이다(17장의 릴리스 전용 버그가 여기서 재현된다).
80.2 volatile은 왜 답이 아닌가
C를 오래 쓴 코드에서 volatile int flag;로 갈래 사이 신호를 주고받는 관행을 자주 본다. 지금은 틀린 관행이다. volatile이 약속하는 것은 하나 뿐이다 — 컴파일러가 이 접근을 없애거나 합치지 않는다. 52장에서 볼 대로 그 목적은 하드웨어 레지스터(값이 바깥에서 바뀌는 자리)였다.
volatile이 약속하지 않는 것이 둘이다. 원자성 — volatile long x;의 x++도 여전히 세 걸음이라 쪼개진다. 그리고 순서와 가시성 — CPU가 쓰기 순서를 바꾸거나 다른 코어에 늦게 보이는 것을 막지 못한다.
| 쪼개지지 않음 | 코어 사이 가시성 | 재배열 차단 | |
|---|---|---|---|
volatile | 아니오 | 아니오 | 아니오(다른 접근 대비) |
_Atomic | 예 | 예 | 예(선택한 순서만큼) |
| 뮤텍스 | 예(구간 전체) | 예 | 예 |
표 80.1
정리하면 이렇다. 하드웨어 레지스터에는 volatile, 갈래 사이 공유에는 원자적 타입이나 뮤텍스. 자바나 C#의 volatile은 이름만 같고 의미가 달라서(그쪽은 실제로 가시성을 보장한다), 그 언어의 습관을 C로 들여오는 것이 흔한 사고의 통로다.
80.3 원자적 타입과 연산
쓰는 법은 단순하다. 타입 앞에 _Atomic을 붙이거나, 헤더가 주는 이름 (atomic_int, atomic_long, atomic_bool, atomic_size_t …)을 쓴다.
#include <stdatomic.h>
atomic_int counter = 0; /* = _Atomic int */
_Atomic long total;연산은 함수(또는 그 이름의 매크로)로 부른다. 평범한 연산자(++, +=)를 써도 원자적으로 동작하지만, 원자적이라는 사실이 코드에 보이지 않아 읽는 사람이 놓치기 쉬우므로 명시적 함수를 권한다.
| 연산 | 하는 일 | 쓰는 자리 |
|---|---|---|
atomic_load | 읽기 | 현재 값 보기 |
atomic_store | 쓰기 | 값 설정 |
atomic_fetch_add·_sub | 더하고 이전 값 반환 | 카운터·통계 |
atomic_fetch_or·_and·_xor | 비트 조작 | 플래그 집합 |
atomic_exchange | 바꾸고 이전 값 반환 | 자리 교체 |
atomic_compare_exchange_strong | 기대값과 같을 때만 바꾼다(CAS) | 잠금 없는 자료구조 |
atomic_compare_exchange_weak | 같지만 헛되이 실패할 수 있다 | 루프 안에서 |
atomic_flag_test_and_set | 가장 원시적인 시험-설정 | 스핀락 |
표 80.2
fetch_add가 이전 값을 돌려준다는 점이 자주 헷갈리는 자리다. “몇 번째 인지”를 얻고 싶으면 반환값을 쓰고, “지금 얼마인지”를 알고 싶으면 반환값에 더한 값이거나 별도의 atomic_load다 — 그런데 후자는 그사이 또 바뀔 수 있다.
반례. 원자적 연산을 여러 번 부르면 그 묶음도 원자적이라고 믿기
if (atomic_load(&count) < LIMIT) /* ① 읽고 */
atomic_fetch_add(&count, 1); /* ② 더한다 — 그사이 남이 끼어든다 */①과 ② 사이에 다른 갈래가 값을 올리면 한도를 넘긴다. 원자성은 연산 하나의 성질이지 구간의 성질이 아니다. 구간을 지키려면 CAS 루프로 묶거나 뮤텍스를 쓴다.
int cur = atomic_load(&count);
do {
if (cur >= LIMIT) break; /* 한도 검사와 갱신을 한 덩어리로 */
} while (!atomic_compare_exchange_weak(&count, &cur, cur + 1));실패하면 cur에 현재 값이 담겨 돌아온다는 것이 이 관용구의 핵심이다. 그래서 루프 안에서 다시 읽을 필요가 없다.
문. compare_exchange의 strong과 weak은 무엇이 다른가? 왜 “헛되이 실패” 하는 판이 따로 있는가?
답. 일부 CPU(ARM 계열 등)는 CAS를 “예약하고-나중에-조건부로 쓰기”라는 두 명령 쌍으로 구현한다. 그사이 인터럽트나 캐시 사정으로 예약이 풀리면, 값이 기대와 같았는데도 실패로 돌아온다 — 이것이 가짜 실패(spurious failure)다. weak은 이 실패를 그대로 노출하는 대신 더 빠르고, strong은 내부에서 다시 시도해 “값이 달랐을 때만 실패”를 보장하는 대신 조금 느리다.
규칙은 간단하다. 루프 안에서는 weak, 루프 없이 한 번만 시도할 때는 strong. 어차피 루프가 다시 돌 것이므로 가짜 실패가 해롭지 않고, 그 대가로 이득만 남는다.
80.4 메모리 순서 — 건드리지 않는 것이 기본
atomic_fetch_add(&x, 1)처럼 순서를 지정하지 않으면 가장 강한 순서인 순차 일관성(memory_order_seq_cst)이 쓰인다. 모든 갈래가 원자적 연산의 순서를 하나의 일관된 이야기로 본다는 뜻이고, 사람이 추론하기 가장 쉬운 모형이다. 대신 가장 비싸다.
C는 여섯 가지 순서를 제공한다. 표로 얼굴만 익힌다 — 대부분의 프로그램은 기본값만 쓰면 된다.
| 순서 | 보장 | 쓰는 자리 |
|---|---|---|
seq_cst | 전역적으로 하나의 순서 | 기본값. 의심스러우면 이것 |
acquire | 이후의 접근이 위로 못 올라감 | 잠금 얻기·소비자 쪽 읽기 |
release | 이전의 접근이 아래로 못 내려감 | 잠금 놓기·생산자 쪽 쓰기 |
acq_rel | 읽고-쓰기 연산에서 둘 다 | CAS 로 상태 전이 |
relaxed | 원자성만. 순서 보장 없음 | 순수 카운터·통계 |
consume | 사실상 폐기(구현이 acquire 로 격상) | 쓰지 않는다 |
표 80.3
가장 흔한 실전 쓰임은 두 가지다. 첫째, 생산자-소비자의 깃발: 데이터를 채운 뒤 release로 깃발을 세우고, 소비자는 acquire로 깃발을 본 뒤 데이터를 읽는다. 이 짝이 “깃발이 보이면 데이터도 보인다”를 보장 한다. 둘째, 순수 통계 카운터: 값의 최종 합만 맞으면 되고 다른 데이터와 순서를 맞출 필요가 없으므로 relaxed가 정확히 맞는 도구다.
반례. 성능을 위해 일단 relaxed로 바꿔 보기
atomic_store_explicit(&ready, 1, memory_order_relaxed); /* 깃발 */relaxed는 이 변수의 원자성만 보장한다. 앞에서 채운 데이터가 상대에게 보인다는 보장이 없으므로, 소비자는 깃발이 선 것을 보고도 채워지기 전의 데이터를 읽을 수 있다. 이런 코드는 x86에서는 대개 멀쩡히 돌다가 ARM 기기에서 재현 불가능한 버그로 나타난다 — 하드웨어마다 허용하는 재배열이 다르기 때문이다.
규칙: 깃발과 데이터가 짝을 이루면 release/acquire. 그 짝을 이해하기 전에는 기본값을 그대로 둔다. 여기서 아끼는 나노초보다 잃는 시간이 훨씬 크다.
80.5 잠금 없음(lock-free)이라는 말
예제 마지막 줄이 atomic_is_lock_free로 물은 것이 그것이다. 원자적 타입이 CPU 명령 하나로 처리되면 잠금 없음이고, 그렇지 않으면 라이브러리가 뒤에서 숨은 잠금을 쓴다. 오늘의 주류 기계에서 포인터 크기 이하의 정수는 대개 잠금 없이 된다. 반면 큰 구조체를 _Atomic으로 감싸면 — 문법은 통과하지만 — 숨은 잠금이 붙어 성능이 뜻밖에 나빠질 수 있다.
흔한 오해. “원자적 연산은 뮤텍스보다 항상 빠르다”
경합이 낮을 때는 대개 맞지만, 경합이 심하면 뒤집힌다. 여러 코어가 같은 캐시 라인을 두고 다투면 그 라인이 코어 사이를 계속 오간다(11장의 거짓 공유가 여기서 재현된다). CAS 루프는 실패할 때마다 다시 도는데, 경합이 심하면 이 재시도가 통째로 낭비다 — 뮤텍스는 실패한 갈래를 재우지만 스핀은 코어를 태운다.
그리고 잠금 없는 자료구조를 직접 짜는 것은 난도가 다른 일이다. ABA 문제(값이 A에서 B로 갔다 A로 돌아와 CAS가 속는 것), 메모리 회수 시점, 진행 보장 — 논문 한 편짜리 주제들이다. 실무의 정답은 대개 이렇다: 카운터와 깃발에는 원자적 타입, 자료구조에는 검증된 라이브러리나 뮤텍스.
80.6 쓸 자리와 쓰지 않을 자리
| 상황 | 권하는 도구 | 이유 |
|---|---|---|
| 통계 카운터 | atomic_fetch_add(relaxed) | 순서가 필요 없다 |
| 종료 요청 깃발 | atomic_bool + release/acquire | 데이터와 짝을 이룬다 |
| 한 번만 초기화 | call_once(<threads.h>) 또는 CAS | 이중 검사 잠금은 손으로 짜지 않는다 |
| 여러 값의 불변식 | 뮤텍스 | 원자성은 변수 하나 단위다 |
| 큰 구조체 공유 | 뮤텍스 | _Atomic 구조체는 숨은 잠금 |
| 신호 처리기와의 공유 | sig_atomic_t 또는 잠금 없는 원자적 타입 | 75장의 제약 |
| 하드웨어 레지스터 | volatile | 갈래 사이 공유가 아니다 |
표 80.4
실제 사례. C11이 메모리 모델을 들여온 이유
C11 이전의 표준에는 갈래가 여럿이라는 개념 자체가 없었다. 스레드는 라이브러리(POSIX 스레드 등)의 일이었고, 언어는 “프로그램이 한 줄기로 흐른다”는 전제 위에서 최적화를 정의했다. 그 틈에서 오랫동안 아무도 정확히 답할 수 없는 질문들이 쌓였다 — 컴파일러가 이 쓰기를 다른 갈래가 본다고 가정해야 하는가, 이 재배열은 합법인가, 뮤텍스 없는 이 코드는 틀린 것인가.
2004년경 자바가 먼저 메모리 모델을 정비했고, C++11과 C11이 그 흐름을 이어 언어 차원의 메모리 모델을 도입했다. 이때 들어온 것이 데이터 경쟁의 정의, 원자적 타입, 그리고 여섯 가지 메모리 순서다. 오늘 우리가 “데이터 경쟁은 정의되지 않은 동작”이라고 한 줄로 말할 수 있는 것은 이 정비 덕분이다 — 그 전에는 그 문장을 적을 언어조차 없었다.
복습 정리
| 기억할 것 | 요지 |
|---|---|
| 데이터 경쟁 | 느림이 아니라 계약 밖. 최적화가 코드를 바꾼다 |
volatile | 갈래 사이 공유의 도구가 아니다. 하드웨어용 |
| 기본 순서 | seq_cst. 대부분 그대로 두는 것이 정답 |
fetch_add | 이전 값을 돌려준다 |
| 연산 두 번 | 묶어도 원자적이 아니다 — CAS 루프나 뮤텍스 |
weak/strong | 루프 안이면 weak |
| 잠금 없음 | 작은 정수는 대개 예. 큰 구조체는 숨은 잠금 |
| 자료구조 | 직접 짜지 않는다 |
표 80.5
쪼개지지 않는 연산을 보았다. 다음 장은 정반대 방향의 도구다 — 연산이 그릇을 넘쳤는지를 계약대로 물어보는, C23의 검사 산술이다.