Semua program yang telah kita tulis sejauh ini berjalan dalam satu alur eksekusi: pernyataan dieksekusi satu demi satu dari atas ke bawah, dan program tidak pernah mengerjakan dua hal pada saat bersamaan. Aplikasi dunia nyata jarang semenyenangkan itu — server kasir harus menerima pesanan dari banyak pelanggan sekaligus, aplikasi desktop harus tetap merespons klik pengguna saat mengunduh berkas besar, dan proses data berat akan membuang waktu jika satu core CPU dipaksa mengerjakan semuanya sendirian. Rust menjawab kebutuhan ini dengan std::thread, sebuah API yang membangun concurrency di atas thread milik sistem operasi. Sebelum kita menulis kode thread pertama, kita perlu memahami model ini: apa itu thread, siapa yang menjadwalannya, dan mengapa Rust memilih thread OS dibanding model lain.

Concurrency dan Parallelism #

Dua istilah ini sering dipakai bergantian, padahal keduanya berbeda. Concurrency berarti program disusun sehingga banyak pekerjaan bisa berada dalam keadaan setengah selesai secara tumpang tindih — pekerjaan A belum rampung, pekerjaan B sudah dimulai. Parallelism berarti pekerjaan benar-benar dieksekusi pada saat yang sama di unit perangkat keras yang berbeda. Sebagai analogi: dua kasir yang melayani dua antrean adalah parallel, sedangkan satu kasir yang mengerjakan pesanan A sebagian, lalu menimbang kopi untuk pesanan B, lalu kembali ke A, adalah concurrent tanpa parallelism. Analogi kasir punya batas — CPU bukan kasir, dan peralihan antar pekerjaan pada CPU dilakukan oleh penjadwal sistem operasi, bukan oleh keputusan siapa pun di dalam program kita.

Dalam model thread OS, concurrency dan parallelism sama-sama mungkin terjadi. Bila jumlah thread melebihi jumlah core, penjadwal menciptakan concurrency dengan memotong waktu core menjadi irisan-irisan kecil dan bergilir memberikannya kepada tiap thread. Bila core tersedia lebih banyak daripada thread, thread-thread tersebut benar-benar berjalan pada saat yang sama — itulah parallelism yang sesungguhnya.

Thread sebagai Unit Eksekusi #

Thread adalah unit eksekusi yang dikenal sistem operasi. Setiap thread memiliki tiga hal: alur instruksi sendiri, tumpukan stack sendiri untuk menyimpan variabel lokal dan jejak pemanggilan fungsi, serta sekumpulan register yang mencatat posisi eksekusi terakhir. Library standar Rust mengalokasikan stack bawaan sebesar 2 MiB untuk setiap thread baru — angka ini bisa diubah lewat thread::Builder bila program butuh stack yang lebih besar atau ingin lebih hemat memori.

Satu proses bisa memuat banyak thread, dan seluruh thread dalam satu proses berbagi ruang memori yang sama: heap, data global, dan kode program. Pemisahan hanya terjadi pada stack dan register. Berbagi memori inilah akar dari seluruh topik section ini — data yang dapat diakses beberapa thread sekaligus adalah tempat race condition lahir, dan Rust membangun seluruh perangkat keamanannya (ownership, Send, Mutex, Arc) tepat di sekitar masalah tersebut.

Penjadwalan dilakukan oleh kernel sistem operasi secara preemptive: kernel boleh menghentikan thread kapan pun ketika thread tersebut sedang berjalan, menyimpan register-nya, lalu memberikan core kepada thread lain. Kita sebagai penulis program tidak bisa berasumsi thread A akan menyelesaikan satu tahap pekerjaan sebelum thread B membaca hasilnya — kecuali kita mengatur sinkronisasi secara eksplisit dengan join, channel, atau lock yang akan kita pelajari satu per satu.

Model 1:1 di Rust #

Model threading Rust bersifat 1:1: setiap pemanggilan std::thread::spawn membuat tepat satu thread sistem operasi — di Linux hal ini terwujud sebagai pthread, di Windows sebagai thread Win32. Konsekuensinya, penjadwalan sepenuhnya menjadi urusan kernel: prioritas, irisan waktu, dan distribusi core dikendalikan oleh sistem operasi yang sudah puluhan tahun diasah untuk pekerjaan ini, bukan oleh Rust. Keuntungannya adalah perilaku yang lugas dan biaya yang dapat diprediksi; kerugiannya, setiap thread memakan biaya pembuatan dan memori yang nyata, sehingga membuat puluhan ribu thread bukan pilihan yang hemat.

Dua Alur Eksekusi dalam Satu Program #

Mari kita lihat model ini bekerja. Program berikut menjalankan satu penjumlahan di thread baru dan satu penjumlahan lain di thread utama, lalu mencetak hasil keduanya:

rust
use std::thread;

fn main() {
    let pekerja = thread::spawn(|| {
        let total: u64 = (1..=1000).sum();
        total
    });

    let hasil_pekerja = pekerja.join().unwrap();
    let hasil_main: u64 = (1001..=2000).sum();

    println!("Hasil worker : {}", hasil_pekerja);
    println!("Hasil main   : {}", hasil_main);
}

Output yang Dihasilkan:

text
Hasil worker : 500500
Hasil main   : 1500500

Mari kita bedah kode di atas:

  1. Baris 4 (let pekerja = thread::spawn(|| {): thread::spawn meminta kernel membuat satu thread OS baru, lalu menjalankan closure di antara || dan } pada thread tersebut. Fungsi ini langsung kembali ke thread utama tanpa menunggu — nilai kembaliannya adalah handle yang akan kita bahas tuntas di Chapter spawn dan join.
  2. Baris 5 (let total: u64 = (1..=1000).sum();): penjumlahan 1 sampai 1000 dijalankan di stack milik thread baru, terpisah dari stack main. Thread utama bebas mengerjakan hal lain pada saat yang sama.
  3. Baris 9 (let hasil_pekerja = pekerja.join().unwrap();): join() memblokir thread utama sampai thread pekerja selesai, lalu mengambil nilai kembalian closure. Berkat baris ini, kedua println! dipastikan berjalan setelah seluruh pekerjaan rampung, sehingga output di atas pasti tersusun seperti yang tercetak pada setiap eksekusi.
  4. Baris 10 (let hasil_main: u64 = (1001..=2000).sum();): penjumlahan kedua dikerjakan oleh thread utama. Penjumlahan ini berjalan setelah join() supaya urutan cetak tetap pasti; pada program sungguhan, pekerjaan berat seperti ini justru diletakkan sebelum join() agar kedua thread benar-benar sibuk bersamaan.

Pada mesin multicore, kedua penjumlahan di atas sungguh dapat berjalan pada dua core berbeda pada saat yang sama — parallelism murni tanpa satu baris kode khusus pun.

Menanyakan Jumlah Core kepada Runtime #

Kadang program perlu tahu berapa banyak pekerjaan paralel yang masuk akal untuk dijadwalkan. thread::available_parallelism memberikan petunjuk berupa jumlah core yang tersedia untuk proses kita:

rust
use std::thread;

fn main() {
    let jumlah_core = thread::available_parallelism().unwrap();
    println!("Jumlah core yang bisa dipakai: {}", jumlah_core.get());
}

Output yang Dihasilkan:

text
Jumlah core yang bisa dipakai: 2

Mari kita bedah kode di atas:

  1. Baris 4 (let jumlah_core = thread::available_parallelism().unwrap();): fungsi ini mengembalikan NonZeroUsize yang membungkus petunjuk jumlah core — misalnya 2 pada mesin dua core atau 8 pada mesin delapan core. Nilainya juga bisa dipengaruhi pembatasan affinity yang diberikan sistem operasi kepada proses.
  2. Baris 5 (println!("Jumlah core yang bisa dipakai: {}", jumlah_core.get());): metode .get() mengambil angka usize dari dalam pembungkus NonZeroUsize sehingga bisa dicetak.

Angka 2 pada output di atas berasal dari eksekusi nyata di mesin uji kami. Angka yang tercetak di komputer pembaca dapat berbeda karena jumlah core tiap mesin tidak sama — justru karena mengikuti mesin tempatnya berjalan, fungsi ini berguna untuk menyesuaikan jumlah worker dengan kemampuan perangkat keras.

Ringkasan #

Rust membangun concurrency di atas thread sistem operasi dengan model 1:1: satu thread::spawn berarti satu thread kernel, lengkap dengan stack sendiri dan penjadwalan preemptive oleh OS. Seluruh thread dalam satu proses berbagi memori yang sama, sehingga keamanan akses data menjadi tanggung jawab bahasa — tugas yang dipegang oleh ownership beserta perangkat bantu di section ini. Concurrency lahir dari irisan waktu penjadwal, parallelism lahir dari core yang berjumlah lebih dari satu, dan available_parallelism membantu mengukurnya. Peralihan dari teori ke praktik dimulai di Chapter berikutnya: meluncurkan thread dan menunggu hasilnya lewat spawn dan join.