Lewati ke konten

Async/Await di Rust

Bayangkan sebuah restoran. Di halaman Konkurensi, kamu sudah belajar tentang thread. Itu seperti mempekerjakan satu pelayan untuk setiap meja. Cara itu berhasil, tapi pelayan itu mahal, dan sebagian besar waktunya mereka cuma berdiri menunggu dapur.

Async itu seperti punya satu pelayan yang mencatat banyak pesanan. Pelayan mencatat pesanan dari meja 1, menyerahkannya ke dapur, dan selagi makanan dimasak, ia pergi ke meja 2. Tidak ada yang berdiri diam tanpa melakukan apa-apa. Itulah ide di balik async dan .await di Rust: selagi satu task (tugas) menunggu (jaringan, file, timer), task lain mendapat giliran berjalan.

Async paling bersinar saat programmu banyak menghabiskan waktu untuk menunggu: web server, klien API, aplikasi chat, bot. Halaman ini memakai tokio, runtime async paling populer untuk Rust.

Standard library Rust memberimu kata kunci async dan .await, tapi tidak menyediakan “mesin” yang benar-benar menjalankan kode async. Mesin itu disebut runtime, dan tokio adalah yang dipakai sebagian besar proyek. Tambahkan ke proyek baru:

Terminal window
cargo new async_demo
cd async_demo
cargo add tokio --features full

Perintah itu menambahkan baris ini ke Cargo.toml-mu:

[dependencies]
tokio = { version = "1", features = ["full"] }

Fitur full menyalakan semuanya (timer, task, jaringan, dan lain-lain). Ini pilihan paling mudah selama kamu belajar. Tombol “Coba sendiri” di halaman ini juga berfungsi, karena Rust Playground sudah punya tokio bawaan.

use std::time::Duration;
async fn make_coffee() -> String {
tokio::time::sleep(Duration::from_millis(20)).await;
String::from("kopi")
}
#[tokio::main]
async fn main() {
println!("Memesan...");
let drink = make_coffee().await;
println!("Ini {} pesananmu", drink);
}

Output:

Memesan...
Ini kopi pesananmu
▶ Coba sendiri

Ada tiga hal baru di sini:

  • async fn membuat fungsi yang bisa berhenti sejenak di tengah jalan lalu melanjutkan nanti.
  • .await artinya “tunggu sampai ini selesai, dan selama menunggu, biarkan task lain berjalan”. Kamu hanya bisa memakai .await di dalam fungsi async.
  • #[tokio::main] menyalakan runtime tokio dan menjalankan async fn main milikmu di atasnya. Tanpa itu, main tidak bisa async.

tokio::time::sleep adalah versi async dari “tunggu sebentar”. Ia hanya menghentikan task ini, bukan seluruh thread.

Bagian inilah yang paling sering membuat orang kaget. Memanggil async fn tidak menjalankannya. Pemanggilan itu mengembalikan sebuah future, yaitu nilai yang berarti “pekerjaan yang akan dilakukan nanti”. Tidak ada yang terjadi sampai kamu memanggil .await padanya. Karena itulah future disebut lazy (malas):

async fn say_hello() {
println!("Halo dari dalam future!");
}
#[tokio::main]
async fn main() {
let future = say_hello(); // belum ada yang berjalan
println!("Future sudah dibuat, tapi belum dimulai");
future.await; // sekarang baru berjalan
println!("Selesai");
}

Output:

Future sudah dibuat, tapi belum dimulai
Halo dari dalam future!
Selesai
▶ Coba sendiri

Anggap future seperti kertas catatan pesanan. Menulis catatannya tidak membuat makanannya matang. Menyerahkannya ke dapur (.await) yang membuatnya dimasak.

Menjalankan beberapa task bersamaan dengan join!

Section titled “Menjalankan beberapa task bersamaan dengan join!”

Sejauh ini kita menunggu satu hal dalam satu waktu. Kekuatan sebenarnya muncul saat kamu menunggu beberapa hal sekaligus. tokio::join! menjalankan beberapa future bersama-sama dan menunggu sampai semuanya selesai. Hasilnya dikembalikan dalam bentuk tuple:

use std::time::Duration;
use tokio::time::sleep;
async fn fetch_user() -> String {
sleep(Duration::from_millis(30)).await; // anggap saja ini panggilan jaringan
String::from("Ana")
}
async fn fetch_order_count() -> u32 {
sleep(Duration::from_millis(20)).await;
3
}
#[tokio::main]
async fn main() {
let (user, orders) = tokio::join!(fetch_user(), fetch_order_count());
println!("{} punya {} pesanan", user, orders);
}

Output:

Ana punya 3 pesanan
▶ Coba sendiri

Kalau kamu menunggu keduanya satu per satu, total waktu tunggunya sekitar 30 + 20 = 50 ms. Dengan join!, kedua penantian terjadi bersamaan, jadi hanya butuh sekitar 30 ms, yaitu waktu yang paling lama. Dengan panggilan jaringan sungguhan, selisih ini cepat sekali bertambah besar.

join! sangat pas kalau jumlah future-nya tetap. Kalau kamu punya daftar pekerjaan, pakai tokio::spawn. Ia menyerahkan future ke runtime sebagai task tersendiri, langsung menjalankannya, dan memberimu sebuah JoinHandle. Nanti, kamu memanggil .await pada handle itu untuk mendapatkan hasilnya:

use std::time::Duration;
use tokio::time::sleep;
async fn download(id: u64) -> String {
sleep(Duration::from_millis(50 - id * 10)).await;
format!("file {} selesai diunduh", id)
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for id in 1..=3 {
let handle = tokio::spawn(download(id)); // mulai berjalan sekarang
handles.push(handle);
}
for handle in handles {
let result = handle.await.unwrap();
println!("{}", result);
}
}

Output:

file 1 selesai diunduh
file 2 selesai diunduh
file 3 selesai diunduh
▶ Coba sendiri

Perhatikan baik-baik: file 3 punya waktu tidur paling pendek, jadi sebenarnya ia selesai paling dulu. Tapi kita mencetak hasilnya sesuai urutan kita menunggu handle-nya, jadi output-nya selalu 1, 2, 3. Ketiga unduhan tetap berjalan bersamaan.

Kenapa ada .unwrap()? Menunggu sebuah JoinHandle menghasilkan Result, karena task-nya mungkin saja panic. Kalau itu terjadi, kamu mendapat Err, bukan program yang crash seluruhnya. Ini memang sengaja dibuat mirip thread::spawn dan .join() dari halaman Konkurensi.

Lupa .await. Karena future itu lazy, lupa .await berarti pekerjaannya tidak pernah dilakukan. Kalau kamu memakai nilainya, compiler akan menghentikanmu:

async fn get_number() -> u32 {
5
}
#[tokio::main]
async fn main() {
let n = get_number(); // lupa .await
println!("{}", n + 1);
}
error[E0369]: cannot add `{integer}` to `impl Future<Output = u32>`
--> src/main.rs:8:22
|
8 | println!("{}", n + 1);
| - ^ - {integer}
| |
| impl Future<Output = u32>

Pesan compiler (selalu dalam bahasa Inggris) ini bilang n adalah future dari u32, bukan u32. Perbaikannya: let n = get_number().await;. Kalau fungsinya tidak mengembalikan apa-apa dan kamu cuma memanggilnya, seperti save_file();, kamu hanya mendapat warning. Jadi, baca warning-mu:

warning: unused implementer of `Future` that must be used
|
7 | save_file();
| ^^^^^^^^^^^
|
= note: futures do nothing unless you `.await` or poll them

Catatan di baris terakhir artinya: future tidak melakukan apa-apa kecuali kamu memanggil .await atau mem-poll-nya.

Memakai std::thread::sleep di dalam kode async. Kode ini bisa di-compile tanpa masalah, dan itulah yang membuatnya licik:

async fn slow_task() {
std::thread::sleep(std::time::Duration::from_secs(1)); // memblokir seluruh thread!
}

std::thread::sleep membekukan seluruh thread. Runtime tidak bisa berpindah ke task lain, jadi semua yang ada di thread itu ikut berhenti, seperti pelayan yang tertidur sambil berdiri di satu meja. Di dalam kode async, selalu pakai tokio::time::sleep(...).await. Hal yang sama berlaku untuk pekerjaan lambat lain yang memblokir, seperti membaca file besar dengan std::fs: tokio punya versi async-nya (tokio::fs), atau kamu bisa memakai tokio::task::spawn_blocking.

Berharap async membuat pekerjaan CPU lebih cepat. Async membantu saat task sedang menunggu. Async tidak membantu saat task sedang sibuk, seperti mengubah ukuran gambar atau mengolah angka. Satu pelayan tidak bisa memasak lebih cepat hanya karena mencatat lebih banyak pesanan. Untuk pekerjaan CPU yang berat, pakai thread (atau crate seperti rayon).

Pakai async kalau… Pakai thread kalau…
Sebagian besar waktu habis untuk menunggu (jaringan, database, timer) Pekerjaannya berupa perhitungan berat
Kamu butuh ribuan task sekaligus (seperti web server) Kamu hanya butuh beberapa hal berjalan paralel
Kamu memakai crate async seperti reqwest atau axum Kamu tidak mau menambah dependensi runtime

Kamu tidak harus memilih salah satu saja. Banyak program sungguhan memakai async untuk jaringan dan thread untuk pekerjaan berat.

Butuh task yang saling mengirim pesan? Tokio punya channel sendiri, tokio::sync::mpsc. Cara kerjanya mirip channel standar di halaman Channel, bedanya kamu menulis rx.recv().await, jadi menunggu pesan tidak memblokir thread.

Sebagian besar ekosistem async Rust dibangun di atas tokio. Ada dua crate yang akan kamu temui di mana-mana:

  • reqwest (bahasa Inggris): klien HTTP untuk memanggil web API. Kamu menulis reqwest::get(url).await untuk mengambil sebuah halaman.
  • axum (bahasa Inggris): web framework untuk membangun server dan API, dibuat oleh tim tokio.

Keduanya cocok dipasangkan dengan serde_json untuk membaca dan menulis JSON.

Tulis tiga fungsi async: make_toast (tidur 30 ms), fry_eggs (tidur 20 ms) dan brew_coffee (tidur 10 ms). Masing-masing mengembalikan String berisi nama makanannya. Jalankan ketiganya bersamaan lalu cetak apa saja yang sudah siap. Petunjuk: tokio::join! menerima beberapa future dan mengembalikan tuple berisi hasilnya.

Lihat solusi
use std::time::Duration;
use tokio::time::sleep;
async fn make_toast() -> String {
sleep(Duration::from_millis(30)).await;
String::from("roti panggang")
}
async fn fry_eggs() -> String {
sleep(Duration::from_millis(20)).await;
String::from("telur goreng")
}
async fn brew_coffee() -> String {
sleep(Duration::from_millis(10)).await;
String::from("kopi")
}
#[tokio::main]
async fn main() {
let (toast, eggs, coffee) = tokio::join!(make_toast(), fry_eggs(), brew_coffee());
println!("Sarapan: {}, {} dan {}", toast, eggs, coffee);
}

Output:

Sarapan: roti panggang, telur goreng dan kopi
▶ Coba sendiri

Kopinya siap paling dulu, tapi tuple-nya selalu kembali sesuai urutan future yang kamu tulis. Seluruh sarapan hanya butuh sekitar 30 ms (hidangan paling lama), bukan 60 ms.

Tulis async fn square_later(n: u64) -> u64 yang tidur selama 10 * n milidetik lalu mengembalikan n * n. Di main, buat satu task untuk setiap angka dari 1 sampai 4, lalu tunggu semuanya dan cetak totalnya. Petunjuk: masukkan setiap handle tokio::spawn(...) ke sebuah Vec, lalu lakukan perulangan atas handle-handle itu dan jumlahkan handle.await.unwrap().

Lihat solusi
use std::time::Duration;
use tokio::time::sleep;
async fn square_later(n: u64) -> u64 {
sleep(Duration::from_millis(10 * n)).await;
n * n
}
#[tokio::main]
async fn main() {
let mut handles = Vec::new();
for n in 1..=4 {
handles.push(tokio::spawn(square_later(n)));
}
let mut total = 0;
for handle in handles {
total += handle.await.unwrap();
}
println!("total = {total}");
}

Output:

total = 30
▶ Coba sendiri

Keempat task langsung dimulai dan tidur bersamaan, jadi ini hanya butuh sekitar 40 ms, bukan 10 + 20 + 30 + 40 = 100 ms.

  • Async memungkinkan satu thread menangani banyak task dengan berpindah-pindah di antara mereka selagi task-task itu menunggu.
  • Rust butuh runtime untuk menjalankan kode async. Tambahkan tokio dengan cargo add tokio --features full dan tandai main dengan #[tokio::main].
  • async fn mengembalikan sebuah future. Future itu lazy: tidak ada yang berjalan sampai kamu memanggil .await.
  • Pakai tokio::join! untuk menunggu beberapa future sekaligus, dan tokio::spawn untuk memulai task dan mendapatkan JoinHandle.
  • Jangan pernah memakai std::thread::sleep di kode async; pakai tokio::time::sleep(...).await.
  • Async itu untuk menunggu, bukan untuk pekerjaan CPU yang berat. Untuk itu, pakai thread.

Kuis singkat

1. Apa yang terjadi kalau kamu memanggil async fn tanpa .await?

2. Apa yang dilakukan #[tokio::main]?

3. Dua future tidur selama 30 ms dan 20 ms. Kira-kira berapa lama tokio::join! atas keduanya?

4. Kenapa std::thread::sleep bermasalah di dalam kode async?

5. Pekerjaan mana yang paling cocok untuk async?

Skor: 0 / 5