Rust 기초: 원자적 타입과 메모리 오더링
여러 스레드에서 카운터 하나를 안전하게 올리고 싶을 때 가장 먼저 떠오르는 게 Mutex<i32>인데요. 그런데 막상 짜놓고 보면 “고작 정수 하나 더하자고 잠금을 잡았다 풀었다 한다고?” 싶어집니다. 실제로 이 패턴은 Sync 트레이트 글에서도 잠깐 언급했던 원자적 타입을 쓰면 훨씬 가볍게 해결할 수 있어요.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let counter = AtomicUsize::new(0);
thread::scope(|s| {
for _ in 0..5 {
s.spawn(|| {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
});
}
});
println!("최종 카운트: {}", counter.load(Ordering::Relaxed));
}
Mutex도 Arc도 안 보이는데 5,000이 정확히 출력됩니다. 그런데 이 코드를 처음 보면 두 가지가 좀 거슬리는데요. &counter로 변경이 되는 게 어떻게 가능한지, 그리고 저 Ordering::Relaxed는 도대체 뭐길래 매번 적어줘야 하는지 말이죠. 이 글에서 원자적 타입이 어떤 도구이고, 메모리 오더링은 왜 존재하며 언제 무엇을 골라야 하는지 차근차근 풀어보겠습니다.
원자적 타입이란?
원자적(atomic) 연산은 “중간에 끼어들 수 없는 하나의 연산”을 뜻합니다. 일반적으로 counter += 1은 메모리에서 값을 읽고, 1을 더하고, 다시 쓰는 세 단계로 나뉘는데요. 두 스레드가 이 세 단계를 번갈아 실행하면 결과가 꼬일 수 있습니다. 원자적 연산은 하드웨어 수준에서 이 세 단계를 통째로 묶어 다른 코어가 끼어들지 못하게 보장해요.
Rust는 std::sync::atomic 모듈에서 이런 타입들을 제공합니다. 정수형은 AtomicI8/AtomicI16/AtomicI32/AtomicI64/AtomicIsize와 부호 없는 AtomicU8/AtomicU16/AtomicU32/AtomicU64/AtomicUsize가 있고, 불리언은 AtomicBool, 포인터는 AtomicPtr<T>가 있어요. 카운터처럼 음수가 필요 없는 경우엔 AtomicUsize를 가장 많이 씁니다.
Mutex<T>와 비교하면 차이가 분명한데요. Mutex는 운영체제 잠금에 의존해서 어떤 타입이든 보호할 수 있지만, 잠금 획득과 해제에 시스템 호출이 들어갈 수 있어 비용이 큽니다. 원자적 타입은 CPU의 단일 명령어(예: x86의 LOCK XADD)로 처리되어 훨씬 빠르지만, 정수와 불리언 같은 작은 값에만 쓸 수 있어요.
또 하나 눈에 띄는 점은 원자적 타입의 모든 변경 메서드가 &self를 받는다는 겁니다.
impl AtomicUsize {
pub fn store(&self, val: usize, order: Ordering);
pub fn fetch_add(&self, val: usize, order: Ordering) -> usize;
// ...
}
원래 Rust에선 값을 바꾸려면 &mut self가 필요한데, 원자적 타입은 불변 참조만으로도 값을 바꿀 수 있어요. 이것이 가능한 건 내부적으로 내부 가변성을 제공하면서 동시에 하드웨어가 동기화를 보장하기 때문이고, 그래서 Arc로 감싸지 않고도 thread::scope 안에서 참조로 공유할 수 있는 거예요.
기본 사용법
AtomicUsize의 가장 자주 쓰는 메서드 네 개부터 보겠습니다. 값을 읽는 load, 값을 쓰는 store, 값을 더하면서 이전 값을 돌려주는 fetch_add, 값을 빼면서 이전 값을 돌려주는 fetch_sub예요.
use std::sync::atomic::{AtomicUsize, Ordering};
fn main() {
let counter = AtomicUsize::new(0);
counter.store(10, Ordering::Relaxed);
println!("현재 값: {}", counter.load(Ordering::Relaxed)); // 10
let prev = counter.fetch_add(5, Ordering::Relaxed);
println!("이전 값: {}, 현재 값: {}", prev, counter.load(Ordering::Relaxed));
// 이전 값: 10, 현재 값: 15
let prev = counter.fetch_sub(3, Ordering::Relaxed);
println!("이전 값: {}, 현재 값: {}", prev, counter.load(Ordering::Relaxed));
// 이전 값: 15, 현재 값: 12
}
fetch_add가 이전 값을 돌려주는 게 특히 유용한데요. 고유 ID를 발급하는 카운터를 만들 때 “방금 내가 받은 ID는 몇 번이지?”를 별도의 잠금 없이 알 수 있거든요.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
fn main() {
let next_id = AtomicUsize::new(1);
thread::scope(|s| {
for _ in 0..3 {
s.spawn(|| {
let my_id = next_id.fetch_add(1, Ordering::Relaxed);
println!("내 ID: {}", my_id);
});
}
});
}
각 스레드가 받는 my_id는 절대 겹치지 않습니다. fetch_add는 “값을 읽고 더하고 쓰는” 동작을 원자적으로 처리하니까요.
좀 더 강력한 메서드도 있는데요. compare_exchange는 “현재 값이 X이면 Y로 바꿔라”라는 조건부 갱신입니다. 잠금 없이 안전한 알고리즘을 짤 때 핵심이 되는 연산이에요.
use std::sync::atomic::{AtomicUsize, Ordering};
fn main() {
let value = AtomicUsize::new(100);
// 현재 값이 100이면 200으로 바꾼다
let result = value.compare_exchange(
100, // 기대 값
200, // 새 값
Ordering::SeqCst, // 성공 시 오더링
Ordering::SeqCst, // 실패 시 오더링
);
println!("{:?}", result); // Ok(100) — 기대 값과 같았으므로 성공, 이전 값 반환
// 이번엔 100이 아니니 실패
let result = value.compare_exchange(100, 300, Ordering::SeqCst, Ordering::SeqCst);
println!("{:?}", result); // Err(200) — 현재 값은 200이므로 실패, 현재 값 반환
println!("최종: {}", value.load(Ordering::SeqCst)); // 200
}
compare_exchange는 “다른 스레드가 사이에 끼어들지 않았을 때만 갱신”이 필요한 상황에서 빛을 발합니다. 예를 들어 한 번만 초기화하는 패턴(if !initialized { initialize(); initialized = true; })을 안전하게 구현할 수 있어요.
메모리 오더링이 필요한 이유
여기까지는 Ordering::Relaxed만 써도 멀쩡히 동작했는데요. 왜 오더링이라는 게 따로 필요한지 짚고 넘어가야 합니다. 현대 컴파일러와 CPU는 성능을 위해 명령어 순서를 자유롭게 바꿉니다. 단일 스레드 입장에선 결과가 똑같으니까 문제가 없어요.
// 우리가 쓴 코드
data = 42;
ready = true;
// CPU가 실제로 실행하는 순서일 수도 있음
ready = true;
data = 42;
단일 스레드에서야 둘 다 같은 결과지만, 다른 스레드가 ready를 보고 “데이터가 준비됐다”라고 판단해서 data를 읽으려 하면 큰일이 납니다. ready는 true인데 data는 아직 0일 수 있거든요.
메모리 오더링은 컴파일러와 CPU에게 “이 연산 주변에선 이 정도의 순서는 지켜라”라고 알려주는 힌트입니다. 강도가 다른 다섯 가지 오더링이 있어요.
| 오더링 | 의미 |
|---|---|
Relaxed | 원자성만 보장. 다른 메모리 연산과의 순서는 보장하지 않음 |
Acquire | 이 연산 이후의 읽기/쓰기가 이 연산보다 먼저 일어나지 않음 (load에만 사용) |
Release | 이 연산 이전의 읽기/쓰기가 이 연산보다 나중에 일어나지 않음 (store에만 사용) |
AcqRel | 읽고 쓰는 연산(fetch_add, compare_exchange)에 Acquire와 Release를 동시에 적용 |
SeqCst | 모든 스레드가 동일한 순서로 관찰. 가장 강한 보장이지만 가장 비쌈 |
처음 보면 머리가 어지러운데요. 실제로 쓸 때는 보통 다음 세 가지 패턴 중 하나입니다. 카운트만 한다면 Relaxed로 충분하고, 데이터를 공개하고 받는 패턴이라면 Release/Acquire 쌍을 쓰며, 헷갈리면 SeqCst를 쓰면 됩니다.
Relaxed: 카운터에 충분한 이유
Relaxed는 원자성만 보장하고 순서는 신경 쓰지 않습니다. 그런데도 도입부의 카운터 예제가 잘 동작한 건, 카운트 자체에는 메모리 순서가 중요하지 않기 때문이에요. 우리가 알고 싶은 건 “5개 스레드가 각각 1,000번 더한 뒤 최종 값”일 뿐이고, 각 fetch_add가 다른 연산보다 먼저인지 나중인지는 무관하거든요.
use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;
static REQUEST_COUNT: AtomicUsize = AtomicUsize::new(0);
fn handle_request() {
// 어디서든 호출 가능, 순서는 무관
REQUEST_COUNT.fetch_add(1, Ordering::Relaxed);
}
fn main() {
thread::scope(|s| {
for _ in 0..10 {
s.spawn(|| {
for _ in 0..100 {
handle_request();
}
});
}
});
println!("총 요청 수: {}", REQUEST_COUNT.load(Ordering::Relaxed));
}
static으로 선언한 카운터를 어디서든 접근할 수 있고, 최종 합계 1,000만 정확하면 되니 가장 가벼운 Relaxed로 충분합니다. 메트릭 수집, 디버깅용 카운터, 통계 같은 경우가 여기에 해당해요.
Release와 Acquire: 데이터 공개 패턴
본격적인 문제가 시작되는 건 한 스레드가 데이터를 준비한 뒤 다른 스레드가 “이제 읽어도 돼”라는 신호를 보고 데이터를 읽는 패턴인데요. 앞서 본 data = 42; ready = true; 같은 상황이에요. 이때 필요한 게 Release-Acquire 쌍입니다.
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::thread;
fn main() {
let data = AtomicU64::new(0);
let ready = AtomicBool::new(false);
thread::scope(|s| {
s.spawn(|| {
// 데이터를 준비한 뒤 ready를 true로 게시(publish)
data.store(42, Ordering::Relaxed);
ready.store(true, Ordering::Release);
});
s.spawn(|| {
// ready가 true가 될 때까지 대기
while !ready.load(Ordering::Acquire) {
std::hint::spin_loop();
}
// 여기서 data를 읽으면 42가 확정적으로 보임
println!("받은 데이터: {}", data.load(Ordering::Relaxed));
});
});
}
Release로 store한 값을 Acquire로 load해서 보면, 그 store 이전의 모든 쓰기가 이 load 이후의 모든 읽기보다 먼저 일어났다는 게 보장됩니다. 그래서 두 번째 스레드가 ready == true를 보았을 때 data == 42도 확정적으로 읽을 수 있어요.
흥미로운 건 data 자체에는 Relaxed를 써도 안전하다는 점입니다. ready에 걸린 Release-Acquire가 두 스레드 사이에 일종의 다리를 놓아주거든요. 그 다리 너머의 모든 연산이 순서대로 보이게 되어, data는 단지 원자성만 보장하면 충분해요.
핵심은 Release와 Acquire가 짝을 이뤄야 한다는 점입니다. store에는 Release, load에는 Acquire를 쓰는 게 관습이에요. 반대로 쓰면 컴파일은 되지만 의미가 어긋나고, 실제로 Ordering::Acquire를 store에 넘기면 패닉이 발생합니다.
SeqCst: 가장 안전한 기본값
SeqCst는 Sequential Consistency의 약자로, 모든 스레드가 모든 SeqCst 연산을 같은 순서로 관찰한다는 점을 가장 강하게 보장합니다. 위에서 본 Release/Acquire는 두 연산 사이의 관계를 보장하지만, 여러 변수와 여러 스레드가 얽힌 복잡한 상황에선 직관과 어긋날 수 있는데요. SeqCst는 이런 미묘한 경우까지 다 막아주는 대신 성능 비용이 가장 큽니다.
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
fn main() {
let x = AtomicBool::new(false);
let y = AtomicBool::new(false);
thread::scope(|s| {
s.spawn(|| x.store(true, Ordering::SeqCst));
s.spawn(|| y.store(true, Ordering::SeqCst));
s.spawn(|| {
while !x.load(Ordering::SeqCst) {}
if y.load(Ordering::SeqCst) {
println!("관찰자 1: y도 true");
}
});
s.spawn(|| {
while !y.load(Ordering::SeqCst) {}
if x.load(Ordering::SeqCst) {
println!("관찰자 2: x도 true");
}
});
});
}
SeqCst를 쓰면 두 관찰자가 본 순서가 일치한다는 게 보장됩니다. 한 관찰자는 “x 먼저, y 나중”으로 보고 다른 관찰자는 “y 먼저, x 나중”으로 보는 모순적 상황이 생기지 않아요.
실무에선 처음엔 SeqCst로 짜고 프로파일링 결과 병목이 보일 때 Release/Acquire나 Relaxed로 약화시키는 게 안전한 접근입니다. 잘못 약화시켜서 생기는 버그는 재현이 어렵고 디버깅이 악몽이거든요.
실전: 한 번만 실행되는 초기화
원자적 타입의 활용을 보여주는 대표 패턴이 한 번만 실행되는 초기화입니다. 여러 스레드가 동시에 호출해도 초기화 코드는 딱 한 번만 실행되어야 할 때 compare_exchange로 깔끔하게 해결할 수 있어요.
use std::sync::atomic::{AtomicBool, Ordering};
use std::thread;
static INITIALIZED: AtomicBool = AtomicBool::new(false);
fn initialize_once() {
// false → true로 바꿀 수 있는 스레드는 단 하나
if INITIALIZED
.compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire)
.is_ok()
{
println!("초기화 실행");
// 여기에 실제 초기화 로직
} else {
println!("이미 초기화됨, 건너뜀");
}
}
fn main() {
thread::scope(|s| {
for _ in 0..5 {
s.spawn(|| initialize_once());
}
});
}
compare_exchange(false, true, ...)는 현재 값이 false일 때만 true로 바꿉니다. 다섯 스레드가 동시에 도달해도 단 하나만 “false였다”는 결과를 받고, 나머지는 “이미 true”라는 에러를 받죠. 결과적으로 초기화 코드는 정확히 한 번만 실행됩니다.
물론 실제 코드에서는 표준 라이브러리의 OnceLock이나 std::sync::Once를 쓰는 게 더 안전하고 표현력도 좋아요. 다만 그 안에서 동작하는 원리가 바로 이 compare_exchange 패턴이라는 걸 알아두면 라이브러리를 더 깊이 이해할 수 있습니다.
마치며
원자적 타입은 잠금 없이 스레드 안전한 연산을 가능하게 해주는 도구입니다. Mutex보다 가볍지만 정수와 불리언 같은 작은 값에만 쓸 수 있다는 제약이 있고요. &self로 값을 바꿀 수 있는 내부 가변성을 제공하면서 하드웨어가 동기화를 보장한다는 점이 핵심이에요.
메모리 오더링은 처음 보면 부담스럽지만 실전 패턴은 셋으로 압축됩니다. 카운트만 한다면 Relaxed로 충분하고, 데이터를 한 스레드가 준비하고 다른 스레드가 받는 게시-구독 패턴이라면 Release/Acquire 쌍을 쓰며, 헷갈리면 SeqCst로 시작해 필요할 때만 약화시키면 됩니다. 잘못 약화시켜서 생기는 버그는 재현이 거의 불가능하니 의심스러우면 더 강한 보장을 선택하세요.
원자적 타입은 락 프리(lock-free) 자료구조의 기반이기도 한데요. compare_exchange를 활용한 락 프리 큐, 스택, 카운터 같은 고급 패턴이 궁금하다면 crossbeam 같은 외부 크레이트의 소스 코드를 따라가 보는 것도 좋은 학습이 됩니다.
더 자세한 내용은 std::sync::atomic - Rust 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0