`Sync`와 `Send` 트레이트를 이용한 확장 가능한 동시성
Send와 Sync가 스레드 간 소유권 이동과 공유를 보장하는 방식을 이해하고 async 런타임·서버 스택으로 확장합니다.
흥미롭게도, 이번 장에서 지금까지 이야기한 동시성 기능은 거의 모두 언어가 아니라 표준 라이브러리에 속합니다. 동시성을 다루는 선택지는 언어나 표준 라이브러리에만 있지 않습니다. 직접 동시성 기능을 쓸 수도 있고, 다른 사람이 쓴 것을 쓸 수도 있습니다. 하지만 언어에 들어 있는 핵심 동시성 개념 가운데에는, 표준 라이브러리가 아니라 언어에 있는 std::marker 트레이트 Send와 Sync가 있습니다.
Send를 사용하여 스레드 사이에 소유권 이동을 허용하기
Send 마커 트레이트는 Send를 구현한 타입의 값의 소유권을 스레드 사이에서 옮길 수 있다는 것을 나타냅니다. 거의 모든 러스트 타입은 Send이지만, Rc<T>를 비롯한 몇 가지 예외가 있습니다. Rc<T>는 Send일 수 없습니다. Rc<T> 값을 클론해 그 클론의 소유권을 다른 스레드로 옮기려 하면, 두 스레드가 동시에 참조 카운트를 바꿀 수 있기 때문입니다. 그래서 Rc<T>는 스레드 안전성의 성능 비용을 치르지 않아도 되는 단일 스레드 상황에서 쓰도록 구현되었습니다. 아래 그림은 Send와 Sync가 각각 무엇을 보장하는지와, 표준 라이브러리의 타입들이 언제 이 트레이트를 구현하는지 정리한 것입니다.
RUST · SEND / SYNC CONTRACTS
Send와 Sync는 이동시키는 대상에 따라 함께 요구될 수 있다
값 T의 이동과 공유 참조 &T의 이동은 서로 다른 조건입니다. 아래 비교는 std 라이브러리 타입을 기준으로 하며, 도구의 소유권 책임과 접근 동기화 책임을 함께 살펴봅니다.
Send · 값 자체의 이동
T: Send값을 스레드 경계로 옮겨도 안전하다는 약속입니다. Rc<T>는 Send가 아닙니다. RefCell<T>는 T: Send이면 이동할 수 있으나 공유 참조는 별도 조건입니다.
Sync · 공유 참조의 이동
T: Sync ⇔ &T: SendT의 공유 참조를 여러 스레드에 보낼 수 있다는 약속입니다. RefCell과 Cell은 Sync가 아닙니다. 값과 공유 참조를 모두 보내는 API는 두 조건이 함께 필요할 수 있습니다.
Arc · 소유권 공유의 조건
Arc<T>: Send + Sync
if T: Send + Sync원자적 참조 카운트가 T의 내부 연산까지 동기화하지는 않습니다. Arc<RefCell<i32>>로 감싸도 안전한 스레드 공유를 얻지 못합니다.
Mutex · 배타 접근의 조건
Mutex<T>: Send + Sync
if T: SendMutex는 T에 대한 동시 접근을 잠금으로 직렬화합니다. 따라서 Arc<Mutex<i32>>는 스레드 간 소유권 공유와 내부 변경을 함께 처리합니다.
일반 구조체 · 필드에서 자동 결정
보통 모든 필드가 해당 auto trait를 만족하면 구조체도 그 trait를 얻습니다. 라이브러리 타입의 명시적 구현과 부정 구현도 적용되므로 Arc/Mutex 같은 래퍼는 실제 바운드를 확인합니다.
unsafe impl · 수동 안전성 약속
unsafe impl Send/Sync는 검사를 강제로 무시해도 된다는 뜻이 아닙니다. 안전한 사용으로 데이터 경합 등이 생기지 않는다는 불변식을 작성자가 입증하고 유지해야 합니다.
Send·Sync는 데이터 경합에 관한 계약입니다. 데드락·논리적 오류·모든 비동기 실행 요구사항까지 자동으로 해결하지는 않습니다.
T: Send는 값 자체를 다른 스레드로 옮겨도 안전하다는 뜻입니다. RefCell<T>는 T가 Send이면 옮길 수 있지만, 공유 참조를 보내는 것은 별개의 조건입니다. T: Sync는 &T가 Send라는 것과 같으며, T의 공유 참조를 여러 스레드에 보낼 수 있다는 뜻입니다. 값과 공유 참조를 모두 보내는 API는 두 조건을 함께 요구할 수 있습니다. Arc<T>는 T가 Send + Sync일 때 Send + Sync이며, 원자적 참조 카운트가 T의 안쪽 연산까지 동기화해 주지는 않으므로 Arc<RefCell<i32>>로는 안전하게 공유할 수 없습니다. Mutex<T>는 T가 Send이면 Send + Sync이며, 락으로 T에 대한 동시 접근을 하나씩 처리하므로 Arc<Mutex<i32>>는 소유권 공유와 안쪽 값의 변경을 함께 다룹니다.
따라서 러스트의 타입 시스템과 트레이트 바운드는 실수로 Rc<T> 값을 스레드 사이로 안전하지 않게 보낼 수 없도록 보장합니다. 공유 상태 동시성에서 그렇게 하려 했을 때, the trait `Send` is not implemented for `Rc<std::sync::Mutex<i32>>` 오류가 났습니다. Send인 Arc<T>로 바꾸자 코드가 컴파일되었습니다.
모두 Send 타입으로 이루어진 타입은 자동으로 Send로도 표시됩니다. 안전하지 않은 러스트에서 이야기할 원시 포인터(raw pointer)를 빼면, 거의 모든 기본 타입은 Send입니다.
Sync를 사용하여 여러 스레드로부터의 접근을 허용하기
Sync 마커 트레이트는 Sync를 구현한 타입을 여러 스레드에서 참조해도 안전하다는 것을 나타냅니다. 다시 말해, 어떤 타입 T는 &T(T의 불변 참조)가 Send라면 Sync를 구현하며, 이는 그 참조를 다른 스레드로 안전하게 보낼 수 있다는 뜻입니다. Send와 비슷하게 원시 포인터를 빼면 거의 모든 기본 타입은 Sync를 구현하고, 모두 Sync를 구현한 타입으로 이루어진 타입도 Sync를 구현합니다.
스마트 포인터 Rc<T>는 Send를 구현하지 않는 것과 같은 까닭으로 Sync도 구현하지 않습니다. RefCell<T>와 내부 가변성 패턴에서 이야기한 RefCell<T> 타입과, 관련된 Cell<T> 계열의 타입은 Sync를 구현하지 않습니다. RefCell<T>가 런타임에 하는 대여 검사의 구현은 스레드 안전하지 않습니다. 스마트 포인터 Mutex<T>는 Sync를 구현하며, 공유 상태 동시성에서 본 것처럼 여러 스레드가 접근을 함께 쓰는 데 쓸 수 있습니다.
Send와 Sync를 손수 구현하는 것은 안전하지 않습니다
Send와 Sync 트레이트로 이루어진 타입은 자동으로 Send와 Sync가 되므로, 이 트레이트들을 직접 구현할 필요는 없습니다. 마커 트레이트이므로 구현할 메서드도 없습니다. 이 트레이트들은 동시성과 관련된 불변 조건을 지키게 하는 데만 쓸모가 있습니다. 보통은 모든 필드가 이 트레이트를 만족하면 구조체도 이 트레이트를 얻지만, 라이브러리 타입에는 명시적인 구현이나 구현하지 않음을 밝힌 구현도 있으므로 Arc나 Mutex 같은 래퍼는 실제 바운드를 확인합니다.
이 트레이트들을 직접 구현하려면 안전하지 않은 러스트 코드를 구현해야 합니다. 안전하지 않은 러스트 코드를 쓰는 것은 안전하지 않은 러스트에서 이야기합니다. 지금 중요한 것은, Send와 Sync가 아닌 부분으로 이루어진 새 동시성 타입을 만들려면 안전성 보장을 지키도록 신중하게 생각해야 한다는 것입니다. unsafe impl Send나 unsafe impl Sync는 검사를 무시해도 된다는 뜻이 아니라, 안전한 코드로 써도 데이터 경쟁이 생기지 않는다는 불변 조건을 작성자가 직접 보장하고 지키겠다는 약속입니다. The Rustonomicon에 이런 보장과 그것을 지키는 방법에 관한 더 많은 정보가 있습니다. Send와 Sync는 데이터 경쟁에 관한 약속이며, 교착 상태나 논리 오류까지 저절로 막아 주지는 않습니다.
더 깊이: 자동 트레이트와 Send가 아닌 MutexGuard
Send와 Sync는 Reference가 자동 트레이트(auto trait)라고 부르는 종류이며, Unpin, UnwindSafe, RefUnwindSafe도 여기에 속합니다. Reference에 따르면 직접 구현하지도, 음의 구현(negative implementation)을 적지도 않은 타입에는 컴파일러가 규칙에 따라 자동으로 구현합니다. 구조체와 열거형, 튜플은 모든 필드가 그 트레이트를 구현하면, 클로저는 캡처한 값의 타입이 모두 구현하면 그 트레이트를 갖습니다. 표준 라이브러리는 *mut T에 Send의 음의 구현을 두어 이 자동 구현을 막으며, 이런 음의 구현은 아직 안정된 방법으로는 표준 라이브러리 밖에서 쓸 수 없습니다.
Sync이지만 Send가 아닌 타입도 있습니다. 락 가드인 MutexGuard가 그 예입니다. MutexGuard 문서는 POSIX 스레드(pthreads)를 쓰는 플랫폼이 락을 얻은 스레드에서 그 락을 풀도록 요구하므로, 가드가 다른 스레드에서 버려지지 않도록 Send를 구현하지 않는다고 설명합니다. 반면 T가 Sync이면 MutexGuard는 Sync입니다. 러스토노미콘은 다른 스레드로 보낼 수 있는 것이 &MutexGuard뿐이고 참조를 버리는 것은 아무 일도 하지 않기 때문이라고 설명합니다.
Tokio 런타임과 async 동시성 연결하기
공식 Book(The Rust Programming Language)은 이 장 다음에 async와 await로 비동기 프로그래밍을 다루는 장을 두지만, 이 교재에는 그 장이 들어 있지 않습니다. 대신 여기서는 비동기 런타임인 Tokio로 태스크 하나를 실행해 보며, 스레드 대신 태스크로 동시성을 다루는 방식을 간단히 맛봅니다. 자세한 내용은 공식 Book의 Fundamentals of Asynchronous Programming 장과 Tokio 튜토리얼을 참고하세요.
async 블록은 아직 끝나지 않은 계산을 나타내는 Future 값을 만듭니다. 이 블록을 만들기만 해서는 안의 코드가 실행되지 않습니다. Tokio 같은 런타임이 Future를 실행해(poll) 진행시키고, 아직 결과가 준비되지 않았다면 Future는 Pending을 돌려주어 멈춥니다. 기다리던 일이 준비되면 런타임이 깨워(wake) 다시 실행합니다. 스레드 방식과 태스크 방식을 비교하는 그림은 우아한 종료와 정리에 있습니다.
태스크 하나를 준비하고 결과 받기
Cargo 기초에서 한 방식으로 연습용 프로젝트를 따로 만듭니다. Tokio는 표준 라이브러리에 없는 외부 크레이트이므로, 의존성을 더할 수 있는 Cargo 프로젝트가 필요합니다.
cargo new tokio_first
cd tokio_firstCargo.toml의 [dependencies]에 Tokio를 더하고, 이 예제에서 쓰는 기능(feature)만 켭니다. 이 글을 쓰는 시점의 최신 버전은 1.54.1입니다.
[dependencies]
tokio = { version = "1.54.1", features = ["rt", "macros", "time"] }rt는 태스크를 실행하는 런타임과 현재 스레드 스케줄러를, macros는 #[tokio::main]을, time은 비동기 타이머를 켭니다. 기능 목록은 Tokio 문서의 기능 설정에 있습니다. src/main.rs는 다음과 같습니다.
use std::time::Duration;
use tokio::time::sleep;
#[tokio::main(flavor = "current_thread")]
async fn main() {
let waiting = async {
println!("task: waiting");
sleep(Duration::from_millis(100)).await;
println!("task: ready");
7
};
println!("main: future created");
let task = tokio::spawn(waiting);
println!("main: task scheduled");
let result = task.await.expect("task failed");
println!("main: result = {result}");
}#[tokio::main]은 런타임을 준비하고 async fn main을 그 위에서 실행하며, flavor = "current_thread"는 태스크를 지금의 스레드 하나에서 실행하도록 정합니다. 태스크마다 새 운영체제 스레드를 만드는 설정은 아닙니다. 이 프로그램은 다음을 출력할 것입니다.
main: future created
main: task scheduled
task: waiting
task: ready
main: result = 7waiting을 만든 뒤에도 안의 코드는 아직 실행되지 않으므로 main의 두 줄이 먼저 나옵니다. tokio::spawn은 태스크를 예약할 뿐 그 자리에서 곧바로 실행하지 않고, 이 단일 스레드 런타임에서 main이 task.await로 기다리기 시작해야 태스크가 실행됩니다. 태스크는 task: waiting을 출력한 뒤 타이머를 기다리는 동안 Pending으로 멈추고, 100밀리초 뒤 깨어나 task: ready를 출력하고 7을 돌려줍니다. task.await는 태스크의 결과를 받는 JoinHandle을 기다려 그 값을 Ok로 받습니다.
런타임에 태스크를 맡길 때도 Send 규칙이 이어집니다. tokio::spawn은 current_thread에서도 Future와 그 결과에 Send + 'static을 요구합니다. 또 async 안에서 std::thread::sleep이나 긴 계산을 부르면 그 태스크를 실행하는 런타임 스레드 전체가 막히므로, 기다릴 때는 tokio::time::sleep처럼 .await로 기다리는 함수를 씁니다. 표준 라이브러리의 std::sync::Mutex는 락을 짧게 쥐고 .await를 넘기지 않을 때 알맞고, 락을 쥔 채 비동기로 기다려야 한다면 tokio::sync::Mutex를 씁니다. 채널도 스레드 사이에는 std::sync::mpsc를, 비동기 태스크 사이에는 tokio::sync::mpsc를 씁니다.
정리
이 교재에서 동시성을 다루는 것은 이것이 마지막이 아닙니다. 19장의 프로젝트에서는 이번 장의 개념을 여기서 다룬 작은 예제보다 더 현실적인 상황에서 씁니다.
앞에서 말했듯 러스트 언어 자체가 동시성을 다루는 방식은 아주 적으므로, 많은 동시성 해결책은 크레이트로 구현됩니다. 이런 크레이트는 표준 라이브러리보다 빨리 발전하므로, 멀티스레드 상황에서 쓸 지금의 최신 크레이트를 온라인에서 찾아보세요.
러스트의 표준 라이브러리는 메시지 전달을 위한 채널과, 동시성 상황에서 쓰기 안전한 Mutex<T>와 Arc<T> 같은 스마트 포인터 타입을 제공합니다. 타입 시스템과 대여 검사기는 이런 해결책을 쓰는 코드에서 데이터 경쟁이나 유효하지 않은 참조가 생기지 않도록 보장합니다. 코드를 컴파일하고 나면, 다른 언어에서 흔한 추적하기 어려운 버그 없이 여러 스레드에서 잘 실행될 것이라고 믿을 수 있습니다. 동시성 프로그래밍은 더 이상 두려워할 개념이 아닙니다. 겁 없이 프로그램을 동시적으로 만들어 보세요.
Send는 값의 소유권을 다른 스레드로 옮겨도 안전하다는 표시이며,Rc<T>와 원시 포인터를 빼면 거의 모든 타입이Send입니다.Sync는&T가Send라는 뜻으로, 여러 스레드에서 참조해도 안전하다는 표시이며,Rc<T>,RefCell<T>,Cell<T>는Sync가 아닙니다.- 모든 필드가
Send나Sync인 타입은 자동으로 그 트레이트를 얻으며, 직접 구현하려면 불변 조건을 스스로 보장하는unsafe impl이 필요합니다. Arc<T>는T: Send + Sync일 때,Mutex<T>는T: Send일 때 스레드 사이에서 공유할 수 있습니다.- 비동기 런타임 Tokio에서는
async블록이 만든Future를 태스크로 실행하며,tokio::spawn도Send + 'static을 요구합니다.
연습 문제
1. 스레드로 보낼 수 있을까
다음 가운데 thread::spawn(move || ...)의 클로저가 캡처해 다른 스레드로 옮길 수 있는 것은 무엇인가요? (가) Vec<String>, (나) Rc<i32>, (다) Arc<Mutex<Vec<i32>>>, (라) Arc<RefCell<i32>>.
해설 보기
(가)는 String과 Vec이 모두 Send이므로 옮길 수 있습니다. (나)는 Rc<T>가 Send가 아니므로 E0277 오류가 납니다. (다)는 Vec<i32>가 Send라 Mutex가 Send + Sync가 되고, 따라서 Arc도 Send이므로 옮길 수 있습니다. (라)는 RefCell<i32>가 Sync가 아니어서 Arc<RefCell<i32>>가 Send가 아니므로 옮길 수 없습니다.
2. 태스크 안에서 잠들기
이 글의 Tokio 예제에서 sleep(Duration::from_millis(100)).await를 std::thread::sleep(Duration::from_millis(100))으로 바꾸면 출력이 달라지나요? 태스크가 여럿이라면 무엇이 문제인가요?
해설 보기
태스크가 하나뿐이므로 출력 순서는 같지만, 그 100밀리초 동안 런타임의 유일한 스레드가 통째로 막힙니다. 태스크가 여럿이라면 한 태스크가 잠든 동안 다른 태스크도 전혀 진행하지 못하므로, 비동기로 동시에 기다리려던 이점이 사라집니다. 비동기 코드에서는 .await로 기다리는 tokio::time::sleep을 써야 기다리는 동안 다른 태스크가 실행됩니다.
이것으로 15장을 마칩니다. 다음 장의 객체 지향 언어의 특성부터는 러스트가 객체 지향 프로그래밍의 개념과 어떻게 이어지는지 살펴봅니다.