Topik ini membahas model eksekusi di balik sintaks async/await, mulai dari trait Future yang menjadi fondasinya, sifat lazy yang sering mengejutkan pemula, peran Waker dalam membangunkan executor, sampai bentuk mesin state yang dibangkitkan kompiler untuk setiap async fn. Model eksekusi ini berbeda dari thread OS yang dibahas pada modul konkurensi: thread adalah unit eksekusi yang dijadwalkan sistem operasi, sedangkan async adalah unit penantian yang dijadwalkan executor di ruang pengguna. Seluruh contoh pada topik ini memakai pustaka standar saja sehingga mekanisme dasarnya terlihat tanpa efek samping runtime apa pun. Setelah membaca topik ini, perilaku await dapat dijelaskan dengan mekanisme poll, dan kesalahan umum seperti memanggil async fn tanpa menunggu hasilnya dapat dihindari.
Future sebagai nilai yang belum selesai#
Future adalah nilai yang mewakili hasil yang mungkin belum tersedia pada saat ini. Konsep ini berbeda dari promise pada bahasa lain karena future di Rust tidak mulai bekerja ketika dibuat. Sebuah future hanya menyimpan mesin state (state machine) yang dapat dimajukan lewat satu metode, yaitu poll. Definisi intinya pada pustaka standar berbentuk seperti berikut.
// tidak-dijalankan
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}Metode poll menerima dua hal, yaitu Pin yang memastikan future tidak berpindah tempat di memori, serta Context yang membawa Waker. Nilai kembalian poll memuat salah satu dari dua kemungkinan. Poll::Ready berarti hasil sudah tersedia dan future selesai, sedangkan Poll::Pending berarti belum selesai sehingga executor perlu mempoll ulang pada waktu yang akan datang.
Seluruh perilaku async di Rust bertumpu pada kontrak ringkas ini, termasuk sifat eksekusi yang lazy (malas). Memanggil async fn tidak menjalankan tubuh fungsinya, melainkan hanya membangun future. Tubuh fungsi itu baru berjalan ketika future dipoll untuk pertama kalinya. Future yang dibuat lalu dijatuhkan tanpa pernah dipoll tidak menghasilkan efek apa pun, dan kompiler tidak menandai keadaan itu sebagai error.
Executor mini dengan park dan unpark#
Executor adalah komponen yang bertugas mempoll future berulang kali sampai selesai. Runtime seperti Tokio menyediakan executor lengkap dengan penjadwalan lintas thread, tetapi inti mekanisme executor dapat ditulis dalam satu berkas pendek memakai pustaka standar. Program berikut membangun block_on, yaitu executor untuk satu future, di atas dua mekanisme thread: park menidurkan thread sampai token unpark tersedia, dan unpark memasang token itu sehingga thread yang tertidur dibangunkan. Waker untuk executor ini dibangun dari RawWaker dengan tabel fungsi yang hanya memanggil unpark.
Fungsi block_on bekerja sebagai perulangan poll sederhana. Fungsi itu menyemat future ke heap dengan Box::pin agar alamatnya stabil, membungkus waker ke dalam Context, lalu mempoll di dalam perulangan. Poll::Ready menghentikan perulangan dan mengembalikan nilai future, sedangkan Poll::Pending membuat thread tertidur lewat park sampai waker membangunkannya. Future contoh bernama TugasTigaPoll menolak siap sebelum dipoll tiga kali dan memanggil wake_by_ref pada setiap jawaban Pending, sehingga urutan baris keluarannya dapat diikuti langkah demi langkah.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll, RawWaker, RawWakerVTable, Waker};
// Waker yang membangunkan thread pemanggilnya lewat mekanisme park/unpark.
fn waker_park() -> Waker {
fn bangunkan(_data: *const ()) {
std::thread::current().unpark();
}
fn bangunkan_ref(_data: *const ()) {
std::thread::current().unpark();
}
fn klon(data: *const ()) -> RawWaker {
RawWaker::new(data, &VTABEL)
}
fn lepas(_data: *const ()) {}
static VTABEL: RawWakerVTable =
RawWakerVTable::new(klon, bangunkan, bangunkan_ref, lepas);
// from_raw aman di sini karena vtable tidak membaca pointer data.
unsafe { Waker::from_raw(RawWaker::new(std::ptr::null(), &VTABEL)) }
}
// Executor mini: mempoll future sampai selesai, lalu tidur dengan park
// selama future masih mengembalikan Pending.
fn block_on<F: Future>(futur: F) -> F::Output {
let mut futur = Box::pin(futur);
let waker = waker_park();
let mut cx = Context::from_waker(&waker);
loop {
match futur.as_mut().poll(&mut cx) {
Poll::Ready(nilai) => return nilai,
Poll::Pending => std::thread::park(),
}
}
}
// Future yang menolak siap sebelum dipoll tiga kali.
struct TugasTigaPoll {
sisa: u32,
}
impl Future for TugasTigaPoll {
type Output = u32;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let tugas = self.get_mut();
if tugas.sisa == 0 {
println!("poll: siap, hasil 7");
Poll::Ready(7)
} else {
tugas.sisa -= 1;
println!("poll: masih menunggu, sisa {}", tugas.sisa);
// meminta executor untuk mempoll ulang segera
cx.waker().wake_by_ref();
Poll::Pending
}
}
}
fn main() {
println!("membuat future");
let tugas = TugasTigaPoll { sisa: 3 };
println!("future dibuat, belum ada satu poll pun");
let hasil = block_on(tugas);
println!("selesai, block_on mengembalikan {}", hasil);
}membuat future
future dibuat, belum ada satu poll pun
poll: masih menunggu, sisa 2
poll: masih menunggu, sisa 1
poll: masih menunggu, sisa 0
poll: siap, hasil 7
selesai, block_on mengembalikan 7Dua baris pertama keluaran membuktikan sifat lazy: di antara pembuatan future dan pemanggilan block_on, tidak ada satu baris poll pun karena tidak ada yang menjalankannya. Setiap jawaban Pending diikuti poll berikutnya tanpa jeda. Wake dipanggil di dalam poll itu sendiri, sehingga token unpark sudah terpasang sebelum park dieksekusi dan park kembali seketika. Pada runtime sungguhan, poll ulang tidak selalu terjadi seketika: wake dipanggil oleh kejadian eksternal seperti data soket yang siap dibaca atau timer yang telah habis, dan di sela itu thread menangani future lain. Alur lengkapnya dirangkum oleh diagram berikut.
Mesin state di balik await#
Setiap async fn diubah oleh kompiler menjadi tipe anonim yang mengimplementasikan Future. Tipe itu pada dasarnya berupa enum dengan satu variant untuk setiap titik jeda, dan setiap variant menyimpan variabel lokal yang masih hidup melewati await bersangkutan. Ketika poll dipanggil, eksekusi berlanjut dari variant yang tersimpan, berjalan sampai await berikutnya, lalu menyimpan state yang baru. Bentuk kira-kiranya dapat ditulis manual seperti berikut.
// tidak-dijalankan
enum MesinAlur {
Mulai,
MenungguPertama { penyangga: [u8; 64] },
MenungguKedua { penyangga: [u8; 64], penyangga_lain: [u8; 128] },
Selesai,
}Ukuran mesin state dapat diukur langsung karena future adalah nilai biasa, bukan objek tersembunyi di heap. Program berikut memakai size_of_val untuk membandingkan dua async fn: yang pertama hanya menyimpan satu u32 melewati await, sedangkan yang kedua menyimpan dua array berukuran 64 dan 128 byte. Program yang sama membuktikan sekali lagi bahwa tubuh async fn tidak berjalan sebelum future-nya dipoll. Kali ini executor tidak dibutuhkan karena future dari sapa cukup dijatuhkan tanpa pernah dipoll.
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
// Tubuh async fn hanya berjalan ketika future-nya di-poll.
async fn sapa() {
println!("tubuh sapa() baru berjalan ketika di-poll");
}
// Future kecil yang selesai seketika pada poll pertama.
struct SelaluSiap;
impl Future for SelaluSiap {
type Output = u32;
fn poll(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<Self::Output> {
Poll::Ready(9)
}
}
// Hanya satu u32 yang hidup melewati titik await.
async fn state_ringkas() -> u32 {
let a = 10u32;
let b = SelaluSiap.await;
a + b
}
// Dua array hidup melewati titik await sehingga tersimpan di mesin state.
async fn state_lebar() -> u32 {
let penyangga = [0u8; 64];
let a = SelaluSiap.await;
let penyangga_lain = [0u8; 128];
let b = SelaluSiap.await;
penyangga[0] as u32 + a + penyangga_lain[1] as u32 + b
}
fn main() {
println!("sebelum membuat future");
let tugas = sapa();
println!("future dibuat, tubuhnya belum berjalan");
drop(tugas);
println!("future dijatuhkan tanpa pernah di-poll");
let ringkas = state_ringkas();
let lebar = state_lebar();
println!(
"ukuran state_ringkas: {} byte",
std::mem::size_of_val(&ringkas)
);
println!(
"ukuran state_lebar: {} byte",
std::mem::size_of_val(&lebar)
);
}sebelum membuat future
future dibuat, tubuhnya belum berjalan
future dijatuhkan tanpa pernah di-poll
ukuran state_ringkas: 8 byte
ukuran state_lebar: 200 byteBaris ketiga menegaskan bahwa teks di tubuh sapa tidak pernah tercetak. Perbandingan ukuran 8 dan 200 byte menunjukkan bahwa kompiler benar-benar menaruh variabel yang hidup melewati await ke dalam enum mesin state: dua array dengan total 192 byte ditambah penanda variant dan penyesuaian perataan menghasilkan 200 byte. Sebaliknya, variabel yang tidak hidup melewati await hanya menempati stack sesaat selama poll berjalan sehingga tidak menambah ukuran future. Implikasinya praktis: memindahkan penyangga besar keluar dari async fn, atau memindahkannya ke heap dengan Box, memperkecil future yang disimpan runtime untuk setiap pekerjaannya.
Kesalahan umum#
Kesalahan pertama adalah memanggil async fn lalu mengabaikan future yang dikembalikan, misalnya dengan menaruhnya pada variabel yang tidak pernah dipakai. Penyebabnya adalah kebiasaan yang dibawa dari bahasa dengan promise yang berjalan otomatis begitu dibuat. Perbaikannya lugas: akhiri pemanggilan dengan .await pada titik ketika hasil dibutuhkan, atau kembalikan future itu agar pemanggillah yang menunggunya.
Kesalahan kedua adalah mengira bahwa dua .await berturut-turut berjalan tumpang tindih. Penantian berbentuk a().await lalu b().await bersifat berurutan karena b() bahkan belum dipanggil sebelum future dari a selesai. Untuk penantian yang tumpang tindih, gunakan alat penggabung seperti join! atau jalankan tiap pekerjaan lewat spawn.
Kesalahan ketiga adalah menaruh kerja yang memblokir thread di antara await, misalnya perhitungan berat atau penantian std::thread::sleep yang panjang. Titik jeda async menyerahkan thread kepada future lain, tetapi kode di luar jeda tetap memonopoli thread tersebut. Pindahkan kerja berat ke mekanisme khusus runtime atau pecah menjadi langkah-langkah kecil agar thread dapat berpindah pekerjaan.
Ringkasan#
- Future adalah nilai yang menyimpan mesin state, dan kemajuannya hanya terjadi ketika executor memanggil poll dengan hasil Ready atau Pending.
- Eksekusi bersifat lazy: memanggil async fn hanya membangun future, dan tubuhnya tidak berjalan sama sekali sebelum poll pertama.
- Waker adalah jalur kabar dari future ke executor: kejadian I/O atau timer memanggil wake sehingga executor mempoll ulang.
- Kompiler menerjemahkan async fn menjadi enum mesin state yang menyimpan variabel yang hidup melewati await, dan ukurannya dapat diukur seperti nilai biasa.
Referensi#
- std::future::Future — Rust standard library — Definisi trait Future beserta kontrak poll dan Waker.
- std::task::Waker — Rust standard library — Mekanisme wake yang dipakai executor untuk penjadwalan ulang.
- Asynchronous Programming in Rust — Rust project — Buku resmi yang membahas executor, future, dan pinning secara mendalam.
- std::thread::park — Rust standard library — Dasar tidur dan bangun thread yang dipakai executor mini topik ini.