러스트의 소유권과 타입시스템은 런타임이 아닌 컴파일 타임에 다양한 동시성 에러를 처리할 수 있게 한다. 그결과 운영환경 배포전 개발 과정에서 코드의 오류를 수정 할 수 있다. 러스트 팀은 이런 기능에 ‘자신있는 동시성(fearless concerrency)’ 라는 별명을 붙였다. 자신있는 동시성 덕분에 버그 없는 코드를 작성하고 새로운 버그의 출현에 대한 걱정없이 쉽게 리팩토링 할 수 있다.

코드를 동시에 실행하기 위한 스레드

  • 요즘 사용하는 대부분 OS는 프로그램의 코드를 process로 실행하며, OS가 한번에 여러개의 process를 관리한다.
  • 프로그램 안에서는 동시에 실행되는 독립된 부분이 있을수 있다. 이렇게 독립된 부분을 실행하는 기능을 스레드(threads)라고 한다.
  • 프로그램의 연산을 여러개의 스레드로 분리하면 성능은 향상되지만 복잡도가 증가한다.
  • 스레드는 동시에 실행되므로 다른 스레드에서 실행되는 코드의 순서를 보장할 수 없다. 이런 특징은 다음과 같은 문제의 원인이 된다.
    • 경합 상태(race conditions): 스레드가 일정하지 않은 순서로 데이터나 자원에 접근하는 상황
    • 데드락(deadlocks): 두 스레드가 모두 서로 다른 자원의 사용을 마칠 때까지 대기해서 두 스레드 모두 대기 상태에 놓이는 상황
    • 특정 상황에서만 발생해서 재현이나 수정이 어려운 버그
  • 러스트는 저수준 언어라서 러스트의 표준 라이브러리는 1:1스레드 구현만을 지원한다. 하지만 오버헤드를 감수하더라도 스레드의 실행을 더 상세히 제어하고 싶은경우 등을 위한 크레이트도 존재한다.

스레드 간 데이터 교환을 위한 메시지 전달

  • 동시성의 안정을 보장하려는 방법 중 빠르게 대중화되고 있는 방법은 message passing 이다.
  • 러스트가 가진 도구 중 message passing 동시성을 구현하기 위한 것은 표준 라이브러리가 구현하는 프로그래밍 개념인 channel이다.
  • channel은 전달자(trasmitter)와 수신자(receiver)로 구성된다
  • channel은 transmitter나 receiver가 해제되면 함께 닫힌다
  • mpsc::channel함수를 이용해 생성한다. 여기서 mpsc는 multiple producer, single consumer의 약자다
  • 간단히 말해 러스트의 표준 라이브러리가 구현하는 channel은 여러 코드가 값을 보낼 수 있지만, 이 값을 수신해서 사용하는곳은 오직 한 곳이이어야 한다

    # 간단한 스레드 사용 예
    use std::thread;
    use std::sync::mpsc;
    
    fn main() {
        let (tx, rx) = mpsc::channel();
    
        thread::spawn(move || {
            let val = String::from("hello");
            tx.send(val).unwrap(); // val 소유권이 receiver로 이동함
        });
    
      // recv: 메시지 수신시까지 주스레드 멈춤
      // try_recv메서드를 이용하면 스레드 멈추지 안고 즉시 리턴
        let received = rx.recv().unwrap();
        println!("received: {}", received);
    }
    

공유 상태 동시성

  • 대부분 프로그래밍 언어에서 채널은 단일 소유권을 의미한다. 일단 값을 채널로 보내면 그 값은 더 이상 유효하지 않기 때문이다.
  • 공유 메모리 동시성은 다중 소유권과 유사하다. 여러개의 스레드가 같은 메모리의 데이터에 동시에 접근할 수 있기 때문이다.
  • 스마트 포인터를 이용하면 다중 소유권을 적용할수 있다.
  • 다중 소유권은 여러 소유자를 관리해야하므로 복잡도가 증가한다.
  • 러스트의 타입 시스템과 소유권 규칙은 이런 관리에 드는 노력을 상당 부분 해소한다.
  • 러스트에서 메모리 공유에 가장 보편적으로 사용되는것이 Mutex이다.

Mutex

  • 뮤텍스는 mutual exclusion(상호 배체)의 약자로 어느 한 시점에 하나의 스레드만 데이터에 접근하도록 한다.
  • 스레드가 뮤텍스 안의 데이터에 접근하려면 먼저 뮤텍스의 lock을 요청해야 한다.
  • lock은 뮤텍스를 구성하는 데이터 구조이며 어느 스레드가 데이터에 배타적으로 접근하고 있는지 추적하는 용도로 사용된다.
  • 뮤텍스는 lock시스템을 이용해 보유하고 있는 데이터를 보호한다.
  • 뮤텍스는 다음의 두가지 규칙을 기억해야 하므로 사용하기가 까다롭다는 평을 듣는다
    • 데이터를 사용하기 전에 반드시 락을 획득해야한다.
    • 뮤텍스가 보호하는 데이터를 사용한 후에는 다른 스레드가 락을 얻을 수 있도록 데이트럴 언락 해야한다.
  • Mutex<T>는 스마트 포인터이다.
  • Arc<T> 타입은 Rc<T>와 같은 동작을 동시성 환경에서 안전하게 사용할 수 있는 타입이다. A는 Atomic의 약자로 카운터의 변경에 대한 원자성을 보장한다는 뜻이다.
  • 표준 라이브러리의 타입이 Arc<T>를 기본으로 사용하도록 구현되지 않은 이유는 스레드 안정성을 보장하려면 성능의 손실이 발생하기 때문이다.

    use std::sync::{Mutex, Arc};
    use std::thread;
    
    fn main() {
        let counter = Arc::new(Mutex::new(0));
        let mut handles = vec![];
    
        for _ in 0..10 {
            let counter = Arc::clone(&counter);
            let handle = thread::spawn(move || {
                let mut num = counter.lock().unwrap();
    
                *num += 1;
            });
            handles.push(handle);
        }
    
        for handle in handles {
            handle.join().unwrap();
        }
    
        println!("Result: {}", *counter.lock().unwrap());
    }
    
  • 위의 코드에서 counter변수는 불변이지만 그 않의 값에 대한 가변 참조를 가져올 수 있다.
  • Mutex<T> 가 Cell 계열 타입처럼 내부 가변성을 지원한다는 뜻이다.
  • RefCell<T>타입을 사용해 Rc<T>타입에 저장된 값을 변경했던 것처럼 Mutex<T>타입을 이용해 Arc<T>타입에 저장된 값을 변경할 수 있다.

Sync와 Send Trait로 동시성 확장하기

  • 러스트는 표준 라이브러리 외에 언어차원 에서도 두개의 동시성 개념을 지원한다.
  • std::marker Trait인 Sync와 Send Trait이 바로 그것이다.
  • Send Trait는 구현하는 타입이 소유권이 다른 스레드로 이전될 수 있을음 표시하는 마커다.
  • 러스트의 거의 모든 타입은 Send Trait를 구현하지만 몇가지 예외도 있다.
  • Rc<T>는 Send Trait를 구현하지 않기 때문에 Rc<T>값을 복제해서 그 소유권을 다른 스레드로 이전하면 두 스레드가 동시에 참조 카운터를 변경하게된다. 그래서 Rc<T>는 스레드 안정성을 위한 성능 손실이 없는 단일 스레드 환경에서만 사용해야 한다.
  • Sync Trait는 여러 스레드가 타입을 안전하게 참조할 수 있음을 표시하기 위한 트레이트다.
  • 어떤 타입 T가 Sync Trait을 구현하고 있고, &T(T의 참조)가 Send Trait를 구현하고 있다면 이 참조는 다른 스레드로 안전하게 전달 할 수 있다.
  • 거의 모든 기본 자료형은 역시 Sync Trait를 구현하고 있다.
  • Rc<T>스마트 포인터는 Send Trait를 구현하지 않는 것과 같은 이유로 Sync Trait도 구현하지 않는다.
  • Send와 Sync Trait로 구성된 타입은 자동으로 구현되기 때문에 트레이트를 직접 구현할 필요가 없을 뿐더러 직접 구현하는것은 안전하지 않다. 게다가 마커 Trait이기 때문에 따로 구현해야할 메서드도 없다.