io-uring과 Rust의 futures 모델을 함께 사용할 때 마주치는 안전성, 취소, 버퍼 소유권 문제를 정리하고, AsyncRead/Write와 버퍼 기반 인터페이스의 관계, 그리고 io-uring 기반 설계에서의 선택지들을 논의한다.
지난가을 나는 io-uring 인스턴스 위에서 futures를 구동하기 위한 안전한 API를 만들기 위해 라이브러리를 작업하고 있었다. liburing에 대한 바인딩인 iou는 공개했지만, ostkreuz라 부른 futures 통합 작업은 끝내 공개하지 않았다. 앞으로 이 작업을 다시 이어갈지는 모르겠지만, 비슷한 목표를 가진 라이브러리를 여러 사람이 이미 쓰기 시작했기에 io-uring과 Rust의 futures 모델로 작업하며 배운 점을 메모해 두고자 한다. 이 글은 io-uring API에 어느 정도 익숙하다는 전제를 깔고 있다. 개요는 이 문서에 잘 정리되어 있다.
무엇보다 먼저: Rust 라이브러리에서 모든 안전한 공개 API의 건전성(soundness)은 절대적으로 필수 최소 기준이다. 비건전성(unsoundness)은 애초에 선택지가 될 수 없으며 고려해서도 안 된다. 만약 Rust의 건전성 보장 안에서 필요한 성능을 도저히 낼 수 없다면, 사용자들은 건전한 버전을 작성한 뒤 Rust 프로젝트와 협력하여 자신들이 필요한 성능 프로파일로도 건전한 API를 만들 수 있도록 Rust 자체를 개선해 나가야 한다. 다만 io-uring으로 고성능 프로그램을 작성하기 위해 Rust에 어떤 변경이 필요한 상황이라고는 나는 생각하지 않는다.
먼저 io-uring과 Rust를 통합할 때의 까다로운 문제를 정리해 보자.
넌블로킹 IO에는 준비(readiness)와 완료(completion)라는 두 가지 종류의 API가 있다. 준비 기반 API에서 프로그램은 어떤 IO 핸들이 IO를 수행하기에 준비됨 상태가 되면 알려 달라고 OS에 요청하고, 그런 다음 프로그램이 IO를 수행한다(핸들이 준비되었기 때문에 블로킹되지 않는다). 완료 기반 API에서 프로그램은 OS에 어떤 핸들에 대해 IO를 수행해 달라고 요청하고, IO가 완료됨 상태가 되면 OS로부터 통지를 받는다. epoll은 준비 기반 API이고, io-uring은 완료 기반 API다.
Rust에서 Future는 단순히 더 이상 poll하지 않음으로써 암묵적으로 취소 가능하도록 되어 있다. 이는 준비 기반 API와는 잘 맞는다. 취소된 Future에 대해 IO가 가능하다는 사실은 간단히 무시하면 되기 때문이다. 그러나 완료 기반 API에 버퍼를 넘겨 IO를 수행하도록 하면, Future를 취소하더라도 커널은 그 버퍼에 읽거나 쓴다.
즉, 어떤 슬라이스를 IO 버퍼로 넘겼다가 그 IO를 기다리는 Future를 취소했다면, 커널이 실제로 완료할 때까지는 그 슬라이스를 다시 사용할 수 없다. 코드로 표현하면 다음과 같다:
// 이 Future는 `file`로부터 `buffer`에 읽기를 기다린다
let future = io_uring.read(&mut file, &mut buffer[..]);
// Future를 취소한다: 이제 이 IO에는 관심이 없다
drop(future);
// 이런, 이것은 커널과의 데이터 레이스다! 커널은
// 여전히 버퍼에 쓰려고 할 것이다. Future를 취소한다고 해서
// 실제 커널 IO가 취소된 것은 아니기 때문이다:
buffer.copy_from_slice(..);
따라서, 사용자 공간 프로그램에서 무슨 일이 일어나더라도, 커널이 IO를 완료할 때까지 커널이 그 버퍼를 논리적으로 빌리고(logically borrowed) 있음을 보장할 수 있는 방법이 필요하다. 이것이 어려운 문제다.
흔히 제안되는 해결책 하나는 Future의 소멸자(destructor)에서 IO 완료를 기다리며 블로킹하는 것이다. 이렇게 하면 Future가 취소되었을 때 IO가 실제로 완료될 때까지 블록된다. 그러나 이것은 안전하지 않으며 애초에 고려 대상이 아니다.
Rust에서는 어떤 객체든 아주 손쉽고 안전하게 누수(leak)시킬 수 있기 때문에, 생애(lifetime)의 끝에서 소멸자가 반드시 실행된다고 가정하는 것은 건전하지 않다. Rust에서 메모리 누수와 관련된 규칙과 사용자가 건전성 면에서 무엇을 기대할 수 있는지에 대해, 나는 세상에서 가장 잘 아는 사람들 중 하나라고 자신 있게 말할 수 있다. 사용자는 소멸자가 실행된다고 영원히 기대할 수 없다. 이 방식은 성립할 방법이 없다.
게다가, 설령 비건전성을 감수한다 해도(혹은 예를 들어 Future의 생성 자체를 unsafe로 만들어 그 비건전성을 “정당화”한다 해도), 이 방법에 의존하려는 시도는 정말로 좋지 않다. 소멸자에서 블로킹한다는 것은 사용자가 실제로는 이 작업을 취소하고 싶어 하지 않는다는 가정에 기반해 보이지만, 사용자들은 실제로 취소를 원한다.
소멸자에서 전체 스레드를 블로킹한다면(현시점 Rust에서 가능한 사실상의 유일한 방법), 이제 해당 IO 때문에 스레드 전체가 막히게 된다. 끔찍한 성능 회귀다. 설령 비동기 소멸자가 있더라도, 결국 그 IO가 완료될 때까지 해당 태스크는 막힌다. 하지만 당신의 라이브러리 위의 사용자 코드에서는 이미 그 IO에 대한 관심을 취소했다. 지금 많은 사람들이 파일 시스템 IO 같은 작업에 io-uring을 고려하고 있어서 이런 블로킹이 괜찮아 보일 수도 있다. 그러나 io-uring은 리눅스에서 모든 IO의 미래이며, 네트워크 IO도 포함된다. IO가 완료될 때까지 태스크를 막아 버리면, 타임아웃이 그 순간부터 작동하지 않는다.
(이 문제는 io-uring에 취소 요청을 제출해 커널 IO를 제때 취소하도록 “완화”할 수는 있다. 하지만 이제 이 Future를 취소하기 위해 더 많은 이벤트를 제출하고 더 많은 시스템 콜을 수행해야 하므로, 여전히 취소가 불필요하게 비싸진다.)
내가 보기엔 취소를 성능 좋게 처리할 수 있는 유일한 방법은 다음과 같다. io-uring은 완료 이벤트를 위해, CQE가 웨이크를 트리거할 수 있도록, 필연적으로 어떤 웨이커(waker)를 할당하게 된다. Future도 이 할당된 웨이커에 접근할 수 있어야 하며, 더 이상 이 Future에 관심이 없음을 등록해 둘 수 있어야 한다. 그렇게 해야 IO가 완료될 때 CQE 처리 코드가 해당 태스크를 깨우지 않는다. 아마 drop에서 커널로 취소 통지 제출을 시도할 수도 있겠지만(적어도 다른 제출의 곁다리로 기회가 될 때만 취소를 제출하는 것도 고려하겠다).
지난 8월 Taylor Cramer가 올린 글에서, 버퍼를 pinning하는 것이 해결책이 될 수 있다고 제안했다. 아이디어는 다음과 같다. Unpin을 구현하지 않는 커스텀 버퍼 타입을 사용하면, 그 버퍼는 무효화되기 전에 반드시 드롭되도록 보장할 수 있다. 여기서 중요한 점은 “반드시 드롭된다”는 보장과는 다르다는 것이다. 드롭되지 않을 수도 있지만, 드롭되지 않더라도 버퍼가 사용하는 메모리는 해제되지 않는다. 이 버퍼 타입은 Taylor의 표현을 빌리면 “스스로 등록 해제한다”는 소멸자를 갖는데—이건 실제로는 불가능하므로—정확히는 버퍼의 소멸자가 Future의 소멸자 대신 이 작업의 완료를 기다리며 블로킹한다는 뜻으로 이해해야 한다.
하지만 이것은 문제의 해결책이 아니다. IO가 완료될 때까지 버퍼가 무효화되지 않는다는 것만으로는 충분하지 않다. 커널이 해제된 메모리에 쓰는 상황은 물론 매우 나쁘지만, 그게 전부가 아니다. 사용자가 Future를 드롭했더라도 여전히 그 버퍼에 대한 핸들을 갖고 있으며, 사용자 공간에서 그 버퍼에 읽거나 쓸 수 있다. 이것 역시 해당 버퍼를 사용하려는 커널 IO와의 데이터 레이스다. 버퍼가 드롭되지 않도록 보장하는 것만으로는 충분치 않다. 커널이 그 버퍼에 대해 배타적 접근권을 갖도록 보장해야 한다.
Rust의 현재 타입 시스템에서 이것을 성사시킬 수 있는 유일한 방법은 논리적 소유권이다. 커널이 버퍼를 소유해야 한다. 빌린 슬라이스를 받아 커널에 넘기고, 커널이 그 위에서 IO를 끝낼 때까지 기다리면서 동시에 사용자 프로그램이 그 버퍼에 동기화 없이 접근하지 못하게 보장하는, 그런 건전한 방법은 없다. Rust의 타입 시스템은 커널의 동작을 모델링할 수 있는 유일한 수단으로서 소유권 이전을 제공할 뿐이다. 나는 모두가 소유권 기반 모델로 전환하길 강력히 권한다. 이것이 건전한 API를 만들 수 있는 유일한 방법이라는 데 매우 확신이 있다.
게다가, 이것은 실제로 이점이 있다. io-uring에는 커널이 버퍼를 직접 관리할 수 있게 해 주는 API가 이미 많고, 지금도 그 수와 복잡도가 증가하고 있다. 버퍼를 소유권으로 건네면 이런 API를 제대로 활용할 수 있고, 장기적으로도 가장 고성능의 해법이 된다. 커널이 버퍼를 소유한다는 전제를 받아들이고, 그 위에 고성능 API를 설계하자.
아직 언급하지 않은 큰 코끼리가 하나 있다. 바로 IO 트레이트다. Read, Write, 그리고 그 확장인 AsyncRead와 AsyncWrite는 모두 호출자(caller)가 버퍼를 관리하고 IO 객체는 그 버퍼로 IO만 수행하는 인터페이스를 전제로 한다. 이는 커널이 버퍼를 소유한다는 전제와 일치하지 않는다. 이러한 API를 안전하게 구현하려면 별도의 버퍼 집합을 관리하고, 넘겨받은 버퍼로 복사해야 한다. 즉, 데이터에 대해 불필요한 memcpy가 한 번 더 일어난다. 썩 좋지는 않다.
그럼에도 나는 async 인터뷰에서(이 사실을 알면서도) AsyncRead와 AsyncWrite를 있는 그대로 std에 병합하자고 주장했다. 왜인가? 이유는 간단하다. AsyncRead와 AsyncWrite는 동기 카운터파트와 마찬가지로, 호출자가 버퍼를 관리하는 읽기/쓰기 인터페이스를 표현한다. 어떤 하부 OS 인터페이스와 안전하게 맞추려면 추가 복사가 필요할 수도 있다. 그럴 경우 그냥 그렇게 하면 된다. 문제없다. 반대로 피호출자(callee)가 버퍼를 관리하는 경우를 위한 인터페이스도 존재한다. 바로 AsyncBufRead다.
AsyncBufRead 트레이트는 io-uring에서의 읽기 동작을 정확히 묘사한다. 당신이 읽고 있는 객체가 버퍼를 직접 관리하고, 필요할 때 그 버퍼에 대한 참조를 건네주는 방식이다. io-uring이 설득력 있는 동기를 제공하는 지금, 쓰기 측을 위한 BufWrite와 AsyncBufWrite도 제공할 수 있을 것이다.
리눅스와 윈도우 모두 최하위 레벨에서 완료 기반 API를 선호하고(예컨대 Windows에서 mio 같은 라이브러리는 바로 이 이유로 버퍼 풀을 사용한다), 이는 커널 혹은 커널에 최대한 가까운 곳에서 버퍼를 관리할 필요를 낳는다. 따라서 최대 성능을 위해 프레임워크들은 점차 버퍼링된 IO 인터페이스 쪽으로 옮겨갈 가능성이 높다. 괜찮다. 우리는 이미 std에 두 종류의 인터페이스를 모두 갖고 있다. 두 인터페이스는 서로 다른 사용 사례를 표현하며, 어떤 영역에서는 한쪽이, 또 다른 때에는 다른 쪽이 우세할 수 있다.
물론, 더 높은 레벨, 즉 호출자 쪽에서 버퍼를 직접 제어하길 원하는 사용자들도 있을 수 있다. 그렇게 하면 다른 최적화를 얻을 수 있기 때문이다. 하지만 이는 커널에 버퍼 제어권을 맡겨 얻는 최적화와 본질적으로 긴장 관계에 있다. 양쪽이 버퍼의 생애를 쉽게 함께 제어할 수는 없다. 아마 io-uring 라이브러리는 추가적인 API를 노출해 이런 사용자들이 원하는 최적화를 어느 정도 복구할 수 있을지도 모른다.
그래서 내가 생각하는 해법은 모두가 채택하고 나아가야 할 것이다. io-uring이 버퍼를 제어한다. io-uring에서 가장 빠른 인터페이스는 버퍼링된 인터페이스다. 비버퍼링 인터페이스는 한 번 더 복사한다. 이제 불가능한 일을 언어에 강요하려는 시도에서 벗어날 수 있다. 그렇다 하더라도 여전히 여러 흥미로운 질문들이 남아 있다.
io-uring은 IO를 실제로 어떻게 관리할지에 대해 엄청난 유연성을 제공한다. 완료 처리를 전담하는 단일 스레드를 둘 것인가, 아니면 이벤트를 제출할 때 기회가 될 때마다 완료 처리를 할 것인가? 파일 시스템 IO에만 io-uring을 쓰고 완료 대기는 epoll 인스턴스에서 할 것인가, 아니면 모든 것을 io-uring으로 옮길 것인가? 아직 epoll을 쓰는 라이브러리들과는 어떻게 잘 통합할 것인가? IO 이벤트들을 어떤 식으로 시퀀싱할 것인가(io-uring은 여러 방법을 제공한다)? 프로그램 전체에 하나의 io-uring을 둘 것인가, 여러 개를 둘 것인가? io-uring의 타임아웃이 사용자 공간의 타임아웃보다 더 나은가?
장기적으로는 최종 사용자들이 이런 선택들을 쉽게 할 수 있도록, 특정 사용 사례에 맞는 동작을 갖는 리액터를 구성할 수 있는 빌더를 제공하길 바란다. 그 답을 찾아가는 동안, 리눅스의 비동기 IO는 흥미로운 시기를 맞을 것이다.