Ngelihat Lebih Dekat pada Traits buat Async
Di sepanjang bab ini, kita sudah memakai trait Future, Stream, dan
StreamExt dengan berbagai cara. Sejauh ini, kita memang sengaja menghindari
membahas terlalu dalam soal gimana detail cara kerja mereka atau gimana mereka
saling berhubungan, yang mana sebenarnya sah-sah saja buat pekerjaan Rust kita
sehari-hari. Tapi kadang-kadang, kita bakal menjumpai situasi di mana kita butuh
memahami lebih banyak detail soal trait-trait ini, bersamaan dengan tipe Pin
dan trait Unpin. Di bagian ini, kita bakal menggalinya secukupnya saja buat
membantu kita di skenario-skenario tersebut, sembari tetap membiarkan pembahasan
yang bener-bener mendalam buat dokumentasi lainnya.
Trait Future
Mari kita mulai dengan melihat lebih dekat gimana cara kerja trait Future.
Beginilah cara Rust mendefinisikannya:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
Definisi trait tersebut menyertakan sejumlah tipe baru dan juga beberapa sintaks yang belum pernah kita lihat sebelumnya, jadi mari kita bedah definisinya satu per satu.
Pertama, associated type Output milik Future menyatakan hasil akhir yang
dihasilkan sama future tersebut. Ini serupa dengan associated type Item
pada trait Iterator. Kedua, Future punya method poll, yang menerima sebuah
referensi Pin spesial buat parameter self-nya dan sebuah referensi mutable
ke tipe Context, serta mengembalikan sebuah Poll<Self::Output>. Kita bakal
ngomongin soal Pin dan Context sebentar lagi. Buat sekarang, mari fokus sama
apa yang dikembalikan sama method-nya, yaitu tipe Poll:
#![allow(unused)]
fn main() {
pub enum Poll<T> {
Ready(T),
Pending,
}
}
Tipe Poll ini mirip sama Option. Ia punya satu varian yang punya nilai,
Ready(T), dan satu lagi yang nggak punya, Pending. Tapi makna Poll itu
jauh beda lho sama Option! Varian Pending mengindikasikan kalau future-nya
masih punya pekerjaan buat dilakukan, jadi si pemanggil perlu mengeceknya lagi
nanti. Varian Ready mengindikasikan kalau Future-nya sudah selesai
mengerjakan tugasnya dan nilai T sudah tersedia.
Catatan: Jarang sekali ada kebutuhan buat memanggil
pollsecara langsung, tapi kalau kita memang butuh melakukannya, ingatlah kalau pada kebanyakan futures, si pemanggil tidak seharusnya memanggilpolllagi setelah future-nya mengembalikanReady. Banyak futures yang bakal panic kalau di-poll lagi setelah mereka jadi siap. Futures yang aman buat di-poll lagi bakal menyatakannya secara eksplisit di dalam dokumentasinya. Perilaku ini mirip sama gimanaIterator::nextbekerja.
Pas kita melihat kode yang memakai await, Rust sebenarnya mengompilasi kode
tersebut di balik layar menjadi kode yang memanggil poll. Kalau kita melihat
balik ke Listing 17-4, di mana kita mencetak judul halaman buat satu URL begitu
dia selesai, Rust mengompilasinya jadi sesuatu yang kira-kira (biarpun nggak
persis sama) kayak gini:
match page_title(url).poll() {
Ready(page_title) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
},
Pending => {
// Terus apa yang ditaruh di sini?
}
}
Apa yang harus kita lakukan pas future-nya masih Pending? Kita butuh suatu
cara buat mencoba lagi, lagi, dan lagi, sampai future-nya akhirnya siap.
Dengan kata lain, kita butuh sebuah loop:
let mut page_title_fut = page_title(url);
loop {
match page_title_fut.poll() {
Ready(value) => match page_title {
Some(title) => println!("The title for {url} was {title}"),
None => println!("{url} had no title"),
}
Pending => {
// lanjut
}
}
}
Tapi kalau seandainya Rust benar-benar mengompilasi kode yang persis kayak gitu,
tiap await jadinya bakal memblokir—persis kebalikan dari apa yang mau kita
capai! Sebaliknya, Rust memastikan kalau perulangannya bisa menyerahkan kontrol
ke sesuatu yang bisa me-pause pekerjaan pada future ini buat mengerjakan
futures lainnya dan baru kemudian mengecek yang satu ini lagi nanti. Kayak
yang sudah kita lihat, “sesuatu” itu adalah sebuah async runtime, dan
pekerjaan penjadwalan dan koordinasi ini adalah salah satu tugas utamanya.
Di bagian “Mengirim Data di Antara Dua Task Memakai Message Passing”, kita mendeskripsikan proses menunggu di rx.recv. Pemanggilan
recv mengembalikan sebuah future, dan me-await future tersebut bakal me-
poll-nya. Kita sudah mencatat kalau sebuah runtime bakal me-pause
future-nya sampai dia siap dengan entah Some(message) atau None pas
channel-nya tutup. Dengan pemahaman kita yang lebih dalam soal trait
Future, dan secara spesifik Future::poll, kita bisa melihat gimana cara
kerjanya. Runtime tahu kalau future-nya belum siap pas dia mengembalikan
Poll::Pending. Kebalikannya, runtime tahu kalau future-nya sudah siap
dan melanjutkannya pas poll mengembalikan Poll::Ready(Some(message)) atau
Poll::Ready(None).
Detail persis soal gimana cara runtime melakukan hal tersebut ada di luar cakupan buku ini, tapi kuncinya adalah melihat mekanisme dasar dari futures: sebuah runtime me-poll tiap future yang jadi tanggung jawabnya, lalu menidurkan kembali future tersebut pas dia belum siap.
Tipe Pin dan Trait Unpin
Dulu di Listing 17-13, kita memakai macro trpl::join! buat menunggu tiga
buah futures. Tapi, sudah jadi hal yang umum kalau kita punya sebuah koleksi
seperti vector yang berisi sejumlah futures yang jumlahnya baru bakal
diketahui pas runtime. Mari kita ubah Listing 17-13 jadi kode di Listing 17-
23 yang menaruh ketiga futures tersebut ke dalam sebuah vector lalu memanggil
fungsi trpl::join_all sebagai gantinya, yang mana kode ini belum bisa di-
compile.
extern crate trpl; // required for mdbook test
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx1 = tx.clone();
let tx1_fut = async move {
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let rx_fut = async {
while let Some(value) = rx.recv().await {
println!("received '{value}'");
}
};
let tx_fut = async move {
// --snip--
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
};
let futures: Vec<Box<dyn Future<Output = ()>>> =
vec![Box::new(tx1_fut), Box::new(rx_fut), Box::new(tx_fut)];
trpl::join_all(futures).await;
});
}
Kita menaruh tiap future di dalam sebuah Box buat menjadikannya sebagai
trait objects, persis kayak yang kita lakukan di bagian “Mengembalikan Error
dari run” di Bab 12. (Kita bakal membahas trait objects secara detail di
Bab 18.) Memakai trait objects membiarkan kita memperlakukan tiap futures
anonim yang dihasilkan sama tipe-tipe ini sebagai tipe yang sama, karena mereka
semua mengimplementasikan trait Future.
Ini mungkin mengejutkan. Lagian, tidak ada satu pun dari blok asinkron tersebut
yang mengembalikan apa-apa, jadi masing-masing memproduksi sebuah
Future<Output = ()>. Tapi ingat kalau Future itu adalah sebuah trait, dan
compiler membikin sebuah enum yang unik buat tiap blok asinkron, biarpun tipe
output mereka identik. Sama halnya kayak kita tidak bisa menaruh dua buah
struct tulisan tangan yang berbeda ke dalam sebuah Vec, kita juga tidak bisa
mencampur aduk enum-enum buatan compiler tersebut.
Terus kita mengoper koleksi futures tersebut ke fungsi trpl::join_all lalu
me-await hasilnya. Tapi, kode ini tidak bisa di-compile; ini dia bagian yang
relevan dari pesan errornya.
error[E0277]: `dyn Future<Output = ()>` cannot be unpinned
--> src/main.rs:48:33
|
48 | trpl::join_all(futures).await;
| ^^^^^ the trait `Unpin` is not implemented for `dyn Future<Output = ()>`
|
= note: consider using the `pin!` macro
consider using `Box::pin` if you need to access the pinned value outside of the current scope
= note: required for `Box<dyn Future<Output = ()>>` to implement `Future`
note: required by a bound in `futures_util::future::join_all::JoinAll`
--> file:///home/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/futures-util-0.3.30/src/future/join_all.rs:29:8
|
27 | pub struct JoinAll<F>
| ------- required by a bound in this struct
28 | where
29 | F: Future,
| ^^^^^^ required by this bound in `JoinAll`
Catatan di pesan error ini ngasih tahu kita kalau kita seharusnya memakai macro
pin! buat me-pin (mematok) nilai-nilainya, yang artinya menaruh mereka di
dalam tipe Pin yang menjamin kalau nilai-nilainya tidak bakal dipindah-pindah
di dalam memori. Pesan error-nya bilang kalau proses pinning itu diwajibkan
karena dyn Future<Output = ()> perlu mengimplementasikan trait Unpin dan
untuk sekarang dia belum melakukannya.
Fungsi trpl::join_all mengembalikan sebuah struct bernama JoinAll. Struct
tersebut sifatnya generik terhadap tipe F, yang dibatasi (constrained) buat
mengimplementasikan trait Future. Menunggu sebuah future secara langsung
dengan await bakal me-pin future-nya secara implisit. Itulah alasan
kenapa kita tidak perlu memakai pin! di semua tempat di mana kita mau me-
await futures.
Tapi, kita ini sedang tidak menunggu sebuah future secara langsung di sini.
Sebaliknya, kita sedang membangun sebuah future baru, JoinAll, dengan cara
meneruskan sekumpulan futures ke fungsi join_all. Signature buat
join_all mewajibkan tipe-tipe dari item di dalam koleksinya buat semuanya
mengimplementasikan trait Future, dan Box<T> mengimplementasikan Future
cuma kalau T yang ia bungkus itu adalah sebuah future yang
mengimplementasikan trait Unpin.
Duh, sangat banyak yang harus diserap ya! Biar benar-benar paham, mari kita gali
sedikit lebih jauh soal gimana cara trait Future sebenarnya bekerja, terutama
seputar urusan pinning. Mari kita lihat lagi definisi dari trait Future:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
pub trait Future {
type Output;
// Method yang wajib ada
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}
}
Parameter cx dan tipe Context-nya adalah kunci gimana sebuah runtime bisa
benar-benar tahu kapan harus mengecek sembarang future yang diberikan sambil
tetap bersifat malas. Sekali lagi, detail gimana itu bekerja ada di luar
cakupan bab ini, dan kita umumnya cuma perlu memikirkan hal ini pas lagi
menulis implementasi Future kustom. Kita bakal fokus saja ke tipe buat
self, karena ini adalah pertama kalinya kita melihat sebuah method di mana
self punya anotasi tipe. Sebuah anotasi tipe buat self bekerja kayak
anotasi tipe buat parameter fungsi lainnya tapi dengan dua perbedaan kunci:
- Ia memberi tahu Rust tipe
selfapa yang harus dimiliki supaya method-nya bisa dipanggil. - Ia nggak boleh sembarang tipe. Ia dibatasi cuma buat tipe tempat method-nya
diimplementasikan, sebuah referensi atau smart pointer ke tipe tersebut,
atau sebuah
Pinyang membungkus referensi ke tipe tersebut.
Kita bakal melihat lebih banyak lagi soal sintaks ini di Bab 18. Buat sekarang, cukup tahu saja kalau kita mau me-poll sebuah
future buat mengecek apakah dia itu Pending atau Ready(Output), kita
butuh referensi mutable yang dibungkus Pin ke tipe tersebut.
Pin adalah sebuah pembungkus (wrapper) buat tipe-tipe yang mirip pointer kayak
&, &mut, Box, dan Rc. (Secara teknis, Pin bekerja dengan tipe-tipe
yang mengimplementasikan trait Deref atau DerefMut, tapi ini secara efektif
ekuivalen dengan bekerja cuma bareng referensi dan smart pointers.) Pin
bukanlah sebuah pointer itu sendiri dan ia tidak punya perilaku miliknya
sendiri kayak Rc dan Arc yang punya fitur reference counting; ia murni
hanyalah alat yang bisa dipakai compiler buat menegakkan batasan (constraints)
pada penggunaan pointer.
Mengingat kembali kalau await itu diimplementasikan lewat pemanggilan-
pemanggilan ke poll mulai bisa menjelaskan pesan error yang kita lihat tadi,
tapi kan tadi itu dalam konteks Unpin, bukan Pin. Jadi apa sebenarnya
hubungan Pin dengan Unpin, dan kenapa Future butuh self buat berada di
dalam tipe Pin supaya bisa memanggil poll?
Ingat dari bagian awal bab ini kalau serangkaian titik await di dalam sebuah future bakal dikompilasi jadi sebuah state machine, dan compiler mastiin kalau state machine tersebut mengikuti semua aturan normal Rust seputar keamanan, termasuk borrowing dan ownership. Biar itu bisa jalan, Rust melihat data apa saja yang dibutuhkan di antara satu titik await dengan titik await berikutnya atau sampai akhir dari blok asinkronnya. Terus dia membikin varian yang korespondensi di dalam state machine hasil kompilasinya. Tiap varian dapet akses yang dibutuhkannya ke data yang bakal dipakai di bagian kode sumber tersebut, entah dengan mengambil kepemilikan dari data tersebut atau dengan mendapatkan referensi mutable atau immutable kepadanya.
Sejauh ini aman: kalau kita bikin kesalahan soal ownership atau referensi di
sebuah blok asinkron tertentu, si borrow checker bakal kasih tahu kita. Tapi
pas kita mau memindah-mindahkan future yang berkaitan sama blok tersebut—kayak
memindahkannya ke dalam sebuah Vec buat dioper ke join_all—urusannya jadi
makin rumit.
Pas kita memindahkan sebuah future—entah itu dengan memasukkannya ke struktur
data buat dipakai sebagai iterator bersama join_all atau dengan
mengembalikannya dari sebuah fungsi—itu sebenarnya bermakna memindahkan state
machine yang Rust bikin buat kita. Dan beda sama mayoritas tipe lain di Rust,
futures yang Rust bikin buat blok asinkron bisa berakhir punya referensi ke
dirinya sendiri di dalam field dari varian yang manapun, kayak yang ditunjukkan
di ilustrasi yang disederhanakan di Gambar 17-4.
Tapi secara bawaan, objek apa saja yang punya referensi ke dirinya sendiri itu sifatnya tidak aman buat dipindahkan, karena referensi itu selalu menunjuk ke alamat memori asli dari apa pun yang dirujuknya (lihat Gambar 17-5). Kalau kita memindahkan struktur datanya itu sendiri, referensi internal tadi bakal ditinggalkan dalam posisi menunjuk ke lokasi yang lama. Padahal, lokasi memori tersebut sekarang sudah tidak valid. Di satu sisi, nilainya tidak bakal di- update pas kita melakukan perubahan ke struktur datanya. Di sisi lain—yang lebih penting—komputernya sekarang bebas buat memakai ulang memori tersebut buat keperluan lain! Kita bisa berakhir membaca data yang sama sekali tidak ada hubungannya nanti.
Secara teori, compiler Rust bisa saja mencoba buat meng-update tiap referensi ke suatu objek setiap kali objek tersebut dipindahkan, tapi itu bisa menambah banyak beban performa, apalagi kalau ada jaring-jaring referensi utuh yang perlu di-update. Kalau kita sebaliknya bisa memastikan struktur data yang dimaksud itu tidak pindah-pindah di memori, kita tidak perlu repot-repot meng- update referensi apa pun. Nah, itulah gunanya borrow checker milik Rust: di dalam kode yang aman, ia mencegah kita dari memindahkan item apa saja yang punya referensi aktif yang menunjuk kepadanya.
Pin dibangun di atas hal tersebut buat memberikan jaminan persis yang kita
butuhkan. Pas kita me-pin sebuah nilai dengan membungkus sebuah pointer ke
nilai tersebut di dalam Pin, dia sudah tidak bisa pindah lagi. Jadi, kalau
kita punya Pin<Box<SuatuTipe>>, kita sebenarnya sedang me-pin nilai
SuatuTipe tersebut, bukan pointer Box-nya. Gambar 17-6 mengilustrasikan
proses ini.
Kenyataannya, pointer Box-nya tetap bisa pindah-pindah dengan bebas. Ingat:
kita peduli buat memastikan kalau data yang ujung-ujungnya sedang direferensikan
itu tetap diam di tempat. Kalau sebuah pointer pindah-pindah, tapi data yang
ditunjuknya tetap ada di tempat yang sama, kayak di Gambar 17-7, nggak bakal
ada masalah potensial. (Sebagai latihan mandiri, coba lihat dokumentasi buat
tipe-tipe tersebut sekaligus modul std::pin dan coba cari tahu gimana cara
kita melakukan ini pakai Pin yang membungkus sebuah Box.) Kuncinya adalah
tipe self-referential-nya itu sendiri nggak bisa pindah, karena ia tetap di-
pin.
Meskipun begitu, mayoritas tipe itu benar-benar aman buat dipindah-pindahkan,
biarpun mereka kebetulan ada di balik sebuah pointer Pin. Kita cuma perlu
mikirin soal pinning pas item-itemnya punya referensi internal. Nilai-nilai
primitif kayak angka dan Boolean itu aman karena mereka jelas nggak punya
referensi internal apa-apa. Begitu juga sama mayoritas tipe yang biasa kita
pakai di Rust. Kita bisa memindah-mindahkan sebuah Vec, misalnya, tanpa perlu
khawatir. Mengingat apa yang sudah kita lihat sejauh ini, kalau kita punya
Pin<Vec<String>>, kita bakal dipaksa melakukan segalanya lewat API milik Pin
yang aman tapi membatasi, padahal sebuah Vec<String> itu selalu aman buat
dipindahkan kalau tidak ada referensi lain kepadanya. Kita butuh sebuah cara
buat memberi tahu compiler kalau tidak apa-apa buat memindah-mindahkan item di
kasus-kasus kayak gini—dan di situlah Unpin beraksi.
Unpin adalah sebuah marker trait, mirip sama trait Send dan Sync yang
kita lihat di Bab 16, dan oleh karenanya tidak punya fungsionalitas miliknya
sendiri. Marker traits eksis cuma buat memberi tahu compiler kalau tipe yang
mengimplementasikan trait tersebut aman buat dipakai di suatu konteks tertentu.
Unpin menginformasikan ke compiler kalau suatu tipe tertentu tidak perlu
menjunjung tinggi jaminan apa pun soal apakah nilai yang dimaksud bisa
dipindahkan dengan aman atau tidak.
Sama kayak Send dan Sync, compiler mengimplementasikan Unpin secara
otomatis buat semua tipe di mana dia bisa membuktikan keamanannya. Kasus
spesialnya, sekali lagi mirip kayak Send dan Sync, adalah pas Unpin
tidak diimplementasikan buat suatu tipe. Notasi buat hal ini adalah
impl !Unpin for SuatuTipe, di mana SuatuTipe adalah nama dari tipe yang
memang perlu menjunjung tinggi jaminan-jaminan tersebut supaya aman kapan pun
sebuah pointer ke tipe tersebut dipakai di dalam sebuah Pin.
Dengan kata lain, ada dua hal yang harus diingat soal hubungan antara Pin dan
Unpin. Pertama, Unpin adalah kasus yang “normal”, dan !Unpin adalah kasus
yang spesial. Kedua, apakah suatu tipe mengimplementasikan Unpin atau
!Unpin itu hanya berpengaruh pas kita lagi memakai pointer yang di-pin ke
tipe tersebut kayak Pin<&mut SuatuTipe>.
Buat menjadikannya konkret, coba pikirkan soal sebuah String: ia punya sebuah
panjang (length) dan karakter-karakter Unicode yang menyusunnya. Kita bisa
membungkus sebuah String di dalam Pin, kayak yang terlihat di Gambar 17-8.
Tapi, String secara otomatis mengimplementasikan Unpin, sama halnya kayak
mayoritas tipe lain di Rust.
Hasilnya, kita bisa melakukan hal-hal yang tadinya ilegal kalau seandainya
String mengimplementasikan !Unpin, kayak misalnya mengganti satu string
dengan yang lain di lokasi memori yang sama persis kayak di Gambar 17-9. Ini
tidak melanggar kontrak dari Pin, karena String nggak punya referensi
internal yang bikin dia jadi nggak aman buat dipindah-pindahkan. Itulah
persisnya kenapa ia mengimplementasikan Unpin bukannya !Unpin.
Nah sekarang kita sudah tahu cukup banyak buat memahami error-error yang
dilaporkan buat pemanggilan join_all tadi balik di Listing 17-23. Kita
awalnya mencoba memindahkan futures hasil produksi blok asinkron ke dalam
sebuah Vec<Box<dyn Future<Output = ()>>>, tapi kayak yang sudah kita lihat,
futures tersebut mungkin saja punya referensi internal, jadi mereka tidak
secara otomatis mengimplementasikan Unpin. Begitu kita me-pin mereka, kita
bisa meneruskan tipe Pin hasilnya ke dalam Vec, dengan rasa percaya diri
kalau data yang mendasari futures tersebut tidak bakal dipindahkan.
Listing 17-24 menunjukkan cara membetulkan kodenya dengan memanggil macro
pin! di tiap tempat di mana ketiga futures tadi didefinisikan dan
menyesuaikan tipe dari trait object-nya.
extern crate trpl; // required for mdbook test
use std::pin::{Pin, pin};
// --snip--
use std::time::Duration;
fn main() {
trpl::block_on(async {
let (tx, mut rx) = trpl::channel();
let tx1 = tx.clone();
let tx1_fut = pin!(async move {
// --snip--
let vals = vec![
String::from("hi"),
String::from("from"),
String::from("the"),
String::from("future"),
];
for val in vals {
tx1.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let rx_fut = pin!(async {
// --snip--
while let Some(value) = rx.recv().await {
println!("received '{value}'");
}
});
let tx_fut = pin!(async move {
// --snip--
let vals = vec![
String::from("more"),
String::from("messages"),
String::from("for"),
String::from("you"),
];
for val in vals {
tx.send(val).unwrap();
trpl::sleep(Duration::from_secs(1)).await;
}
});
let futures: Vec<Pin<&mut dyn Future<Output = ()>>> =
vec![tx1_fut, rx_fut, tx_fut];
trpl::join_all(futures).await;
});
}
Contoh ini sekarang sudah bisa di-compile dan dijalankan, dan kita bisa menambah atau menghapus futures dari vector-nya pas runtime lalu menggabungkan mereka semua.
Pin dan Unpin itu paling banyak kepake pas lagi membangun lower-level
libraries, atau pas kita lagi membangun sebuah runtime itu sendiri, bukannya
buat kode Rust sehari-hari. Tapi pas kita melihat trait-trait ini muncul di
dalam pesan error, setidaknya sekarang kita punya gambaran yang lebih oke soal
gimana cara membetulkan kode kita!
Catatan: Kombinasi antara
PindanUnpinini memungkinkan implementasi aman dari keseluruhan kelas tipe-tipe kompleks di Rust yang tadinya bakal terbukti menantang gara-gara mereka bersifat self-referential. Tipe-tipe yang mewajibkanPinpaling sering muncul di Rust asinkron saat ini, tapi sekali-sekali, kita mungkin juga bakal menjumpai mereka di konteks lain.Detail spesifik soal gimana
PindanUnpinbekerja, dan aturan apa saja yang wajib mereka junjung tinggi, sudah dibahas secara ekstensif di dalam dokumentasi API buatstd::pin, jadi kalau kita tertarik buat belajar lebih lanjut, itu adalah tempat yang bagus buat memulai.Kalau kita mau memahami gimana cara kerja di balik layarnya secara lebih detail lagi, silakan baca Bab 2 dan 4 dari buku Asynchronous Programming in Rust.
Trait Stream
Sekarang setelah kita punya pemahaman yang lebih dalam soal trait Future,
Pin, dan Unpin, kita bisa mengalihkan perhatian kita ke trait Stream.
Kayak yang sudah kita pelajari sebelumnya di bab ini, streams itu mirip kayak
iterator asinkron. Tapi beda sama Iterator dan Future, Stream itu tidak
punya definisi di dalam standard library pada saat tulisan ini dibuat, tapi
emang ada definisi yang sangat umum dari crate futures yang dipakai di
seluruh ekosistem.
Mari kita ulas balik definisi dari trait Iterator dan Future sebelum
melihat gimana sebuah trait Stream mungkin bakal menggabungkan keduanya. Dari
Iterator, kita dapet ide soal urutan (sequence): method next-nya
menyediakan sebuah Option<Self::Item>. Dari Future, kita dapet ide soal
kesiapan seiring berjalannya waktu: method poll-nya menyediakan sebuah
Poll<Self::Output>. Buat merepresentasikan serangkaian item yang mulai siap
seiring waktu, kita mendefinisikan sebuah trait Stream yang menggabungkan
fitur-fitur tersebut:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>
) -> Poll<Option<Self::Item>>;
}
}
Trait Stream mendefinisikan sebuah associated type bernama Item buat tipe
dari item-item yang diproduksi sama stream-nya. Ini mirip sama Iterator, di
mana itemnya bisa berjumlah nol sampai banyak, dan beda sama Future, yang
mana output-nya selalu satu, biarpun itu cuma tipe unit ().
Stream juga mendefinisikan sebuah method buat mendapatkan item-item tersebut.
Kita menamainya poll_next, buat memperjelas kalau dia me-poll pakai cara
yang sama kayak yang dilakukan Future::poll dan memproduksi serangkaian item
pakai cara yang sama kayak yang dilakukan Iterator::next. Tipe kembaliannya
menggabungkan Poll dengan Option. Tipe luarnya adalah Poll, karena dia
wajib dicek kesiapannya, persis kayak sebuah future. Tipe dalamnya adalah
Option, karena dia butuh memberi tanda apakah masih ada pesan lagi, persis
kayak sebuah iterator.
Sesuatu yang sangat mirip dengan definisi ini kemungkinan besar bakal berakhir menjadi bagian dari standard library milik Rust. Sembari menunggu, ia adalah bagian dari peralatan milik mayoritas runtimes, jadi kita bisa mengandalkannya, dan segala hal yang kita bahas selanjutnya secara umum bakal tetap berlaku!
Tapi di contoh-contoh yang kita lihat di bagian “Streams: Futures dalam
Urutan” tadi, kita tidak memakai poll_next maupun
Stream, melainkan malah memakai next dan StreamExt. Tentu saja kita bisa
bekerja secara langsung mengikuti API poll_next dengan cara menulis tangan
state machine Stream kita sendiri, persis kayak kita juga bisa bekerja
bareng futures secara langsung lewat method poll-nya. Tapi memakai await
itu jauh lebih enak, dan trait StreamExt menyuplai method next supaya kita
bisa melakukan hal itu:
#![allow(unused)]
fn main() {
use std::pin::Pin;
use std::task::{Context, Poll};
trait Stream {
type Item;
fn poll_next(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
) -> Poll<Option<Self::Item>>;
}
trait StreamExt: Stream {
async fn next(&mut self) -> Option<Self::Item>
where
Self: Unpin;
// other methods...
}
}
Catatan: Definisi asli yang kita pakai sebelumnya di bab ini kelihatan sedikit berbeda dari ini, karena ia mendukung versi Rust yang dulu belum mendukung penggunaan fungsi asinkron di dalam traits. Hasilnya, ia kelihatannya kayak gini:
fn next(&mut self) -> Next<'_, Self> where Self: Unpin;Tipe
Nexttersebut adalah sebuahstructyang mengimplementasikanFuturedan mengizinkan kita buat menamai lifetime dari referensi keselfdenganNext<'_, Self>, supayaawaitbisa bekerja bareng method ini.
Trait StreamExt juga merupakan rumah dari semua method menarik yang tersedia
buat dipakai bareng streams. StreamExt secara otomatis diimplementasikan
buat tiap tipe yang mengimplementasikan Stream, tapi trait-trait ini
didefinisikan secara terpisah supaya komunitas bisa beriterasi pada API-API
kemudahan tanpa mengganggu trait dasarnya.
Di versi StreamExt yang dipakai di crate trpl, trait-nya tidak cuma
mendefinisikan method next tapi juga menyuplai implementasi default dari
next yang secara benar menangani detail-detail pemanggilan
Stream::poll_next. Ini artinya bahkan pas kita perlu menulis tipe data streaming
kita sendiri, kita cuma harus mengimplementasikan Stream, dan nantinya
siapa saja yang memakai tipe data kita bisa memakai StreamExt beserta method-
methodnya secara otomatis.
Nah, segitu saja pembahasan kita soal detail-detail tingkat rendah pada trait- trait ini. Sebagai penutup, mari kita pertimbangkan gimana futures (termasuk streams), tasks, dan threads semuanya saling melengkapi!