ABOUT ME

-

Total
-
  • Rust 문법: Ordering (Relaxed, Release, Acquire, AcqRel, SeqCst)
    컴퓨터/Rust 2023. 5. 11. 22:55
    728x90
    반응형

    소개

    Rust를 쓰다가 여러 스레드 상황에서 공유 변수를 사용해야 하는 상황이 생겼다.

    CTRL+C를 백그라운드에서 캡처하고 (tokio::signal::ctrl_c) CTRL+C를 누르면 exit_flag를 true로 만든다.

    그러면 메인 함수 루프에서는 어떻게 읽고 종료해야 할까? 여기서 나올 수 있는 개념이 메모리 순서이다.

     

    즉, 멀티스레드 프로그래밍에서 원자적 연산은 데이터가 여러 스레드를 통해 안전하게 처리되도록 보장하는 데 중요하다.

    원자적이지 않은 연산은 여러 스레드의 상대적 타이밍에 따라 연산의 출력이 달라지는 경합 조건을 초래할 수 있으며, 이러한 조건은 디버깅하기 어려운 다양한 문제를 일으킬 수 있다.

    Rust의 std::sync::atomic::Ordering 열거형은 이러한 원자적 연산에 대한 메모리 순서를 지정하는 데 사용된다.

    (메모리 순서는 다중 스레드 프로그램에서 메모리 연산(읽기 및 쓰기)이 다른 스레드에게 어떻게 순서화되어 보이는지에 대한 것)

     

    메모리 순서의 다섯 가지 유형은 다음과 같다:

    1. Relaxed: 특정한 순서화는 강제되지 않으며, 단지 연산이 원자적임을 보장한다. 이는 연산이 모든 스레드에게 원자적으로 보인다는 것을 보장하지만, 다른 스레드가 이 연산을 다른 연산과 관련하여 어떤 순서로 볼지에 대한 보장은 없다. 이것은 가장 약한 순서화 형태로, 가장 적은 보장을 제공하지만 컴파일러와 CPU에 가장 많은 최적화를 허용한다.
    2. Release: 이 순서화는 저장 연산(데이터 쓰기)에 사용된다. 현재 스레드의 이전 연산이 저장 연산 이전에 모두 수행되도록 보장한다. Acquire 또는 그보다 강한 방식으로 로드하는 다른 스레드는 Release로 저장된 것과 그 이전의 모든 연산을 볼 수 있다.
    3. Acquire: 이는 로드 연산(데이터 읽기)에 사용된다. 로드된 값이 Release 또는 그보다 강한 방식으로 저장된 경우, 현재 스레드의 로드 이후에 있는 모든 연산은 로드 이후에 수행된다. 스레드는 저장된 값을 및 저장 이전에 발생한 모든 연산을 볼 수 있다.
    4. AcqRel: 이는 Acquire와 Release의 조합이다. 데이터를 로드하고 저장하는 연산에 사용된다. 로드에는 Acquire 순서가 제공되고, 저장에는 Release 순서가 제공된다.
    5. SeqCst: 가장 강력한 보장을 제공한다. AcqRel와 유사하지만 추가적인 보장을 제공한다. 모든 스레드가 모든 순차 일관성 연산을 동일한 순서로 보는 것과 같다. 이는 가장 제한적인 순서화로, 컴파일러와 CPU 최적화를 가장 제한하지만 연산 순서에 대한 가장 강력한 보장을 제공한다.

     

    예제

    두 개의 원자 변수 x와 y가 각각 초기값으로 0을 가지고 있고, 두 개의 스레드 A와 B가 있다고 가정하자.

     

    1. 스레드 A는 다음을 수행:

    x.store(1, Ordering::Release);

    2. 스레드 B는 다음을 수행:

    if y.load(Ordering::Acquire) > 0 {
        println!("{}", x.load(Ordering::Relaxed));
    }


    만약 스레드 B가 1을 출력한다면, 이는 스레드 A에 의해 x로의 1 저장이 보이는 것을 의미한다.

    이는 y에 Release로 저장된 것을 Acquire로 로드하는 것이 가능하도록 해주는 Acquire 순서화 때문이다.

    이는 해당 스레드 이전에 발생한 모든 연산 (또한 x로의 Release 저장)도 볼 수 있음을 보장한다.

    반면에, 만약 스레드 A가 x.store(1, Ordering::Relaxed) 대신에 수행한다면, 스레드 B가 Release 순서로 y에 저장된 것을 본다 하더라도 0을 출력할 수도 있다.

    이는 Relaxed 순서화가 다른 스레드가 연산을 특정한 순서로 볼 수 있는 것을 보장하지 않기 때문이다.

    왜 이러한 다양한 순서화가 필요한 것일까?

    각 순서화는 성능과 보장 사이의 다른 균형을 제공한다.

    Relaxed는 최적화를 가장 많이 허용하므로 (따라서 최상의 성능을 제공할 수도 있음) 가장 약한 보장을 제공하며, 올바른 멀티스레드 코드를 작성하기 어렵게 만든다.

     

    비유

    여기까지 이해하기가 어렵다면 다음과 같은 비교를 해보자:

    한 개의 도서관에 여러 명의 사서와 책을 가진 비유를 사용해 보겠다. (단순화해서)

    도서관은 공유 메모리 공간이고, 책은 data이며, 사서는 thread이다.

    사서는 주로 두 가지 작업을 수행할 수 있다: 책을 책장에 넣기(데이터 저장)와 책장에서 책을 찾아오기(데이터 로드).

    • Relaxed Ordering: 이는 사서들이 서로 통신이나 조정 없이 각자의 작업에 전념하는 상황이다. 사서들은 원하는 때에 책을 책장에 넣거나 가져올 수 있으며, 다른 사서가 책장에 넣은 책을 즉시 볼 수 없을 수도 있다. 각 사서는 자신의 작업에 집중하기 때문이다.
    • Release and Acquire Ordering: 이제 한 가지 규칙이 생겼다. 희귀한 책을 책장에 넣을 때 (Release), 사서는 종을 울린다. 다른 사서가 종소리를 듣게 되면, 희귀한 책이 새로 들어왔다는 사실을 알고 카탈로그를 업데이트한 후에 다른 희귀한 책을 가져올 수 있다 (Acquire). 이렇게 함으로써, 사서들은 카탈로그를 업데이트하기 전에 책장에 있는 모든 희귀한 책에 대해 알고 있음을 확신할 수 있다.
    • AcqRel Ordering: 이는 사서가 책을 책장에 넣는 것과 가져오는 것 모두에 종소리 규칙을 따르는 사서이다. 희귀한 책을 책장에 넣을 때, 사서는 종을 울린다 (Release) 그리고 종소리를 듣게 되면, 다른 희귀한 책을 가져오기 전에 카탈로그를 업데이트한다 (Acquire).
    • SeqCst Ordering: 이는 엄격한 사서 코디네이터가 있는 상황과 같다. 책이 넣어지거나 가져올 때마다 코디네이터는 스피커로 알림을 전달하여 모든 사서가 변경 사항을 정확한 순서로 알게 한다. 이렇게 하면 모든 사서가 어느 시점에서도 동일한 도서관 상태를 공유하게 된다.

     

    사용 예제

     

    Relaxed (느슨한 순서화)


    Relaxed 모드에서는 연산들이 재배치될 수 있다. 이 모드는 연산의 순서가 중요하지 않을 때 사용된다.

    예를 들어, 증가 작업의 순서는 상관없고 최종적인 카운트만 중요한 로깅 카운터의 경우이다.

    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::thread;
    
    let counter = AtomicUsize::new(0);
    let mut handles = vec![];
    
    for _ in 0..10 {
        let handle = thread::spawn(|| {
            counter.fetch_add(1, Ordering::Relaxed);
        });
        handles.push(handle);
    }
    
    for handle in handles {
        handle.join().unwrap();
    }
    
    println!("Result: {}", counter.load(Ordering::Relaxed));


    출력 결과는 항상 10이 된다. 모든 증가 연산은 원자적이지만, 어떤 순서로 수행되었는지는 알 수 없다.


    Release/Acquire (발행 및 획득 순서화)


    Release/Acquire 의미론은 스레드가 저장하기 전에 수행한 모든 작업이 데이터를 로드하는 스레드에게 보이도록 보장한다.

    이를 사용하여 하나의 스레드에서 다른 스레드로 안전하게 데이터를 전송할 수 있다. 예를 들어:

    use std::sync::atomic::{AtomicBool, Ordering};
    use std::thread;
    
    let data_ready = AtomicBool::new(false);
    let data = 123;  // 전송할 데이터
    
    let producer = thread::spawn(move || {
        // 계산 수행...
        data_ready.store(true, Ordering::Release);
    });
    
    let consumer = thread::spawn(move || {
        while !data_ready.load(Ordering::Acquire) {}
        // 데이터를 사용할 준비가 됨...
    });
    
    producer.join().unwrap();
    consumer.join().unwrap();



    consumer 스레드는 data_ready가 true가 될 때까지 대기하며, true를 확인하면 producer 스레드에서 data_ready가 true로 설정되기 전에 수행된 모든 작업을 볼 수 있다.



    AcqRel (획득 및 발행 순서화)


    이 모드는 Acquire 및 Release의 효과를 결합한다. 데이터를 로드하고 저장하는 작업에 사용된다.

    예를 들어, 비교-교환(compare-and-swap) 연산에서:

    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::thread;
    
    let data = AtomicUsize::new(1);
    
    let handle = thread:: spawn(move || {
    let old = data.load(Ordering::Relaxed);
    data.compare_and_swap(old, old * 2, Ordering::AcqRel);
    });
    
    handle.join().unwrap();
    
    println!("Result: {}", data.load(Ordering::Relaxed));


    여기서 compare_and_swap은 먼저 이전 값(Release 의미론으로)을 로드한 다음, 이전 값이 변경되지 않았다면 새로운 값을 저장한다 (Acquire 의미론으로).

     

    SeqCst (순차 일관성 순서화)


    SeqCst는 단일하고 전역적으로 합의된 순서를 보장한다. 모든 스레드가 연산의 순서에 동의해야 할 때 사용된다.

    예를 들어:

    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::thread;
    
    let x = AtomicUsize::new(0);
    let y = AtomicUsize::new(0);
    
    let handle1 = thread::spawn(move || {
        x.store(1, Ordering::SeqCst);
        let b = y.load(Ordering::SeqCst);
        b
    });
    
    let handle2 = thread::spawn(move || {
        y.store(1, Ordering::SeqCst);
        let a = x.load(Ordering::SeqCst);
     a
    });
    
    let r1 = handle1.join().unwrap();
    let r2 = handle2.join().unwrap();
    
    assert!(r1 == 1 || r2 == 1);

    여기서 두 개의 스레드가 있다.

    하나는 x에 값을 저장한 다음 y에서 값을 로드하고, 다른 하나는 y에 값을 저장한 다음 x에서 값을 로드한다.

    SeqCst 순서화를 사용하면 적어도 한 개의 로드 연산이 다른 스레드의 저장 연산을 볼 수 있음을 보장한다.

    만약 여기서 Relaxed, Acquire, 또는 Release를 사용한다면, (적지만 드물게) 두 개의 로드 연산이 모두 0을 보게 될 수 있다.

    왜냐하면 이러한 순서화는 모든 스레드가 동의할 수 있는 전역적인 순서를 제공하지 않기 때문이다.

    하지만 SeqCst는 그렇기 때문에 적어도 한 개의 로드 연산이 다른 스레드의 저장 연산 이후에 수행되며, 따라서 1을 볼 수 있다.

    728x90

    '컴퓨터 > Rust' 카테고리의 다른 글

    Rust 문법: Box  (0) 2023.05.13
    Rust: 구글 Bard 바드 CLI 앱 만들기  (1) 2023.04.28
    Rust: async의 늪  (0) 2023.04.18

    댓글