I/O Async

Operasi berkas dan jaringan secara async.

Daftar isi

Topik ini membahas operasi I/O secara async dengan dua wakil yang paling sering dipakai: berkas lewat tokio::fs dan jaringan TCP lewat tokio::net. Contoh utamanya adalah satu program utuh yang menulis dan membaca berkas, lalu menjalankan server serta klien TCP kecil di alamat 127.0.0.1 di dalam proses yang sama sehingga dapat dijalankan tanpa jaringan eksternal. Setelah mengikuti topik ini, operasi berkas dan soket dapat ditulis tanpa memblokir worker thread, dan hasil komunikasi antar-task dapat dikumpulkan secara tertib.

Mengapa I/O menjadi wilayah async#

Sebagian besar waktu program server dihabiskan untuk menunggu, bukan menghitung: menunggu data sampai dari soket, menunggu disk selesai membaca, atau menunggu batas waktu berlalu. Thread OS yang menunggu dengan cara blokir tidak dapat dipakai untuk apa pun selama penantian itu, sedangkan future hanya menyerahkan kendali pada titik await sehingga satu thread dapat menangani ribuan penantian sekaligus. Mekanisme pemantauan kesiapan seperti epoll memberi tahu driver I/O ketika sebuah soket layak dibaca. Driver memanggil Waker task yang bersangkutan, lalu scheduler mempoll ulang task itu. Rangkaian ini persis seperti putaran poll yang dibahas pada topik mental model async/await.

Aspekpekerjaan padat komputasipekerjaan padat I/O
hambatan utamasiklus CPUwaktu tunggu perangkat dan jaringan
efek asyncnyaris tidak ada, thread tetap sibuk menghitungsatu thread melayani banyak penantian
alat yang tepatthread biasa atau spawn_blockingawait pada operasi async

Dengan demikian, async bukan pengganti thread untuk semua kebutuhan, melainkan alat yang tepat untuk beban yang didominasi penantian. Operasi berkas menempati posisi khusus. Pustaka standar belum menyediakan API async untuk berkas, sehingga tokio menyediakannya dengan jalur tersendiri yang dibahas setelah program utuh berjalan.

Proyek contoh: berkas lalu jaringan#

Proyek contoh kembali disiapkan di /tmp dengan nama berbeda agar tidak bertabrakan dengan proyek lain. Fitur tokio yang dibutuhkan bertambah: net untuk TcpListener dan TcpStream, fs untuk operasi berkas, serta io-util untuk metode read_exact, write_all, dan shutdown.

toml Cargo.toml
[package]
name = "async-io"
version = "0.1.0"
edition = "2021"

[dependencies]
tokio = { version = "1", features = ["macros", "rt-multi-thread", "net", "fs", "io-util"] }

Program pada main.rs terdiri atas dua bagian yang berbagi satu async fn main yang mengembalikan io::Result, sehingga operator ? dapat dipakai langsung untuk error I/O. Berkas ini ditandai tidak-dijalankan karena bergantung pada crate tokio, dan eksekusinya berlangsung lewat Cargo pada blok bash di bawahnya.

rust main.rs
// tidak-dijalankan
use std::io;

use tokio::fs;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};

#[tokio::main]
async fn main() -> io::Result<()> {
    fs::create_dir_all("/tmp/async-io-data").await?;
    let jalan = "/tmp/async-io-data/catatan.txt";
    fs::write(jalan, "suhu 26\nsuhu 27\n").await?;
    let teks = fs::read_to_string(jalan).await?;
    println!(
        "berkas dibaca kembali: {} baris, {} byte",
        teks.lines().count(),
        teks.len()
    );

    let pendengar = TcpListener::bind("127.0.0.1:0").await?;
    let alamat = pendengar.local_addr()?;
    println!("server siap di {}", alamat.ip());

    let server = tokio::spawn(async move {
        let (mut aliran, _) = pendengar.accept().await?;
        let mut permintaan = [0u8; 4];
        aliran.read_exact(&mut permintaan).await?;
        aliran.write_all(b"PONG").await?;
        aliran.shutdown().await?;
        Ok::<_, io::Error>(String::from_utf8_lossy(&permintaan).into_owned())
    });

    let mut klien = TcpStream::connect(alamat).await?;
    klien.write_all(b"ping").await?;
    let mut balasan = [0u8; 4];
    klien.read_exact(&mut balasan).await?;

    let diterima = server.await.unwrap()?;
    println!("server menerima: {}", diterima);
    println!("klien menerima: {}", String::from_utf8_lossy(&balasan));
    Ok(())
}
bash
# mulai dari proyek baru di /tmp agar pohon konten situs tetap bersih
rm -rf /tmp/async-io
# buat proyek binary baru tanpa repositori git
cargo new /tmp/async-io -q --vcs none
# salin berkas proyek dari bundle topik ini
cp Cargo.toml /tmp/async-io/
cp main.rs /tmp/async-io/src/
# jalankan programnya dengan direktori target di luar proyek
CARGO_TARGET_DIR=/tmp/target-async-io cargo run -q --manifest-path /tmp/async-io/Cargo.toml
text Keluaran
berkas dibaca kembali: 2 baris, 16 byte
server siap di 127.0.0.1
server menerima: ping
klien menerima: PONG

Bagian berkas: tulis lalu baca#

Tiga baris pertama program menyiapkan data uji yang netral, yaitu catatan dua baris berisi nilai suhu. Fungsi create_dir_all memastikan folder tujuan ada tanpa error bila folder itu sudah dibuat sebelumnya, sehingga program dapat dijalankan berulang dengan hasil yang sama. fs::write menimpa isi berkas dengan byte yang diberikan, lalu read_to_string membaca seluruh isinya sebagai teks UTF-8, dan kedua fungsi itu adalah future yang harus di-await. Keluaran baris pertama memperlihatkan hasil penghitungan lines dan panjang byte, yaitu dua baris dengan total 16 byte, sebagai bukti bahwa penulisan dan pembacaan benar-benar terjadi.

Operator ? bekerja pada seluruh bagian karena async fn main mengembalikan io::Result<()>, pola yang lazim untuk program yang seluruh kegagalannya berjenis I/O. Setiap kegagalan, mulai dari direktori yang tak dapat dibuat sampai koneksi yang ditolak, berhenti di titik ? lalu dilaporkan sebagai baris Error: pada keluaran program. Untuk kebutuhan pelaporan yang lebih kaya dengan konteks berlapis, error I/O dapat dibungkus tipe error milik program sendiri.

Bagian jaringan: pendengar dan klien dalam satu program#

Pendengar diikat ke 127.0.0.1:0, dan port nol berarti sistem operasi memilih satu port yang sedang bebas. Port hasil pilihan itu dibaca lewat local_addr tanpa dicetak angkanya, lalu dipakai klien dari dalam program yang sama, sehingga contoh tidak pernah bertabrakan dengan layanan lain dan keluarannya tetap deterministik. Bagian server dijalankan sebagai task terpisah lewat tokio::spawn karena accept menunggu koneksi masuk yang tidak akan pernah datang bila klien tidak sempat dibuat.

Urutan komunikasinya sederhana dan setiap langkahnya menunggu lawannya secara eksplisit. Klien mengirim tepat empat byte ping. Server membacanya dengan read_exact yang menunggu hingga penyangga empat byte terisi penuh, membalas PONG, lalu memanggil shutdown untuk menyatakan bahwa tidak ada data lagi yang akan dikirim. Baris cetak sengaja dikumpulkan di main setelah JoinHandle selesai di-await, bukan disebar ke dalam task, karena pencetakan dari beberapa task yang serentak dapat muncul dalam urutan yang berubah antar-jalankan. Data yang diterima server dikembalikan sebagai nilai lewat Ok sehingga main dapat mencetaknya berdampingan dengan balasan yang diterima klien.

Kesalahan umum#

Kesalahan pertama adalah memanggil fs::write tanpa .await. Fungsi itu mengembalikan future. Tanpa penantian atau spawn, penulisan tidak pernah terjadi dan tidak ada pesan error yang menyertainya. Akhiri setiap operasi async dengan .await, termasuk operasi yang terasa seperti prosedur biasa.

Kesalahan kedua adalah mencampur objek std::net ke dalam kode async dan memblokir worker thread tanpa peringatan. Kompiler tidak melarang pemakaian std::net karena tipe itu sah di mana saja, tetapi setiap read yang blokir menahan seluruh task pada worker thread yang menjalankannya. Gunakan padanan tokio untuk semua objek yang menunggu, atau pindahkan kode blokir tersebut ke spawn_blocking.

Kesalahan ketiga adalah mengikat port tetap untuk contoh yang dijalankan berulang kali. Port lama bisa saja masih dipakai proses lain sehingga bind gagal dan program berhenti di awal. Gunakan port nol untuk membiarkan sistem operasi memilih, lalu baca alamat hasilnya lewat local_addr seperti pada contoh.

Ringkasan#

  • Operasi I/O async mengubah waktu tunggu menjadi titik await sehingga satu thread melayani banyak penantian, sedangkan pekerjaan padat komputasi tetap lebih tepat pada thread biasa.
  • tokio::fs menjalankan API berkas blokir pada thread pool khusus, sementara tokio::net memakai driver I/O berbasis kesiapan soket.
  • read_exact dan write_all menjamin penyangga terisi dan terkirim penuh, dan shutdown menandai akhir arah kirim pada soket.
  • Keluaran komunikasi antar-task dijaga deterministik dengan mengumpulkan hasil lewat nilai kembalian task lalu mencetaknya dari satu tempat.

Referensi#