Rust (러스트) 멀티스레딩 향상: 고급 Arc (아크) 최적화

Rust (러스트) 멀티스레딩 향상: 고급 Arc (아크) 최적화

Rust (러스트) 프로그래밍에서 Arc (아크, 원자적 참조 카운팅)를 뮤텍스(Mutex (뮤텍스) 등)와 결합하는 것은 멀티스레드 환경에서 데이터를 공유하고 수정하는 데 사용되는 일반적인 패턴입니다.

그러나 이 접근 방식은 특히 락 경합이 심한 경우 성능 병목 현상을 유발할 수 있습니다.

이 글에서는 스레드 안전성을 유지하면서 락 경합을 줄이고 성능을 향상시키는 몇 가지 최적화 기법을 살펴봅니다.

예를 들어, 다음과 같은 경우를 고려해 보겠습니다.

세분화된 락 사용

성능을 향상시키는 한 가지 방법은 더 세분화된 락을 사용하는 것입니다.

이는 데이터 구조를 여러 부분으로 분해하고 각 부분에 자체 잠금 메커니즘을 두어 달성할 수 있습니다.

예를 들어, 읽기 작업이 쓰기 작업을 훨씬 초과하는 경우 Mutex (뮤텍스)를 RwLock (알더블유락)으로 바꾸면 효율성을 향상시킬 수 있습니다.

샘플 코드는 데이터 구조 T의 각 부분을 자체 RwLock (알더블유락)에 배치하여 이러한 부분의 독립적인 잠금 및 잠금 해제를 허용하는 방법을 보여줍니다.

use std::sync::{Arc, RwLock}; // Arc와 RwLock을 사용하기 위해 가져옵니다.
use std::thread; // 스레드 생성을 위해 가져옵니다.

// T가 두 부분으로 구성된 복잡한 데이터 구조라고 가정합니다.
struct T {
    part1: i32,
    part2: i32,
}

// T의 각 부분을 자체 RwLock에 배치합니다.
struct SharedData {
    part1: RwLock<i32>, // part1을 위한 RwLock
    part2: RwLock<i32>, // part2를 위한 RwLock
}

// 이 함수는 데이터에 대한 빈번한 접근 및 수정을 시뮬레이션합니다.
fn frequent_access(data: Arc<SharedData>) {
    {
        // 수정해야 하는 부분만 잠급니다.
        let mut part1 = data.part1.write().unwrap(); // part1에 대한 쓰기 락을 얻습니다.
        *part1 += 1; // part1을 수정합니다.
    } // part1에 대한 락은 여기서 해제됩니다.

    // 다른 부분은 동시에 읽거나 쓸 수 있습니다.
    // ...
}

fn main() {
    // SharedData를 Arc로 감싸서 여러 스레드에서 공유할 수 있도록 합니다.
    let data = Arc::new(SharedData {
        part1: RwLock::new(0), // part1 초기값 0
        part2: RwLock::new(0), // part2 초기값 0
    });

    // 공유 데이터 접근을 시연하기 위해 여러 스레드를 생성합니다.
    let mut handles = vec![];
    for _ in 0..10 { // 10개의 스레드를 생성합니다.
        let data_clone = Arc::clone(&data); // Arc를 복제하여 스레드로 이동시킵니다.
        let handle = thread::spawn(move || { // 새 스레드를 시작합니다.
            frequent_access(data_clone); // 각 스레드는 frequent_access 함수를 실행합니다.
        });
        handles.push(handle); // 스레드 핸들을 저장합니다.
    }

    // 모든 스레드가 완료될 때까지 기다립니다.
    for handle in handles {
        handle.join().unwrap(); // 각 스레드의 완료를 기다립니다.
    }

    // 최종 값을 출력합니다. (읽기 락을 사용하여 안전하게 접근합니다.)
    println!("Final values: Part1 = {}, Part2 = {}", data.part1.read().unwrap(), data.part2.read().unwrap());
}



이 예제에서는 std::sync::RwLock (에스티디 더블콜론 씽크 더블콜론 알더블유락)을 사용하여 더 세분화된 잠금을 달성합니다.

RwLock (알더블유락)은 여러 리더 또는 단일 라이터를 허용하므로 읽기 작업이 쓰기 작업을 훨씬 초과하는 시나리오에서 매우 유용합니다.

이 예제에서는 T의 각 부분이 자체 RwLock (알더블유락)에 배치됩니다.

이를 통해 이러한 부분을 독립적으로 잠글 수 있으므로 스레드 안전성을 희생하지 않고 성능을 향상시킬 수 있습니다.

한 부분이 수정되는 동안에는 해당 부분의 락만 유지되고 다른 부분은 다른 스레드에서 읽거나 쓸 수 있습니다.

이 방법은 데이터 구조를 상대적으로 독립적인 부분으로 명확하게 분해할 수 있는 상황에 적합합니다.

이러한 시스템을 설계할 때는 데이터 일관성과 교착 상태(deadlock)의 위험을 신중하게 고려해야 합니다.

데이터 복제 및 잠금 지연

또 다른 방법은 데이터를 수정하기 전에 복제하고 공유 데이터를 업데이트할 때만 잠그는 것입니다.

이 접근 방식은 뮤텍스가 유지되는 시간을 줄여 성능을 향상시킵니다.

이 방법에서는 락 외부에서 데이터를 복제한 다음 잠금 없이 복사본을 수정합니다.

공유 데이터를 업데이트해야 할 때만 업데이트를 위해 락을 다시 획득합니다.

이렇게 하면 락 유지 시간이 줄어들어 다른 스레드가 공유 리소스에 더 빨리 접근할 수 있습니다.

use std::sync::{Arc, Mutex}; // Arc와 Mutex를 사용하기 위해 가져옵니다.
use std::thread; // 스레드 생성을 위해 가져옵니다.

// T가 복제 가능한 복잡한 데이터 구조라고 가정합니다.
#[derive(Clone)] // T 구조체에 Clone 트레잇을 파생시킵니다.
struct T {
    value: i32,
}

// 이 함수는 데이터에 대한 빈번한 접근 및 수정을 시뮬레이션합니다.
fn frequent_access(data: Arc<Mutex<T>>) {
    // 락 외부에서 데이터를 복제합니다.
    let mut data_clone = {
        let data_locked = data.lock().unwrap(); // data에 대한 락을 얻습니다.
        data_locked.clone() // 데이터를 복제합니다.
    }; // 이 블록이 끝나면 data_locked에 대한 락이 자동으로 해제됩니다.

    // 복제된 데이터를 수정합니다.
    data_clone.value += 1;

    // 공유 데이터를 업데이트할 때만 뮤텍스를 잠급니다.
    let mut data_shared = data.lock().unwrap(); // data에 대한 락을 다시 얻습니다.
    *data_shared = data_clone; // 수정된 복제본으로 공유 데이터를 업데이트합니다.
} // 이 블록이 끝나면 data_shared에 대한 락이 자동으로 해제됩니다.

fn main() {
    // T 타입의 데이터를 Mutex로 감싸고, 이를 다시 Arc로 감싸서 여러 스레드에서 공유합니다.
    let data = Arc::new(Mutex::new(T { value: 0 }));

    // 공유 데이터 접근을 시연하기 위해 여러 스레드를 생성합니다.
    let mut handles = vec![];
    for _ in 0..10 { // 10개의 스레드를 생성합니다.
        let data_clone = Arc::clone(&data); // Arc를 복제하여 스레드로 이동시킵니다.
        let handle = thread::spawn(move || { // 새 스레드를 시작합니다.
            frequent_access(data_clone); // 각 스레드는 frequent_access 함수를 실행합니다.
        });
        handles.push(handle); // 스레드 핸들을 저장합니다.
    }

    // 모든 스레드가 완료될 때까지 기다립니다.
    for handle in handles {
        handle.join().unwrap(); // 각 스레드의 완료를 기다립니다.
    }

    // 최종 값을 출력합니다. (락을 사용하여 안전하게 접근합니다.)
    println!("Final value: {}", data.lock().unwrap().value);
}



이 코드의 목적은 뮤텍스(Mutex (뮤텍스))가 유지되는 시간을 줄여 성능을 향상시키는 것입니다.

이 과정을 단계별로 분석해 보겠습니다.

락 외부에서 데이터 복제:

let mut data_clone = {
    let data_locked = data.lock().unwrap();
    data_locked.clone()
};



여기서는 먼저 data.lock().unwrap() (데이터 점 락 괄호 점 언랩)을 사용하여 data에 대한 락을 얻고 즉시 데이터를 복제합니다.

복제 작업이 완료되면 블록({})의 범위가 끝나고 락이 자동으로 해제됩니다.

이는 복제된 데이터를 조작하는 동안 원본 데이터가 잠겨 있지 않음을 의미합니다.

복제된 데이터 수정:

data_clone.value += 1;

data_clone (데이터 클론)은 data의 복사본이므로 잠금 없이 자유롭게 수정할 수 있습니다.

이것이 성능 향상의 핵심입니다.

잠재적으로 시간이 많이 걸리는 데이터 수정 중에 락을 유지하는 것을 피해 다른 스레드가 락을 기다리며 차단되는 시간을 줄입니다.

공유 데이터를 업데이트할 때만 뮤텍스 잠금:

let mut data_shared = data.lock().unwrap();
*data_shared = data_clone;



수정이 완료된 후 data에 대한 락을 다시 획득하고 수정된 data_clone (데이터 클론)으로 업데이트합니다.

이 단계는 공유 데이터에 대한 업데이트가 스레드로부터 안전하도록 보장하는 데 필요합니다.

중요한 점은 이 짧은 업데이트 단계 동안에만 락이 유지된다는 것입니다.

락 유지 시간을 줄임으로써 이 접근 방식은 특히 락 경합이 심한 멀티스레드 환경에서 성능에 매우 중요합니다.

락 유지 시간이 짧을수록 다른 스레드가 공유 리소스에 더 빨리 접근할 수 있으므로 애플리케이션의 전반적인 응답성과 처리량이 향상됩니다.

그러나 이 방법에는 비용도 따릅니다.

데이터를 복제해야 하므로 메모리 사용량이 증가하고 더 복잡한 동기화 로직이 필요할 수 있습니다.

따라서 이 방법을 사용하기로 결정할 때는 특정 상황에 따라 장단점을 따져보는 것이 중요합니다.