Understanding Concurrency Types in Rust

Exploring the concurrency types in Rust

November 19, 2024


동시성 타입 비교

러스트는 안전한 동시성 처리를 위해 다양한 타입을 제공합니다. 각 타입은 특정 상황에 최적화되어 있으며, 다음과 같은 주요 타입들이 있습니다.

특징RefCell<T>Rc<T>Mutex<T>Arc<T>
주요 특징단일 스레드 내부 가변성 제공단일 스레드 다중 소유권 제공다중 스레드 데이터 보호스레드 안전 다중 소유권
소유권 관리단일 소유권다중 소유권 (참조 카운팅)단일 소유권다중 소유권 (원자적 참조 카운팅)
접근 방식borrow()/borrow_mut()clone()lock()clone()
스레드 안전성단일 스레드만단일 스레드만다중 스레드 안전다중 스레드 안전
안전성 검사런타임 대여 규칙컴파일 타임락 기반 동기화원자적 연산
메모리 관리일반 해제참조 카운트 0 시 해제일반 해제원자적 참조 카운트 0 시 해제
주요 사용 사례단일 스레드 가변 데이터단일 스레드 공유 데이터멀티스레드 가변 데이터멀티스레드 공유 데이터
성능 특성런타임 검사 오버헤드가벼운 참조 카운팅락 획득/해제 오버헤드원자적 연산 오버헤드

실행 환경별 타입 선택

싱글스레드 환경

싱글스레드 환경에서는 Rc<T>RefCell<T>의 조합이 주로 사용됩니다.

1. 기본적인 가변 데이터 공유

use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    // 기본적인 가변 데이터 공유 예시
    let data = Rc::new(RefCell::new(vec![1, 2, 3]));
    let data_clone = Rc::clone(&data);

    // RefCell을 통한 가변 접근
    data.borrow_mut().push(4);
    println!("원본 데이터: {:?}", data.borrow());

    // 복제된 참조를 통한 접근
    data_clone.borrow_mut().push(5);
    println!("수정된 데이터: {:?}", data.borrow());

    // Rc의 강한 참조 수 확인
    println!("참조 카운트: {}", Rc::strong_count(&data));
}

2. 순환 참조 구조와 해결 방법

use std::rc::{Rc, Weak};
use std::cell::RefCell;

struct Node {
    next: Option<Rc<RefCell<Node>>>,
    prev: Option<Weak<RefCell<Node>>>, // Weak 참조 사용
    value: i32,
}

impl Node {
    fn new(value: i32) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(Node {
            next: None,
            prev: None,
            value,
        }))
    }
}

fn main() {
    // 노드 생성
    let first = Node::new(1);
    let second = Node::new(2);

    // 양방향 연결 설정
    {
        let mut first_ref = first.borrow_mut();
        let mut second_ref = second.borrow_mut();

        // first -> second
        first_ref.next = Some(Rc::clone(&second));
        // second -> first (약한 참조)
        second_ref.prev = Some(Rc::downgrade(&first));
    }

    // 노드 값 확인
    println!("첫 번째 노드: {}", first.borrow().value);
    if let Some(next) = &first.borrow().next {
        println!("두 번째 노드: {}", next.borrow().value);
    }
}

3. 복잡한 데이터 구조

use std::rc::Rc;
use std::cell::RefCell;

#[derive(Debug)]
struct Document {
    content: String,
    revisions: Vec<String>,
    version: i32,
}

impl Document {
    fn new(content: &str) -> Rc<RefCell<Self>> {
        Rc::new(RefCell::new(Document {
            content: content.to_string(),
            revisions: Vec::new(),
            version: 0,
        }))
    }

    fn update_content(&mut self, new_content: &str) {
        self.revisions.push(self.content.clone());
        self.content = new_content.to_string();
        self.version += 1;
    }

    fn get_history(&self) -> Vec<String> {
        self.revisions.clone()
    }
}

fn main() {
    let doc = Document::new("초기 내용");

    // 문서 수정
    {
        let mut doc_mut = doc.borrow_mut();
        doc_mut.update_content("첫 번째 수정");
    }

    {
        let mut doc_mut = doc.borrow_mut();
        doc_mut.update_content("두 번째 수정");
    }

    // 현재 상태 출력
    let doc_ref = doc.borrow();
    println!("현재 내용: {}", doc_ref.content);
    println!("버전: {}", doc_ref.version);
    println!("수정 이력: {:?}", doc_ref.get_history());
}

주의사항:

  • RefCell의 런타임 검사는 성능에 영향을 줄 수 있습니다.
  • 중첩된 borrow_mut() 호출은 패닉을 일으킬 수 있습니다.
  • 순환 참조는 메모리 누수의 원인이 될 수 있으므로 주의해야 합니다.
  • Weak<T>를 사용하여 순환 참조 문제를 해결할 수 있습니다.

멀티스레드 환경

멀티스레드 환경에서는 Arc<T>Mutex<T>의 조합이 필요합니다.

1. 기본적인 스레드 간 데이터 공유

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));
    let mut handles = vec![];

    // 여러 스레드에서 데이터 수정
    for i in 0..3 {
        let data_clone = Arc::clone(&data);
        let handle = thread::spawn(move || {
            thread::sleep(Duration::from_millis(10 * i as u64));
            let mut data = data_clone.lock().unwrap();
            data.push(i + 4);
            println!("스레드 {}: {:?}", i, *data);
        });
        handles.push(handle);
    }

    // 모든 스레드 완료 대기
    for handle in handles {
        handle.join().unwrap();
    }

    // 최종 결과 확인
    println!("최종 데이터: {:?}", *data.lock().unwrap());
}

2. 복잡한 동시성 시나리오

use std::sync::{Arc, Mutex};
use std::thread;
use std::time::Duration;

#[derive(Debug)]
struct SharedState {
    counter: i32,
    messages: Vec<String>,
}

fn main() {
    let state = Arc::new(Mutex::new(SharedState {
        counter: 0,
        messages: Vec::new(),
    }));

    // 읽기 스레드
    let state_clone = Arc::clone(&state);
    let reader = thread::spawn(move || {
        for _ in 0..5 {
            {
                let state = state_clone.lock().unwrap();
                if !state.messages.is_empty() {
                    println!("읽기: {:?}", state.messages);
                }
            } // 락 자동 해제
            thread::sleep(Duration::from_millis(100));
        }
    });

    // 쓰기 스레드
    let state_clone = Arc::clone(&state);
    let writer = thread::spawn(move || {
        for i in 0..5 {
            {
                let mut state = state_clone.lock().unwrap();
                state.counter += 1;
                state.messages.push(format!("메시지 {}", i));
                println!("쓰기: 카운터 = {}", state.counter);
            } // 락 자동 해제
            thread::sleep(Duration::from_millis(50));
        }
    });

    // 스레드 종료 대기
    reader.join().unwrap();
    writer.join().unwrap();

    // 최종 상태 확인
    let final_state = state.lock().unwrap();
    println!("최종 상태: {:?}", *final_state);
}

주의사항:

  • Mutex 락은 가능한 짧게 유지해야 합니다.
  • 데드락을 피하기 위해 락 획득 순서를 일관되게 유지해야 합니다.
  • lock() 호출 실패 처리를 항상 고려해야 합니다.
  • 긴 작업은 락 밖에서 수행하는 것이 좋습니다.

동시성 패턴과 사용 사례

생산자-소비자 패턴

use std::sync::{Arc, Mutex};
use std::thread;

let queue = Arc::new(Mutex::new(Vec::new()));
let queue_clone = Arc::clone(&queue);

// 생산자 스레드
let producer = thread::spawn(move || {
    let mut queue = queue_clone.lock().unwrap();
    queue.push(1);
});

// 소비자 스레드
let consumer = thread::spawn(move || {
    let mut queue = queue.lock().unwrap();
    if let Some(item) = queue.pop() {
        println!("처리된 아이템: {}", item);
    }
});

성능 고려사항

락 경합 최소화

  • 락 보유 시간 최소화
  • 세밀한 락 범위 설정
  • 필요한 경우에만 락 사용

메모리 사용 최적화

  • 불필요한 클론 피하기
  • 적절한 타입 선택으로 오버헤드 최소화

러스트의 동시성 타입은 각각의 사용 사례에 맞게 선택되어야 합니다. 싱글스레드와 멀티스레드 환경에 따라 적절한 타입을 선택하고, 성능과 안전성을 모두 고려하여 구현하는 것이 중요합니다.