Nova 자바스크립트 엔진의 GC 핸들을 Rust의 라이프타임으로 모델링하는 과정에서, 가비지 컬렉션 핸들은 공변이 아니라 반변 라이프타임을 가져야 한다는 결론에 이르는 글.
이 블로그에서는 이전에 Nova 자바스크립트 엔진이 Rust의 빌림 검사기(borrow checker)를 이용해 가비지 컬렉션을 모델링하는 방법과 그 사용법에 대해 썼고, 내가 그 모델을 어떻게 떠올렸는지 장황하게 풀어놓기도 했으며, 가비지 컬렉션 일반에 대한 철학적 토대에 대해서도 썼다. 무엇보다도, 많은 기여자들과 함께 이 모델을 사용해 10만 줄이 넘는 Rust 코드로 구성된 자바스크립트 엔진을 작성했는데, 이는 훌륭함과 끔찍함이 반반씩 섞인 결과물이다. 훌륭한 점은, 빌림 검사기가 “루트로 잡히지 않은(unrooted) 핸들”이 가비지 컬렉션 세이프포인트를 지나 스택에 남아 있지 않음을 검사할 수 있도록, 가비지 컬렉션 핸들을 설명해낼 수 있다는 데 있다. 하지만 끔찍한 점은, 그걸 달성하는 방식 때문에 코드가 let handle = handle.bind(nogc) 와 handle.unbind() 호출로 뒤범벅된 수프가 되어버린다는 것이다. 불과 지난달, 노르웨이의 한 대학 직원이 이 시스템을 보고 이렇게 말했다. “C++보다 더 나쁘네.”
그동안 나는 이 모델을 “가비지 컬렉션을 모델링하는 올바른 방식”이라고 가정해 왔고, 수동적인 부분들과 몇몇 제약은 단지 Rust 빌림 검사기의 한계 때문이라고 생각했다. 그런데 지난 주말, 이 시스템의 아주 역발상적인 제약을 설명하기 위한 안전성 주석(safety comment)을 쓰고 있던 중 많은 것이 바뀌었다.
가비지 컬렉션 시스템에는 항상 GC 대상 데이터를 저장하는 어떤 힙 구조가 있다. 그리고 그 힙에는 가비지 컬렉션 핸들, 즉 자기참조(self-reference)가 들어있다. 힙에 저장된 단일 핸들 Handle<'_, T>를 하나 생각해 보고, '_에 어떤 올바른 라이프타임을 부여해야 하는지 따져보자.
이것은 가비지 컬렉션 시스템이므로, 이 Handle<'_, T>가 힙에 존재하는 한(그리고 어떤 루트에서든 도달 가능하기만 하다면) T 역시 살아있다. Handle은 살아있는데 T가 죽어 있는 것은 잘못이지만, 가비지 컬렉터가 Handle을 드롭(drop)하는 순간 T도 드롭할 수 있다. 이는 T를 이동(move)시키는 경우에도 마찬가지다. 개념적으로는 데이터가 이동될 때 먼저 새 위치로 복사한 뒤, 새 Handle<'_, T>로 기존 핸들을 대체하고, 그 다음에야 원래의 T를 드롭할 수 있다고 말할 수 있다. (이 점이 실제 GC 구현에서의 톰브스톤(tombstone) 등과 어떻게 연결되는지도 주목하라.) 힙이 드롭되면 그 안의 모든 Handle도 함께 드롭된다. 하지만 힙이 프로그램 종료까지 살아있다면 Handle도 마찬가지로 살아있게 된다. 그러므로 올바른 라이프타임은 힙의 소유자에 의해 결정되는 어떤 'external이어야 하는 것처럼 보이지만, 편의를 위해 여기서는 'static 라이프타임을 쓰자.
이제 스택에 있는 단일 핸들 Handle<'_, T>를 생각해 보자. 이들은 루트로 잡히지 않은 핸들이며, 우리의 가비지 컬렉터는 스택 스캐닝을 하지 않는다. 즉, T는 다음 가비지 컬렉션 실행까지만 존재가 보장된다. 우리가 Handle<'_, T>를 얻었다는 사실 자체가 최소한 핸들을 얻는 시점에는 T가 존재해야 함을 의미하지만, 가비지 컬렉션이 실행되면 T가 드롭되거나 이동되어 핸들이 더 이상 유효한 값을 가리키지 않을 수도 있다. 따라서 Handle에 부여할 수 있는 라이프타임은 “가비지 컬렉션이 발생하지 않는 것이 보장되는 기간”인 어떤 'local이다. 이 'local은 당연히 'static보다 짧다. 이제 힙에 있는 핸들에 대한 가변 참조를 얻고, 여기에 로컬 핸들의 복사본을 써넣으려 한다고 상상해 보자:
let local_handle: Handle<'local, T> = local;
let heap_mut: &mut Handle<'static, T> = heap.get_mut();
*heap_mut = local_handle;
가비지 컬렉션 용어로 이는 (거의) 로컬 핸들을 “루팅(rooting)”하는 행위다. 로컬 핸들을 가비지 컬렉터가 볼 수 있는 힙에 저장하여, 그 수명을 늘리는 것이다. 그러니 논리적으로는 이 코드는 완전히 괜찮다. 하지만! 오늘날의 Nova 자바스크립트 엔진에서 이 코드는 컴파일되지 않는다. 우리의 핸들은 일반 참조처럼 라이프타임 파라미터에 대해 공변(covariant)이고, Rust 참조로 위 코드를 쓰면 다음과 같다:
let local_handle: &'local T = &0;
let heap_mut: &mut &'static T = heap.get_mut();
*heap_mut = local_handle;
이 코드는 절대로 컴파일되면 안 되며, 컴파일되지 않아야 한다. 여기서 코드가 말하는 바는 “heap_mut은 프로그램 끝까지 유효한 T에 대한 참조를 저장할 수 있는 장소”인데, 우리가 저장하려는 참조는 이 함수 호출이 끝날 때까지만 유효하다. 참조의 라이프타임이 너무 짧으며, 이를 허용하면 use-after-free로 이어진다. 따라서 가비지 컬렉션 핸들에 공변 라이프타임을 두는 것은 동작하지 않는다. 인터넷에는 빌림 검사기가 이런 걸 허용하지 않는다고 불평하는 글이 많겠지만, 여기서 빌림 검사기가 use-after-free를 막는 것은 전적으로 옳다. 그런데 가비지 컬렉션 핸들에서는 바로 이것을 하고 싶고, 그러려면 unsafe Rust로 가야 한다. 이것이 바로 지난 주말 내가 안전성 주석을 쓰고 있던 종류의 코드다. 핵심만 남기면 대략 이런 모습이다:
let local_handle: Handle<'local, T> = local;
let heap_mut: &mut Handle<'static, T> = heap.get_mut();
// SAFETY: 힙에서 온 Handle의 라이프타임을 로컬 라이프타임으로 줄이는 것은 안전하다.
// Handle을 복사하면 그것은 반드시 'local이 되며,
// 반대로 'local Handle을 힙에 저장하면 'static이 된다.
let heap_mut: &mut Handle<'local, T> = unsafe { core::mem::transmute(heap_mut) };
*heap_mut = local_handle;
그리고 그때 깨달았다. 이건 (라이프타임) 반변성(contravariance)이다!
반변 라이프타임은 추론하기가 고통스럽다. 타입 시스템에서 반변성의 기본 아이디어는 그리 복잡하지 않다. 두 타입 T와 U가 있고 T ≤ U(즉, T가 U보다 더 구체적이거나, T가 상위 타입 U의 서브타입)라 하자. 어떤 제네릭 타입 C<X>가 반변이라면 C<U> ≤ C<T>가 된다. 순서가 뒤집힌다는 점에 주목하라!
반변 제네릭 타입의 예로는 제네릭 매개변수 하나를 받는 함수, f<T>(T)가 있다. 내가 동물을 달라고 했는데 네가 고양이를 주면 괜찮다. 고양이는 동물의 서브타입이니까. 하지만 “어떤 동물이든 인자로 호출할 수 있는 함수”를 달라고 했는데, 네가 “고양이만으로 호출 가능한 함수”를 주면 괜찮지 않다. 어떤 고양이든 받는 함수는 어떤 동물이든 받는 함수의 서브타입이 아니다. Cat ≤ Animal임에도 f(Animal) ≤ f(Cat)처럼 순서가 뒤집힌다.
라이프타임에 적용하면 의미는 다음과 같다. 공변인 경우, 내가 'a 라이프타임을 요구하면 너는 'a와 같거나 더 긴 라이프타임을 줄 수 있다. 예를 들어 &'a T를 받는 함수는 &'static T로 호출해도 괜찮다. 나는 그것을 더 짧은 라이프타임을 가진 것처럼 쓰기만 하면 되기 때문이다. 반변인 경우, 너는 'a와 같거나 더 짧은 라이프타임을 줄 수 있다. Rust에서는 fn(&'a T)로 이를 나타낼 수 있는데, 이는 “'a 라이프타임의 참조로 호출할 수 있는 함수를 달라”는 뜻이다.
어떤 함수가 fn(&'a T)를 받는다면, 그것은 이 함수를 호출할 수 있는 어떤 기간 'a가 있다는 의미다. 물론 더 오래 유효한 참조로도 호출할 수 있다(그 더 긴 참조가 최소한 'a의 일부 기간 동안은 유효하기만 하면). 하지만 우리가 그 함수를 들고 있는 쪽(호출자)이라면, 호출자들보다 “앞서 나가서” 우리가 요구하는 라이프타임을 늘릴 수도 있다. 이는 함수를 fn(&'static T) 타입의 어떤 장소에 재할당함으로써 한다(또는 'a보다 긴 다른 'external을 써도 된다). 즉, 더 짧은 'a를 가진 복합 타입(참조 하나를 인자로 받는 함수)을 더 긴 'static을 가진 복합 타입 자리에 대입한다. 이는 'a 자체를 'static으로 늘린다는 뜻이 아니다. 단지 더 짧은 라이프타임 파라미터를 가진 복합 타입을 더 긴 라이프타임 파라미터를 가진 것 대신 사용할 수 있다는 뜻이다. 함수 인자 관점에서는, (겉보기로는) 호출자에게 더 긴 라이프타임을 요구하지만, 함수 내부에서는 여전히 모든 인자를 더 짧은 'a로 취급한다.
이 동작의 아주 좋은 예는 Rust 언어 Zulip의 Boxy가 제공한 것이다:
static BORROW: T = 0;
fn foo<'a>(fnptr: fn(&'a T)) {
// 호출자인 우리는 `fnptr`에 넘기기 전에 `BORROW`의 라이프타임을 줄일 수 있다.
// `fnptr`는 `'a` 라이프타임의 빌림을 기대한다.
let local: &'a T = &BORROW;
fnptr(local);
// 또는 함수 포인터 자체가 모든 호출자에 대해 이 일을 하게 만들 수도 있다!
let local_fnptr: fn(&'static T) = fnptr;
local_fnptr(&BORROW);
// 이 암묵적 서브타이핑을 클로저로 쓰면 *명시적으로* 이해하는 데 도움이 된다.
let local_closure = |param: &'static T| {
let param: &'a T = param;
fnptr(param);
};
local_closure(&BORROW);
}
이제 이를 커스텀 반변 라이프타임 타입으로 옮겨오면, 상황은 더 복잡해지기 시작한다. 함수 예시는 충분히 단순하지만, 커스텀 타입으로 다시 써 보자:
static BORROW: &'static T = &T::new();
fn foo<'a>(cov: Contravariant<'a, T>) {
let local: &'a T = BORROW;
cov.f(local);
let local_cov: Contravariant<'static, T> = cov;
local_cov.f(BORROW);
let local_closure = |param: &'static T| {
let param: &'a T = param;
cov.f(param);
};
local_closure(BORROW);
}
이쯤이면 머리가 어지러울 수도 있다! 우리는 Contravariant<'a, T>를 인자로 받았는데, 그 값을 Contravariant<'static, T> 자리에 쓸 수 있다! 꽤 이상해 보이지만, 그게 바로 반변성이다.
이제 반변 참조 타입을 다루게 되었으니, 그것이 실제로 무엇을 의미하는지도 생각해야 한다. 이를 돕기 위해 반변성에 대한 또 다른 관점을 도입하자. 반변 타입은 “타입(또는 그 서브타입)을 던져 넣고 다시는 꺼내지 않는 싱크(sink)”로 해석할 수 있다. 이는 반변 참조가 “쓰기 전용(write-only) 참조”임을 암시한다. 안전하고 무조건적으로 읽는 것은 불가능하지만, 전체 라이프타임 동안 쓸 수는 있다. 그럼 그게 무슨 쓸모가 있나? 이는 그 위에 어떤 API를 설계하느냐에 달려 있지만, 가능성은 있다. 익숙한 쓰기 전용 타입의 예로는 MaybeUninit가 있지만, 그것도 영원히 쓰기 전용이 아니라 “읽어도 안전하다고 확신할 때까지”만 그렇다. 반변 참조도 마찬가지다. 읽어도 안전하다는 확신을 얻기 전까지는 쓰기만 할 수 있다. 어려운 부분은 반변 참조를 통해 안전하게 읽을 수 있음을 증명하는 것을 어떻게 모델링할지, 즉 그 주변에 어떤 안전한 API를 설계할지다.
여기에는 추가적인 난점이 있다. 반변 참조를 함수 사이로 안전하게 전달하려면 Rust에 새 기능이 필요할 가능성이 크다. 그 기능이란 “피호출 함수 끝까지 살지 않는 라이프타임”이다. 반변 참조 그 자체는 그것을 통해 읽는 것이 안전하다고 보장하지 않는다. 따라서 이를 함수의 매개변수로 받는 것은 위험천만하다. 참조가 유효하다고 가정할 수 없고, 네 함수 안에서 수행한 작업 때문에 읽기가 비정상(unsound)이 될 수도 있다. 그런데도 라이프타임은 표준 Rust의 “이 함수 호출이 끝날 때까지”라는 형태를 띠며, 이는 안전한 코드를 쓰는 데 전혀 도움이 되지 않는다.
현재 Rust에서는, 네 함수 안에서 생성된 라이프타임만이 함수 안에서 끝날 수 있다. 그래서 함수 내부에서는 반변 참조를 만들고, 그 라이프타임을 일반 공변 참조와 “섞을(mix)” 수 있다. 그러면 그 반변 참조는 공변 참조가 무효화될 때 자동으로 무효화된다. 이를 이용하면, 반변 참조의 라이프타임을 어떤 증명 값(proof value)에 대한 공변 참조의 라이프타임과 섞어 안전한 API를 설계할 수 있다. 일반 공변 참조와 달리, 반변 참조와 “섞어 넣은” 증명 값(참조가 아니라 값 자체로!)을 동시에 함수 호출에 넘겨 증명을 전달할 수도 있다. 하지만 현재 Rust에서는 함수 내부에 들어오면 반변 참조의 라이프타임이 다시 익숙한 “이 함수 호출이 끝날 때까지”로 확장되어, 그 증명 매개변수에 의해 제한되지 않는다. 따라서 오늘날의 함수 인터페이스에서는 이 안전한 API가 동작하지 않는다.
결국 문제는 이것이다. 반변 참조와 증명 매개변수를 받는 순간, 너는 호출자가 유효한 증명을 줬다고 믿어야 하고, 더 중요하게는 실수하지 않았다고 믿어야 한다. 다시 말하겠다. 반변 참조는 매개변수로 받든(반환값으로 내보내든) 호출자(또는 피호출자)가 실수하지 않을 것을 요구한다! 이는 “극도로 Rust답지 않다”고 불려 왔는데, 그렇게 말하는 것도 틀리지 않다. 이는 Rust의 뛰어남을 지탱하는 핵심인 로컬 추론(local reasoning)을 완전히 망가뜨리기 때문이다. 그래서 “callee 안에서 끝나는 매개변수 라이프타임”이 필요해진다. 그렇게 되면 (어떻게든) 반변 참조를 증명 값의 존재에 묶인 라이프타임으로 전달할 수 있고, 그러면 누구도 실수하지 않는다고 가정해야 하는 저주에서 벗어날 수 있다. 알다시피, 실수는 항상 일어난다.
그렇다고 해도, 이 반변 참조의 근본적 비안전성은 이를 고려하기만 한다면 치명적인 장애물은 아니다. Nova에서는 핸들이 “실수 없이” 사용될 것을 전제하지 않기 때문에, 읽기에 사용하기 전 항상 유효성을 검사한다. 그러면 핸들 실수는 경계 검사로 인한 패닉을 유발하거나, 어떤 자바스크립트 값이 같은 타입의 다른 값으로 바뀌는 결과로 이어진다. 전자는 불행하지만 안전하고, 후자는 절대 일어나선 안 되는 일이며 자바스크립트 언어 차원에서의 “정의되지 않은 동작(undefined behaviour)”을 구성한다. 최악의 경우 Nova를 실행하는 자바스크립트 런타임에 대한 공격 벡터로 활용될 수도 있으니 일반적으로 좋지 않다. 하지만 그렇다고 해서 즉시 Rust의 UB가 발생하여 모든 것이 무너진다는 보장은 아니다. 필요하다면 세대(generational) 핸들을 사용해 이를 방지할 수도 있다. 다행히도 힙 핸들에는 이를 위해 사용할 수 있는 24개의 미사용 비트가 있다.
이제 이것이 Nova 자바스크립트 엔진에 구체적으로 무엇을 의미하는지 생각해 볼 때다. 반변 핸들이 우리가 갖게 될 것임은 분명하다. 그것이 가비지 컬렉션의 실제 의미론과 맞아떨어지고, 큰 단점인 비안전성은 어차피 이미 다뤄야 하는 문제이기 때문이다. 아직 본격적으로 작업을 시작하기 전에 뒤집어 볼 돌도 더 있고 확인할 타이어도 더 있지만, 가까운 미래에 Nova의 자바스크립트 값(Value)들이 큰 변화를 겪게 될 것처럼 보인다! 이 변화에는 매우 좋은 점들이 있다. 예를 들어 보자. 다음은 오늘날 엔진에 있는 코드 일부다:
pub(crate) fn set<'a>(
agent: &mut Agent,
o: Object,
p: PropertyKey,
v: Value,
throw: bool,
mut gc: GcScope<'a, '_>,
) -> JsResult<'a, ()> {
let nogc = gc.nogc();
let o = o.bind(nogc);
let p = p.bind(nogc);
let v = v.bind(nogc);
let scoped_p = p.scope(agent, nogc);
let success = o
.unbind()
.internal_set(agent, p.unbind(), v.unbind(), o.unbind().into(), gc.reborrow())
.unbind()?;
let gc = gc.into_nogc();
let p = scoped_p.get(agent).bind(gc);
if !success && throw {
return throw_set_error(agent, p, gc).into();
}
Ok(())
}
이 함수는 객체에 값을 설정하는 데 쓰이며, 자바스크립트 코드가 o.p = v; 또는 o[p] = v;를 수행할 때 호출된다. 이는 Nova 엔진 코드의 “흠잡을 데 없는” 예시인데, 가비지 컬렉터 관점에서 완전히 올바를 뿐 아니라, 빌림 검사기가 GC 안전성을 검증하도록 작성되어 있기 때문이다. 모든 핸들 매개변수는 함수 진입 시 GC 라이프타임에 바인딩되고, scoped_p.get(agent) 호출에서 반환되는 PropertyValue<'static>도 마찬가지다. 비록 그 시점은 let gc = gc.into_nogc(); 호출 이후(즉, 함수 안에 더 이상 GC 세이프포인트가 없다는 증명 이후)임에도 그렇다. 하지만 이런 완벽함의 대가는 .unbind() 호출 7개다. 이는 각 핸들이 Gc 매개변수에 대한 공유 공변 참조를 들고 있고, gc.reborrow()가 호출되면 그 참조들이 무효화되는데도 공변 라이프타임은 internal_set 호출이 끝날 때까지(또는 그 이상) 유효해야 한다고 요구하기 때문이다. 그래서 핸들은 함수 호출 경계에서 공변 참조를 “잊어버리도록” 언바인딩되어야 한다.
그렇다면 반변 핸들을 쓰면 어떻게 될까? 보자:
pub(crate) fn set<'a>(
agent: &mut Agent,
o: Object,
p: PropertyKey,
v: Value,
throw: bool,
mut gc: GcScope<'a>,
) -> JsResult<'a, ()> {
let nogc = gc.nogc();
let o = o.local();
nogc.join(o);
let p = p.local();
nogc.join(p);
let v = v.local();
nogc.join(v);
let scoped_p = p.scope(agent, nogc);
let success = o.internal_set(agent, p, v, o.into(), gc.reborrow())?;
let gc = gc.into_nogc();
let p = scoped_p.get(agent);
gc.join(p);
if !success && throw {
return Err(throw_set_error(agent, p, gc));
}
Ok(())
}
가장 중요한 변화는 internal_set 호출 자체다. .unbind()와 .bind(gc.nogc()) 호출이 모두 사라졌다. 특히 사용성(ergonomics) 측면에서 중요한 점은, 이제 오류를 ? 연산자로 다시 던질 수 있다는 것이다. 더 이상 .unbind()?.bind(gc.nogc()) 같은 체인을 쓸 필요가 없다. 현재 Nova 코드베이스에는 이런 동작을 하는 곳이 거의 800군데나 있는데, 이를 없애면 많은 기여자들이 웃게 될 것이다.
하지만 편의성 일부는 잃는다. 매개변수 바인딩이 더 이상 let o = o.bind(nogc); 한 줄로 끝나지 않고, 두 번의 호출이 필요하다. 먼저 let o = o.local();을 호출한다. 이는 매개변수(문제의 “이 함수 호출 끝까지” 라이프타임을 가진 값)를 이 함수 안에서 끝나는 라이프타임을 가진 로컬 생성 핸들로 섀도잉(shadowing)한다. 두 번째는 nogc.join(o); 호출이다. 이는 반변 핸들의 라이프타임을 gc.nogc() 호출에서 얻은 로컬 &Gc 참조의 공변 라이프타임과 “섞어” 결합한다. (또는 이것을, “싱크”에 유효한 값을 써넣어 이제 정상적인 읽기 전용이 아닌 ‘원래는 쓰기 전용인 참조’에서 읽어도 안전함을 스스로에게 증명하는 시점이라고 생각해도 된다.) 그 후 gc.reborrow() 호출로 로컬 &mut Gc 참조를 만들면, 핸들의 라이프타임이 섞여 있던 &Gc 참조가 무효화되고, 그에 따라 핸들도 무효화된다. 중요한 것은 반변 참조에서는 더 짧은 라이프타임을 더 긴 것 대신 사용할 수 있다는 점이다. 즉 gc.reborrow() 호출 직전(편의상 마지막 인자이기도 하며, 사실상 바로 이 이유 때문에 마지막에 두는 게 좋다)에 internal_set에 넘기는 핸들은, “이 호출 끝까지” 라이프타임을 가진 함수 매개변수 대신 안전하게 사용될 수 있다. 그리고 이것이 &Gc 참조의 라이프타임을 internal_set 끝까지 확장하는 것이 아니기 때문에(&'static T를 &'a T 대신 쓴다고 해서 'a가 'static이 되지 않는 것과 같다), 무효화가 이미 전달된 반변 핸들을 무효화시키지도 않는다.
따라서 Gc<'_> 마커 타입과 함께 “바인딩된” 핸들을 호출에 넘길 수 있게 되는 것은 매우 중요하다. 바인딩 편의성 일부를 잃는 것은 그에 비하면 사소한 문제다. 게다가 간단한 매크로로 많은 편의성을 되찾을 수도 있다.
가비지 컬렉션 핸들이 실제로는 라이프타임 반변이며, 반변 참조가 Rust 라이프타임 시스템의 버그가 아니라 어떤 의미를 부여할 수 있는 실제 개념임을 어느 정도는 설득했기를 바란다. 다만 그 의미가 무엇인지에 대해 강하고 간결한 주장을 하지는 못했을 것이라 생각한다. 솔직히 나 자신도 아직 정확히 모르기 때문이다. 하지만 가비지 컬렉션 핸들의 라이프타임 반변성은 하나의 힌트를 준다. 가비지 컬렉션은 일반적으로 순환(cyclical) 구조에 적용된다.
나는 증명은 없지만 꽤 강하게 믿고 있다. 반변 참조는 일반적인 자기참조 데이터 구조(self-referential data structures)를 설명하는 데 어떤 역할을 할 것이다. 그 역할이 무엇이며 어떤 형태가 될지는 아직 모르지만, 올바른 API 디자인이 있다면 반변 참조는 이전에는 접근이 막혀 있던 많은 영역에 라이프타임의 즐거움을 가져다줄 수 있을 것이라 생각한다. 관심이 있다면, 노드 포인터 대신 반변 참조를 사용해 이중 연결 리스트(doubly-linked list)를 작성해 보거나, 내부적으로 반변 참조에 바인딩되는 자기참조 데이터 구조를 통해 'external 라이프타임을 전달하면 어떤 모습일지 시도해 보길 권한다. 특히 흥미로운 점은 그 라이프타임을 다시 역방향으로도 스레딩(threading)할 수 있는지 여부다. 즉 데이터 구조에서 소유자에게 되돌아오는 어떤 콜백 API가, 반변 라이프타임이 둘을 결합(join)해 줌으로써 이득을 볼 수 있는지 보는 것이다.
의외로 놀랍고 긍정적인 결과가 나올지도 모른다! 아니면 내가 완전히 미친 소리를 하고 있는 걸지도. 시간과 노력이 알려주겠지. 그때까지, 계속 반대로 가자!