Dasar-dasar Pemrograman Asinkron: Async, Await, Futures, dan Streams
Banyak operasi yang kita suruh komputer buat lakukan bisa memakan waktu agak lama buat selesai. Bakal menyenangkan sekali kalau kita bisa melakukan hal lain selagi kita nungguin proses-proses yang panjang itu buat kelar. Komputer modern menawarkan dua teknik buat mengerjakan lebih dari satu operasi pada satu waktu: paralelisme (parallelism) dan konkurensi (concurrency). Namun, logika program kita biasanya ditulis dengan gaya yang linear. Kita pengen bisa menentukan operasi apa aja yang harus dilakukan program dan titik-titik di mana sebuah fungsi bisa berhenti sejenak (pause) dan bagian lain dari program bisa berjalan sebagai gantinya, tanpa perlu menentukan di awal secara persis urutan dan cara tiap potongan kode itu berjalan. Pemrograman asinkron (asynchronous programming) adalah sebuah abstraksi yang memungkinkan kita mengekspresikan kode kita dalam bentuk titik-titik jeda potensial dan hasil akhirnya, yang mana bakal menangani detail koordinasinya buat kita.
Bab ini dibangun di atas penggunaan threads buat paralelisme dan konkurensi
di Bab 16 dengan memperkenalkan pendekatan alternatif buat nulis kode: futures,
streams, dan sintaks async dan await di Rust yang memungkinkan kita buat
mengekspresikan gimana operasi-operasi tersebut bisa bersifat asinkron, serta
crates pihak ketiga yang mengimplementasikan asynchronous runtimes: kode yang
mengelola dan mengoordinasi eksekusi dari operasi-operasi asinkron tersebut.
Mari kita pertimbangkan sebuah contoh. Katakanlah kita lagi ngekspor video perayaan keluarga yang sudah kita bikin, sebuah operasi yang bisa memakan waktu mulai dari beberapa menit sampai berjam-jam. Ekspor video bakal memakai sebanyak mungkin tenaga CPU dan GPU yang bisa dia dapatkan. Kalau kita cuma punya satu core (inti) CPU dan sistem operasi kita tidak nge-pause ekspor itu sampai ia selesai—yaitu, kalau ia mengeksekusi ekspornya secara sinkron (synchronously)—kita tidak bakal bisa ngelakuin hal lain di komputer kita saat tugas itu berjalan. Itu bakal jadi pengalaman yang cukup bikin frustrasi. Untungnya, sistem operasi di komputer kita bisa, dan emang ngelakuin itu, menginterupsi proses ekspor secara kasatmata cukup sering biar kita bisa mengerjakan tugas lain di saat yang bersamaan.
Sekarang katakanlah kita lagi men-download video yang di-share sama orang lain, yang juga bisa memakan waktu lumayan lama tapi tidak terlalu menyita banyak waktu CPU. Di kasus ini, CPU harus menunggu data dari jaringan buat tiba. Meskipun kita bisa mulai membaca data tersebut begitu dia mulai berdatangan, itu bisa memakan waktu beberapa saat sampai semuanya muncul. Bahkan setelah semua datanya ada, kalau videonya sangat besar, itu bisa memakan waktu setidaknya satu atau dua detik buat nge-load semuanya. Itu mungkin tidak kedengeran terlalu lama, tapi itu adalah waktu yang sangat lama buat prosesor modern, yang mana bisa melakukan miliaran operasi setiap detik. Sekali lagi, sistem operasi kita bakal menginterupsi program kita secara kasatmata buat membiarkan CPU mengerjakan tugas lain sembari nunggu pemanggilan jaringan selesai.
Ekspor video adalah contoh dari operasi CPU-bound atau compute-bound (bergantung pada prosesor). Dia dibatasi oleh potensi kecepatan pemrosesan data komputer di dalam CPU atau GPU, dan seberapa besar dari kecepatan itu yang bisa didedikasikan buat operasi tersebut. Download video adalah contoh dari operasi I/O-bound (bergantung pada input-output), karena dia dibatasi oleh kecepatan input dan output komputer; dia cuma bisa berjalan secepat data yang bisa dikirim lewat jaringan.
Di dua contoh ini, interupsi kasatmata dari sistem operasi ngasih suatu bentuk konkurensi. Namun, konkurensi itu cuma terjadi di tingkat keseluruhan program: sistem operasi menginterupsi satu program buat membiarkan program lain mengerjakan tugasnya. Di banyak kasus, karena kita paham program kita di tingkat yang jauh lebih spesifik (granular) ketimbang sistem operasi, kita bisa menemukan peluang-peluang buat konkurensi yang tidak bisa dilihat sama sistem operasi.
Misalnya, kalau kita lagi bikin tool buat mengelola download file, kita seharusnya bisa menulis program kita sedemikian rupa sehingga pas mulai nge-download satu file UI-nya tidak bakal macet (lock up), dan user seharusnya bisa memulai banyak download di saat yang bersamaan. Namun, banyak API sistem operasi buat berinteraksi dengan jaringan itu sifatnya blocking (memblokir); yakni, mereka memblokir progress program sampai data yang mereka proses itu benar-benar siap seutuhnya.
Catatan: Ini adalah cara kerja kebanyakan pemanggilan fungsi, kalau dipikir-pikir. Namun, istilah blocking biasanya dikhususkan buat pemanggilan fungsi yang berinteraksi dengan file, jaringan, atau sumber daya lain di komputer, karena itu adalah kasus-kasus di mana suatu program individu bakal dapat keuntungan kalau operasi tersebut non-blocking.
Kita bisa menghindari memblokir main thread kita dengan membikin sebuah thread khusus buat men-download tiap file. Namun, beban (overhead) dari sumber daya sistem yang dipakai sama threads itu pada akhirnya bakal jadi masalah. Bakal lebih oke kalau pemanggilan fungsinya dari awal emang tidak memblokir, dan sebaliknya kita bisa menentukan sejumlah tugas yang pengen diselesaikan program kita lalu membiarkan runtime memilih urutan dan cara terbaik buat menjalankannya.
Nah, itulah persisnya yang dikasih sama abstraksi asinkron (singkatan dari async) di Rust buat kita. Di bab ini, kita bakal belajar semua hal tentang asinkron selagi kita membahas topik-topik berikut:
- Gimana cara memakai sintaks
asyncdanawaitdi Rust dan mengeksekusi fungsi-fungsi asinkron dengan sebuah runtime - Gimana cara memakai model asinkron buat nyelesein beberapa tantangan yang sama seperti yang udah kita lihat di Bab 16
- Gimana multithreading dan asinkron menyediakan solusi yang saling melengkapi, yang bisa kita gabungkan di banyak kasus
Tapi, sebelum kita lihat gimana cara kerja asinkron di praktiknya, kita perlu sedikit belok sebentar buat ngebahas perbedaan antara paralelisme dan konkurensi.
Paralelisme dan Konkurensi
Sejauh ini kita memperlakukan paralelisme dan konkurensi seolah-olah maknanya bisa ditukar-tukar. Sekarang kita harus bisa membedakannya dengan lebih presisi, karena perbedaannya bakal kerasa pas kita udah mulai bekerja.
Bayangin aja cara-cara beda yang bisa dipakai sebuah tim buat ngebagi kerjaan di suatu proyek software. Kita bisa menugaskan satu orang beberapa tugas, menugaskan tiap anggota satu tugas, atau memakai campuran dari kedua pendekatan tersebut.
Saat satu orang individu mengerjakan beberapa tugas yang berbeda sebelum satupun dari tugas-tugas itu selesai, ini dinamakan konkurensi. Salah satu cara buat mengimplementasikan konkurensi adalah mirip kayak punya dua project berbeda yang lagi dibuka di komputer kita, dan pas kita bosan atau mentok di satu project, kita beralih ke project yang satu lagi. Kita cuma satu orang, jadi kita tidak bisa bikin progress di kedua tugas pada waktu yang sama persis, tapi kita bisa multitask, bikin progress pada satu tugas secara bergantian dengan beralih di antara mereka (lihat Gambar 17-1).
Saat sebuah tim membagi sekelompok tugas dengan nyuruh setiap anggota mengambil satu tugas lalu mengerjakannya sendirian, ini dinamakan paralelisme. Setiap orang di tim tersebut bisa membikin progress pada waktu yang sama persis (lihat Gambar 17-2).
Di kedua alur kerja (workflows) ini, kita mungkin harus mengoordinasikan antara tugas-tugas yang berbeda. Mungkin kita pikir tugas yang diberikan ke satu orang itu benar-benar independen dari pekerjaan orang lain, tapi ternyata dia butuh orang lain di tim itu buat nyelesein tugas mereka lebih dulu. Beberapa pekerjaan bisa dilakukan secara paralel, tapi sebagian darinya itu sebenarnya serial: dia cuma bisa terjadi dalam satu urutan, satu tugas setelah tugas lainnya, kayak di Gambar 17-3.
Sama halnya, kita mungkin sadar kalau salah satu tugas kita sendiri itu bergantung sama tugas kita yang lainnya. Sekarang pekerjaan konkuren kita juga udah jadi serial.
Paralelisme dan konkurensi juga bisa bersinggungan satu sama lain. Kalau kita tahu kalau rekan kerja kita lagi mentok sampai kita nyelesein salah satu tugas kita, kita mungkin bakal memusatkan seluruh usaha kita pada tugas itu buat “membuka jalan” (unblock) rekan kerja kita tersebut. Kita dan rekan kerja kita tidak lagi bisa bekerja secara paralel, dan kita juga tidak lagi bisa bekerja secara konkuren di tugas kita masing-masing.
Dinamika dasar yang sama mulai berlaku pada perangkat lunak dan perangkat keras. Di mesin yang punya satu core CPU, CPU cuma bisa melakukan satu operasi pada satu waktu, tapi dia tetap bisa bekerja secara konkuren. Memakai alat seperti threads, proses, dan asinkron, komputer bisa mem-pause satu aktivitas lalu beralih ke aktivitas lain sebelum akhirnya berputar kembali ke aktivitas pertama tadi. Di mesin dengan banyak core CPU, dia juga bisa mengerjakan tugas secara paralel. Satu core bisa ngerjain satu tugas sementara core lain ngerjain tugas yang sama sekali tidak ada hubungannya, dan operasi-operasi itu benar-benar terjadi pada waktu yang bersamaan.
Menjalankan kode asinkron di Rust biasanya terjadi secara konkuren. Tergantung pada perangkat keras, sistem operasi, dan async runtime yang lagi kita pakai (async runtimes bakal dibahas sebentar lagi), konkurensi itu mungkin juga memakai paralelisme di balik layar.
Sekarang, mari kita selami gimana cara kerja pemrograman asinkron di Rust sebenarnya.