런타임 아키텍처 (Runtime Architecture)
지금까지 비동기 런타임을 추상적인 개념으로만 이야기해 왔습니다. 이제 런타임이 실제로 어떻게 구현되어 있는지 좀 더 깊이 파고들어 봅시다. 곧 보게 되겠지만, 런타임의 구조는 우리가 작성하는 코드에도 직접적인 영향을 미칩니다.
종류
tokio는 크게 두 가지 종류의 런타임을 제공합니다.
tokio::runtime::Builder를 통해 런타임을 구성할 수 있습니다:
Builder::new_multi_thread: 다중 스레드(Multi-thread)tokio런타임을 생성합니다.Builder::new_current_thread: 실행을 위해 현재 스레드(Current thread) 하나에만 의존하는 런타임을 생성합니다.
참고로 #[tokio::main]은 기본적으로 다중 스레드 런타임을 사용하고, #[tokio::test]는 기본적으로 현재 스레드 런타임을 사용합니다.
현재 스레드 런타임 (Current-thread Runtime)
이름에서 알 수 있듯이, 태스크를 스케줄링하고 실행하기 위해 런타임이 시작된 하나의 OS 스레드만 사용합니다. 이 런타임을 사용하면 **동시성(Concurrency)**은 있지만 **병렬성(Parallelism)**은 없습니다. 여러 비동기 태스크가 번갈아 가며 실행(Interleaving)되지만, 특정 시점에 실행 중인 태스크는 언제나 최대 하나입니다.
다중 스레드 런타임 (Multi-thread Runtime)
반면 다중 스레드 런타임을 사용하면 특정 시점에 최대 N개의 태스크가 병렬로 실행될 수 있습니다. 여기서 N은 런타임이 사용하는 스레드 수이며, 기본적으로 시스템의 CPU 코어 수와 일치합니다.
또한 tokio는 **작업 훔치기(Work-stealing)**라는 기법을 사용합니다. 어떤 스레드가 할 일이 없어 유휴 상태가 되면 그냥 놀지 않고, 전역 큐나 다른 스레드의 로컬 큐에서 실행 대기 중인 태스크를 ‘훔쳐와서’ 대신 실행합니다. 이 방식은 특히 스레드 간에 작업량이 불균형할 때 성능을 크게 향상시키며, 응답 지연 시간(Latency)을 줄이는 데 큰 도움이 됩니다.
영향
tokio::spawn은 어떤 종류의 런타임에서 실행하든 상관없이 잘 작동합니다. 하지만 그 대가로 함수 시그니처가 ‘최악의 경우’(즉, 다중 스레드 환경)를 가정하고 제약이 걸려 있습니다:
pub fn spawn<F>(future: F) -> JoinHandle<F::Output>
where
F: Future + Send + 'static,
F::Output: Send + 'static,
{ /* */ }
여기서 Future 트레이트는 잠시 제쳐두고 나머지 제약 조건들을 살펴봅시다.
spawn은 모든 입력값이 Send여야 하며 'static 라이프타임을 가져야 한다고 요구합니다.
'static 제약 조건은 std::thread::spawn과 같은 이유로 존재합니다. 생성된 태스크가 자신을 만든 컨텍스트보다 오래 살아남을 수 있기 때문에, 컨텍스트가 사라지면 같이 해제될 수 있는 로컬 데이터에 의존해서는 안 되기 때문입니다.
fn spawner() {
let v = vec![1, 2, 3];
// `&v`가 태스크보다 오래 산다는 보장이 없으므로 컴파일되지 않습니다.
tokio::spawn(async {
for x in &v {
println!("{x}")
}
})
}
반면 Send 제약 조건은 tokio의 작업 훔치기 전략 때문에 필요합니다. 스레드 A에서 생성된 태스크가 나중에 스레드 B로 이동되어 실행될 수 있기 때문에, 태스크 자체가 스레드 경계를 넘나들 수 있어야(Send) 하는 것이죠.
fn spawner(input: Rc<u64>) {
// `Rc`는 `Send`가 아니므로 이 코드는 작동하지 않습니다.
tokio::spawn(async move {
println!("{}", input);
})
}
Exercise
The exercise for this section is located in 08_futures/03_runtime