이 질문의 역사는 Rust 1.36에서 std::task::Waker의 안정화로 거슬러 올라가며, 이는 실행자가 준비 상태를 미래에게 알릴 수 있는 표준화된 메커니즘을 도입했습니다. 그 이전에는 비동기 프레임워크가 박스 클로저나 커스텀 알림 특성에 의존하여 할당 오버헤드를 부과하고 C 라이브러리와의 매끄러운 통합을 방해했습니다. RawWaker API는 개발자가 원시 포인터 및 함수 포인터 테이블(RawWakerVTable)에서 Waker 인스턴스를 구성할 수 있도록 하여 Rust의 안전 요구 사항에 맞춰 C++의 가상 테이블을 모델링하도록 설계되었습니다.
문제는 RawWaker 구성이 Rust의 소유권 및 빌리기 시스템을 완전히 우회하기 때문에 발생합니다. 프로그래머는 네 가지 주요 불변성을 수동으로 보장해야 합니다: 데이터 포인터는 모든 Waker 복제본의 수명 동안 유효해야 하며(원본뿐만 아니라), 네 개의 vtable 함수(clone, wake, wake_by_ref, drop)는 스레드 안전(Send 및 Sync)해야 하고, clone 함수는 동일한 기본 작업 상태를 참조하는 새 RawWaker를 반환해야 합니다. 또한, vtable은 FFI 호환성과 안정적인 호출 규칙을 보장하기 위해 extern "C" ABI를 사용해야 합니다.
해결책은 unsafe 불변성을 엄격히 준수하는 것을 요구합니다. 데이터 포인터는 일반적으로 'static 데이터에 참조해야 하거나 공유 소유권을 관리하기 위해 Arc로 래핑되어야 합니다. vtable 함수는 참조 카운팅 의미론을 올바르게 구현해야 합니다: clone은 카운트를 증가시켜야 하고, drop은 그것을 감소시켜야 하며, wake는 알림 후 감소해야 합니다(소비하는 Waker). ABI 계약을 위반하면—예를 들어 Rust 호출 규칙 대신 **extern "C"**를 사용하는 경우—실행자가 이러한 포인터를 호출할 때 정의되지 않은 동작이 발생하며, 스택 손상, 인수 정렬 오류 또는 잘못된 메모리 주소로 점프하는 등의 문제가 발생할 수 있습니다.
use std::sync::Arc; use std::task::{RawWaker, RawWakerVTable, Waker}; struct TaskState { id: u64, } unsafe fn clone_waker(data: *const ()) -> RawWaker { let arc = Arc::from_raw(data as *const TaskState); let _ = Arc::clone(&arc); let _ = Arc::into_raw(arc); // Leak back to avoid drop RawWaker::new(data, &VTABLE) } unsafe fn wake_waker(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); drop(arc); // Drop the Arc, releasing the reference } unsafe fn wake_by_ref(data: *const ()) { let arc = Arc::from_raw(data as *const TaskState); // Wake logic here, then leak back let _ = Arc::into_raw(arc); } unsafe fn drop_waker(data: *const ()) { let _ = Arc::from_raw(data as *const TaskState); // Implicit drop releases memory } static VTABLE: RawWakerVTable = RawWakerVTable::new( clone_waker, wake_waker, wake_by_ref, drop_waker, ); fn create_waker(state: Arc<TaskState>) -> Waker { let ptr = Arc::into_raw(state) as *const (); unsafe { Waker::from_raw(RawWaker::new(ptr, &VTABLE)) } }
고주파 거래 시스템을 개발하는 것을 고려해 보십시오. 여기서 Rust 비동기 런타임은 레거시 C++ 시장 데이터 피드 라이브러리와 인터페이스해야 합니다. C++ 라이브러리는 void* 컨텍스트 및 함수 포인터를 수신하는 등록 함수를 제공하며, 가격 업데이트가 도착할 때 콜백을 호출합니다. 엔지니어링 과제는 메시지당 할당 오버헤드를 도입하지 않고 C++ 콜백 메커니즘과 Rust 미래를 연결하는 Waker를 생성하는 것입니다. 지연 요구 사항이 마이크로초 수준의 웨이크 시간을 요구하므로 말입니다.
한 가지 해결책은 Waker의 데이터 포인터로서 Box<dyn Fn() + Send> 클로저를 저장하는 것이었습니다. 이 접근 방식은 Rust의 소유권 시스템을 통한 메모리 안전성을 제공하고 통합을 간단하게 했습니다. 그러나 이는 모든 시장 데이터 구독에 대해 허용할 수 없는 힙 할당 지연과 가상 배치 오버헤드를 도입하여 시스템의 제로 카피 아키텍처를 위반했습니다. 더욱이, FFI 경계를 넘은 박스 클로저의 수명 관리는 위험해졌습니다. 왜냐하면 C++ 라이브러리의 비동기 정리가 C++ 라이브러리가 콜백을 호출하는 것을 멈추기 전에 Rust 쪽에서 Waker를 삭제하면 dangling pointer를 남길 수 있기 때문입니다.
대안으로는 전역 정적 해시 맵을 사용하여 정수 ID를 작업 핸들에 매핑하고 ID를 void* 컨텍스트로 전달하는 것이었습니다. 이는 할당을 제거하고 웨이크 작업 중 O(1) 조회를 제공했습니다. 그러나 이는 작업이 피드에서 등록 취소 없이 완료될 경우 메모리 누수 위험을 발생시켰으며, 정적 맵은 높은 시장 데이터 처리량에서 혼잡 병목 현상이 되는 Mutex 동기화를 요구했습니다. 이는 모든 CPU 코어에서 웨이크 알림을 효과적으로 직렬화했습니다.
선택한 솔루션은 데이터 포인터가 C++ 콜백 컨텍스트와 완료 플래그를 포함하는 **Arc<TaskState>**를 보유하는 사용자 정의 RawWaker를 구현했습니다. RawWakerVTable 함수는 unsafe extern "C" 스로크로 구현되어 void*를 다시 Arc 포인터로 안전하게 변환하여 FFI 경계에서의 올바른 참조 카운팅을 보장했습니다. 이 디자인은 메시지당 할당을 제거하고 Arc 구조를 재사용하여, Arc의 원자적 작업을 통해 스레드 안전성을 유지하며, 마지막 Waker 복제가 삭제될 때만 참조 카운트를 감소시켜 메모리 안전성을 확보했습니다. 결과적으로 서브 마이크로초 웨이크 지연을 달성하고 Rust/C++ 경계에서 메모리 안전성 보장을 유지하며 Miri의 정의되지 않은 동작 감지 및 수백만 개의 동시 가격 업데이트가 포함된 스트레스 테스트를 성공적으로 통과했습니다.
Executor가 단일 스레드일지라도 왜 RawWakerVTable 함수가 스레드 안전해야 합니까? (Send + Sync)
Waker 유형은 Clone, Send, Sync를 구현하여 실행자의 스레딩 모델에 관계없이 스레드 경계를 넘어 이동할 수 있습니다. 미래가 Waker를 보유하고 이를 spawn_blocking 작업 또는 std::sync::mpsc 채널에 전달할 때, Waker는 그것을 생성한 것과 다른 스레드에서 호출될 수 있습니다. vtable 함수가 단일 스레드 접근을 가정하면—for instance Rc 또는 동기화되지 않은 static mut를 사용하는 경우—they create data races when wake() is called concurrently. Furthermore, async runtimes like Tokio or async-std may migrate tasks between worker threads for load balancing, meaning the Waker could be cloned and dropped on threads different from its creation site. 스레드 안전성 요구 사항은 Waker가 프로그램 전반에 걸쳐 어떻게 공유되든지 간에 알림 메커니즘이 유효하게 유지되도록 보장합니다.
clone 함수가 다른 vtable과 함께 RawWaker를 반환하면 어떤 재앙적 실패가 발생합니까?**
Waker 계약은 모든 Waker 복제본이 동일한 기본 작업을 나타내고 호출될 때 동일하게 행동해야 한다고 요구합니다. clone이 다른 작업과 연결된 vtable을 가리키는 RawWaker를 반환하면—아마도 null 함수 포인터를 포함하는—실행자는 작업을 알릴 때 잘못된 웨이크 논리를 호출할 수 있습니다. 이는 관련이 없는 작업을 깨우거나(논리적 손상) 잘못된 메모리로 점프하게 됩니다(세그먼트 오류). 구체적으로, 실행자는 일반적으로 내부 큐에 Waker 복제본을 저장합니다. 이벤트가 발생하면 이러한 저장 핸들에서 **wake()**를 호출합니다. 불일치한 vtable은 데이터 포인터(작업 컨텍스트)가 잘못된 함수 시그니처를 통해 해석되어 즉각적인 정의되지 않은 동작으로 이어지며, vtable 함수가 포인터를 잘못된 유형으로 캐스팅하거나 잘못된 오프셋에서 필드를 액세스할 때 발생합니다.
왜 extern "C" ABI가 vtable 함수에 대해 필수적인가요? 기본적으로 Rust ABI가 아니라?
RawWakerVTable은 FFI 호환성과 ABI 안정성을 보장하기 위해 extern "C" 함수 포인터를 지정합니다. Rust ABI는 컴파일러 버전이나 최적화 수준에 따라 안정적이지 않습니다. 함수 서명이 컴파일러 내부, 인라인 결정 또는 대상을 기준으로 변경될 수 있습니다. **extern "C"**를 사용하면 호출 규칙이 플랫폼의 C 표준을 따르게 되어 vtable이 C 코드와 호환되며 실행자가 wake() 또는 **clone()**을 호출할 때 정의되지 않은 동작을 방지합니다. 또한, extern "C" ABI는 특정 레지스터 사용 및 스택 정리 규칙을 요구하여 Waker가 언어 경계를 넘어 안전하게 전달될 수 있도록 합니다. 이 제약이 없다면 동적 라이브러리에 대한 링킹이나 Rust 컴파일러 업그레이드가 함수 호출 규칙을 변경하여 실행자가 wake() 또는 **clone()**을 호출할 때 스택 손상이나 인수 정렬 오류를 초래할 수 있습니다.