T: Send + Sync + 'static 완전히 이해하기

Doosan published on
8 min, 1469 words

Categories: Post

Rust에서 스레드나 비동기 작업을 다루다 보면 다음 제약을 자주 만난다.

T: Send + Sync + 'static

처음 보면 T를 프로그램이 종료될 때까지 유지하면서 다른 스레드로 보내라는 뜻처럼 보인다. 하지만 실제 의미는 다르다.

먼저 한 문장으로 정리하면 다음과 같다.

T: Send + Sync + 'staticT의 소유권을 다른 스레드로 안전하게 옮길 수 있고, 여러 스레드가 T를 공유 참조로 안전하게 접근할 수 있으며, T 안에 수명이 짧은 참조가 없다는 뜻이다.

Send, Sync, 'static은 서로 독립된 조건이다. 하나씩 분리해서 살펴보자.

Send: 다른 스레드로 소유권을 옮겨도 안전하다

Send는 값을 한 스레드에서 다른 스레드로 안전하게 옮길 수 있음을 나타내는 마커 트레이트다.

pub unsafe auto trait Send {}

대부분의 일반적인 타입은 구성 요소가 모두 Send라면 자동으로 Send가 된다. 예를 들어 String, Vec<T>, Box<T>는 내부 타입이 조건을 만족하면 다른 스레드로 이동할 수 있다.

반면 Rc<T>Send가 아니다. 참조 카운트를 원자적으로 갱신하지 않기 때문에 여러 스레드에서 사용하면 안전하지 않다.

use std::rc::Rc;
use std::thread;

fn main() {
    let value = Rc::new(String::from("hello"));

    thread::spawn(move || {
        println!("{value}");
    });
}

이 코드는 컴파일되지 않는다. move 클로저가 Rc<String>의 소유권을 새 스레드로 옮기려고 하지만 Rc<String>: Send가 아니기 때문이다.

Sync: 여러 스레드가 공유 참조로 접근해도 안전하다

Sync는 여러 스레드가 하나의 값에 대한 공유 참조를 안전하게 사용할 수 있음을 나타내는 마커 트레이트다.

pub unsafe auto trait Sync {}

Rust 표준 라이브러리는 그 관계를 다음과 같이 정의한다.

TSync라는 것은 &TSend라는 것과 같다.

즉, &T를 다른 스레드로 보내도 안전하다면 T: Sync다. Sync는 “소유권을 이동해도 되는가?”가 아니라 “공유 참조를 여러 스레드에서 사용해도 되는가?”에 답한다.

Cell<T>는 두 조건의 차이를 잘 보여준다. Cell<i32>는 소유권 전체를 다른 스레드로 옮길 수 있으므로 Send지만, 공유 참조를 통해 내부 값을 변경할 수 있고 그 변경을 동기화하지 않으므로 Sync는 아니다.

use std::cell::Cell;

fn require_send<T: Send>() {}
fn require_sync<T: Sync>() {}

fn main() {
    require_send::<Cell<i32>>();
    require_sync::<Cell<i32>>(); // 컴파일 오류: Cell<i32>는 Sync가 아니다.
}

여러 스레드에서 값을 공유해야 한다면 보통 Arc<T>를 사용한다.

use std::sync::Arc;
use std::thread;

fn main() {
    let value = Arc::new(String::from("hello"));
    let cloned = Arc::clone(&value);

    let handle = thread::spawn(move || {
        println!("{cloned}");
    });

    handle.join().unwrap();
}

단, Arc<T>라고 해서 내부 값이 자동으로 스레드 안전해지는 것은 아니다. Arc는 참조 카운트만 원자적으로 관리한다. Arc<T>를 스레드 사이에서 안전하게 이동하려면 내부의 TSend + Sync 조건을 만족해야 한다.

공유하면서 값을 변경해야 한다면 Arc<Mutex<T>>, Arc<RwLock<T>>, 원자 타입처럼 접근 자체를 동기화하는 도구가 추가로 필요하다.

다음처럼 공유 값을 백그라운드 스레드로 보내는 제네릭 API에서 세 조건이 함께 등장할 수 있다.

use std::fmt::Debug;
use std::sync::Arc;
use std::thread::{self, JoinHandle};

fn print_in_background<T>(value: Arc<T>) -> JoinHandle<()>
where
    T: Debug + Send + Sync + 'static,
{
    thread::spawn(move || println!("{value:?}"))
}
  • Send: Arc<T>의 소유권을 새 스레드로 옮길 수 있어야 한다.
  • Sync: 여러 Arc가 같은 T를 공유 참조로 접근할 수 있어야 한다.
  • 'static: T가 호출자의 스택에 있는 수명이 짧은 참조를 포함해서는 안 된다.

'static: 값이 영원히 살아야 한다는 뜻이 아니다

'static은 두 위치에서 서로 다르게 보인다.

첫 번째는 참조 자체의 수명이다.

let message: &'static str = "hello";

문자열 리터럴은 프로그램 바이너리에 저장되므로 이 참조는 프로그램 전체 실행 시간 동안 유효하다.

두 번째는 타입에 붙는 수명 제약이다.

T: 'static

이 표현은 T가 프로그램 종료까지 반드시 살아 있어야 한다는 뜻이 아니다. T 안의 모든 참조가 'static보다 짧지 않아야 한다는 뜻이다. 더 실용적으로 말하면, T가 수명이 짧은 빌린 값을 포함하지 않아야 한다.

소유한 값은 보통 이 조건을 자연스럽게 만족한다.

fn accept_static<T: 'static>(value: T) {
    drop(value);
}

fn main() {
    let message = String::from("hello");
    accept_static(message);
}

String은 자신의 데이터를 소유하므로 String: 'static이다. 하지만 위 함수는 값을 즉시 버린다. 따라서 T: 'static이 값 자체를 영원히 살게 한다는 해석은 틀렸다.

다음 타입은 수명이 짧은 참조를 포함하므로 'static 조건을 만족하지 못한다.

fn accept_static<T: 'static>(_: T) {}

fn main() {
    let message = String::from("hello");
    let borrowed = &message;

    accept_static(borrowed);
}

borrowed의 타입은 지역 변수 message의 수명에 묶인 &String이다. 이 참조는 'static이 아니다.

thread::spawn에는 Send + 'static만 보일까?

std::thread::spawn의 시그니처를 단순화하면 다음과 같다.

pub fn spawn<F, T>(f: F) -> JoinHandle<T>
where
    F: FnOnce() -> T + Send + 'static,
    T: Send + 'static,

새 스레드는 spawn을 호출한 함수보다 오래 실행될 수 있다. JoinHandle을 버리면 스레드는 분리되어 계속 실행될 수도 있다. 컴파일러는 새 스레드가 언제 종료될지 알 수 없으므로 클로저가 지역 변수의 짧은 참조를 들고 떠나는 것을 허용할 수 없다. 이것이 'static이 필요한 이유다.

클로저 자체는 새 스레드로 이동한다. 따라서 클로저가 캡처한 값도 안전하게 이동할 수 있어야 한다. 이것이 Send가 필요한 이유다.

반환값 T에도 같은 제약이 붙는다. T는 새 스레드에서 생성되어 JoinHandle<T>에 보관된 뒤 join을 호출한 스레드로 전달되므로 Send여야 한다. 일반 스레드와 JoinHandle<T>은 생성된 스코프 밖으로 이동할 수 있으므로, T: 'static은 반환값이 해당 스코프에 묶인 짧은 참조를 포함하지 않도록 보장한다.

이 제약이 새 스레드 자신의 지역 변수를 가리키는 참조의 반환을 막는 것은 아니다. 그런 참조는 함수가 끝나면 무효가 되므로 'static 제약과 관계없이 빌림 검사기가 금지한다. 반면 ScopedJoinHandle<'scope, T>은 스코프 밖으로 나갈 수 없으므로 T: 'scope만 요구하며, 바깥 스코프에서 빌린 값을 결과로 반환할 수 있다.

여기에는 Sync가 없다. 값을 새 스레드 하나로 이동하기만 한다면 여러 스레드가 동시에 공유 참조로 접근하지 않기 때문이다. 예를 들어 Cell<i32>Sync가 아니지만 소유권 전체를 새 스레드로 옮기는 다음 코드는 유효하다.

use std::cell::Cell;
use std::thread;

fn main() {
    let value = Cell::new(42);

    thread::spawn(move || {
        value.set(43);
        assert_eq!(value.get(), 43);
    })
    .join()
    .unwrap();
}

따라서 스레드를 사용한다는 이유만으로 모든 제네릭 타입에 Sync를 추가하면 안 된다. 실제로 여러 스레드가 같은 값을 공유할 때만 필요하다.

move는 빌린 값을 'static으로 만들지 않는다

다음 코드는 흔히 만나는 실패 사례다.

use std::thread;

fn main() {
    let message = String::from("hello");
    let borrowed = &message;

    thread::spawn(move || {
        println!("{borrowed}");
    });
}

move는 클로저가 캡처한 변수를 값으로 가져가게 한다. 하지만 여기서 가져가는 값은 String이 아니라 &String이다. 참조를 이동해도 그 참조가 가리키는 데이터의 수명은 늘어나지 않는다.

소유한 String 자체를 클로저로 옮기면 해결된다.

use std::thread;

fn main() {
    let message = String::from("hello");

    let handle = thread::spawn(move || {
        println!("{message}");
    });

    handle.join().unwrap();
}

이제 클로저가 데이터를 직접 소유한다. StringSend + 'static을 만족하므로 새 스레드가 얼마나 오래 실행되든 참조가 무효해질 위험이 없다.

빌린 값을 사용해야 한다면 thread::scope

항상 데이터를 복제하거나 Arc로 감쌀 필요는 없다. 스레드가 특정 범위 안에서 반드시 종료된다는 사실을 보장할 수 있다면 std::thread::scope를 사용할 수 있다.

use std::thread;

fn main() {
    let message = String::from("hello");

    thread::scope(|scope| {
        scope.spawn(|| {
            println!("{message}");
        });
    });
}

thread::scope는 범위를 벗어나기 전에 내부에서 생성한 모든 스레드를 조인한다. 따라서 새 스레드가 message보다 오래 살 수 없고, 클로저가 non-'static 참조를 빌릴 수 있다.

여기서도 스레드 경계는 존재하므로 안전한 스레드 간 이동에 필요한 Send 조건까지 사라지는 것은 아니다. 사라지는 것은 독립적으로 실행되는 스레드에 필요했던 'static 제약이다.

세 조건은 서로 독립적이다

다음처럼 생각하면 구분하기 쉽다.

타입SendSync'static이유
String데이터를 소유하며 안전하게 이동하거나 공유할 수 있다.
Rc<String>아니요아니요데이터를 소유하지만 참조 카운트가 스레드 안전하지 않다.
Cell<i32>아니요소유권은 이동할 수 있지만 공유 참조를 통한 변경은 동기화되지 않는다.
지역 String을 가리키는 &String아니요참조 대상은 공유해도 안전하지만 지역 값의 수명에 묶여 있다.
&'static str참조 대상이 프로그램 전체 실행 시간 동안 유효하다.

즉, 소유한 타입이라고 해서 반드시 SendSync인 것은 아니며, 스레드 안전한 참조라고 해서 반드시 'static인 것도 아니다.

비동기 코드에서도 같은 원리다

멀티 스레드 런타임에서 사용하는 tokio::spawn도 일반적으로 future에 Send + 'static을 요구한다.

  • Send: 런타임이 future를 다른 워커 스레드로 옮길 수 있다.
  • 'static: 생성된 task가 현재 함수보다 오래 실행될 수 있다.

spawn_local은 task를 현재 로컬 실행기에서만 실행하므로 Send를 요구하지 않는다. 하지만 task가 호출자의 스택을 자유롭게 빌릴 수 있다는 뜻은 아니므로 일반적으로 'static 제약은 여전히 남는다.

tokio::spawn도 future 자체에 Sync를 요구하지는 않는다. 다만 future가 &T를 잡은 채 .await 지점을 넘어가고 런타임이 그 future를 다른 스레드로 옮길 수 있다면, 그 참조가 Send이기 위해 T: Sync가 간접적으로 필요해질 수 있다.

언제 직접 추가해야 할까?

제네릭 타입에 습관적으로 Send + Sync + 'static을 추가하면 API가 불필요하게 제한된다. 실제 동작이 요구할 때만 추가하는 것이 좋다.

Send가 필요한 대표적인 경우는 다음과 같다.

  • 값을 다른 OS 스레드로 이동한다.
  • 멀티 스레드 비동기 실행기가 작업을 워커 스레드 사이에서 이동할 수 있다.
  • 값을 스레드 안전한 큐나 채널을 통해 전달한다.

Sync가 필요한 대표적인 경우는 다음과 같다.

  • 여러 스레드가 &T를 동시에 사용한다.
  • Arc<T>로 같은 값을 여러 스레드나 task가 공유한다.
  • 서버 상태, 핸들러, 콜백처럼 하나의 인스턴스를 동시 호출자들이 공유한다.

'static이 필요한 대표적인 경우는 다음과 같다.

  • 작업이 현재 함수 호출보다 오래 유지될 수 있다.
  • 값을 장기 보관하는 객체나 전역 레지스트리에 저장한다.
  • 콜백이나 task를 나중에 실행하기 위해 저장한다.

Send + 'static이 필요한 경우는 “호출자의 수명과 독립적으로 실행될 작업을 다른 스레드로 보낸다”라고 이해하면 된다. 여기에 Sync까지 붙는다면 그 값을 여러 스레드가 공유 참조로 접근할 수 있다는 계약도 추가된다.

use std::thread::{self, JoinHandle};

fn run_in_background<T>(value: T) -> JoinHandle<()>
where
    T: Send + 'static,
{
    thread::spawn(move || drop(value))
}

반대로 함수가 값을 동기적으로 사용하고 반환한다면 대개 이런 제약은 필요 없다. 짧은 참조를 받으면서도 안전하게 작업할 수 있기 때문이다.

컴파일 오류를 만났을 때의 판단 순서

Send + Sync + 'static 관련 오류가 나오면 무조건 제약을 추가하기보다 다음 순서로 확인하자.

  1. 작업이 정말 호출자보다 오래 살아야 하는가? 그렇지 않다면 직접 실행하거나 thread::scope처럼 수명이 제한된 API를 고려한다.
  2. 클로저가 참조 대신 소유한 값을 가져갈 수 있는가? move와 함께 값을 이동하거나 필요한 부분만 복제한다.
  3. 여러 작업이 데이터를 공유해야 하는가? 필요하다면 Arc, Mutex, 채널 같은 동시성 도구를 고려한다.
  4. 타입이 왜 Send가 아닌가? Rc, 스레드 로컬 값, 락 가드처럼 스레드 경계를 넘으면 안 되는 값이 포함되어 있는지 확인한다.
  5. 타입이 정말 Sync여야 하는가? 소유권을 한 스레드로 이동할 뿐이라면 Sync는 필요하지 않을 수 있다. 공유가 필요하다면 동기화되지 않은 내부 가변성이 있는지 확인한다.
  6. 정말 멀티 스레드 실행이 필요한가? 단일 스레드 로컬 실행기를 사용할 수 있다면 SendSync 요구를 피할 수 있다.

T: Send + Sync + 'static은 컴파일러를 달래기 위한 주문이 아니다. API가 값의 소유권을 어디로 이동시키는지, 값을 어떻게 공유하는지, 그리고 얼마나 오래 보관할 수 있는지를 호출자에게 알리는 계약이다.

참고 자료