단일 스레드 C++로 작성된 퍼저 코어와 멀티 스레드·비동기 Rust 컨트롤러를 안전하고 인체공학적으로 연결하기 위해, 스레드-안전하지 않은 객체/함수 문제를 해결한 설계와 구현 경험을 공유합니다.
Product
Solutions
Company
Resources
What is Antithesis?How Antithesis worksHow we're differentProblems we solveSecurity approach
FintechBlockchainDatabasesCloud infrastructureCustomer storiesWorking with Antithesis
Contact usBackstoryLeadershipCareersBrand
Distributed systems reliability glossaryCost of outages white paperDeterministic simulation testing primerProperty-based testing primerAutonomous testing primerTechniques to improve software testingCatalog of reliability properties for blockchainsTest ACID-compliance with a Ring test

Senior Engineer, Antithesis
2026년 1월 29일
이 블로그 글은 Shuxian Wang과 제가 Rust NYC가 주최한 Rust UnConf에서 발표한 내용을 바탕으로 각색한 것입니다. UnConf는 몇 시간 동안 깊이 있는 기술 대화를 나누는(그리고 젤라또도 먹는) 정말 멋진 Rust 애호가들의 모임이었습니다.
여러분이 테스트할 소프트웨어를 우리에게 주면, 우리는 그것을 결정적 하이퍼바이저 위에서 컨테이너로 실행합니다. 결정적 하이퍼바이저(또는 Determinator)는 개발에 수년이 걸렸으며, 비결정적인 연산(시간 얻기, 난수와 관련된 모든 것, 모든 입력 등)을 제어 신호(control signals) 스트림으로 제어되는 결정적 버전으로 대체합니다. 주어진 제어 신호 집합에 대해 Determinator는 매번 정확히 동일하게 동작합니다.
Antithesis의 퍼저(fuzzer) 는 이 모든 것을 제어하는 프로그램입니다. 퍼저는 Determinator에 어떤 제어 신호를 보내야 시스템을 조작해 버그를 찾을 수 있는지 알아냅니다.
퍼저는 제어 신호 바이트로 상태 트리를 만들고, 그 바이트들 중 일부는 버그를 찾고 일부는 찾지 못합니다:

퍼저의 논리적 부분을, 저는 컨트롤러(controller) 라고 부르겠습니다. 컨트롤러는 어디서 시작할지 와 어떤 입력을 줄지 를 결정합니다. 예를 들어 아래 그림에서는 초록색 상태( f3 라벨)에서 시작해서 입력 바이트 6f, 64, 70을 제공합니다:

퍼저는 단일 스레드 C++로 작성되어 있습니다.1 우리는 다양한 방식으로 버그를 찾으려는 여러 컨트롤러를 가지고 있고, 이들은 아래와 같은 콜백 인터페이스를 통해 퍼저 코어와 상호작용합니다:2
poll_for_inputs(&controller) -> (start state, inputs)
advertise_outputs(&controller, states)
퍼저의 메인 루프는 (1) 컨트롤러의 poll_for_inputs를 호출해 “어디서 시작하고 무엇을 해야 하지?”를 묻고, (2) 컨트롤러의 advertise_outputs를 호출해 “네가 말한 대로 했고, Determinator에서 실행했을 때 시스템이 반환한 출력 3은 이거야”라고 알려줍니다.
몇 년 전 우리는 퍼저가 Rust를 호출할 수 있는 기능을 추가했습니다. 이를 통해 새로운 제어 전략을 더 쉽게 구현할 수 있었기 때문입니다. 이 기능은 새로운 제어 전략을 연구 하는 데 사용해 왔지만, Rust 코드는 프로덕션에서는 전혀 사용되지 않습니다. 그리고 Rust 쪽은 멀티 스레드이며 비동기(async)입니다. Rust 쪽 컨트롤러는 C++의 콜백 지향 인터페이스가 아니라, 대략 “여기서 시작해서 이 입력을 주고, 되돌아오는 출력을 await한다”라는 비동기 인터페이스를 사용합니다.
이 글은 멀티 스레드 비동기 Rust를 단일 스레드 동기 C++와 어떻게 인터페이스했는지에 대한 이야기입니다. 내용은 90% Rust, 10% C++이고, Rust 쪽은 확실히 깊게 들어갑니다. 하지만 걱정하지 마세요. 함께 따라가 드리고, 가는 길에 농담도 한두 개는 할지도 모릅니다.
배경 정보부터 시작하겠습니다. 비동기 불일치 문제( async mismatch )라는 추가 복잡성 없이 C++과 Rust를 어떻게 결합할까요? 그리고 C++이라는 추가 복잡성 없이 Rust에서 어떤 동기 코드와 비동기 코드를 어떻게 결합할까요?
그리고 이 둘을 결합하면 어떤 문제가 생길까요?
Rust와 C++의 상호 운용을 위해 우리는 Rust 크레이트 cxx를 사용합니다. 이 크레이트는 C++과 Rust 사이에 외부 함수 인터페이스(FFI)를 만들어 줍니다.4 cxx는 세 가지 종류를 정의할 수 있게 해 줍니다:
extern Rust 타입: C++에 노출되는 Rust 타입. cxx 툴링은 포함할 수 있는 C++ 헤더를 만들고, C++ 호출 규약을 Rust 호출 규약으로 변환하는 코드를 생성하여 C++에서 Rust를 호출할 수 있게 합니다.
extern C++ 타입: Rust에 노출되는 C++ 타입. 함수 호출에 대한 Rust 시그니처를 지정하면, cxx가 (일정 규칙에 따라) 기존 C++ 함수와 매칭합니다. cxx는 호출 규약을 적절히 변환하여 Rust 코드가 해당 시그니처의 Rust 함수를 호출하는 것만으로 C++을 호출할 수 있게 합니다.
공유 구조체(shared structures): 메서드/함수가 없는 타입(즉, 순수 struct). Rust에서 선언하면 cxx가 C++ 헤더를 만들어 주어 C++ 쪽에서 생성하거나 사용하거나 둘 다 할 수 있습니다. 이런 struct는 자유롭게 주고받을 수 있습니다. 앞의 두 타입과의 큰 차이는, 공유 구조체는 “타입이 있는 메모리 레이아웃”일 뿐이고 양쪽에 연결된 실행 가능한 함수 코드가 없어서 호출 규약 변환이 없다는 점입니다.
이 글은 Rust 쪽에 관한 내용이지만, C++ 쪽에서는 보통 pimpl 관용구로 함수를 감싸서, Rust 쪽을 바꿀 때마다 C++ 전체를 다시 컴파일하지 않도록 합니다.
핵심 아이디어는 다음과 같습니다. 아래 다이어그램의 “비동기 부분”에 해당하는, 비동기·멀티 스레드 Rust 코드를 작성하고, 그 코드가 async 채널을 통해 정보를 보냅니다.5 채널의 다른 쪽 끝은 동기 Rust 코드에 있고, 이 동기 Rust 코드는 C++에서 동기적으로 호출됩니다. 동기 Rust는 async 채널로 데이터를 보내고 받으며 6, C++ 형식과 Rust 형식 사이의 변환을 담당합니다. 더 복잡한 컨트롤러 로직은 순수 Rust로 유지되어, 객체 변환이나 sync/async 불일치 같은 C++ 관련 걱정에서 격리됩니다.

준비는 끝났으니, 위에 그려진 코드로 좀 더 깊이 들어가 봅시다.
이 코드에는 기술적 문제가 있습니다: start와 result_states는 C++ 객체(State 타입)인데, 이를 스레드 사이로 주고받아야 합니다. execute를 호출할 때 start를 오른쪽에서 왼쪽으로 전달하는데, run이 보내고 poll_for_inputs가 받습니다. 그리고 advertise_outputs에서는 result_states를 왼쪽에서 오른쪽으로 전달하는데, run이 받습니다. 기본적으로 cxx 는 C++ 타입에 대해 Send나 Sync를 구현하지 않습니다. ( inputs도 보내긴 하지만, 그건 Rust 네이티브 객체입니다.)
Rust에서 스레드 안전성을 이야기할 때 Send와 Sync 논의가 빠질 수 없습니다. Send와 Sync는 두 가지 마커 트레이트(즉, 메서드가 없는 트레이트)로, 어떤 타입에 대해 스레드 관련 연산이 안전한지(또는 안전하지 않은지)를 여러분에게 알려줄 뿐 아니라—더 중요하게는—컴파일러에게 알려줍니다.
표준 라이브러리 문서를 약간 바꿔 말하면:
T가 Send라는 것은, 서로 다른 스레드에서 T 또는 &mut T 형태로 독점 접근(exclusive access) 을 갖는 것이 안전하다는 뜻입니다.T가 Sync라는 것은, 서로 다른 스레드에서 &T 형태로 공유 접근(shared access) 을 갖는 것이 안전하다는 뜻입니다.또 하나 잘 알려진 결과로 T가 Sync인 것은 &T가 Send인 것과 동치입니다.
보통 컴파일러는 Rust 코드에 대한 추론을 바탕으로 Send와 Sync를 자동으로 구현합니다. 하지만 Rust 컴파일러는 C++ 코드를 추론할 수 없으므로 자동 구현을 해 주지 않으며, 필요하다면 여러분이 수동으로 구현할 수는 있습니다(그렇게 하는 것이 타당할 때에 한해서).
제가 90년대부터 C++을 해왔고, Rust는 2년 조금 넘게 했다는 걸 지금쯤 말하는 게 좋겠네요. 그리고 우리가 얘기하는 사건은 2년 전, 제가 Rust 초심자였을 때 벌어진 일입니다. 변명이라면 변명이죠.
Rust 컴파일러가 State가 Send를 구현하지 않아 스레드 경계를 넘어 전달할 수 없다고 불평하길래, 저는 이렇게 썼습니다:
unsafe impl Send for State {}
깜짝 퀴즈: 이게 잘 끝났을까요?
물론 아니었습니다. 간헐적인 세그폴트가 발생했습니다. 그리고 우리가 모두 알다시피, Rust 코드에서 세그폴트는 일어나면 안 되죠. 정말 하면 안 되는 unsafe를 쓰지 않는 한 말입니다.
구체적으로 왜 실패했을까요?
C++ 쪽에는 이런 코드가 있습니다:
struct State {
ref_ptr<StateImpl> impl;
...
}
여기서 ref_ptr는 레퍼런스 카운팅 포인터를 구현하는 클래스입니다. Rust의 Rc나 Arc, C++의 shared_ptr와 비슷합니다. 특히 ref_ptr는 스레드 안전하지 않습니다. 따라서 Rust 쪽에서 State 객체를 사용할 때 가끔 레이스 컨디션 7이 발생하여 레퍼런스 카운트 값이 잘못되고, 아직 사용 중인 객체를 삭제해 버립니다. 그 객체에 접근하려 하면 세그폴트가 납니다.
따라서 State는 명백히 Send가 아닙니다. 레퍼런스 카운트에 영향을 주는 작업(클론 또는 드롭)은 메인 C++ 스레드에서만 할 수 있습니다. 앞의 인터루드 식으로 말하면: 다른 스레드에서 State를 소유하는 것은 안전하지 않습니다. T를 소유하면 가능한 연산인 clone과 drop이 refcount를 바꾸는데, 이게 다른 스레드에서 안전하지 않기 때문입니다. 같은 이유로 다른 스레드에서 &State를 갖는 것도 안전하지 않습니다. 다른 스레드에서 공유 참조를 복제할 수 있기 때문입니다.
클론과 드롭—즉 refcount에 영향을 주는 일—을 제외하면, 기본 객체를 충분히 오래 유지하기만 하면 다른 스레드에서 State를 사용 하는 것은 괜찮아야 합니다. 그래서 다음 해결책을 만들었습니다.
해결책은 두 개의 Rust struct를 포함합니다. CppOwner는 메인 스레드에만 존재하고 원본 C++ 객체를 소유합니다. CppBorrower는 C++ 객체에 대한 참조처럼 동작합니다. CppBorrower는 스레드 사이로 전달해도 괜찮지만, CppOwner는 메인 스레드에 있어야 합니다. 드롭할 때는 CppBorrower를 전부 먼저 드롭한 다음, 메인 스레드에서 CppOwner를 드롭해야 합니다:
메인 스레드 전용:
pub struct CppOwner<T> {
value: Arc<T>
}
impl<T> CppOwner<T> {
pub fn borrow(&self) -> CppBorrower<T> {
CppBorrower { value: self.value.clone() }
}
pub fn has_borrowers(&self) -> bool {
Arc::strong_count(&self.value) > 1
}
}
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
if self.has_borrowers() {
panic!("No!");
}
}
}
모든 스레드:
pub struct CppBorrower<T> {
value: Arc<T>
}
impl<T> Clone for CppBorrower<T> {
fn clone(&self) -> Self {
Self { value: self.value.clone() }
}
}
unsafe impl<T: Sync> Send for CppBorrower<T> {}
impl Deref ...
이를 사용 하려면, 메인 스레드에서 CppOwner를 만들고 “in flight” 집합에 보관한 뒤, 필요한 곳에 CppBorrower를 전달합니다:
// On the main thread
let cpp_state = CppOwner::new(state.cpp_clone());
// Send borrow to other threads via the async channel
channel.send(cpp_state.borrow());
// Keep track of the CppOwner
self.in_flight.insert(cpp_state);
나중에(하지만 역시 메인 스레드에서), 더 이상 borrower가 없는 “in flight” C++ 객체를 찾아 드롭합니다:
// Later, but still on the main thread
self.in_flight.retain(|s| s.has_borrowers());
위 코드의 메커니즘을 보면 알 수 있듯, 필요한 refcount를 얻고 객체를 전달할 수 있게 하려고 Arc를 사용하고 있습니다.
요약하면: 메인 스레드에서 CppOwner를 만들고 “in flight” 집합으로 추적합니다. 필요할 때 CppBorrower를 만들어 자유롭게 전달합니다. CppBorrower가 더 이상 없으면 메인 스레드에서 CppOwner를 가비지 컬렉션(드롭)합니다. 그리고 (C++) refcount에 영향을 주는 연산은 오직 메인 스레드의 CppOwner에서만 일어나므로, 이전의 레이스 컨디션을 피할 수 있습니다.
이 접근은 잘 동작했습니다. 우리는 이를 약 2년간 사용했습니다.
그런데 우리의 설계 선택을 다시 생각하게 만든 일이 생겼습니다: 누군가가 퍼저의 Rust 인터페이스를 사용하려 했는데, 프로덕션 코드에서!
이로 인해 방법론을 더 깊게 고민하게 되었습니다. 가비지 컬렉션이 그다지 효율적이지 않았습니다. 때때로 존재하는 모든 CppOwner를 훑으며 “너 삭제해도 되니?”를 묻습니다. 이를 너무 자주 하면 쓸데없는 작업이 많아지고, 너무 드물게 하면 필요 이상으로 메모리를 사용합니다. 근본적으로 우리가 해야 하는 작업량은 삭제 횟수 에 비례해야 하는데, 현재 구현은 객체 수 에 비례합니다. 혹은 객체 수 × 반복 횟수 에 비례할지도요. 연구 워크플로에서는 동시에 존재하는 객체 수가 많지 않아 괜찮았지만, 프로덕션에서는 문제가 되었습니다. (다르게 말하면, 작업량이 삭제해야 할 적은 수가 아니라 유지하는 객체 수에 의해 결정됩니다.)
이전에는 CppOwner가 Arc<T>(여기서 T는 C++ 타입)를 가지고 있었습니다:
struct CppOwner<T> {
value: Arc<T>
}
CppOwner는 메인 스레드에 남아 있어야 했고, CppBorrower만 전달할 수 있었습니다.
새로운 해결책은 CppOwner가 T를 직접 소유하도록( Arc<T>가 아니라) 하는 것입니다:
struct CppOwner<T> {
value: T
}
그리고 Arc<CppOwner<T>>를 전달합니다. 이를 안전하게 하기 위해 마지막 참조 카운트가 사라져 CppOwner<T>를 드롭할 때, T를 메인 스레드로 다시 보내 삭제합니다.
이 계획은 좋아 보이지만 즉시 걸림돌이 있습니다. 이렇게 하려면 CppOwner<T>가 Send여야 하는데, 이는 T가 Send일 때만 자동으로 성립합니다. 그리고 모든 C++ 타입에 대해 unsafe impl Send를 하고 싶지는 않습니다(앞서 세그폴트가 얼마나 위험한지 봤죠). 그렇다면 T가 Send가 아닐 때 어떻게 CppOwner<T>를 Send로 만들 수 있을까요?
컴퓨터 과학에는 한 겹 더 간접층을 추가하면 모든 걸 해결할 수 있다는 속담이 있습니다. 해봅시다.
다음은 T를 스레드 경계 너머로 몰래 운반할 수 있는 SendWrapper<T> 구조체입니다:
pub struct SendWrapper<T>(T);
// Even when T: !Send
unsafe impl<T> Send for SendWrapper<T> {}
핵심은 주석입니다: SendWrapper<T>는 T 자체가 Send가 아니어도 Send입니다.
하지만 T가 Send일 수도 아닐 수도 있으므로, T에 대한 독점 접근을 얻는 것은 안전하지 않습니다. 따라서 SendWrapper<T>가 이를 막도록 주의해야 합니다. &mut T를 얻을 방법을 노출하지 않는 것은 비교적 쉽습니다(예: SendWrapper는 DerefMut을 구현하지 않음). 그러나 SendWrapper가 드롭되지 않도록도 해야 합니다. 그래서 우리는 이런 코드를 작성했습니다:
impl<T> Drop for SendWrapper<T> {
fn drop(&mut self) {
panic!("Cannot drop a SendWrapper!")
}
}
이제 CppOwner의 Drop에서 T를 메인 스레드로 돌려보내도록 올바른 로직을 넣고 싶습니다:
pub struct CppOwner<T>(ManuallyDrop<SendWrapper<T>>);
CppOwner는 이제 SendWrapper를 포함합니다(ManuallyDrop로 감쌌는데, 이는 “컴파일러가 T의 소멸자를 자동으로 호출하지 못하게 하는 래퍼”입니다).
CppOwner의 Drop 구현은 SendWrapper를 꺼내서 특별한 “드롭 큐(drop queue)”로 밀어 넣어, 메인 스레드로 보내 삭제합니다:
impl<T> Drop for CppOwner<T> {
fn drop(&mut self) {
let val: SendWrapper<T> = unsafe { ManuallyDrop::take(&mut self.0) };
DROP_QUEUE.push(val);
}
}
잠깐, DROP_QUEUE가 뭐죠? 이것은 새로운 DropQueue 타입의 static 인스턴스입니다. 타입 정의는 다음과 같습니다:
pub struct DropQueue<T>(ConcurrentQueue<SendWrapper<T>>);
그리고 DropQueue에는 메인 스레드에서 T를 꺼내 드롭하는 drain 메서드가 있습니다. (T를 메인 스레드에서만 드롭하는 것이 안전하므로, drain도 메인 스레드에서만 호출하는 것이 안전합니다.)
impl<T> DropQueue<T> {
// SAFETY: Only call on main thread
pub unsafe fn drain(&self) {
for val in self.0.try_iter() {
drop(unsafe { val.unwrap_unchecked() })
}
}
}
여기서는 앞서 다루지 않은 SendWrapper의 다른 함수도 사용합니다.8 SendWrapper에서 T를 꺼내는 unsafe 메서드입니다.
impl<T> SendWrapper<T> {
pub unsafe fn unwrap_unchecked(self) -> T
}
이제 가비지 컬렉션 문제를 해결했습니다. CppOwner의 drop이 객체를 메인 스레드로 보내 파괴하고, 우리가 수행하는 작업은 반복 횟수가 아니라 드롭해야 하는 것의 수에 비례합니다. 또한 CppBorrower는 대부분 제거되고, Arc<CppOwner>로 대체되었습니다.
SendWrapper는 CppOwner 내부에서만 사용되고, 외부 코드가 직접 호출할 수 있게 노출하지 않습니다.시간을 되돌려, SendWrapper 개선 이전이지만 CppOwner/CppBorrower를 쓰던 시점으로 돌아가 봅시다. 그때 우리는 또 다른 문제를 발견했습니다.
당시 작성했던 코드는 이렇습니다:
async fn run() {
loop {
let (start, inputs) = create_rollout_somehow();
let result_states = execute(start, inputs).await;
let details = result_states.get_details();
do_something_with(details);
}
}
여기서 start는 C++의 State 타입 객체이며, 이를 CppOwner<State>와 CppBorrower<State>로 변환해 가비지 컬렉션/refcount는 올바르게 동작했습니다. 하지만 C++ 쪽의 get_details 9 함수는 스레드 간 호출이 안전하지 않았고, 메인 스레드에서만 안전하게 호출할 수 있었습니다. (더 일반적으로, C++ 쪽 함수는 스레드 간 호출이 안전할 수도 안전하지 않을 수도 있는데, 이 함수는 안전하지 않았습니다.)
get_details는 테스트 대상 시스템에서 무슨 일이 일어났는지에 대한 더 자세한 정보를 알려줍니다. 어떤 로그 메시지를 남겼는지, 어떤 assertion을 맞았는지, 프로세스나 컨테이너가 크래시했는지 등.여기에도 해결책이 있습니다:

get_details처럼 메인 스레드에서만 호출할 수 있는 함수가 있으면, 직접 호출하는 대신 간접층을 둡니다.
비동기 부분에서는:
동기 Rust 부분에서는:
그 다음 비동기 쪽에서는 이 “결과” 채널에서 결과를 await합니다.
이 일반 패턴은 잘 동작했습니다. 메인 스레드에서 무엇을 호출할지, 임의의 스레드에서 무엇을 호출할지에 대해 주의하기만 하면 됐습니다.
이를 프로덕션에 대비하면서 우려가 된 점은, 매우 미묘한 스레드 안전성 이야기였습니다. Rust 모델에서는 _struct_가 스레드 안전하거나 안전하지 않거나 둘 중 하나입니다(그리고 스레드 안전성에는 Send와 Sync 두 종류가 있습니다). 그런데 우리가 발견한 것은, 어떤 struct는 일부 메서드 는 스레드 안전하지만 다른 메서드는 그렇지 않을 수 있다는 점입니다. 이 언어 간 불일치는 꽤 근본적입니다. 어느 한쪽이 옳고 그르다는 문제가 아니라, 그냥 다릅니다. 그리고 우리는 둘이 함께 동작하게 만들어야 합니다.
또한 초기 해결책은 C++ 프로그래머로서의 저는 마음에 들었지만, Rust 프로그래머로서의 저는 마음에 들지 않았습니다. 핵심은 “매우 조심하고, 네가 하는 일을 열심히 생각해라”로 요약됩니다. 이는 C++에서는 완전히 자연스러운 분위기지만, Rust는 그렇지 않습니다. Rust의 방식은 “문제를 사람이 수동으로 찾는 게 아니라 컴파일러의 도움을 받아라”입니다. 제 초기 해결책은 전혀 Rust스럽지 않았습니다.
이 문제를 요약하면, 우리는 다음을 원합니다:
더 낫고 더 “Rust다운” 해결책은 두 부분으로 구성됩니다: (1) 특정 함수가 메인 스레드에서만 호출되도록 보장하는 MainThreadToken, 그리고 (2) 어떤 함수가 다른 스레드에서 호출해도 안전한지 아닌지에 대한 규약과 규칙입니다.
특정 함수를 메인 스레드에서만 호출 가능하다고 표시하고 싶어서 MainThreadToken 구조체를 만들었습니다. 이는 증명-운반자(proof-carrier)입니다: MainThreadToken을 가지고 있다는 것은 메인 스레드에 있다는 증거입니다. 코드는 놀랍도록 단순합니다:
#[derive(Clone, Copy)]
pub struct MainThreadToken(PhantomData<*mut ()>);
PhantomData를 써본 적이 없다면, 이는 실제로 데이터를 저장하진 않지만 컴파일러에게 “이것이 이 타입을 가진 것처럼 취급하라”고 알려주는 Rust 구성요소입니다. 따라서 이 토큰은 RAM을 0바이트 차지하지만, 타입이 *mut()인 것처럼 행동합니다. 그리고 *mut 덕분에 이 struct는 Send도 Sync도 아닙니다.
여기서 약간 속임수를 썼습니다. 이 코드는 MainThreadToken을 스레드 간에 전달할 수 없게 하지만, 메인 스레드에서 생성했다 는 보장은 없습니다. 다른 스레드에서 만들어도 그 스레드에 “고정”될 뿐, 메인 C++ 스레드에 고정되는 게 아니죠. 그래서 코드가 조금 더 필요합니다:
pub static MAIN_THREAD_ID . . .
// Somewhere guaranteed to be in the main thread
initialize(MAIN_THREAD_ID);
/// # Safety
/// This function must be called from the main fuzzer thread.
pub unsafe fn new() -> Self {
assert_eq!(*MAIN_THREAD_ID, std::thread::current().id());
Self(PhantomData)
}
여기에는 겹치는 두 가지 제어가 있습니다. (1) unsafe 키워드와 Safety 주석이 올바르게 사용하도록 충분한 압박을 주고, (2) 런타임 체크가 있어 잘못 사용하면 정의되지 않은 동작이 아니라 panic이 납니다.10
일반적으로 이 new()는 자주 호출하면 안 됩니다. 한 번 생성한 후, 같은 스레드에서 Copy 또는 Clone하면 됩니다.
이게 어떻게 도움이 되죠? 예를 들어 앞에서 이야기한 drain 메서드처럼, 메인 스레드에서만 호출해도 안전한 함수가 있다면:
// SAFETY: Only call on main thread
pub unsafe fn drain(&self)
이를 MainThreadToken으로 안전하게 만들 수 있습니다:
pub fn drain(&self, _token: MainThreadToken)
구현에서 MainThreadToken을 실제로 사용 하지는 않습니다. 그저 이를 소유하고 있어 함수 호출 시 전달할 수 있다는 사실 자체가 우리가 메인 스레드에 있다는 의미입니다. 그래서 컴파일러가 정적으로 drain 같은 메서드를 올바른 곳에서만 호출하는지 체크할 수 있습니다.
cxx 크레이트는 C++ 메서드를 두 종류로 구분합니다: const 메서드(Rust 쪽에서는 &self)와 non-const 메서드(Rust 쪽에서는 Pin<&mut Self>)입니다.
| C++ | Rust |
|---|---|
int get_data() const; | fn get_data(&self) -> i32; |
void push(int x); | fn push(self: Pin<&mut Self>, x: i32) -> (); |
우리는 non-const 쪽은 그대로 두겠습니다. &mut Self는 이들을 동시 호출하지 못하게 보장하고, SendWrapper는 &mut 접근을 주지 않도록 특별히 막기 때문입니다. 따라서 non-const 함수는 C++에서 메인 스레드에서만 호출됩니다.
하지만 const 메서드는 좀 까다롭습니다. C++에서 const는 함수 안에서 객체 표현의 비트가 바뀌지 않는다는 뜻입니다. 하지만 어떤 클래스가 다른 클래스를 가리키는 포인터를 가지고 있고, 포인터 자체는 바뀌지 않더라도 다른 클래스 내부는 동시에 바뀔 수 있습니다.11
예를 들어, 다른 코드가 other 포인터의 복사본을 가지고 있다면 x를 바꾸어 do_const의 반환값에 영향을 줄 수 있습니다.
struct Other {
int x;
};
struct MyObj {
Other* other;
int do_const() const {
return other->x;
}
};
더 나아가서, do_const는 포인터 other 자체는 바꿀 수 없지만 x는 바꿀 수 있습니다. 따라서 other->x++는 do_const 안에서 완전히 유효합니다.
결국 const만으로는 C++ 메서드가 다른 스레드에서 안전하게 호출 가능한지 알 수 없으니, 우리가 자체 구분을 만들겠습니다. 다른 스레드에서 호출해도 안전한 것을 “sync”, 그렇지 않은 것을 “unsync”라고 부르겠습니다. (정확한 정의는 아래에 나옵니다.)
C++ 쪽에서 SYNC와 UNSYNC를 위한, 아무 동작도 하지 않는 “마커 매크로” 두 개를 정의합니다:
#define SYNC
#define UNSYNC
이들은 실제로 아무 것도 하지 않습니다. 하지만 함수에 라벨을 붙이는 데 사용할 수 있습니다. 예:
int get_immutable_data() SYNC const;
int get_mutable_data() UNSYNC const;
예를 들어 get_immutable_data는 생성자에서 설정된 ID 같은 불변 데이터를 반환하고, get_mutable_data는 포인터를 역참조하여 내부 객체의 값(위 MyObj 예시처럼 동시 변경될 수 있는)을 반환한다고 상상할 수 있습니다.
이 매크로는 컴파일러를 위한 것이 아니라 우리를 위한 것입니다. 클래스 정의 시 코드 리뷰에서 (1) SYNC/UNSYNC를 썼는지, (2) 올바르게 썼는지를 쉽게 확인할 수 있습니다. 매크로를 전혀 쓰지 않았으면 “이거 정리해서 추가해 주세요”라고 리뷰를 되돌립니다. 또한 매크로가 정의된 곳에는 의무 사항을 정확히 설명하는 아주 긴 주석이 있습니다(이 블로그 글이 주석 형태로 들어 있다고 상상해 보세요).
그리고 이왕 하는 김에, unsync 함수의 이름도 unsync임을 강조하도록 바꿔 봅시다. 지금 당장은 도움이 안 되지만, 나중에 Rust로 넘어갈 때 도움이 됩니다.12
그래서 이제 우리는 이렇게 됩니다:
int get_immutable_data() SYNC const;
int get_mutable_data_unsync() UNSYNC const;
_unsync가 들어가길 원합니다.그렇다면 이 우리가 만든 SYNC/UNSYNC 구분은 정확히 무엇을 의미할까요?
반드시 “const, sync”는 다른 “const, sync” 및 “const, unsync”와 동시 호출해도 안전해야 합니다. 하지만 “const, unsync”는 “const, sync”와 함께일 때만 안전하고, 다른 “const, unsync”와는 함께일 때 안전하지 않습니다. 그리고 둘 다 non-const 메서드와 동시에 호출하면 안전하지 않습니다. 이를 표로 정리하면:
| 동시성 안전? | non-const | const, sync | const, unsync |
|---|---|---|---|
| non-const | N | N | N |
| const, sync | N | Y | Y |
| const, unsync | N | Y | N |
아래처럼 SYNC 메서드라면:
int get_immutable_data() SYNC const;
Rust 쪽에서는 기본 시그니처를 그대로 사용합니다:
fn get_immutable_data(&self) -> i32;
아래처럼 UNSYNC 메서드라면:
int get_mutable_data_unsync() UNSYNC const;
Rust 쪽에서는 메서드를 unsafe로 만들고 Safety 주석을 추가합니다:
/// # Safety: main thread only
unsafe fn get_mutable_data_unsync(&self) -> i32;
이 시점에서, Rust 타입(cxx가 C++ 타입으로부터 파생한 타입)을 Sync로 표시할 수 있습니다. Send는 아니고 Sync만입니다(공유 참조를 갖는 것이 스레드 경계를 넘어도 안전하다는 의미).
마지막으로 MainThreadToken을 사용해 _unsync 함수의 안전한 버전을 만듭니다:13
fn get_mutable_data(&self, _token: MainThreadToken) -> i32 {
// SAFETY: We're on the main fuzzer thread as we own a `MainThreadToken`.
unsafe { self.get_mutable_data_unsync() }
}
이 클래스를 사용하는 사람은 safe 버전을 호출해야 합니다. (또한 _unsync 접미사를 함수 이름 끝에 붙인 이유도 여기에 있습니다. safe 버전이 이상한 이름이 아니라 원래 이름을 가질 수 있게 하려고요.)
SendWrapper<T>로 잠시 돌아가 봅시다. 아까 말하지 않았지만, 구현 일부는 이렇습니다:
unsafe impl<T> Send for SendWrapper<T> {}
// &SendWrapper<T> -> &T, only if T is Sync
impl<T: Sync> Deref for SendWrapper<T> . . .
따라서 SendWrapper는 T가 무엇이든 항상 Send를 구현하지만, T가 Sync를 구현할 때만 Deref를 구현합니다. 즉 여러분은 (1) T나 &mut T를 얻을 수 없고, (2) T가 Sync일 때만 &T를 얻을 수 있습니다(&T가 스레드 경계를 넘어 사용해도 안전하다는 뜻).
말로 풀면 꽤 복잡하죠. 이게 무슨 뜻일까요? 이는 C++ struct에서 파생된 Rust struct를 몇 가지 종류로 정의할 수 있다는 뜻입니다:
SendWrapper<T>로 몰래 전달할 수는 있지만, 다른 스레드에서 어떤 메서드도 호출할 수 없는 struct(1)이 대체 왜 유용한지 한참 쳐다보면 의문이 들 수도 있습니다. 핵심은 마지막 몇 단어입니다: “다른 스레드에서 메서드를 호출할 수 없다.” 요청 객체 모델을 사용하면 SendWrapper<T>를 스레드 간에 주고받고, 메서드 호출은 메인 스레드에서 수행할 수 있습니다.
(2)의 경우, Deref 구현 때문에 다른 스레드에서 클래스 메서드를 호출하려면 원본 C++ 타입 T에 대해 Sync를 구현해야 합니다:
// Long #Safety comment
unsafe impl Sync for SomeCppType {}
앞서와 같이, impl Sync 라인이 들어가는 cxx 파일 14 부분에는 이 전체 스킴과 여러분의 의무를 설명하는 긴 Safety 주석이 있습니다.
정리하면, 어떤 타입에 대해 다른 스레드에서 어떤 함수를 호출하려면 다음을 합니다:
C++ 쪽에서는:
각 const 메서드에 대해, 여러 스레드에서 호출해도 안전한지에 따라 SYNC 또는 UNSYNC 마커를 사용합니다.15
unsync 함수는 이름 끝에 _unsync 접미사를 붙입니다.
코드 리뷰로 모든 올바른 의무 사항이 지켜지는지 확인합니다.
앞에서는 “동시(concurrently)”라고 했지만, 실제 조건은 “여러 스레드에서(on multiple threads)”입니다. 예를 들어 동시에가 아니더라도, 한 스레드에서만 호출 가능하고 다른 스레드에서는 순차 호출조차 허용되지 않으면 unsync입니다.
Rust 쪽에서는:
C++ 쪽과 같은 이름을 사용합니다.
_unsync 메서드는 unsafe로 만들고 적절한 Safety 주석을 추가합니다. (Safety 주석은 관례이지만, clippy가 이를 강제하는 데 도움을 줍니다.)
MainThreadToken을 사용해 _unsync 접미사가 없는 safe 버전을 만듭니다.16
타입을 Sync로 표시합니다. (unsafe _unsync 메서드는 그로부터 “옵트아웃”하고, safe 버전은 MainThreadToken을 요구하여 올바른 스레드를 강제합니다.)
추가적인 안전 요구 사항이 있을 수도 있습니다.
이 해결책은 훨씬 더 “Rust답지만”, C++ 프로그래머인 저도 만족시킵니다. C++ 쪽에서는 무엇이 정확히 무엇인지 정의했고, 코드 리뷰에서 무엇을 확인해야 하는지 알고 있습니다. 그로써 우리가 노출 하는 함수가 올바르게 구성되었는지 보장합니다. (C++ 프로그래머인 저는 “조심해라, 그리고 개발자 가 코드 리뷰에서 뭔가를 검증한다”라는 접근도, 검증해야 할 것이 무엇인지 명확히 정의되어 있다면 괜찮습니다.) Rust 쪽에서는 위 규칙을 적용하고 나면, 함수가 사용되는 위치에서 올바르게 사용되는지를 컴파일러 가 확인할 수 있습니다. 그리고 둘의 조합이 전체가 함께 잘 동작하도록 보장합니다.
여기까지 정말 많은 내용을 다뤘습니다. 우리는 cxx 를 사용해 C++에서 Rust를 호출하고, C++ 코드는 단일 스레드이지만 Rust 코드는 멀티 스레드입니다. 이로 인해 두 가지 주요 문제가 생깁니다.
첫 번째 문제는 스레드 안전하지 않은 객체 입니다. 우리는 원래 CppOwner와 CppBorrower로 이를 해결했습니다. 그 다음 SendWrapper로 C++ 타입을 스레드 경계 너머로 몰래 전달하고, CppOwner가 드롭 시 SendWrapper를 메인 스레드로 보내 안전하게 삭제되도록 하는 새 버전으로 개선했습니다.
두 번째 문제는 스레드 안전하지 않은 함수 입니다. 우리는 C++ 쪽에서 함수 이름과 태깅 규약을 만들고, Rust 쪽에서 일부 함수를 unsafe로 표시하는 것으로 이를 해결했습니다. 또한 메인 스레드에서만 가질 수 있는 증명-운반자 타입 MainThreadToken을 만들었습니다. 이를 이용해 unsafe 함수들의 안전한 버전을 만들었습니다.
두 문제 모두에서 두 번째/후기 해결책에 도달했을 때, 우리는 컴파일러가 올바르게 호출되고 있는지 확인할 수 있도록 충분한 정보를 제공하는, 매우 Rust다운 해결책으로 전환했습니다. 따라서 퍼저의 Rust 부분은 다른 개발자들이 사용하더라도 프로덕션에서 사용할 준비가 되었습니다.
여기까지 읽고 “이 글은 너무 짧고 디테일이 부족한 게 불만이네”라고 생각하셨다면, 좋은 소식이 있습니다. Part 2가 곧 나올 예정인데, 제 공범인 Shuxian이 이 프리미티브들이 우리가 주장한 동작을 한다는 것을 어떻게 형식적으로 증명했는지 설명할 겁니다.


어디든 붙이고 칭찬이 컴파일되는 걸 지켜보세요.
어디든 붙이고 칭찬이 컴파일되는 걸 지켜보세요.


끝까지 왔습니다!
무료 스티커를 가져가세요.
© Antithesis Operations LLC