Rust에서 tokio 없이 stdlib만으로 비동기 태스크 로컬을 구현하는 방법을 설명합니다.
짧게 말하자면, 정확히 이런 제약이 어디서 오는지는 잠시 제쳐 두고, 다음과 같은 조건이 있다고 가정해 봅시다:
- Rust에서 어떤 데이터를 비동기 태스크와 연관짓고 싶고, 그 태스크가 poll되는 동안에는 함수 인자로 직접 아래로 전달하지 않고도 더 깊은 호출 스택에서 그 데이터를 꺼내 쓸 수 있기를 원합니다.
tokio생태계는 사용하고 싶지 않습니다.
즉, tokio::task_local!가 하는 일을 하고 싶지만 tokio는 쓰고 싶지 않다는 뜻입니다. 그럼 어떻게 할까요? 음... 이걸 해 주는 다른 걸 찾지 못해서, 그냥 제가 직접 만들었습니다 (:
우리가 원하는 것을 이해하려면, 먼저 표준 라이브러리가 제공하는 것만으로는 왜 충분하지 않은지 이해해야 합니다. 그리고 표준 라이브러리가 제공하는 것을 이해하려면, 그것이 의존하는 실행 모델부터 이해해야 합니다. 어쨌든 제목부터가 "from scratch"였잖아요1 :P
대부분의 운영체제는 코드 실행을 프로세스와 스레드라는 두 개의 뚜렷한 단위로 구성합니다. 프로세스는 대략적으로 "어떤 메모리가 보이는가"를, 스레드는 대략적으로 "어떤 명령어가 실행 중인가"를 구분합니다. 하나의 프로세스에는 하나 이상의 스레드가 들어갈 수 있고, 한 프로세스 안에 여러 스레드가 있으면 컴퓨터 과학자들은 이를 "멀티스레드 아키텍처"와 "공유 메모리 병렬성"이라고 부릅니다. 말 그대로 하나의 프로세스 안에서 여러 스레드가 메모리를 공유하는 것입니다.
이 프로세스/스레드 모델에서는, 스레드들이 실행 중 서로의 발을 밟지 않도록 프로그래머가 어느 정도 신경 써야 합니다. 대부분의 평범한 코드는 자신이 만지는 메모리에 오직 자기만 접근한다고 가정하며, 다른 스레드가 잠깐 한눈판 사이에 그 위에 낙서해 버리면 아주 불쾌해집니다. 만약 스레드들이 서로의 메모리를 건드리지 않거나, 혹은 서로의 건드림을 조율하는 어떤 메커니즘이 있다면2, 그 성질을 "스레드 안전성"이라고 부릅니다.
당연히 스레드 안전성은 매우 중요한 성질입니다. 실제로 이를 컴파일 타임에 보장하는 것은 Rust의 큰 동기 중 하나이기도 하죠. 그래서 우리의 최종 목표를 달성하는 동안에도 이 성질은 유지해야 합니다. 스레드 안전성을 보장하는 전략은 여러 가지가 있지만, 가장 단순한 것은 단연 "그냥 스택 변수 쓰면 됨 lol"입니다. 각 스레드의 스택은 구조적으로 서로 고유하므로3, 스택 위에 값을 만들고 그 참조를 필요한 곳까지 인자로 내려 보내면 끝, 쉽죠.
하지만 기억하세요. 우리에겐 특별한 요구사항이 있습니다:
[...] 더 깊은 호출 스택에서 그 데이터를 꺼내 쓸 수 있기를 원합니다. 단, 함수 인자로 직접 아래로 전달하지 않고.
그러므로 우리가 정말 원하는 것은, 여러 스레드가 하나의 전역을 공유할 때 생기는 복잡함 없이 동작하는 "전역" 변수와 더 비슷한 무언가입니다. 다행히 이런 것은 이미 존재합니다! 스레드 로컬 저장소, 즉 TLS는, 오직 하나의 스레드만 접근할 수 있다는 의미에서 "로컬"이면서도 어떤 함수에서든 만질 수 있다는 의미에서 "전역"인 변수를 만들어 줍니다. 플랫폼마다 TLS 구현은 꽤 다를 수 있지만, 우리가 알아야 할 것은 이것뿐입니다: 각 스레드에는 자기 전역 변수를 놓을 자기만의 자리가 있다. 더 이상 발 밟기 없음, 모두 행복. C/C++에서는 이를 thread_local keyword로 사용하고, Rust에서는 std::thread_local! macro를 사용합니다.
결론부터 말하면 아닙니다! 파이를 굽기 전에 우주를 조금 더 만들어야 하는데, 구체적으로는 운영체제 스레드와 Rust 태스크의 차이를 이해해야 합니다.
스레드의 경우, 운영체제가 "지금 이 CPU 코어에서 어떤 스레드가 실행 중인가?", "실행 가능하지만 아직 돌고 있지 않은 스레드는 무엇인가?", "자 이제 다른 스레드로 전환할 시간이다" 같은 일을 자동으로 관리해 줍니다. 하지만 운영체제 수준에서 스레드를 전환하는 것은 일반 코드에 비해 꽤 느린 작업입니다4. 그래서 언어들은 이런 느려짐을 피하면서도 동시성 코드의 다른 장점을 유지하기 위해, 종종 운영체제의 도움 없이 "유저스페이스"에서 자기들만의 스레드 비슷한 것을 만듭니다. 이런 스레드 비슷한 것들은 어떤 요소에 따라 Green Threads 혹은 Coroutines라고 불립니다.
이 요소들에 대한 곁다리 설명
주요 기준은 "스스로 실행될 수 있는가"(Green Thread, 즉 선점형 멀티태스킹) 아니면 "누군가가 실행을 밀어줘야 하는가"(Coroutine, 즉 협력형 멀티태스킹)입니다. Green Thread의 흔한 예로는 Erlang의 프로세스와 Go의 goroutines가 있고, Coroutine의 예로는 Python의 Coroutines, C++의 Coroutines, 그리고 Rust의 Future&Iterator 트레잇이 있습니다. Javascript의 Promise 객체는 흥미로운데, 얼핏 보면 async/await 문법 때문에 Coroutine처럼 보이지만, 실제로는 자기 힘으로 알아서 실행을 시작하기 때문에 Green Thread에 가깝고, await 문법은 그저 콜백 체이닝을 멋지게 감싼 형태일 뿐입니다.
아무튼!! 이런 구분은 대체로 꽤 논쟁적이고, 제가 뭔가 잘못 이해한 부분을 친절하게 바로잡아 주는 댓글도 분명 달리겠죠 (:
지금은 그냥 이것들을 전부 "태스크"라는 커다란 우산 아래 묶어 두겠습니다. 최대한 많은 사람을 동시에 화나게 하기 위해서요 :)
어쨌든! 여기서 중요한 점은 태스크는 스레드가 아니다라는 것입니다. 따라서 우리는 오직 스레드 로컬 저장소만 사용할 수는 없습니다. 왜냐하면:
특히, "태스크가 실행을 시작할 때 스레드 로컬을 설정하고, 태스크가 끝나면 해제한다"는 계획은 동작하지 않습니다. 왜냐하면:
이 둘 다 우리를 슬프게 만드는 실패 모드입니다 :(
자! 여기까지 왔으면, 여러분도 태스크 로컬이라는 특수한 구성물이 정말 필요하다는 데 동의하셨기를 바랍니다. 이제 만들어 봅시다...
Rust의 태스크는 오직 Future::poll() 메서드가 호출될 때만 실제로 일을 한다5는 방식으로 동작합니다. 그리고 여기서 마침내 핵심 통찰이 나옵니다: 태스크 로컬 값을 가지려면, 어떤 전역 값을 오직 poll()이 호출되는 동안에만 설정하면 됩니다. 그 전역으로 스레드 로컬 값을 사용하는 것이 가능한 이유는, 그 단 한 번의 poll() 호출이 지속되는 동안에는 스레드가 바뀔 수 없기 때문입니다. 그러니 그 한 스레드 안에서 원하는 대로 저장하고 복원하면 됩니다:
다가오는 unsafe 블록 사용에 대한 곁다리 설명
그건 무시하셔도 됩니다. 저건 Pin 의미론을 다루기 위한 것인데, 실제 코드베이스에서는 이를 안전하게 처리해 주는 pin_project crate를 쓰는 편이 좋습니다. 여기서는 그 crate 없이 코드를 보여 줌으로써, 별 마법은 없고 그냥 stdlib 함수들일 뿐이라는 점을 보여 주고 싶었습니다.
use std::cell::RefCell;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::thread::LocalKey;
/// Container type that implements [`std::future::Future`] by wrapping an existing one, as well as saving/restoring a task-local made available to it.
pub struct Scoped<F: Future, T: 'static> {
/// Thread-Local Storage that holds the current task-local value.
tls: &'static LocalKey<RefCell<Option<T>>>,
/// When the future IS NOT being polled: the value we want to store is inside `curr`.
/// When the future IS being polled: the previous value stored inside `curr`, if we are running inside another [`Scoped`].
curr: Option<T>,
/// The future we're wrapping.
fut: F,
}
impl<F: Future, T: 'static> Future for Scoped<F, T> {
type Output = F::Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
// SAFETY: `this` never moves out its value, so `self` stays pinned.
let this = unsafe { &mut self.get_unchecked_mut() };
// SAFETY: because `self` is still pinned, so is `fut`.
let fut = unsafe { Pin::new_unchecked(&mut this.fut) };
// The swap only ever moves values _stored inside_ `self`;
// it doesn't change the location of `self` directly.
let curr = &mut this.curr;
let mut swap = || this.tls.with_borrow_mut(|prev| std::mem::swap(curr, prev));
// Swap in the value from "the stack" (`this.curr`) into "global memory" (`this.tls`) while polling.
swap();
let out = fut.poll(cx);
// Swap the value from "global memory" back onto "the stack" to save it for when we get polled later.
swap();
out
}
}
이 긴 설명이 우리가 왜 이런 식으로 하는지 이해하는 데 도움이 되었기를 바랍니다. 그냥 허공에서 구현을 꺼내 온 것이 아니라는 점, 왜 생성자나 Drop 구현이 아니라 poll() 안에 저장/복원 로직을 넣는지도요.
이제 남은 일은 이 래퍼에 좀 더 tokio 같은 API를 제공하는 도우미를 붙이는 것뿐입니다. 먼저, what tokio::task::LocalKey has와 매우 비슷한 scope()와 with() 함수를 제공할 헬퍼 구조체를 정의합니다:
pub struct ScopeBuilder<T: 'static> {
tls: &'static LocalKey<RefCell<Option<T>>>,
}
impl<T: 'static> ScopeBuilder<T> {
/// Create a new task wrapper builder for the given Thread-Local Storage
pub const fn new(tls: &'static LocalKey<RefCell<Option<T>>>) -> Self {
Self { tls }
}
/// Given the Thread-Local Storage provided at builder creation, construct a [`Scoped`] future that will expose the given `value`.
pub fn scope<F: Future>(&self, value: T, fut: F) -> Scoped<F, T> {
Scoped {
tls: self.tls,
curr: Some(value),
fut,
}
}
/// Read from the Thread-Local Storage, which will be `Some` if we are inside a [`Scoped`] future.
/// Takes a callback to ensure the lifetimes work out.
pub fn with<V>(&self, f: impl FnOnce(Option<&T>) -> V) -> V {
self.tls.with_borrow(|value| f(value.as_ref()))
}
}
그리고 완전성을 위해, tokio::task_local!와 비슷한 매크로도 정의합니다. 이 매크로는 스레드 로컬 저장소와 연결된 ScopeBuilder를 동시에 만들며, 사용하기도 편합니다:
#[macro_export]
macro_rules! task_local {
($(static $ident:ident : $ty:ty ;)*) => {$(
static $ident: $crate::ScopeBuilder<$ty> = {
std::thread_local! {
static LOCAL: std::cell::RefCell<Option<$ty>> = const { std::cell::RefCell::new(None) };
}
$crate::ScopeBuilder::new(&LOCAL)
};
)*}
}
이걸 모두 합치면, 사용 예시는 대략 이런 모습이 됩니다:
task_local! {
static SOME_GLOBAL: usize;
}
/// Prints the value currently contained in [`SOME_GLOBAL`]
fn print() {
let v = SOME_GLOBAL.with(|v| v.map(|v| *v));
match v {
Some(v) => println!("{v}"),
None => println!("None"),
}
}
#[tokio::main]
async fn main() {
// Define a stack of wrapped tasks, showing how the task-local is saved/restored
print();
tokio::spawn(SOME_GLOBAL.scope(5138008, async {
print();
tokio::spawn(SOME_GLOBAL.scope(69, async {
print();
tokio::spawn(SOME_GLOBAL.scope(42, async {
print();
})).await.unwrap();
print();
})).await.unwrap();
print();
})).await.unwrap();
print();
}
각 .scope() 호출 바깥에 tokio::spawn()이 있는 점에 주목하세요. 이렇게 해야 여러 태스크가 동시에 실행될 때 무슨 일이 일어나는지 제대로 테스트할 수 있습니다. 하지만 각 async {} 호출 바깥에는 tokio::spawn()이 없습니다. 그래야 print() 호출들이 자신을 둘러싼 .scope()와 같은 태스크 안에서 실행되기 때문입니다.
이를 Rust playground에서 실행해 보면, 다음과 같은 출력을 관찰할 수 있습니다:
None
5138008
69
42
69
5138008
None
우리가 설계한 그대로죠 :)
이 글에서는 stdlib만 사용해서 Rust에서 태스크 로컬을 작성하는 완전한(!!) 방법을 보여 주었습니다. 이 방법은 "한 스레드 위의 여러 태스크" 경우를 스택과 스레드 로컬 사이의 swap으로 처리하고, "여러 스레드에 걸친 하나의 태스크" 경우도 처리합니다. std::thread::LocalKey는 "현재 스레드의 TLS에 어떻게 접근하는가"를 뜻하므로, 결과적으로 만들어지는 Scoped future는 실제로 다른 스레드로 보내질 수도 있고, 그 경우에는 자신이 실행되기 시작한 스레드의 TLS를 그대로 사용하게 됩니다.
이 글의 소스 코드는 제가 만들고 있는 더 큰 crate 모음의 일부에서 직접 복사해 온 것입니다. 언젠가 crates.io에 올리는 것도 고려할 수 있겠지만, 워낙 작아서 정말 원한다면 어떤 프로젝트에든 그냥 vendor해서 넣어도 될 정도입니다.
아무튼 여기까지입니다. 즐겁게 읽으셨고, 어쩌면 뭔가 하나쯤 배워 가셨기를 바랍니다! 다음에 또 만나요!!
이 긴 도입부를 직접 쓰는 대신 Kora의 Building an AsyncIO executor for the 3DS를 링크할까도 생각했습니다. 그 글도 아주 비슷한 내용을 다루고 있으니까요. 하지만 세상에 설명 하나쯤 더 있는 건 나쁠 것 없잖아요, 그렇죠? ↩
예: Mutex, Condition Variable, Semaphore 등. 가능한 한 모든 것을 음탕하게 표현하는 제 습관에 대해서는 사과하지 않겠습니다. 왜냐하면, 좀 생각해 보면, 정말로 생각해 보면, 동시성은 yur라는 사실을 당신도 깨닫게 될 테니까요. 정확히 어떤 맛인지는 언어마다 다릅니다: Rust => 천천히 타오르는 감정선, Go => 유독한 관계, C++ => 파멸 예정. 제 주장은 여기까지입니다. ↩
스택이라는 개념과 "어떤 코드가 실행되고 있는가"라는 개념은 너무 밀접하게 연결되어 있어서, 적어도 현재의 언어와 컴퓨터 아키텍처에서는 달리 될 수가 없습니다. ↩
네, 이건 자세히 들어가진 않겠습니다. 이유가 정말 복잡하고, 솔직히 저도 완전히 이해하고 있지는 않습니다. 뭐 타이머 인터럽트가 어떻고, 파이프라인 스톨이 어떻고, %cr3가 어떻고... 정도? "아무튼 이런 사실이 있다는 건 알고 있어야 함" 정도의 TL;DR이면 충분합니다. ↩
이 정의는 사실 태스크가 Coroutine인지 Green Thread인지에 실제로 의존하지 않는다는 점도 참고하세요! 맞습니다, Rust Future는 실제로 Coroutine이고, poll() 메서드는 대개 그 사실을 잘 드러냅니다. 하지만 Green Thread 기반 시스템도 비슷한 보장을 가진 API를 노출할 수 있고, 아마 이런 scoped wrapper를 만들기 위한 목적으로 그렇게 할 수도 있을 것입니다. ↩