질문에 대한 답변
비동기 미래가 대기 지점, 즉 **tokio::select!**에서 형제 분기가 완료될 때 중단된 상태에서 드롭되면, 보유한 리소스를 파괴하기 위해 Drop 구현이 동기적으로 실행됩니다. 비동기 정리(cleanup)가 필요한 리소스를 소유하는 경우(예: TcpStream을 플러시할 때, 프로토콜 종료 프레임을 전송할 때 또는 데이터베이스 트랜잭션을 커밋할 때) 위험이 발생합니다. Drop 특성에는 비동기 컨텍스트가 없기 때문입니다. 미래가 상태를 부분적으로 수정한 후(예: 파일 버퍼의 절반을 기록한 경우) 최종화하기 전에 취소되면, 동기 Drop은 정리 작업의 완료를 .await 할 수 없어 시스템이 불일치 상태에 놓이거나 리소스가 유출될 가능성이 있습니다. 아키텍처적 해결책은 드롭 가드 패턴을 도입하는 것입니다: 리소스를 감싸는 가드 구조체를 사용하여 그 Drop 구현이 비동기 정리를 수행하도록 일정을 잡거나 동기적 폴백 정리를 수행하게 하는 것입니다.(차단 리스크를 수용하기 위해) 이를 통해 임시 파일 삭제와 같은 중요한 불변 속성이 비동기 코드에 의존하지 않고도 결국 강제되도록 합니다.
실제 상황
우리는 tokio::spawn이 동시에 파일 업로드를 처리하는 고성능 미디어 수집 서비스를 개발했습니다. 각 업로드 작업은 디스크의 임시 파일에 청크를 기록하고, 외부 프로세스를 통해 바이러스 스캔을 수행한 후, 최종적으로 확인된 파일을 원래 저장소 버킷으로 원자적으로 이동했습니다. 요구 사항은 엄격했습니다: 클라이언트가 연결이 끊기면(바이러스 스캔과 원자 이동 간의 **select!**를 통한 작업 취소 유발), 디스크 공간 소모를 방지하기 위해 임시 파일을 즉시 삭제해야 했습니다.
해결책 1: Drop에서 동기 정리 수행. 우리는 std::fs::File과 경로 문자열을 감싸는 TempFileGuard 구조체를 구현했습니다. 그 Drop 구현에서 우리는 임시 파일을 삭제하기 위해 std::fs::remove_file을 동기적으로 호출했습니다. 장점: 코드는 간단하였고 스택 언와인딩 또는 취소 중에 실행 보장이 있었습니다. 단점: std::fs::remove_file은 차단 syscall입니다. Tokio 런타임의 워커 스레드에서 실행할 때, 높은 디스크 부하에서 밀리초 동안 스레드를 차단하여 다른 작업이 기아 상태에 빠지게 하고 비동기 비차단 계약을 위반했습니다. 또한 임시 파일이 네트워크 파일 시스템(NFS)에 있다면, 차단 시간이 몇 초로 늘어나 심각한 지연 пузырлы를 초래할 수 있습니다.
해결책 2: 발생한 정리 작업. 가드의 Drop에서 우리는 경로 문자열을 캡처하고 비동기적으로 tokio::fs::remove_file을 실행하기 위해 분리된 tokio::task를 시작했습니다. 장점: 이렇게 하면 런타임에 대한 제어가 즉시 반환되어 대기 시간이 유지됩니다. 단점: 런타임이 이미 종료 중이거나 극도의 부하 상태에 있다면, 정리 작업이 실행되지 않을 수 있어 리소스 유출로 이어질 수 있습니다. 또한 이 패턴은 가드가 런타임에 대한 Clone 핸들을 보유해야 하므로 구조체의 생명 주기를 복잡하게 만들고, 런타임이 가드 전에 드롭되면 사용 후 해제 위험이 발생할 수 있습니다.
해결책 3: 동기 폴백이 있는 명시적 취소 토큰. 우리는 tokio_util::sync::CancellationToken을 사용하고 업로드 논리를 설계하여 원자 이동 전에 취소를 확인했습니다. 취소된 경우, 파일 크기가 특정 임계값 이하일 때만 동기 삭제를 시도했고, 그렇지 않으면 전용 백그라운드 정리 스레드( std::thread를 통해 생성)로 대기열에 등록되었습니다. 가드의 Drop은 패닉의 드문 엣지 케이스만 처리했으며, 동기 삭제는 마지막 수단으로 사용되었습니다. 선택한 해결책: 우리는 옵션 3을 선택했습니다. 작은 파일에 대해 동기 경로를 통한 결정론과 느린 작업을 위한 백그라운드 스레드의 확장성을 균형 있게 유지하면서 Tokio 작업자 스레드를 차단하지 않는다고 결론지었습니다. 그 결과, 10,000개의 동시 취소 로드 테스트 동안 유출된 임시 파일이 없었고, p99 대기 시간은 배경 스레드가 NFS 지연 패널티를 흡수했기 때문에 안정적으로 유지되었습니다.
후보자들이 자주 놓치는 부분
비동기 정리를 수행하기 위해 Drop 구현 내에서 block_on을 호출하는 것이 대부분의 비동기 런타임에서 근본적으로 UNSOUND한 이유는 무엇입니까?
Drop 내에서 block_on을 호출하려고 하면 재진입 위험이 발생합니다. Drop은 스택 언와인딩 도중이나 미래가 취소될 때 동기적으로 호출됩니다. 현재 스레드가 Tokio(또는 async-std) 런타임의 작업자 스레드인 경우, block_on은 새 미래의 실행을 완료하기 위해 리액터를 실행하려고 시도합니다. 그러나 런타임은 현재 작업(드롭되고 있는 작업)이 스레드를 해제하기를 기다리고 있습니다. 이로 인해 교착 상태가 발생합니다: block_on은 정리 미래를 위해 리액터가 폴링되기를 기다리지만, 리액터는 block_on 내부에서 스레드가 차단되어 있기 때문에 진행할 수 없습니다. 또한 Tokio와 같은 런타임은 이러한 시나리오를 방지하기 위해 중첩된 block_on 호출을 감지했을 때 명시적으로 패닉합니다. 올바른 접근법은 정리를 동기적으로 수행하거나 채널을 통해 전용 스레드로 오프로드하여 결코 소멸자 내에서 비동기 실행기를 차단하지 않아야 합니다.
std::pin::Pin의 맥락에서, select!에서 사용되는 미래는 반드시 Unpin이거나 명시적으로 핀된 이유는 무엇이며, 이것이 부분 드롭 시 메모리 안전성을 어떻게 방지합니까?
**select!**는 여러 미래를 무작위로 폴링합니다. 만약 미래가 !Unpin인 경우(예: 자기 참조 포인터 또는 침입 리스트 링크를 포함하는 경우), 첫 번째 poll 이후에 이동하면 이러한 포인터가 무효화됩니다. Pin은 미래의 메모리 위치가 안정적으로 유지되도록 보장합니다. **select!**는 미래가 이동을 허용하는 Unpin이거나 이미 특정 메모리 위치(스택 또는 힙)에 Pin되어 있어야 합니다. 한 분기가 완료되면, **select!**는 다른 미래를 드롭합니다. 만약 미래가 Unpin이라면, 드롭 글루로 이동됩니다. 만약 그것이 Pin되어 있다면, 제자리에 드롭됩니다. 메모리 안전 보장은 Pin이 드롭이 원래 메모리 주소에서 미래에 대해 호출됨을 보장하여, 자기 참조 미래가 폴링된 후에 이동될 경우 유발될 수 있는 사용 후 해제 또는 댕글링 포인터 문제를 방지합니다. 후보자들은 종종 Pin이 폴링뿐만 아니라 취소된 미래의 파괴 의미에도 영향을 미친다는 점을 간과합니다.