atomic 타입과 Ordering 열거형
보통 빌드한 프로그램을 실행하면 인간이 느끼기엔 빠르게 느껴집니다. (항상 그런건 아닙니다.)
근데 컴퓨터(하드웨어)는 그렇지 않습니다:
특히 램(RAM
)은 생각보다 느립니다. 실제로 봐도 CPU와 램은 떨어져있죠.
이들이 데이터를 주고받느라(사이클) 꽤 많은 사이클을 소비합니다. <-- 이건 손해죠.
CPU
는 어떠한 연산을 1번만에 처리하는 반면, 램과 데이터를 주고받느라 여러 사이클을 돌려야 하기 때문입니다.
그럼 어떻게 해야할까요?: 램은 CPU
에 박아 넣으면 됩니다. (물리적으로)
CPU
마다 다르겠지만, CPU
의 구조는 대충 이렇게 생겼습니다:
MC (Memory Controller)
: 램과 소통하는 역할입니다. 여기서 램 슬롯 등을 제어합니다.Core
:CPU
의 그 코어가 맞습니다.Cache
: 우리가 찾던CPU
에 박혀있는 램입니다. (물리적으로Core
보다 더 큰 사이즈를 가지고 있습니다.) (L1
,L2
,L3
등)- ... 그리고
misc
(minimal instruction set computers
),qpi
(quick path interconnect
) 등이 있습니다. 우리가 알아야할 것은 아닙니다.
Cache
(캐시) 부분을 봅시다: L1
은 8~64KB
정도로 제한됩니다. L2
와 L3
는 이것보다 더 크며, L3
는 8MB
정도 됩니다.
우리가 생각하는 8GB
정도의 램은 아닙니다. 그러니깐 이름이 캐시 메모리죠.
아무튼 캐시를 사용하면, 램의 사이클보다 더 적은 사이클로, 빠르게 처리할 수 있습니다.
CPU가 특정 주소의 데이터에 접근하면, 먼저 캐시에서 찾습니다.
있으면 그 값을 읽고 (cache hit
), 없으면 램에 접근합니다. (cache miss
)
그리고, 그 값을 캐시에 저장합니다.
그런데 캐시는 상당히 제한되어있습니다. 당연하겠지만, CPU
는 자주 접근하는 주소를 모르기 때문에, 그냥 무식하게 다 찼으면 CPU
마다 다른 방식으로 처리합니다.
여기서 등장하는 다른 용어가 있습니다: CPU
파이프라이닝 (pipelining
)입니다. 매우 간단하게 설명하자면, 병렬 처리를 위해 존재합니다
예를 들어, 4단계의 어떤 사이클이 있다고 생각합시다. 한 사이클을 돌고 다른 사이클을 실행하는 것 보단, 동시에 실행하는 것이 효율적일 것입니다.
방금 CPU
명령어 실행 사이클을 말했습니다. CPU
의 명령어 실행 사이클은 다음과 같습니다:
fetch
: 명령어를 읽음decode
: 명령어 해석execute
: 명령어 실행write
: 결과를 씀
또 이상한게 있습니다. 컴파일 시 명령어 재배치가 일어날 수 있습니다. 말 그대로, 명령어가 재배치가 되는 경우입니다.
아마 그 코드는, 재배치가 되던 안되던 같은 값을 반환하며, CPU
파이프라이닝을 효율적으로 하기 위해 명령어 재배치가 일어났을 것입니다.
(참고로 godbolt
같은 곳에서 이를 재현하려 하면, 명령어 재배치가 일어나지 않을 수 있습니다. 이는 CPU
마다 다릅니다.)
그런데 이렇게 지 마음대로 수정해버리면, 그것이 제대로 작동한다는 보장이 있을까요? 이를 해결하기 위해, 수정 순서(modification order
)가 존재합니다. 이는 어떤 값에 대한, 값의 변화를 기록합니다.
예를 들어, 변수 foo
를 선언하고, 3개의 스레드 A
, B
, C
를 동시에 실행합니다.
A 스레드
:foo
에1
을 대입. 그리고 약간의 딜레이 후,foo
에2
를 대입: 이때의 수정 순서는1 -> 2
가 됩니다.B 스레드
:foo
에3
을 대입. 그리고foo
를2
번 읽음: 이때의 수정 순서는3 -> 4 -> 2
가 됩니다.C 스레드
:foo
를 읽음. 그리고foo
에4
를 대입. 그리고foo
를 읽음: 이때의 수정 순서는1 -> 4 -> 2
가 됩니다.
좀 복잡하게 설명했지만, 우리가 볼 것은 다음과 같습니다:
만약 어떤 스레드가 3
을 읽었다면, 다음엔 3
, 4
, 2
중 하나가 읽합니다.
위에서 서술하지 않는 내용이 있는데, 바로 캐시는 코어마다 가지고 있습니다. (ex, Core 1
의 L1
, L2
, L3
)
만약 캐시에서만 3
을 기록하고 있다면, 다른 코어에선 그 값이 3
인 것을 보장할 수 없습니다.
즉, 동기화 작업은 리소스가 시간을 많이 소비하는 작업입니다.
atomic
서론이 좀 길었는데, 본론으로 돌아와 atomic
에 대해 알아봅시다.
C++를 배워보았다면, atomic
을 어느 정도 알 것입니다. 사실상 러스트의 atomic
은 C++의 atomic
을 구현한 것입니다.
atomic
은 원자적 연산 (한 번에 일어나는 명령어 연산) 입니다. 1개의 명령어 이므로, 처리했다와 안했다로만 존재합니다.
(어셈블리어 코드에 lock
접두어가 포함되게 되는데, lock
은 CPU
명령어 실행 사이클을 한번에 처리합니다.)
atomic
을 사용하는 예제:
use std::{ sync::{atomic::*, *}, time::*, *, }; fn main() { let spinlock = Arc::new(AtomicUsize::new(1)); let t = { let spinlock_clone = Arc::clone(&spinlock); thread::spawn(move || { while spinlock_clone.load(Ordering::Relaxed) == 1 { hint::spin_loop(); thread::sleep(Duration::from_secs(2)); spinlock_clone.store(3, Ordering::SeqCst); } }) }; while spinlock.load(Ordering::SeqCst) != 3 { hint::spin_loop(); if spinlock.load(Ordering::SeqCst) == 3 { println!("{}", spinlock.load(Ordering::SeqCst)); } } t.join().unwrap(); }
Spin Lock
은 다른 스레드가 어떤 리소스를Lock
하고 있다면, 현재 스레드를 기다리고, 락이 풀리면 현재 스레드가 그 리소스에 접근하는 동기화 기법입니다. 자세한 내용은 이 글에서 다루지 않습니다.
Ordering
위 코드에서 처음 보는 것들이 많이 등장했습니다: 바로 Ordering
열거형입니다.
Relaxed (store, load, modify)
: 가장 느슨한 조건입니다. 즉, 다른 메모리 접근들과 순서가 바뀌어도 무방합니다. 아무런 제약이 없으므로,CPU
마음대로 재배치가 가능합니다. (결과가 동일하다면)Release (store, modify), Acquire (load, modify)
:Relaxed
는 아무런 제약이 없어서, 사실상Atomic
을 쓸 이유가 없어집니다.Release
와Acquire
는 그것보단 조금 더 엄격합니다:Release
는 재배치를 금지합니다.Acquire
로 읽는다면,Release
이전의 명령어들이 스레드에 의해 관찰될 수 있어야 합니다.AcqRel (modify)
:Acquire
+Release
SeqCst (store, load, modify)
:SeqCst
는 순차적 일관성(Sequential Consistency
)을 보장합니다. 쉽게 말해서 재배치도 없고 모든 스레드에서 동일한 값을 관찰할 수 있습니다. 대신 동기화 비용이 클 수 있습니다.
또한, store
와 load
함수는 atomic
객체에 대해 쓰기 및 읽기를 가능케 하는 함수입니다. 이 함수의 인자에 Ordering
열거형이 전달됩니다.
그런데 우린 의문점이 하나 있습니다: "왜 Atomic
은 제네릭(Atomic<T>
)을 사용하지 않는가?" 이겠죠.
C++에서도 클래스 템플릿을 사용하여, atomic
을 제네릭으로 사용할 수 있습니다. (atomic<T>
)
그 이유는 생각보다 간단한데, 예를 들어, Atomic<[usize; 3]>
같은 건 하드웨어가 지원하지 않습니다.
이것의 해결법은 atomic
크레이트나 Mutex<T>
를 사용하는 방법이 있습니다. 물론 둘 모두 Atomic
의 작동 방식과는 다르긴 합니다.
Atomic
은 하드웨어와 동시성 프로그래밍을 둘 다 이해하고 있어야 함으로, 상당히 어려운 개념에 속합니다.
이해하지 못했다면, 그냥 Mutex<T>
나 RwLock<T>
을 쓰는것이 올바른 선택입니다.