Rust의 ZIP 포맷 사례를 통해 동기/비동기 I/O로 갈라진 생태계의 비용과, EOCD 탐색·문자 인코딩·경계 모호성 같은 형식적 난제를 짚고, sans-io 방식의 rc-zip과 상태 기계 설계, tokio·io_uring·monoio 비교, 실제 통합 예시를 통해 포맷/프로토콜 구현의 바람직한 방향을 제안한다.
Rust 프로그래밍 언어에서 ZIP 파일을 압축 해제하기 위한 가장 인기 있는 선택지는 이름 그대로 zip이라는 크레이트다 — 이 글을 쓰는 시점에 4,800만 회 다운로드되었다. 기능이 매우 풍부하며, 다양한 압축 방식과 암호화를 지원하고, ZIP 파일 쓰기까지 지원한다.
하지만 ZIP 파일을 읽을 때 그 크레이트를 모든 사람이 쓰는 건 아니다. 일부 애플리케이션은 네트워크에서 내려받은 아카이브를 풀어야 하기에 비동기 I/O를 쓰면 이득을 본다.
예를 들어 Rust로 작성된 uv 파이썬 패키지 관리자가 그렇다. uv는 zip 크레이트를 쓰지 않고 async_zip 크레이트를 쓴다. 유지보수자는 한 명이고 관심도도 훨씬 덜 받는다.
이 상황은 Rust에서 꽤 흔하다. 같은 기능이 동기 인터페이스와 비동기 인터페이스 둘 다로 다시 작성된다. 생태계가 갈라지고, 노력이 중복되고, 당연히 버그도 더 많아진다.
문자 인코딩 차이 ------------------------------ ZIP 포맷을 다루다 보면 쉽지 않은 부분이 아주 많아서 아쉽다. 오래되고 구석구석이 거친 형식이라 온갖 엣지 케이스가 있다.
ZIP 포맷에는 ISO 표준이 있고, 대부분은 무료로 볼 수 있는 PKWARE APPNOTE에 설명되어 있지만, 내가 itch.io에서 일할 때 그랬듯이 야생의 ZIP 파일들을 들여다보면 놀랄 일이 여전히 많다.
ZIP 포맷은 UTF-8이 보편 채택되기 전의 물건이다. Windows가 아직도 UTF-16을 쓴다는 얘기는 하지 말자, 지금은 그걸 잠시 잊고 싶다. 게다가 요즘은 UTF-8 코드 페이지도 있으니, 글쎄.
포맷이 UTF-8보다 먼저 만들어졌다는 건 ZIP 파일 안의 파일명 인코딩이 예전에는 시스템의 코드 페이지 설정에 따라 달랐다는 뜻이다.
2007년에야 앱 노트가 업데이트되어, 파일 이름과 코멘트가 실제로는 UTF-8로 인코딩되었다는 걸 나타내는 “추가 필드(extra field)” 값이 문서화되었다.
같은 나라에서 플로피 디스크로 ZIP 파일을 서로 주고받던 시절엔 아마 이게 괜찮았을 것이다. 하지만 itch.io에서는 일본 개발자가 탐색기(Explorer)의 내장 ZIP 생성 도구를 써서 파일명을 Shift-JIS로 인코딩한 사례가 있었는데, Shift-JIS는 JIS X 0201의 후속으로 1969년에 제정된 일본 공업규격 단일 바이트 문자 인코딩이다.
그러나 대부분의 ZIP 도구들은 그 파일을 코드 페이지 437, 즉 1981년 IBM 퍼스널 컴퓨터의 문자 집합(‘PC’가 거기서 왔다!)으로 인코딩된 것처럼 취급했다. 공정하게 보자면 UTF-8 플래그가 꺼져 있을 때 서구권에서는 꽤 그럴싸한 추측이긴 하다.
포맷이 파일명이 “UTF-8”인지 “UTF-8이 아닌지”만 알려주기 때문에, itch.io 데스크톱 앱이 전 세계의 게임을 설치할 수 있도록 내가 고안한 해결책은…
…ZIP 파일에서 모든 텍스트(파일명, 코멘트 등)를 모아 통계 분석을 하고, 특정 바이트 시퀀스의 빈도로부터 문자 집합을 추정하는 것이다. 예를 들어 Shift-JIS라면 이런 것들이다:
var commonChars_sjis = []uint16{
0x8140, 0x8141, 0x8142, 0x8145, 0x815b, 0x8169, 0x816a, 0x8175, 0x8176, 0x82a0,
0x82a2, 0x82a4, 0x82a9, 0x82aa, 0x82ab, 0x82ad, 0x82af, 0x82b1, 0x82b3, 0x82b5,
0x82b7, 0x82bd, 0x82be, 0x82c1, 0x82c4, 0x82c5, 0x82c6, 0x82c8, 0x82c9, 0x82cc,
0x82cd, 0x82dc, 0x82e0, 0x82e7, 0x82e8, 0x82e9, 0x82ea, 0x82f0, 0x82f1, 0x8341,
0x8343, 0x834e, 0x834f, 0x8358, 0x835e, 0x8362, 0x8367, 0x8375, 0x8376, 0x8389,
0x838a, 0x838b, 0x838d, 0x8393, 0x8e96, 0x93fa, 0x95aa,
}
이렇게 확률 목록을 얻고, 가장 높은 걸 고르고… 잘 되기를 비는 것이다!
이걸 해주는 다른 도구는 나는 알지 못한다 — 다시 한다면, 업로드 창에 개발자들이 무엇이든 던져 넣도록 허용하기보다 표준 아카이브 포맷만 요구했을 것이다.
플랫폼 차이 -------------------- 하지만 이것만이 ZIP 파일 포맷의 구린 부분은 아니다.
예를 들어, ZIP은 파일과 디렉터리를 제대로 구분하지 않는다. 길이가 0이고 경로가 슬래시로 끝나면 디렉터리다.
~/zip-samples
❯ unzip -l wine-10.0-rc2.zip | head -8
Archive: wine-10.0-rc2.zip
Length Date Time Name
--------- ---------- ----- ----
0 12-13-2024 22:32 wine-10.0-rc2/
0 12-13-2024 22:32 wine-10.0-rc2/documentation/
8913 12-13-2024 22:32 wine-10.0-rc2/documentation/README-ru.md
5403 12-13-2024 22:32 wine-10.0-rc2/documentation/README-no.md
5611 12-13-2024 22:32 wine-10.0-rc2/documentation/README-fi.md
그렇다면 Windows에서는?
우선, 모든 Windows API는 경로 구분자로 슬래시를 사용할 수 있다는 사실을 알고 있는가.
그리고 둘째로, 이 점은 앱 노트가 아주 명확히 말하고 있다:
저장된 경로에는 드라이브나 장치 문자, 또는 선행 슬래시가 포함되면 안 된다(MUST NOT). 모든 슬래시는 아미가와 유닉스 파일 시스템과의 호환성을 위해 역슬래시 ‘\’가 아니라 정방향 슬래시 ‘/’여야 한다(MUST).
PKWARE APPNOTE.TXT v6.3.10, section 4.4.17: file name
물론, ZIP이 실제로 유닉스에서 만들어졌다면 엔트리에 모드가 있고, 그 모드 비트를 보면 디렉터리인지, 일반 파일인지, 심볼릭 링크인지 알 수 있다.
야생에서는 심볼릭 링크의 대상이 엔트리의 내용으로 들어가 있는 경우가 많지만, 당연히 그건 APPNOTE에 적힌 방식은 아니다.
APPNOTE에는 유닉스 추가 필드에 가변 크기 데이터 필드가 있으며, 여기에 심볼릭 링크나 하드 링크의 대상을 저장할 수 있다고 되어 있다.
“할 수 있다(can)”에 강조표시.
ZIP 아카이브를 만들 수 있는 도구가 너무 많았고, 표준화는 (UTF-8 파일명을 의무화한) ISO 표준으로 나중에야 이루어졌기 때문에, APPNOTE는 규범적이기보다 기술적(descriptive) 접근을 취한다.
야생에서 발견되는 다양한 ZIP 포맷 구현을 가치 판단 없이 문서화할 뿐, 각 소프트웨어 저자의 선택에 대해 평가하지 않는다.
그래서 대부분의 ZIP 파일을 지원하려면 DOS 방식 타임스탬프와 유닉스 방식 타임스탬프를 모두 읽을 수 있어야 한다. 둘은 완전히 다르다.
예컨대 DOS 타임스탬프는 정말 괴상하다.
32비트에 들어가는데 절반은 시간, 절반은 날짜다 — 여기까진 좋아 보인다…
날짜에서 일(day)은 5비트 정수, 월(month)은 4비트 정수, 연(year)은 1980년부터 세는 7비트 정수 — 시간은 2초 단위로 저장된다! 음… 재밌다.
누군가가 IEEE 754가 “이상하다”고 말할 때마다 생각난다. 0.1 + 0.2를 하면 0.3 뒤에 소수점 이하가 주렁주렁 붙는 그 얘기 말이다.
중앙 디렉터리 끝(EOCD) 레코드 ----------------------------------- 좋다, 이런 건 최근 도구로 만든 파일이라면 대체로 무시해도 될 디테일이다.
하지만 ZIP 포맷의 가장 기본적이고 근본적인 부분조차 약간은 저주받아 있다?
대부분의 파일 형식은 매직 넘버로 시작하고, 그다음 메타데이터가 들어 있는 헤더가 나오고, 그다음에 실제 본문, 즉 이미지라면 픽셀 데이터, 모델이라면 정점 좌표 같은 본체가 온다.
fasterthanli.me/content/img on main [!?]
❯ hexyl logo-round-2.png | head
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00000000│ 89 50 4e 47 0d 0a 1a 0a ┊ 00 00 00 0d 49 48 44 52 │×PNG__•_┊⋄⋄⋄_IHDR│
│00000010│ 00 00 01 00 00 00 01 00 ┊ 08 06 00 00 00 5c 72 a8 │⋄⋄•⋄⋄⋄•⋄┊••⋄⋄⋄\r×│
│00000020│ 66 00 00 2a b5 7a 54 58 ┊ 74 52 61 77 20 70 72 6f │f⋄⋄*×zTX┊tRaw pro│
│00000030│ 66 69 6c 65 20 74 79 70 ┊ 65 20 65 78 69 66 00 00 │file typ┊e exif⋄⋄│
│00000040│ 78 da a5 9c 6b 76 5d b7 ┊ 8e 84 ff 73 14 77 08 7c │x×××kv]×┊×××s•w•|│
│00000050│ 93 18 0e 9f 6b f5 0c 7a ┊ f8 fd 15 8f e4 eb 38 ce │ו•×k×_z┊×ו×××8×│
│00000060│ ed a4 db 89 25 59 3a da ┊ 9b 9b 00 0a 55 00 78 dc │××××%Y:×┊××⋄_U⋄x×│
│00000070│ f9 ef ff ba ee 5f ff fa ┊ 57 f0 3e 54 97 4b eb d5 │×××××_××┊W×>T×K××│
│00000080│ 6a f5 fc c9 96 2d 0e be ┊ e8 fe f3 67 bc 8f c1 e7 │j××××-•×┊×××g××××│
하지만 ZIP은 아니다! ZIP 파일을 올바르게 읽는 유일한 방법은 파일 끝에서부터 시작해서 중앙 디렉터리 끝(EOCD, End Of Central Directory) 레코드의 시그니처를 찾을 때까지 거꾸로 거슬러 올라가는 것이다.
그래서 zip 크레이트의 API를 보면 입력이 Read와 Seek를 모두 구현해야 한다. ZIP 파일의 엔트리 목록만 나열하려 해도 여기저기 이동해야 하기 때문이다.
impl<R: Read + Seek> ZipArchive<R> {
/// Read a ZIP archive, collecting the files it contains.
///
/// This uses the central directory record of the ZIP file, and ignores local file headers.
pub fn new(reader: R) -> ZipResult<ZipArchive<R>> {
// ✂️
}
}
이걸 제대로 하는 건 생각만큼 간단하지 않다!
원래 zip 크레이트는 파일 거의 끝에서 4바이트씩 읽고 EOCD 시그니처와 일치하지 않으면 1바이트씩 왼쪽으로 옮기는 식이었다. 엄청나게 낭비가 심했다.
나중에 나온 async_zip 크레이트는 이를 개선해 2 KiB씩 읽고, 시그니처가 두 버퍼에 걸쳐 있을 수 있는 경우를 처리하기 위해 시그니처 크기를 뺀 2 KiB만큼 왼쪽으로 이동한다. 꽤 영리하다! 주석에 따르면 zip 방식보다 500배 빨랐다고 한다.
zip 크레이트도 2024년 5월에 512바이트 단위로 읽도록 따라잡으면서, 2024년 8월에 EOCD 탐색 로직의 버그를 고치기 전까지는 훨씬 빨라졌다. 꽤 재미있는 버그였다.
경계 모호성 ------------------ 대부분의 파일 형식에는 일종의 프레이밍 메커니즘이 있다. 파일을 앞으로 읽어 가면서 길이 접두가 붙은 레코드를 순서대로 만난다.
MP4, 정확히는 MPEG-4 Part 14는 이를 ‘박스(box)’라고 부른다. 미디어 저작 소프트웨어는 플레이어가 반드시 알 필요는 없는 메타데이터를 많이 쓰는데, 설령 전혀 모르는 타입의 박스라도 누구든 건너뛸 수 있다.
이 성질 덕에 데이터와 구조를 헷갈릴 여지도 없다. 각 박스에는 타입이 있고, 그 타입은 유효한 UTF-8 바이트 시퀀스일 수 있다. 하지만 지금 읽는 게 박스 타입인지, 미디어 파일의 저자 이름인지가 모호해지는 일은 결코 없다.
그러나 ZIP 포맷에서는 파일 끝에서 거꾸로 스캔하기 때문에, 코멘트나 파일 경로의 일부를 읽다가 EOCD 시그니처 바이트와 우연히 일치할 수 있다.
이것이 2024년 8월에 zip 크레이트에서 고쳐진 버그다. EOCD 시그니처처럼 보이는 첫 지점에서 멈추는 대신, 이제 파일 전체를 계속 스캔하면서 시그니처처럼 보이는 모든 오프셋을 기록한다.
하지만 수 기가바이트짜리 파일을 512바이트 단위로 통째로 읽고, 매번 뒤로 시크하는 건, 어떤 장치에서건 가능한 최악의 읽기 패턴이 아닐까? 유저랜드나 커널에서 하는 버퍼링이… 그걸 상정한 적이 없다.
4GB 파일이라면 EOCD만 찾는 데 시스템 콜을 800만 번 해야 한다는 예를 들려 했는데, 그러다 GitHub 저장소의 이 댓글을 보게 되었다:
200GB ZIP 파일(안에 233,899개 파일)이 네트워크 공유로 접근되는 환경에서 이 PR을 시험해 봤습니다.
…그 사람이 잘못한 건 하나도 없지만, 그럼에도, 세상에나.
링크한 코드의 복잡함이 낯설다면, ZIP 파일의 앞이나 뒤에 쓰레기 데이터가 있어도 대부분의 도구는 여전히 압축을 풀 수 있음을 기억하자.
예컨대 자기 해제 ZIP 파일은 네이티브 실행 파일로 시작한다(‘MZ’ 참고).
~/Downloads
❯ file winzip76-downwz.exe
winzip76-downwz.exe: PE32 executable (GUI) Intel 80386, for MS Windows
~/Downloads
❯ hexyl --length 64 winzip76-downwz.exe
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00000000│ 4d 5a 90 00 03 00 00 00 ┊ 04 00 00 00 ff ff 00 00 │MZ×⋄•⋄⋄⋄┊•⋄⋄⋄××⋄⋄│
│00000010│ b8 00 00 00 00 00 00 00 ┊ 40 00 00 00 00 00 00 00 │×⋄⋄⋄⋄⋄⋄⋄┊@⋄⋄⋄⋄⋄⋄⋄│
│00000020│ 00 00 00 00 00 00 00 00 ┊ 00 00 00 00 00 00 00 00 │⋄⋄⋄⋄⋄⋄⋄⋄┊⋄⋄⋄⋄⋄⋄⋄⋄│
│00000030│ 00 00 00 00 00 00 00 00 ┊ 00 00 00 00 20 01 00 00 │⋄⋄⋄⋄⋄⋄⋄⋄┊⋄⋄⋄⋄ •⋄⋄│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
…그리고 ZIP 파일이 뒤에 덧붙는다(‘PK’ 참고).
~/Downloads
❯ unzip -l winzip76-downwz.exe | head
Archive: winzip76-downwz.exe
warning [winzip76-downwz.exe]: 2785280 extra bytes at beginning or within zipfile
(attempting to process anyway)
Length Date Time Name
--------- ---------- ----- ----
2700 09-06-2024 18:34 common/css/common.css
21825 09-06-2024 18:34 common/css/jquery-ui.css
30945 09-06-2024 18:34 common/img/arrow.png
14982 09-06-2024 18:34 common/img/button-hover.png
14982 09-06-2024 18:34 common/img/button-normal.png
728365 09-06-2024 18:34 common/img/centerImg.png
17027 09-06-2024 18:34 common/img/close-hover.png
~/Downloads
❯ hexyl --skip 2785280 --length 64 winzip76-downwz.exe
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│002a8000│ 50 4b 03 04 14 00 00 00 ┊ 08 00 51 94 26 59 ad 3a │PK•••⋄⋄⋄┊•⋄Q×&Y×:│
│002a8010│ 80 57 d1 02 00 00 8c 0a ┊ 00 00 15 00 00 00 63 6f │×Wו⋄⋄×_┊⋄⋄•⋄⋄⋄co│
│002a8020│ 6d 6d 6f 6e 2f 63 73 73 ┊ 2f 63 6f 6d 6d 6f 6e 2e │mmon/css┊/common.│
│002a8030│ 63 73 73 b5 56 cb 6e db ┊ 30 10 bc 07 c8 3f 10 30 │css×V×n×┊0•ו×?•0│
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘
2024년 12월, 이 글을 다시 쓰던 중 11주에 걸친 논의 끝에 PR이 머지되었는데, EOCD 탐지 알고리즘을 또다시 갈아엎어 8월에 들어간 큰 성능 회귀를 해결했다.
async_zip 크레이트가 zip 크레이트에서 고쳐졌다가 다시 고쳐진 버그의 영향을 받았을까? 아마도! 2024년 4월이 마지막 릴리스라서, 누가 알겠나.
I/O를 전혀 하지 않기 ------------------------ 확인하지 않았다. 나는 내 ZIP 크레이트 rc-zip을 갖고 있기 때문이다. 세 가지 중 최고라고 생각한다. 문자 집합 감지를 하기 때문만이 아니라, zip이나 async-zip과 달리 특정 I/O 스타일에 묶여 있지 않기 때문이다.
게다가 멋진 로고도 있다. 뛰어난 실력의 Misia가 만들어 주었다:
라이트 모드와 다크 모드에서 로고가 다르게 보인다!
sans-io 접근엔 충분한 선행 사례가 있다. nom의 Geoffroy Couprie에게도 크레딧을 드리고 싶다. 5년 전 rc-zip을 시작할 때 그가 이 접근을 권했고, 나는 그 조언을 기쁘게 따랐다.
Rust 생태계에도 sans-io 사례가 이미 있다. rustls 크레이트는 꽤 근접해 있다. 아직 표준 Read와 Write 트레이트에 자신을 묶어 두긴 하지만, 라이브러리 소비자는 read_tls와 write_tls를 언제 호출할지 자유롭게 고를 수 있어 mio 같은 완료 기반 라이브러리와 매끄럽게 통합된다.
tokio-rustls와 tokio의 통합은 약간 더 어색하다.
C 생태계에서는 sans-io 패턴이 더 흔하다. 애초에 표준 I/O 인터페이스가 없기 때문이다. API에서 파일 디스크리터를 받도록 할 수는 있지만 그건 매우 제약적일 것이다.
예를 들어 ZStandard 디컴프레션 API는 이렇게 생겼다:
// from the `zstd-sys` crate
pub unsafe extern "C" fn ZSTD_decompressStream(
zds: *mut ZSTD_DStream,
output: *mut ZSTD_outBuffer,
input: *mut ZSTD_inBuffer,
) -> usize
입력과 출력 버퍼는 단순히 포인터, 크기, 현재 위치다:
struct ZSTD_inBuffer {
pub src: *const c_void,
pub size: usize,
pub pos: usize,
}
decompressStream을 호출하면 입력/출력 버퍼의 pos가 갱신되고, 호출자는 그 구조체의 값으로 무슨 일이 일어났는지 판단한다.
입력의 현재 위치가 크기보다 작다면, 이 호출에서 입력의 일부만 소비되었다는 뜻이고, 나머지는 다음 호출로 다시 넘겨야 한다. 예컨대 디코더가 출력 버퍼에 충분한 공간이 없었을 때 이런 일이 생긴다!
출력의 현재 위치가 출력 크기보다 작다면, 디코더가 완전히 끝났고 남은 버퍼를 모두 플러시했다는 의미다.
반면 출력 위치가 출력 버퍼 크기와 같다면, 더 큰(또는 추가) 출력 버퍼로 다시 호출해야 한다는 뜻이다.
이 모든 상태를 정확히 처리하는 건 놀라울 만큼 까다롭다. 디컴프레서가 더 많은 입력을 필요로 할 수도 있고, 줄 입력이 없을 수도 있다 — 쉽게 무한 루프로 빠질 수 있다! 대신 더 줄 입력이 없다는 걸 신호로 전달하고, 입력이 잘렸다고 판단되면 오류를 내도록 해야 한다.
rc-zip의 구조 ----------------------- rc-zip도 같은 방식을 택한다. 다만 처음 해야 할 일이 파일 끝에서 거꾸로 스캔하는 것이고, 그다음에는 ZIP 파일의 개별 엔트리를 임의 순서로 추출하고, 건너뛰고, 되돌아가기도 해야 하니… 순차 스캔과는 거리가 멀다!
이를 위해 두 개의 상태 기계를 노출한다. ArchiveFsm은 중앙 디렉터리를 읽어 Archive를 돌려준다. 거기서부터는 오프셋, 압축 방식 등 정보를 알고 있으니 EntryFsm을 만들어 개별 엔트리를 읽을 수 있다.
ArchiveFsm을 완결까지 몰아가는 루프는 단순하다.
pub fn wants_read(&self) -> Option<u64>
먼저 wants_read를 호출한다 — 상태 기계가 더 많은 데이터를 원하면 Some을 돌려주며, 파일에서 어디를 읽어야 할지 오프셋을 준다. 대부분은 우리가 마지막으로 읽은 곳의 다음이지만 항상 그런 건 아니다!
pub fn space(&mut self) -> &mut [u8]
Some을 돌려줬다면 space를 호출해 내부 버퍼를 가변으로 빌린다. Rust는 생 포인터를 다루지 않는다. 슬라이스를 돌려주고, 거기에 최대 얼마까지 데이터를 쓸 수 있는지 알 수 있다.
pub fn fill(&mut self, count: usize) -> usize
읽기를 수행한 뒤에는 fill을 호출해, 몇 바이트를 읽었는지 알려 준다. 표준 Read 트레이트와 마찬가지로, 0바이트 읽기는 EOF를 의미한다.
표준 Read 트레이트에서는 전달된 버퍼의 길이가 0일 때도 0을 돌려줄 수 있지만, ArchiveFsm에서는 그런 일이 없다.
마지막으로, 머신에 입력을 먹인 뒤에는 process를 호출할 수 있는데, 이 설계가 꽤 마음에 든다…
pub fn process(self) -> Result<FsmResult<Self, Archive>, Error>
…바로 이 메서드는 상태 기계를 ‘소비’하기 때문이다! 일이 끝났다면 FsmResult의 Done 변형을 돌려주고, 그 상태 기계의 메서드를 다시는 실수로 호출할 수 없다.
완료되지 않았다면 — 더 많은 입력이 필요해 루프를 한 바퀴 더 돌아야 한다면 — Continue 변형을 돌려주면서 소비자에게 상태 기계의 소유권을 돌려준다.
/// Indicates whether or not the state machine has completed its work
pub enum FsmResult<M, R> {
/// The I/O loop needs to continue, the state machine is given back.
Continue(M),
/// The state machine is done, and the result is returned.
Done(R),
}
물론 타입스테이트로 더 깊이 들어갈 수도 있겠지만, 지금의 설계가 꽤 만족스럽다. rc-zip-sync를 통해 동기 I/O에, rc-zip-tokio를 통해 비동기 I/O에 쉽게 꽂힌다.
io_uring 갖고 놀기 -------------------------
그렇게 말하긴 했지만 — rc-zip-tokio 구현은 사실 꽤 난잡하다. 리눅스에서 비동기 파일 I/O가 난장판이기 때문이다. tokio가 리눅스에서 비동기 파일 읽기를 어떻게 하는지 아는가? 백그라운드 스레드를 쓴다!
tokio로 파일을 읽는 게 표준 라이브러리보다 느리다는 블로그를 볼 때마다 생각난다. 그럴 수밖에! 일을 얼마나 많이 하고 있는데!
참고로, 이는 ‘파일’에만 해당한다. TCP 소켓은 tokio가 진가를 발휘하는 영역이다.
/dev/urandom에서 1 기비바이트를 읽는 간단한 예로 tokio 버전과 libstd 버전을 비교해 보면 성능 차이가 보인다:
use std::io::Read;
use tokio::{fs::File, io::AsyncReadExt};
#[tokio::main]
async fn main() {
use std::time::Instant;
const SIZE: usize = 1024 * 1024 * 1024;
eprintln!("============= starting async");
let start_async = Instant::now();
let mut f = File::open("/dev/urandom").await.unwrap();
let mut buffer = vec![0; SIZE];
f.read_exact(&mut buffer[..]).await.unwrap();
let duration_async = start_async.elapsed();
eprintln!("============= done async");
eprintln!("============= starting sync");
let start_sync = Instant::now();
let mut f = std::fs::File::open("/dev/urandom").unwrap();
let mut buffer = vec![0; SIZE];
f.read_exact(&mut buffer[..]).unwrap();
let duration_sync = start_sync.elapsed();
eprintln!("============= done sync");
eprintln!("Async operation took: {:?}", duration_async);
eprintln!("Sync operation took: {:?}", duration_sync);
}
내 리눅스 서버에서는 동기 버전이 일관되게 더 빨랐다.
숫자 자체는 중요하지 않다 — 재미있는 건 lurk로 파헤쳐 보는 일이다. Rust로 만든 strace류 도구다.
strace 로고가 타조인 거 아는가? 이제 알았다!
lurk로 보면, 비동기 버전은 이런 걸 잔뜩 한다:
[1000458] read(9, "\u0007×[Ã\toP©w«mÉOþþ«u\u00128Bz°©4Å©o\u000e-ñR`çâ8\bFu¦¼è¸$»æÔg!e¶ãçYëurw{fED-jø%r", 2097152) = 0x200000
[1000457] futex(0x7FFFF7CD6C20, 128, 1, 0x0, 93824993192448, 140737304358928) = 0
[1000458] futex(0x7FFFF7CD6C20, 129, 1, 0x1, 0, 140736615946240) = 1
[1000457] futex(0x7FFFF7CD5660, 129, 1, 0x7FFFF7AD4648, 93824993193232, 93824993192960) = 1
[1000458] futex(0x7FFFF7CD5660, 128, 1, 0x7FFFF7CD4B98, 140736615946240, 0) = 0
[1000458] read(9, "¿CÙ37ý¶äh÷ÉÏQ3$¡\u001bÂè\u0001zzCÍ\u0014ÌÄ\u001e@\f}éTö\u000bz¾è#<ÀvrJÌ_\u0015\u0013¤\u0004\\Çd\r\bØÿ.A\nð·
éWGã@¨Âǯ=,\fOò$S̺Ç<·\u0014x\rÏÆgPDʼÖ×\u0006FK\u0001H\u000eµXÐzf·IøgÊæ«Ueªd\u001b^).s¢ÑNwáaÝtq©\u0004F±^Vc¡ÎäQ\u001c\u0016ñ±\u001e~j\tBÿwácÊÉ,èa úòöæÔ
;Äp¯\u0019ߺL)\u0004§m[f,¨\u0002á#n\u0013 Þ\u0013¨ò\u007fÞâ\u0006Èx<Z\u001diw\u0012\u0012î´¼ífÕ¿Y*ë\u0018Ûjéml.M\u0002ïô¨¿!Ô\bÆ$ \u0010\u007f18X<þ¢\u0017¥X\tqçHl|N\u000fIj®\u000fäY¥vÙÐPêßJ*cÝ^é3\u0006ÆÝoΦdú±|é\u0010Y\rÀ¥í§~\u007f¯.Çugh·>obP=ó]Úà\u0019WÆF÷\u0016m;âið\u0011Ú\u0015´Fã¦\bMîç(¸*¹{^ùJ}¯ëMâ°Y(\f\bû-F+ãx2
\u0002»Ë}SÈlþ3`jLc\f:3·:t\u0001?\"^{\u0012\u0007\u001fô1ø¸ÄÂ÷ìÎ\"îuÉůXq\b;_\u0003\nQ\u001dâhG\"ê.\u0007øOùæ\u0006áôéEj.\"l;9oP}99©\u001f!<~2Ø\u0011¦.ÒÃER<E0Ê¿Ïaôú\u0013\u0006º,\u0011ùÙëÿÎ#\rû÷èÜð;dUK\u0019\u001d\u0001eOBï$R¡u¨óþtÚÍu1C3d£é»|$¡z pè&\u0007l\u0013ÍGçÜÔVë:2\"¥Dà", 2097152) = 0x200000
[1000457] futex(0x7FFFF7CD6C20, 128, 1, 0x0, 93824993192960, 140737304358928) = 0
[1000458] futex(0x7FFFF7CD6C20, 129, 1, 0x1, 0, 140736615946240) = 1
[1000457] futex(0x7FFFF7CD5660, 129, 1, 0x7FFFF7AD4648, 93824993193616, 93824993193344) = 1
[1000458] futex(0x7FFFF7CD5660, 128, 1, 0x7FFFF7CD4B98, 140736615946240, 0) = 0
[1000458] read(9, "©Âׯ^kd±2Þ\u0015õ³gó=Çø½29Ç\u0003{Ù&¶«â\u001c\u000fYT]wfx/ù¥°Á\u0017b\u0014ϤK7U\u0005m#þÒ\u001dÛ'J\fÓ\u0005^cãNÌ¢[i'4\u001fû\bûQD\b.Ýt¾*\u001b\u001cßóµÇD)Í\u0016uèÅù\t\ná὿(\róî¹\u0014\u001fƼÚ\u0010ÜËaÑ#M½).¬?XDÓ\u0018Æ/ËüSÉÏj{éF³Lßÿ²wò±Ì`£µ÷¬`QÚÕrÃÅXèË6\u001c÷I¸íGÊ!®Ò(\r¬#
\u001b.Ïx\u0010ãtÄ\râ¡.´ÿÅ×àV@ü\u0016,aÀÎ\"µp-NÇ+ôÝÐó \u0012dȨRÍã=\u001c!4Ej)ÝBQZ½ÓµÕÄBfÜÔqÛ\r\u001céB \u0001é-\u0014`\u001c²hÖ£äxÀè\r\u0019#¹ò8ù\u000e7\u000bƬbÔ9\u001bï\u0001¨?§U¨ù[g!P¶9;\nß.¢,)Bò\u0006#ò§Ïb*Um\u0016Zúpb)î³×\u000fHC¿\u0010\u000e", 2097152) = 0x200000
[1000457] futex(0x7FFFF7CD6C20, 128, 1, 0x0, 93824993193344, 140737304358928) = 0
[1000458] futex(0x7FFFF7CD6C20, 129, 1, 0x1, 0, 140736615946240) = 1
[1000457] futex(0x7FFFF7CD5660, 129, 1, 0x7FFFF7AD4648, 93824993194128, 93824993193856) = 1
[1000458] futex(0x7FFFF7CD5660, 128, 1, 0x7FFFF7CD4B98, 140736615946240, 0) = 0
[1000458] read(9, "çCÙÍ96´æ]è*7jtbäÿïÕTý5\u0004ö¾f\fYEW0«ÞOì\u0010\u000fô\u0012U¯á)ð=\"á
8bnÓÙþï^«ÀÀÕÆãÈ\u000em\u001d_Y\bÀ\u0004ô\r¾$:ó(»Ó
\u0017°Cá(.¥à×9ÈÛ\u0002ébª\u0002eüÛÕDÞFaøp#\u001fOJÛ'¢ÐÇØÃ÷±*9¥¥ÁC
2ý\u0006\u001fN", 2097152) = 0x200000
[1000457] futex(0x7FFFF7CD6C20, 128, 1, 0x0, 93824993193856, 140737304358928) = 0
[1000458] futex(0x7FFFF7CD6C20, 129, 1, 0x1, 0, 140736615946240) = 1
[1000457] futex(0x7FFFF7CD5660, 129, 1, 0x7FFFF7AD4648, 93824993194512, 93824993194240) = 1
[1000458] futex(0x7FFFF7CD5660, 128, 1, 0x7FFFF7CD4B98, 140736615946240, 0) = 0
한 스레드가 128 KiB씩 읽고, 다른 스레드를 깨우고, 그 스레드가 또 일을 큐에 넣고… 프로그램이 실행되는 동안 그 춤을 8천 번쯤 반복한다.
반면 동기 버전은 단지 이렇게 한다:
[1000457] write(2, "============= starting sync\n====...", 28) = 28
[1000457] openat(4294967196, "/dev/urandom", 524288) = 10
[1000457] mmap(0x0, 1073745920, 3, 34, 4294967295, 0) = 0x7FFF1FFFE000
[1000457] read(10, "7¹5T\t4B{&ð_\u000f\u007fògÚ2\u0015¤(è6Và\\ʵzO\u000e]\u000bñ\u001cW¿GMxó\u0011¿ª°\u001b;zâÞÕjySdDiÉùTµ\u001f~\u0010ÙÄÜ8gë\u0012æ'_[Ìdò\u007fme¨º%Ä\u0012l³6?óÝbæ
Ƭ®Ñ,\u001f\u0014^\u0001Ç,ª\u000b\u0014\"²(çݯ\u0017ÖÄ÷T_¢\u0007", 1073741824) = 0x40000000
============= done sync
[1000457] write(2, "============= done sync\nAsync op...", 24) = 24
장엄하게, 1 GiB짜리 단 한 번의 read 시스템 콜.
코드 블록을 스크롤해 보면 read가 0x40000000을 돌려주는 걸 볼 수 있다.
하지만 이건 tokio의 잘못이 아니다, 적어도 전적으로는 아니다. 리눅스에는 오랫동안 쓸 만한 비동기 파일 읽기 방법이 없었다 — io_uring이 등장하기 전까지는.
그 지독한 테스트 프로그램을 바꿔서 읽기 크기를 최대 128 KiB(어차피 tokio가 그렇게 한다)로 강제하고, tokio-uring 변형을 추가해 보면, 동기 버전과 꾸준히 비슷한 성능을 내고, “클래식” tokio보다 약 10% 정도 꾸준히 빠르다.
정확한 숫자는 말하지 않겠다. 내 셋업이 부끄럽기도 하고, 원하는 결과를 내도록 숫자를 조정할 수도 있다 — 내가 보여 주고 싶은 건 tokio-uring 버전의 읽기 루프다:
[1047471] io_uring_enter(13, 0, 0, 0, 0x0, 128) = 0
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
[1047471] io_uring_enter(13, 1, 0, 0, 0x0, 128) = 1
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
[1047471] write(10, "\u0001", 8) = 8
[1047471] write(10, "\u0001", 8) = 8
[1047471] io_uring_enter(13, 0, 0, 0, 0x0, 128) = 0
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
[1047471] io_uring_enter(13, 1, 0, 0, 0x0, 128) = 1
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
[1047471] write(10, "\u0001", 8) = 8
[1047471] write(10, "\u0001", 8) = 8
[1047471] io_uring_enter(13, 0, 0, 0, 0x0, 128) = 0
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
[1047471] io_uring_enter(13, 1, 0, 0, 0x0, 128) = 1
[1047471] epoll_wait(9, 0x7FFFA0000CB0, 1024, 4294967295) = 1
안정 상태에서는, 읽기 작업을 제출하기 위해 io_uring_enter를 호출하고, 완료된 작업을 기다리기 위해 epoll_wait를 호출하고, 그리고… 자신을 깨우기 위해 write를 호출한다. tokio 채널이 그렇게 동작하기 때문이다!
보고 싶은가? 스택트레이스(일부)다:
Thread 22 "zipring" hit Catchpoint 1 (call to syscall write), 0x00007ffff7dd027f in write () from /lib/x86_64-linux-gnu/libc.so.6
(gdb) bt
#0 0x00007ffff7dd027f in write () from /lib/x86_64-linux-gnu/libc.so.6
#1 0x00005555555bad90 in std::sys::pal::unix::fd::FileDesc::write () at std/src/sys/pal/unix/fd.rs:306
#2 std::sys::pal::unix::fs::File::write () at std/src/sys/pal/unix/fs.rs:1289
#3 std::fs::{impl#6}::write () at std/src/fs.rs:937
#4 0x000055555559aa54 in mio::sys::unix::waker::Waker::wake () at src/sys/unix/waker/eventfd.rs:53
#5 0x0000555555592015 in tokio::runtime::io::driver::Handle::unpark () at src/runtime/io/driver.rs:208
#6 tokio::runtime::driver::IoHandle::unpark () at src/runtime/driver.rs:198
#7 tokio::runtime::driver::Handle::unpark () at src/runtime/driver.rs:90
#8 0x00005555555994ef in tokio::runtime::scheduler::current_thread::{impl#7}::wake_by_ref () at src/runtime/scheduler/current_thread/mod.rs:700
#9 tokio::runtime::scheduler::current_thread::{impl#7}::wake () at src/runtime/scheduler/current_thread/mod.rs:694
#10 tokio::util::wake::wake_arc_raw<tokio::runtime::scheduler::current_thread::Handle> () at src/util/wake.rs:60
#11 0x0000555555572c16 in core::task::wake::Waker::wake () at /home/amos/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/core/src/task/wake.rs:459
#12 tokio_uring::runtime::driver::op::Lifecycle::complete () at src/runtime/driver/op/mod.rs:283
#13 0x0000555555570d9f in tokio_uring::runtime::driver::Ops::complete () at src/runtime/driver/mod.rs:491
#14 tokio_uring::runtime::driver::Driver::dispatch_completions () at src/runtime/driver/mod.rs:92
#15 0x0000555555575826 in tokio_uring::runtime::driver::handle::Handle::dispatch_completions () at src/runtime/driver/handle.rs:45
#16 tokio_uring::runtime::drive_uring_wakes::{async_fn#0} () at src/runtime/mod.rs:165
#17 tokio::runtime::task::core::{impl#6}::poll::{closure#0}<tokio_uring::runtime::drive_uring_wakes::{async_fn_env#0}, alloc::sync::Arc<tokio::task::local::Shared, alloc::alloc::Global>> () at /home/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/runtime/task/core.rs:331
#18 tokio::loom::std::unsafe_cell::UnsafeCell::with_mut<tokio::runtime::task::core::Stage<tokio_uring::runtime::drive_uring_wakes::{async_fn_env#0}>, core::task::poll::Poll<()>, tokio::runtime::task::core::{impl#6}::poll::{closure_env#0}<tokio_uring::runtime::drive_uring_wakes::{async_fn_env#0}, alloc::sync::Arc<tokio::task::local::Shared, alloc::alloc::Global>>> () at /home/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/tokio-1.42.0/src/loom/std/unsafe_cell.rs:16
✂️
op을 제출할 때(io_uring 용어로는 “비동기 시스템 콜”), tokio-uring은 Lifecycle 열거형에서 보듯 웨이커를 붙잡아 둔다:
#[allow(dead_code)]
pub(crate) enum Lifecycle {
/// The operation has been submitted to uring and is currently in-flight
Submitted,
/// The submitter is waiting for the completion of the operation
Waiting(Waker),
/// The submitter no longer has interest in the operation result. The state
/// must be passed to the driver and held until the operation completes.
Ignored(Box<dyn std::any::Any>),
/// The operation has completed with a single cqe result
Completed(cqueue::Entry),
/// One or more completion results have been recieved
/// This holds the indices uniquely identifying the list within the slab
CompletionList(SlabListIndices),
}
그 Waker는 사실 박스된 트레이트 객체의 변장일 뿐이다:
pub struct Waker {
waker: RawWaker,
}
pub struct RawWaker {
data: *const (),
vtable: &'static RawWakerVTable,
}
…그리고 vtable에는 clone, wake, wake_by_ref, drop 함수가 들어 있다:
pub struct RawWakerVTable {
clone: unsafe fn(*const ()) -> RawWaker,
wake: unsafe fn(*const ()),
wake_by_ref: unsafe fn(*const ()),
drop: unsafe fn(*const ()),
}
그리고, wake_by_ref를 호출했을 때 실제로 무슨 일이 벌어지는지는 mio 크레이트의 소관이다. 리눅스에서는 eventfd를 사용한다 — 이벤트 신호만을 위해 파일 디스크리터를 만들 수 있는 API다! 파이프보다 싸고, 정규 파일, 네트워크 소켓 등과 마찬가지로 epoll로 다중화할 수 있다.
이렇게 epoll과 io_uring을 섞어 쓰는 오버헤드 때문에 tokio와 완전히 분리된 자신만의 런타임을 만든 사람들도 있다. Datadog는 glommio를, Bytedance는 monoio를, vertexclique는 nuclei를 만들었다. 흥미로운 작업이 차고 넘친다!
우리 테스트 프로그램에 monoio 변형을 추가해 보면, 뜨거운 루프가 오직 io_uring_enter만 남는 걸 볼 수 있다:
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
[1142572] io_uring_enter(9, 1, 1, 1, 0x0, 128) = 1
✂️
하지만 이는 ‘진짜’ 벤치마크가 아니라는 점을 강조하고 싶다. 실제 시스템의 성능에 대해 실제 벤치마크조차 알려주는 게 거의 없는데, 이 테스트 프로그램은 그런 의도조차 없었다. 우리는 단지 다양한 시스템이 어떻게 동작하는지 톡톡 건드려 본 것이다.
monoio에 rc-zip 꽂아 보기 ---------------------------
그럼에도 monoio는 유망해 보인다. 마무리로, rc-zip-monoio 패키지를 만들어 보자 — 할 수 있으니까!
간단하게, 파일에 대한 참조를 받고 Archive 또는 에러를 돌려주는 단일 비동기 함수 하나만 구현해 보자.
pub async fn read_zip_from_file(file: &File) -> Result<Archive, Error> {
// TODO: the rest of the owl
}
여기서의 파일 타입은 monoio에서 온다. 네이티브 read_at 메서드가 딸려 있다. 다만 시그니처가 보통 tokio의 그것과는 다르다:
pub async fn read_at<T: IoBufMut>(
&self,
buf: T,
pos: u64,
) -> BufResult<usize, T>
pub type BufResult<T, B> = (Result<T>, B);
버퍼의 ‘소유권’을 가져가고, 작업이 실패했더라도 다시 돌려준다.
이는 Rust에서 메모리 안전한 io_uring 인터페이스에 필요한 제약이다. 작업이 완료되거나 취소되기 전에 버퍼가 해제되는 것을 막는다. 커널에 버퍼의 소유권을 넘겨주는 것과 같다.
최근에 이 주제로 훌륭한 P99 conf 발표가 있었다. 발표자는… 어라, 나네! 그리고 셜록도. 아앗.
이 API 때문에 코드 구조가 약간 특이해진다.
우선, 우리의 버퍼는 Vec<u8>이 아니다 — 용량과 길이를 따로 추적할 필요가 없고, 커질 필요도 없다. 그래서 256 KiB 크기의 u8 박스드 슬라이스를 쓴다. 전부 초기화된 상태다. MaybeUninit은 오늘 범위를 벗어난다:
let mut buf = vec![0u8; 256 * 1024].into_boxed_slice();
파일 크기를 알아낸 뒤 상태 기계를 만들고 루프에 들어간다:
let meta = file.metadata().await?;
let size = meta.len();
let mut fsm = ArchiveFsm::new(size);
loop {
// rest of the code goes here
}
루프에서, 만약 머신이 읽기를 원하면…
if let Some(offset) = fsm.wants_read() {
// rest of the code goes here
}
…제일 먼저 할 일은 우리가 최대 얼마까지 읽을 수 있는지 계산하는 것이다.
머신이 감당할 수 있는 양보다 더 읽고 싶지는 않지만, 현 rc-zip API 때문에 머신의 버퍼를 그대로 쓸 수는 없다. 머신은 버퍼를 가변으로 ‘빌려줄’ 뿐, 소유권을 주지 않는다. 그래서 커널로 소유권을 이전할 수가 없다.
우리 버퍼로 읽어들인 다음, 머신의 버퍼로 복사해야 한다.
이 문제를 해결하도록 rc-zip API를 바꾸는 건 비교적 쉽지만, 깨지는 변경이다. 그래서 오늘은 하지 않겠다. 다만 장차 고려하고 있다.
최대 읽기 크기는 우리 버퍼 크기와 머신 버퍼 크기 중 최소값이다:
let dst = fsm.space();
let max_read = dst.len().min(buf.len());
그다음에는 SliceMut<Box<[u8]>>를 얻는다. monoio가 제공하는 타입이다(tokio-uring에도 비슷한 것이 있다). 슬라이스 같지만 소유권이 있다! 너무 많이 읽지 않도록 보장해 준다.
let slice = IoBufMut::slice_mut(buf, 0..max_read);
나는 호출을 완전 수식형으로 썼다(slice.slice_mut() 대신). 그 함수가 어디서 오는지 — monoio의 IoBufMut 트레이트 — 를 분명히 하고 싶었기 때문이다.
그리고 파일에는 네이티브이자 ‘진짜’ read_at 메서드가 있다:
let (res, slice) = file.read_at(slice, offset).await;
약속대로, 작업 성공 여부와 관계없이 버퍼를 돌려받는다. 그러니 먼저 에러를 전파하고, 그다음 우리가 읽은 바이트 수만큼 머신 버퍼에 복사하고, fill로 그 크기를 알려 준다:
let n = res?;
(dst[..n]).copy_from_slice(&slice[..n]);
fsm.fill(n);
…마지막으로, read_at에서 돌려준 SliceMut 안에 숨겨져 있던 우리 버퍼의 소유권을 되찾는다:
buf = slice.into_inner();
이 때문에 buf가 가변 바인딩이어야 한다! 우리는 루프 한 바퀴 동안 그것을 이동(move)시켰고, 되돌려 놓는 조건으로 그렇게 했다. 만약 되돌리지 않았다면, Rust 컴파일러는 부드럽지만 단호하게 진행을 거부했을 것이다:
error[E0382]: borrow of moved value: `buf`
--> rc-zip-monoio/src/lib.rs:35:42
|
27 | let mut buf = vec![0u8; 256 * 1024].into_boxed_slice();
| ------- move occurs because `buf` has type `Box<[u8]>`, which does not implement the `Copy` trait
...
30 | loop {
| ---- inside of this loop
...
35 | let max_read = dst.len().min(buf.len());
| ^^^ value borrowed here after move
...
41 | let slice = IoBufMut::slice_mut(buf, 0..max_read);
| ------------------------------------- `buf` moved due to this method call, in previous iteration of loop
|
note: `slice_mut` takes ownership of the receiver `self`, which moves `buf`
--> /Users/amos/.cargo/registry/src/index.crates.io-6f17d22bba15001f/monoio-0.2.4/src/buf/io_buf.rs:256:22
|
256 | fn slice_mut(mut self, range: impl ops::RangeBounds<usize>) -> SliceMut<Self>
| ^^^^
help: you can `clone` the value and consume it, but this might not be your desired behavior
|
41 | let slice = IoBufMut::slice_mut(buf.clone(), 0..max_read);
| ++++++++
그 뒤에는 상태 기계에서 process를 호출하고, 루프를 빠져나갈지 계속할지 결정하면 된다:
fsm = match fsm.process()? {
FsmResult::Done(archive) => {
break Ok(archive);
}
FsmResult::Continue(fsm) => {
fsm
}
}
끝! 전체 코드는 다음과 같다:
use monoio::{buf::IoBufMut, fs::File};
use rc_zip::{
error::Error,
fsm::{ArchiveFsm, FsmResult},
parse::Archive,
};
pub async fn read_zip_from_file(file: &File) -> Result<Archive, Error> {
let meta = file.metadata().await?;
let size = meta.len();
let mut buf = vec![0u8; 256 * 1024].into_boxed_slice();
let mut fsm = ArchiveFsm::new(size);
loop {
if let Some(offset) = fsm.wants_read() {
let dst = fsm.space();
let max_read = dst.len().min(buf.len());
let slice = IoBufMut::slice_mut(buf, 0..max_read);
let (res, slice) = file.read_at(slice, offset).await;
let n = res?;
(dst[..n]).copy_from_slice(&slice[..n]);
fsm.fill(n);
buf = slice.into_inner();
}
fsm = match fsm.process()? {
FsmResult::Done(archive) => {
break Ok(archive);
}
FsmResult::Continue(fsm) => fsm,
}
}
}
그리고 이것을 사용하는 프로그램:
use monoio::fs::File;
use rc_zip_monoio::read_zip_from_file;
#[cfg(not(target_os = "linux"))]
type DefaultDriver = monoio::LegacyDriver;
#[cfg(target_os = "linux")]
type DefaultDriver = monoio::IoUringDriver;
fn main() {
monoio::start::<DefaultDriver, _>(async_main())
}
async fn async_main() {
let zip_path = [
std::env::var("HOME").unwrap().as_str(),
"zip-samples/wine-10.0-rc2.zip",
]
.join("/");
let file = File::open(&zip_path).await.unwrap();
let archive = read_zip_from_file(&file).await.unwrap();
for (i, e) in archive.entries().enumerate() {
println!("- {}", e.sanitized_name().unwrap_or_default());
if i > 10 {
break;
}
}
}
이 프로그램은 내 메인 머신인 macOS에서도 monoio의 레거시 드라이버로 돌아가고, 리눅스에서도 io-uring 드라이버로 돌아간다!
io_uring_setup 호출부터 파일 목록을 출력하기까지, 단 한 번의 read나 write 시스템 콜도 보이지 않는다 — 전부 io-uring op로 이뤄진다:
amos in 🌐 brat in monozip on main via 🦀 v1.83.0
❯ lurk -f ./target/release/monozip
[2705391] execve("", "", "") = 0
✂️
[2705391] io_uring_setup(1024, 0x7FFFFFFFCE50) = 3
[2705391] mmap(0x0, 65536, 3, 32769, 3, 268435456) = 0x7FFFF7DA4000
[2705391] mmap(0x0, 37184, 3, 32769, 3, 0) = 0x7FFFF7D9A000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] mmap(0x0, 266240, 3, 34, 4294967295, 0) = 0x7FFFF7D59000
[2705391] mmap(0x0, 266240, 3, 34, 4294967295, 0) = 0x7FFFF7D18000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] brk(0x55555565B000) = 0x55555565B000
[2705391] mmap(0x0, 233472, 3, 34, 4294967295, 0) = 0x7FFFF7CDF000
[2705391] mremap(0x7FFFF7CDF000, 233472, 462848, 1, 0x0) = 0x7FFFF7C6E000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] brk(0x55555567C000) = 0x55555567C000
[2705391] mremap(0x7FFFF7C6E000, 462848, 921600, 1, 0x0) = 0x7FFFF7B8D000
[2705391] brk(0x55555569D000) = 0x55555569D000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] brk(0x5555556BE000) = 0x5555556BE000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] brk(0x5555556DF000) = 0x5555556DF000
[2705391] mremap(0x7FFFF7B8D000, 921600, 1839104, 1, 0x0) = 0x7FFFF79CC000
[2705391] brk(0x555555700000) = 0x555555700000
[2705391] io_uring_enter(3, 1, 1, 1, 0x0, 128) = 1
[2705391] brk(0x555555721000) = 0x555555721000
[2705391] brk(0x555555743000) = 0x555555743000
[2705391] mmap(0x0, 151552, 3, 34, 4294967295, 0) = 0x7FFFF7CF3000
[2705391] mremap(0x7FFFF7CF3000, 151552, 299008, 1, 0x0) = 0x7FFFF7CAA000
[2705391] mremap(0x7FFFF7CAA000, 299008, 593920, 1, 0x0) = 0x7FFFF7C19000
[2705391] brk(0x555555764000) = 0x555555764000
[2705391] mremap(0x7FFFF7C19000, 593920, 1183744, 1, 0x0) = 0x7FFFF78AB000
[2705391] brk(0x555555785000) = 0x555555785000
[2705391] brk(0x5555557A6000) = 0x5555557A6000
[2705391] mremap(0x7FFFF78AB000, 1183744, 2363392, 1, 0x0) = 0x7FFFF766A000
[2705391] brk(0x5555557C7000) = 0x5555557C7000
[2705391] munmap(0x7FFFF79CC000, 1839104) = 0
[2705391] munmap(0x7FFFF7D18000, 266240) = 0
[2705391] munmap(0x7FFFF7D59000, 266240) = 0
[2705391] write(1, "- wine-10.0-rc2/\nxp 00000000 00:...", 17) = 17
[2705391] write(1, "- wine-10.0-rc2/documentation/\n:...", 31) = 31
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 46) = 46
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 46) = 46
[2705391] write(1, "- wine-10.0-rc2/documentation/RE...", 43) = 43
[2705391] munmap(0x7FFFF766A000, 2363392) = 0
[2705391] io_uring_enter(3, 2, 0, 0, 0x0, 128) = 2
[2705391] munmap(0x7FFFF7D9A000, 37184) = 0
[2705391] munmap(0x7FFFF7DA4000, 65536) = 0
[2705391] close(3) = 0
[2705391] sigaltstack(0x7FFFFFFFDD80, 0x0) = 0
[2705391] munmap(0x7FFFF7FC0000, 12288) = 0
[2705391] exit_group(0) = ?
보이는 시스템 콜은 brk와 mmap 관련뿐이다. 확실히 힙 할당과 관련 있다.
다른 상태 기계인 EntryFsm의 구현은 독자에게 연습 문제로 남긴다. rc-zip 저장소의 드래프트 PR을 보면 된다 — 한편으로는 읽기가 선형이라 더 단순하고, 다른 한편으로는 파일을 디컴프레션하면서 데이터가 실제로 스트리밍되므로 더 복잡하다.
하지만 한 번만 구현하면, rc-zip이 지원하는 모든 압축 방식(Deflate, bzip2, LZMA, ZStandard)을 그대로 쓸 수 있다!
맺음말 ------------- 동기/비동기 간극을 피하려는 다른 시도들(예: 키워드 제네릭)도 있지만, 나는 앞으로의 길은 포맷·프로토콜 등을 ‘sans-io’ 방식으로 구현하는 것이라고 믿는다.
libstd와 tokio를 통합하려는 접근은 옳지 않다고 생각한다. 둘 다 io_uring 같은 현대 I/O API와 호환되지 않기 때문이다.
내 HTTP 구현인 loona가 특정 I/O 모델에 묶여 있다는 걸 잘 알면서도 이렇게 말한다. 나는 한 번에 하나의 문제를 해결하려 했고, HTTP의 내부 동작을 아직 배우는 중이었다.
이제 돌아보니, loona를 완전히 sans-io로 다시 쓰면 재미있을 것 같다. 그러면 모든 문맥에서 쓸 수 있을 것이다. monoio 같은 것으로 고성능 프락시, “클래식” tokio로 웹 애플리케이션, 그리고 비동기를 원치 않거나 필요로 하지 않는 간단한 CLI 도구의 동기 인터페이스까지!
또 I/O 버퍼와 디코딩 버퍼 사이의 복사가 없이 가도록 rc-zip 인터페이스를 바꾸고 싶다 — API를 ‘uring 친화적’으로 만드는 일은 많은 부분을 다시 생각하게 만든다.
그리고 표준 I/O 추상화가 아예 없는 C 같은 생태계나, 추상화 수준이 훨씬 높은 Node.js 같은 생태계가 Rust처럼 덜 유연한 모델을 전제로 쓰여진 코드가 많은 생태계보다 io_uring을 더 빨리 받아들인 것도 흥미롭다.
보았는가? 나도 Rust를 깔 수 있다! 나, 홍보대사 아니다.
후원자 여러분께 감사드립니다:
가능하다면, 감당할 수 있는 티어로 이 작업을 후원해 주세요:
브론즈 티어* 보너스 콘텐츠(여러 Rust 코드베이스 등) 접근