Rust tokio::fs: 비동기 파일 I/O와 그 한계
std::fs 함수는 모두 동기적입니다. 호출하면 그 자리에서 디스크를 기다리며 멈춰 있죠. Tokio 같은 비동기 런타임 위에서 이 함수를 그냥 부르면, 파일을 기다리는 동안 같은 스레드에서 돌아야 할 다른 태스크가 같이 멈춰버립니다.
이번 글에서는 이 문제를 풀기 위한 tokio::fs 모듈을 살펴보고, 한 가지 흥미로운 사실(사실은 진짜 비동기가 아님)을 짚어보겠습니다.
tokio::fs는 std::fs의 비동기 버전
tokio::fs는 std::fs와 거의 같은 인터페이스를 비동기 버전으로 제공하는데요. 함수 이름과 시그니처가 거의 똑같고, 다만 모두 async이고 Future를 돌려준다는 차이가 있습니다.
use tokio::fs;
#[tokio::main]
async fn main() -> std::io::Result<()> {
// 한 번에 읽기
let content = fs::read_to_string("config.toml").await?;
println!("{content}");
// 한 번에 쓰기
fs::write("output.txt", "Hello, async!").await?;
Ok(())
}
Cargo.toml에는 tokio 크레이트의 fs 기능을 켜둬야 합니다.
[dependencies]
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
File 핸들을 직접 다루는 패턴도 같은 모양입니다.
use tokio::fs::File;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let mut file = File::open("config.toml").await?;
let mut buf = String::new();
file.read_to_string(&mut buf).await?;
let mut out = File::create("output.txt").await?;
out.write_all(b"Hello, async!").await?;
Ok(())
}
AsyncReadExt/AsyncWriteExt를 import해야 read_to_string이나 write_all 같은 메서드가 보인다는 점만 주의하면, std::fs 기초에서 본 흐름이 거의 그대로 이어집니다.
한 가지 흥미로운 사실: 진짜 비동기는 아니다
여기서 깜짝 놀랄 만한 사실이 있는데요. tokio::fs는 사실 진짜 비동기 파일 I/O가 아닙니다.
리눅스를 비롯한 대부분의 OS는 일반 파일에 대한 진짜 비동기 I/O API가 빈약합니다. 네트워크 소켓에는 epoll이나 kqueue 같은 비차단 인터페이스가 잘 갖춰져 있지만, 파일 I/O는 본질적으로 차단 연산이라는 게 오랜 시간 동안의 현실이었죠.
그래서 Tokio가 택한 전략은 다음과 같습니다. 파일 I/O 호출을 만나면 별도의 스레드 풀(spawn_blocking 풀)에 작업을 던져두고, 그 스레드가 동기 파일 I/O를 수행하는 동안 메인 스레드는 다른 태스크를 처리합니다.
async fn read_to_string(...).await
└─ 내부적으로 spawn_blocking에 std::fs 호출을 위임
└─ 별도 스레드에서 차단 호출 수행
└─ 결과를 await 시점으로 돌려보냄
호출자 입장에서는 비동기처럼 보이지만, 실제로는 백그라운드 스레드에서 차단 호출이 일어나는 거죠.
이게 왜 중요하냐면, tokio::fs가 std::fs보다 빠르지 않다는 뜻이기 때문입니다. 오히려 스레드 풀을 거치는 오버헤드 때문에 단일 호출은 더 느릴 수 있죠. tokio::fs의 가치는 “메인 스레드를 차단하지 않는다”는 한 가지에 있습니다.
그럼에도 쓰는 이유
진짜 비동기가 아니라면 왜 쓸까요? 답은 단순합니다. 비동기 컨텍스트에서 동기 파일 호출을 하면 다른 태스크가 모두 멈춥니다.
웹 서버를 예로 들어보면, 요청 핸들러 안에서 큰 파일을 std::fs::read_to_string으로 읽으면 그 핸들러를 돌리던 스레드가 디스크를 기다리며 멈춥니다. 같은 스레드에서 처리되던 다른 요청들도 모두 같이 대기 상태가 되죠. 멀티스레드 런타임이라면 다른 스레드는 영향을 안 받지만, 그래도 한 스레드만큼의 동시성이 통째로 사라집니다.
tokio::fs를 쓰면 이 차단이 백그라운드 스레드 풀로 격리됩니다. 메인 스레드는 다른 태스크를 자유롭게 처리하고, 파일 작업이 끝나면 결과를 받아 처리를 이어가는 식이죠.
async fn handler() -> Result<String, std::io::Error> {
// 잘못된 패턴: 핸들러 스레드를 차단함
// let config = std::fs::read_to_string("config.toml")?;
// 올바른 패턴: 백그라운드로 위임
let config = tokio::fs::read_to_string("config.toml").await?;
Ok(config)
}
tokio::fs vs spawn_blocking 직접 호출
tokio::fs를 쓰지 않고 직접 spawn_blocking으로 동기 호출을 감싸도 같은 효과를 낼 수 있습니다.
let content = tokio::task::spawn_blocking(|| {
std::fs::read_to_string("config.toml")
}).await??;
언제 이 패턴을 쓸까요? 첫째로 std::fs에는 있지만 tokio::fs에는 없는 함수가 가끔 있는데, 그럴 때 직접 감싸야 합니다. 둘째로 여러 동기 호출을 묶어 한 번의 spawn_blocking으로 처리하면 스레드 풀 오버헤드를 줄일 수 있습니다. 짧은 호출 여러 개를 매번 위임하는 것보다 한 번에 모아 처리하는 게 효율적일 때가 있죠.
다만 보통은 tokio::fs가 가장 무난합니다. 인터페이스가 익숙하고, 잘 다듬어져 있고, 흔한 사용 사례를 모두 커버하니까요.
진짜 비동기가 필요하다면
리눅스 5.1 이후로 io_uring이라는 진짜 비동기 I/O 인터페이스가 들어왔습니다. 이걸 활용하는 tokio-uring 크레이트를 쓰면 스레드 풀을 거치지 않는 진짜 비동기 파일 I/O를 할 수 있죠.
// tokio-uring 사용 예 (리눅스 전용)
let file = tokio_uring::fs::File::open("foo.txt").await?;
let buf = vec![0; 4096];
let (res, buf) = file.read_at(buf, 0).await;
let bytes_read = res?;
다만 현재 시점에서는 다음 같은 제약이 있습니다. 리눅스 전용이라 크로스 플랫폼 코드에는 못 쓰고, 호환성을 위해 다른 Tokio API와는 별도의 런타임 같은 걸 쓰며, 인터페이스가 tokio::fs와 달라서 코드 변경이 큽니다. 대부분의 워크로드에는 tokio::fs로 충분하고, io_uring은 진짜 고성능이 필요한 자리에서만 검토하시면 됩니다.
흔한 함정: 동기 호출 섞기
비동기 코드에서 가장 자주 마주치는 함정은 무심코 std::fs를 섞어 쓰는 건데요. 코드 리뷰 도중 발견되는 일이 잦습니다.
// 위험: async 함수 안에서 동기 fs 호출
async fn load_config() -> std::io::Result<String> {
std::fs::read_to_string("config.toml") // 차단!
}
이 호출은 await 없이 그 자리에서 끝나기 때문에 컴파일러가 경고를 안 해줍니다. 하지만 호출되는 순간 그 스레드가 디스크를 기다리며 멈춥니다.
대처법은 단순합니다. 비동기 컨텍스트라면 tokio::fs를 쓰거나 spawn_blocking으로 감싸세요.
async fn load_config() -> std::io::Result<String> {
tokio::fs::read_to_string("config.toml").await
}
특히 시작 시점에 한 번만 호출되는 코드(예: 서버 부팅 시 설정 로드)라면 동기 std::fs를 그대로 써도 큰 문제는 없습니다. 차단이 되더라도 그 시점에는 다른 태스크가 없으니까요. 요청 처리 핸들러 같은 핫 패스에서만 신경 써주면 됩니다.
마치며
tokio::fs는 std::fs의 비동기 외관을 입혀주는 모듈입니다. 본질은 백그라운드 스레드에 동기 호출을 위임하는 거지만, 비동기 컨텍스트에서 메인 스레드를 차단하지 않는다는 결정적인 가치를 제공하죠. 인터페이스가 거의 같아서 학습 비용도 거의 없고요.
비동기 환경이 아니거나, 시작 시점의 한 번뿐인 호출이라면 std::fs를 그대로 쓰는 게 단순합니다. 효율적인 줄 단위 처리가 필요하다면 BufReader/BufWriter를, 안전한 경로 처리는 Path와 PathBuf를 함께 보시면 좋습니다.
더 자세한 내용은 tokio::fs 공식 문서를 참고하세요.
This work is licensed under
CC BY 4.0