Rust의 Atomics 설명: 동시성의 불가분성
Emily Parker
Product Engineer · Leapcell

Atomic Types and Atomic Operations
원자성은 CPU에 의해 중단되거나 컨텍스트 스위칭되지 않는 일련의 기계 명령을 의미합니다. 이러한 명령은 함께 그룹화될 때 원자적 연산을 형성합니다. 멀티 코어 CPU에서 코어가 원자적 연산을 실행하기 시작하면 원자적 연산이 방해받지 않도록 다른 CPU 코어의 메모리 연산을 일시 중지합니다.
원자적 연산은 나눌 수 없고 중단할 수 없는 하나 이상의 연산을 의미합니다. 동시 프로그래밍에서는 일련의 연산이 원자성을 보장하기 위해 CPU 수준에서 특정 보장이 제공되어야 합니다. 원자적 연산은 단일 단계 또는 여러 단계로 구성될 수 있지만 이러한 단계의 순서는 중단되어서는 안 되며 실행은 다른 메커니즘에 의해 중단되어서는 안 됩니다.
참고: 원자적 연산은 CPU 명령에 의해 직접 지원되므로 일반적으로 잠금 또는 메시지 전달보다 성능이 훨씬 좋습니다. 잠금에 비해 원자적 유형은 개발자가 잠금 획득 및 해제를 관리할 필요가 없으며 수정 및 읽기와 같은 연산도 지원하여 동시 성능이 더 높습니다. 거의 모든 프로그래밍 언어가 원자적 유형을 지원합니다.
원자적 유형은 개발자가 원자적 연산을 더 쉽게 구현할 수 있도록 도와주는 데이터 유형입니다. 원자적 유형은 잠금 해제되지만 잠금 해제는 대기 해제를 의미하지 않습니다. 내부적으로 원자적 유형은 CAS 루프를 사용하므로 경쟁이 많을 때는 여전히 대기가 필요합니다! 그럼에도 불구하고 일반적으로 잠금보다 낫습니다.
참고: CAS는 Compare and Swap의 약자입니다. 단일 명령을 사용하여 특정 메모리 주소를 읽고 해당 값이 지정된 예상 값과 일치하는지 확인합니다. 그렇다면 값을 새 값으로 업데이트합니다.
동시성 기본 요소로서 원자적 연산은 다른 모든 동시성 기본 요소를 구현하기 위한 초석입니다. 거의 모든 프로그래밍 언어가 원자적 유형과 연산을 지원합니다. 예를 들어 Java는 java.util.concurrent.atomic
에 많은 원자적 유형을 제공하고, Go는 sync/atomic
패키지를 통해 지원을 제공하며, Rust도 예외는 아닙니다.
참고: 원자적 연산은 CPU 수준의 개념입니다. 프로그래밍 언어에는 동시성 기본 요소라는 유사한 개념이 있습니다. 이는 커널에서 외부적으로 호출할 수 있도록 제공하는 함수이며 이러한 함수는 실행 중에 중단될 수 없습니다.
Atomic Primitives in Rust
Rust에서 원자적 유형은 std::sync::atomic
모듈에 있습니다.
이 모듈에 대한 설명서에서는 원자적 유형을 다음과 같이 설명합니다. Rust의 원자적 유형은 스레드 간의 기본적인 공유 메모리 통신을 제공하며 다른 동시성 유형을 구축하기 위한 기반 역할을 합니다.
std::sync::atomic
모듈은 현재 다음 12개의 원자적 유형을 제공합니다.
AtomicBool
AtomicI8
AtomicI16
AtomicI32
AtomicI64
AtomicIsize
AtomicPtr
AtomicU8
AtomicU16
AtomicU32
AtomicU64
AtomicUsize
원자적 유형은 일반 유형과 크게 다르지 않습니다. 예를 들어 AtomicBool
과 bool
은 전자가 다중 스레드 컨텍스트에서 사용될 수 있는 반면 후자는 단일 스레드 사용에 더 적합하다는 점을 제외하면 크게 다르지 않습니다.
AtomicI32
를 예로 들어 보겠습니다. এটি একটি স্ট্রাক্ট হিসাবে সংজ্ঞায়িত করা হয়েছে এবং এতে এটমিক অপারেশন সম্পর্কিত নিম্নলিখিত পদ্ধতিগুলি অন্তর্ভুক্ত রয়েছে:
pub fn fetch_add(&self, val: i32, order: Ordering) -> i32 - атомিক типе адырдык (же кемитүү) аткарат pub fn compare_and_swap(&self, current: i32, new: i32, order: Ordering) -> i32 - CAS (Rust 1.50дө эскирип калган, compare_exchange менен алмаштырылган) pub fn compare_exchange(&self, current: i32, new: i32, success: Ordering, failure: Ordering) -> Result<i32, i32> - CAS pub fn load(&self, order: Ordering) -> i32 - атомдук типтен маанини окуйт pub fn store(&self, val: i32, order: Ordering) - атомдук түргө маани жазат pub fn swap(&self, val: i32, order: Ordering) -> i32 - маанилерди алмаштырат
보시다시피 각 메서드는 Ordering
매개변수를 사용합니다. Ordering
은 해당 작업에 대한 메모리 장벽의 강도를 나타내는 열거형이며 원자적 연산의 메모리 순서를 제어하는 데 사용됩니다.
참고: 메모리 순서는 CPU가 메모리에 액세스하는 순서를 나타내며 이는 다음과 같은 영향을 받을 수 있습니다.
- 코드의 명령문 순서
- 컴파일 시간에 메모리 액세스를 재정렬하는 컴파일러 최적화 (메모리 재정렬)
- 액세스 순서를 방해할 수 있는 런타임 시 CPU 수준의 캐싱 메커니즘
pub enum Ordering { Relaxed, Release, Acquire, AcqRel, SeqCst, }
Rust에서 Ordering
의 열거형 값은 다음을 나타냅니다.
- Relaxed – 가장 느슨한 규칙으로 컴파일러나 CPU에 제한을 가하지 않아 최대한의 재정렬이 가능합니다.
- Release – 메모리 장벽을 설정하여 그 이전의 모든 작업이 이 작업보다 먼저 발생하도록 보장합니다. 그 이후의 작업은 그 이전으로 재정렬될 수 있습니다 (쓰기에 사용).
- Acquire – 메모리 장벽을 설정하여 그 이후의 모든 작업이 이 작업 이후에 발생하도록 보장합니다. 그 이전의 작업은 그 이후로 재정렬될 수 있습니다. 다른 스레드에서 Release와 함께 사용되는 경우가 많습니다 (읽기에 사용).
- AcqRel – Acquire와 Release의 조합으로 메모리 순서의 양방향을 모두 보장합니다.
load
의 경우 Acquire처럼 동작하고,store
의 경우 Release처럼 동작합니다.fetch_add
와 같은 메서드에서 자주 사용됩니다. - SeqCst (Sequentially Consistent) – AcqRel의 더 강력한 버전입니다. 스레드 내에서
SeqCst
원자적 연산 주변의 작업 재정렬이 허용되지 않습니다. 또한 모든 스레드에서 모든SeqCst
연산에 대해 일관된 전역 순서를 보장합니다. 성능이 낮지만 가장 안전한 옵션입니다.
Ordering
열거형을 사용하면 개발자는 기본 메모리 순서 동작을 사용자 지정할 수 있습니다.
참고: 메모리 순서란 무엇입니까? Wikipedia에서: 메모리 순서는 CPU가 주 메모리에 액세스하는 순서입니다. 컴파일 시간에 컴파일러에 의해 또는 런타임 시 CPU에 의해 결정될 수 있습니다. 이는 메모리 연산 재정렬 및 순서가 틀린 실행을 반영하며, 서로 다른 메모리 구성 요소 간의 버스 대역폭 사용을 최대화하도록 설계되었습니다. 대부분의 최신 프로세서는 명령을 순서 없이 실행합니다. 따라서 스레드 간의 동기화를 보장하려면 메모리 장벽이 필요합니다.
메모리 순서를 더 잘 이해하기 위해
AtomicI32
에서 작동하는 두 개의 스레드를 상상해 보십시오. 초기값이 0이라고 가정합니다. 한 스레드는 쓰기를 수행하여 값을 10으로 업데이트하고 다른 스레드는 읽기를 수행합니다. 쓰기가 읽기보다 먼저 완료되면 읽기 스레드가 10을 확실히 볼 수 있습니까? 대답은 반드시 그렇지는 않습니다. 컴파일러 최적화 및 CPU 전략으로 인해 업데이트된 값이 여전히 레지스터에 있고 아직 메모리에 플러시되지 않았을 수 있습니다. 레지스터-메모리 동기화를 보장하려면 메모리 순서가 필요합니다.
Release
는 레지스터 값이 메모리에 기록되도록 보장합니다.Acquire
는 로컬 레지스터를 무시하고 메모리에서 직접 읽습니다. 예를 들어Ordering::Release
로store
를 호출한 다음Ordering::Acquire
를 사용하여load
를 호출하면 읽기 스레드가 가장 최근 값을 읽도록 보장할 수 있습니다.
Using Atomic in Multithreading
모든 원자적 유형이 Sync
트레이트를 구현하기 때문에 스레드 간에 원자적 변수를 공유하는 것은 안전합니다. 그러나 원자적 유형 자체가 공유 메커니즘을 제공하지 않으므로 일반적인 접근 방식은 원자적으로 참조 카운트된 스마트 포인터인 Arc
내부에 배치하는 것입니다. 다음은 공식 설명서의 간단한 스핀록 예제입니다.
use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use std::thread; fn main() { // Create a lock using an atomic type and share ownership via Arc let spinlock = Arc::new(AtomicUsize::new(1)); // Increase the reference count let spinlock_clone = spinlock.clone(); let thread = thread::spawn(move || { // SeqCst ordering: store (write) operation uses release semantics // meaning operations before the store cannot be reordered after it spinlock_clone.store(0, Ordering::SeqCst); }); // Use a while loop to wait for the critical section to become available // SeqCst ordering: load (read) operation uses acquire semantics // meaning operations after the load cannot be reordered before it // The write instruction from the thread above ensures that // subsequent reads/writes will not be reordered before it while spinlock.load(Ordering::SeqCst) != 0 {} if let Err(panic) = thread.join() { println!("Thread had an error: {:?}", panic); } }
참고: 스핀록은 스레드가 다른 스레드에 의해 이미 보유된 잠금을 획득하려고 할 때 즉시 획득할 수 없는 잠금 메커니즘을 의미합니다. 대신 스레드는 기다렸다가 잠시 후에 다시 시도합니다.