원자 타입(Atomic types)(C11)
원자 타입(Atomic types)(C11)
여러 스레드가 같은 객체를 동시에 읽고 쓰다 보면 데이터 경쟁(data race)이 생겨요. 그런 문제를 피하려고 C는 **원자 타입(atomic type)**을 제공해요. 원자 타입 객체는 데이터 경쟁으로부터 자유로운 유일한 객체고, 스레드 간 동기화의 기초가 되죠.
출처: cppreference
본문
문법
_Atomic ( type-name ) |
(1) | (C11부터) |
_Atomic type-name |
(2) | (C11부터) |
- 타입 지정자(type specifier)로 사용. 새로운 원자 타입을 지정해요.
- 타입 한정자(type qualifier)로 사용. type-name의 원자 버전을 지정해요. 이 역할에서는
const,volatile,restrict와 함께 섞어 쓸 수 있는데, 다른 한정자와 달리 type-name의 원자 버전은 크기·정렬·객체 표현이 달라질 수 있어요.
| 자리 | 의미 |
|---|---|
type-name |
배열이나 함수가 아닌 모든 타입. (1)의 경우 type-name은 원자 타입이거나 cvr-한정 타입일 수도 없어요. |
<stdatomic.h> 헤더는 atomic_bool부터 atomic_uintmax_t까지 많은 편의 타입 별칭을 정의해요. 이 별칭들은 키워드를 내장·라이브러리 타입과 함께 쓸 때 훨씬 쓰기 쉽게 만들어 주죠.
_Atomic const int* p1; // p is a pointer to an atomic const int
const atomic_int* p2; // same
const _Atomic(int)* p3; // same
컴파일러가 매크로 상수 __STDC_NO_ATOMICS__를 정의한다면 _Atomic 키워드는 제공되지 않아요.
동작 방식(Explanation)
원자 타입의 객체는 데이터 경쟁으로부터 자유로운 유일한 객체예요. 즉 두 스레드가 동시에 수정하거나, 한 스레드가 수정하고 다른 스레드가 읽는 것이 허용돼요.
각 원자 객체에는 그 자신의 수정 순서(modification order)가 있어요. 수정 순서는 그 객체에 가해진 수정들의 전체 순서(total order)예요. 어떤 스레드의 관점에서 어떤 원자 M의 수정 A가 같은 원자 M의 수정 B보다 happens-before라면, M의 수정 순서에서 A는 B보다 먼저 나타나요.
각 원자 객체가 자기 자신의 수정 순서를 가진다고 해서 단일한 전체 순서가 존재하는 건 아니라는 점을 주의하세요. 서로 다른 스레드는 서로 다른 원자 객체에 대한 수정을 서로 다른 순서로 관찰할 수 있어요.
모든 원자 연산에 보장되는 일관성(coherence) 종류는 네 가지예요.
- write-write 일관성: 원자 객체
M을 수정하는 연산A가M을 수정하는 연산B보다 happens-before라면,M의 수정 순서에서A가B보다 먼저 나타나요. - read-read 일관성: 원자 객체
M의 값 계산A가M의 값 계산B보다 먼저이고,A가M에 대한 부수 효과(side effect)X에서 값을 가져온다면,B가 계산하는 값은X가 저장한 값이거나,M에 대한 또 다른 부수 효과Y가 저장한 값이에요. 이때Y는M의 수정 순서에서X보다 나중에 나타나요. - read-write 일관성: 원자 객체
M의 값 계산A가M에 대한 연산B보다 happens-before라면,A는M의 수정 순서에서B보다 먼저 나타나는 부수 효과X에서 값을 가져와요. - write-read 일관성: 원자 객체
M에 대한 부수 효과X가M의 값 계산B보다 happens-before라면,B의 평가는X의 값이거나,M의 수정 순서에서X보다 나중에 나타나는 부수 효과Y에서 값을 가져와요.
일부 원자 연산은 동기화 연산(synchronization operation)이기도 해요. 여기에는 추가로 release 의미론, acquire 의미론, 또는 순차 일관(sequentially-consistent) 의미론이 붙을 수 있어요. 자세한 내용은 memory_order를 참고하세요.
내장 증감 연산자와 복합 대입 연산자는 완전히 순차 일관 순서(마치 memory_order_seq_cst를 쓰는 것처럼)를 가진 read-modify-write 원자 연산이에요. 더 느슨한 동기화 의미론이 필요하다면 표준 라이브러리 함수를 대신 쓸 수 있어요.
원자 속성은 오로지 lvalue 표현식에만 의미가 있어요. lvalue-to-rvalue 변환(원자 위치에서 CPU 레지스터로의 메모리 읽기를 모델링)은 다른 한정자들과 함께 원자성을 벗겨 내요.
주의할 점(Notes)
원자 struct/union의 멤버에 접근하는 것은 미정의 동작(undefined behavior)이에요.
라이브러리 타입 sig_atomic_t는 스레드 간 동기화나 메모리 순서를 제공하지 않아요. 그저 원자성만 제공할 뿐이죠.
volatile 타입도 스레드 간 동기화·메모리 순서·원자성을 제공하지 않아요.
구현들은 가능한 모든 타입 T에 대해 C의 _Atomic(T) 표현이 C++의 std::atomic<T> 표현과 동일하도록 보장하는 것이 권장돼요. 원자성과 메모리 순서를 보장하는 메커니즘도 호환돼야 하죠.
키워드(Keywords)
_Atomic
예제
원자 카운터와 일반 카운터를 열 개의 스레드가 동시에 천 번씩 증가시키는 코드예요. 원자 카운터는 정확히 10000이 되지만, 일반 카운터는 데이터 경쟁 때문에 그보다 작은 값이 나오는 경우가 많아요.
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>
atomic_int acnt;
int cnt;
int f(void* thr_data)
{
for (int n = 0; n < 1000; ++n)
{
++cnt;
++acnt;
// for this example, relaxed memory order is sufficient, e.g.
// atomic_fetch_add_explicit(&acnt, 1, memory_order_relaxed);
}
return 0;
}
int main(void)
{
thrd_t thr[10];
for (int n = 0; n < 10; ++n)
thrd_create(&thr[n], f, NULL);
for (int n = 0; n < 10; ++n)
thrd_join(thr[n], NULL);
printf("The atomic counter is %u\n", acnt);
printf("The non-atomic counter is %u\n", cnt);
}
가능한 출력:
The atomic counter is 10000
The non-atomic counter is 8644
같은 조건에서 일반 카운터 cnt는 실행할 때마다 값이 들쭉날쭉하지만, 원자 카운터 acnt는 항상 10000으로 수렴해요. 이게 원자성이 가져다주는 차이예요.
더 알아보기
- 동시성 지원 라이브러리(Concurrency support library): 스레드, 원자 연산, 뮤텍스 같은 도구 모음.
memory_order: 원자 연산의 메모리 순서 의미론.