Topik ini membahas dua wilayah memori yang dipakai setiap program Rust, yaitu stack dan heap, serta cara Rust mengembalikan memori tanpa garbage collection. Pembahasan mencakup perbedaan biaya alokasi pada kedua wilayah tersebut, pola RAII yang menautkan umur sumber daya pada lingkup variabel, dan pemanggilan drop ketika lingkup berakhir. Setelah membaca topik ini, penempatan sebuah nilai di stack atau di heap dapat dikenali dari tipenya, dan waktu pembersihan memorinya dapat diprediksi tanpa menjalankan alat tambahan.
Stack dan heap#
Stack dan heap adalah dua wilayah memori yang tersedia bagi program saat berjalan, tetapi keduanya dikelola dengan cara yang berbeda. Stack menyimpan nilai secara bertumpuk mengikuti pemanggilan fungsi: setiap kali fungsi dipanggil, sebuah frame didorong ke atas tumpukan, dan ketika fungsi selesai, frame itu dilepas. Cara ini sangat cepat karena kompilator hanya memindahkan penunjuk tumpukan, tetapi seluruh data di dalamnya harus berukuran tetap dan sudah diketahui pada saat kompilasi.
Heap dipakai untuk data yang ukurannya baru diketahui saat program berjalan atau yang ukurannya dapat berubah. Alokasi di heap meminta pengelola memori mencari ruang kosong yang cukup, mencatat pembukuan alokasinya, lalu mengembalikan sebuah penunjuk ke ruang tersebut. Proses ini lebih lambat daripada penyerahan ruang di stack, dan setiap akses datanya harus melewati penunjuk itu.
| Aspek | Stack | Heap |
|---|---|---|
| Cara memperoleh ruang | didorong dan dilepas mengikuti pemanggilan fungsi | dialokasikan satu per satu saat program berjalan |
| Ukuran data | tetap dan diketahui saat kompilasi | dapat berubah saat program berjalan |
| Kecepatan | sangat cepat karena hanya memindahkan penunjuk tumpukan | lebih lambat karena ada pembukuan alokasi |
| Contoh tipe | u32, f64, bool, tuple tetap | String, Vec, Box |
Sebuah nilai String menempati kedua wilayah sekaligus, sehingga tipe ini menjadi contoh terbaik untuk melihat pembagian kerjanya. Struktur kendalinya berupa penunjuk ke heap beserta panjang dan kapasitas, dan struktur kendali itu tersimpan di stack. Isi teksnya tersimpan di heap. Diagram berikut memperlihatkan pembagian tersebut untuk sebuah String yang berisi teks baterai.
Mengelola memori tanpa garbage collection#
Bahasa pemrograman umumnya menempuh salah satu dari dua jalan untuk mengelola memori. Jalan pertama adalah pengelolaan manual seperti pada C: program memanggil alokasi dan pembebasan memori sendiri. Pada jalan ini, kebocoran muncul apabila pembebasan terlewat, dan kerusakan memori muncul apabila pembebasan berjalan dua kali. Jalan kedua adalah garbage collection seperti pada Java, Go, dan banyak bahasa skrip: sebuah komponen runtime memantau nilai yang masih terjangkau, lalu membebaskan sisanya secara berkala. Pendekatan kedua menyingkirkan sebagian besar pekerjaan programer, tetapi pemantauan dan pemindaian tetap berjalan saat program aktif, dan momen pembebasannya tidak dapat ditentukan dari kode.
Rust menempuh jalan ketiga yang disebut ownership: setiap nilai memiliki tepat satu pemilik, dan ketika pemiliknya keluar dari lingkup, nilai itu dijatuhkan dengan memanggil drop. Seluruh pemeriksaan kepemilikan dilakukan oleh kompilator, bukan oleh runtime, sehingga tidak ada pemindaian berkala dan tidak ada jeda yang datang tiba-tiba. Pembebasan terjadi pada titik yang dapat dibaca langsung dari struktur blok kode, sehingga urutan pembersihannya deterministik. Biaya yang biasanya dibayar lewat garbage collection atau disiplin manual berubah menjadi pekerjaan pemeriksaan pada saat kompilasi.
Ukuran tipe dan penempatan di stack#
Kompilator menempatkan nilai di stack apabila ukuran tipenya tetap dan diketahui sejak kompilasi, dan ukuran itu dapat diukur langsung dengan fungsi std::mem::size_of. Program berikut mencetak ukuran beberapa tipe yang sering dipakai pada arsitektur 64 bit.
fn main() {
println!("ukuran u32 : {} byte", std::mem::size_of::<u32>());
println!("ukuran f64 : {} byte", std::mem::size_of::<f64>());
println!("ukuran (u32, u32) : {} byte", std::mem::size_of::<(u32, u32)>());
println!("ukuran String : {} byte", std::mem::size_of::<String>());
println!("ukuran &str : {} byte", std::mem::size_of::<&str>());
}ukuran u32 : 4 byte
ukuran f64 : 8 byte
ukuran (u32, u32) : 8 byte
ukuran String : 24 byte
ukuran &str : 16 byteAngka pada keluaran itu menjelaskan tempat tinggal masing-masing tipe. Tipe u32 menempati 4 byte dan tuple (u32, u32) menempati 8 byte, sehingga keduanya cukup digelarkan di stack. String berukuran 24 byte meskipun teks yang dibawanya dapat panjang, karena 24 byte tersebut hanyalah struktur kendalinya: penunjuk ke heap sepanjang 8 byte, panjang 8 byte, dan kapasitas 8 byte. Tipe &str berukuran 16 byte karena berisi penunjuk dan panjang tanpa kapasitas. Dengan demikian, alokasi heap tidak pernah terjadi hanya karena sebuah variabel bertipe String dideklarasikan, melainkan ketika datanya dibuat atau bertambah.
Ukuran yang tetap itulah syarat penempatan di stack, dan pola yang sama berlaku untuk tipe lain yang memakai heap. Tipe Vec menyimpan penunjuk, panjang, dan kapasitas di stack sambil menunjuk deret elemennya di heap, sedangkan Box menyimpan satu penunjuk berukuran 8 byte ke nilai yang dipindahkannya ke heap. Kompilatorlah yang menentukan penempatan ini berdasarkan tipenya, bukan programer, sehingga keputusan stack atau heap tidak pernah bergantung pada kebiasaan penulisan kode. Pola struktur kendali tetap di stack dan data tak tentu ukurannya di heap inilah yang menjadi landasan seluruh aturan kepemilikan pada modul ini.
Pola RAII: pembersihan saat lingkup berakhir#
RAII (Resource Acquisition Is Initialization) adalah pola penataan sumber daya yang menautkan umur sumber daya pada umur sebuah objek di dalam lingkup. Sumber daya diambil ketika objek dibuat, dan dikembalikan ketika objek dijatuhkan, tanpa bergantung pada pemanggilan fungsi pembersihan secara manual. Rust menerapkan pola ini untuk memori sekaligus untuk berkas, koneksi jaringan, dan kunci akses melalui trait Drop, yang metodenya dijalankan otomatis ketika pemilik nilai keluar dari lingkupnya.
Program berikut mendefinisikan tipe Catatan yang mengimplementasikan Drop agar momen pembebasannya terlihat dari keluaran. Perhatikan urutan pesannya, terutama kapan pesan penghapusan muncul dibandingkan pesan lainnya.
struct Catatan {
nama: String,
}
impl Drop for Catatan {
fn drop(&mut self) {
println!("Catatan '{}' dihapus dari memori", self.nama);
}
}
fn main() {
{
let a = Catatan {
nama: String::from("sensor-suhu"),
};
println!("Membuat catatan '{}'", a.nama);
} // a di-drop saat blok ini berakhir
println!("Blok dalam sudah selesai");
let b = Catatan {
nama: String::from("tekanan-udara"),
};
println!("Membuat catatan '{}'", b.nama);
println!("Program selesai");
} // b di-drop saat fungsi main berakhir
Membuat catatan 'sensor-suhu'
Catatan 'sensor-suhu' dihapus dari memori
Blok dalam sudah selesai
Membuat catatan 'tekanan-udara'
Program selesai
Catatan 'tekanan-udara' dihapus dari memoriKeluaran tersebut menegaskan dua hal. Pertama, catatan sensor-suhu dihapus sebelum baris Blok dalam sudah selesai tercetak, karena variabel a hidup di dalam blok kurung kurawal dalam dan drop dipanggil tepat saat blok itu ditutup. Kedua, catatan tekanan-udara dihapus setelah baris Program selesai, karena variabel b hidup sampai fungsi main berakhir. Tidak ada satu pun panggilan pembersihan yang ditulis secara manual di dalam program, sehingga kebocoran karena lupa membersihkan tidak mungkin terjadi pada pola ini. Kompilasi dan jalannya program dapat diperiksa dengan dua perintah berikut.
export PATH="$HOME/.cargo/bin:$PATH"
# kompilasi berkas lingkup.rs lalu jalankan hasilnya
rustc --edition=2021 lingkup.rs -o lingkup
./lingkupMembuat catatan 'sensor-suhu'
Catatan 'sensor-suhu' dihapus dari memori
Blok dalam sudah selesai
Membuat catatan 'tekanan-udara'
Program selesai
Catatan 'tekanan-udara' dihapus dari memoriKesalahan umum#
Kesalahan pertama adalah menganggap Rust memiliki garbage collection karena variabel “dibersihkan otomatis”. Pembersihan pada Rust tidak dijalankan oleh runtime yang memindai memori, melainkan oleh pemanggilan drop yang sudah dipastikan kompilator pada titik keluar lingkup. Akibatnya, urutan pembebasan selalu dapat dibaca dari struktur blok, dan tidak ada momen ketika program berhenti sejenak untuk pemindaian. Perbaikannya ada pada cara memandang masalahnya: jadikan lingkup sebagai alat penataan umur sumber daya, bukan sekadar pembatas kode. Penutupan berkas, sebagai contoh, cukup ditautkan pada variabel penangan berkasnya, sehingga pemanggilan penutupan manual tidak pernah dibutuhkan.
Kesalahan kedua adalah menganggap seluruh isi String berada di heap. Struktur kendali berukuran 24 byte tetap tinggal di stack, dan hanya data teksnya yang dialokasikan di heap. Penempatan ini penting ketika mengukur biaya penyalinan nilai, karena menyalin struktur kendali tidak sama dengan menyalin isi teks. Urutan penjatuhannya juga mengikuti urutan deklarasi secara terbalik, sehingga nilai yang dideklarasikan paling akhir dijatuhkan paling dahulu. Pembedaan antara menyalin struktur kendali dan menyalin isi teks menjadi dasar bagi konsep move dan clone pada aturan kepemilikan.
Ringkasan#
- Stack menyimpan nilai berukuran tetap yang diketahui saat kompilasi, sedangkan heap menyimpan data yang dialokasikan saat program berjalan.
- Rust tidak memakai garbage collection: aturan ownership yang diperiksa saat kompilasi menentukan kapan
dropdipanggil. - Pola RAII menautkan umur sumber daya pada lingkup variabel, sehingga pembersihan terjadi secara deterministik tanpa panggilan manual.
Stringberukuran 24 byte di stack berupa penunjuk, panjang, dan kapasitas, sedangkan isi teksnya berada di heap.
Referensi#
- Understanding Ownership — The Rust Programming Language — Bab resmi yang memperkenalkan stack, heap, dan kepemilikan memori di Rust.
- std::mem::size_of — Rust standard library — Dokumentasi fungsi ukuran tipe yang dipakai pada contoh di topik ini.
- Trait Drop — Rust standard library — Dokumentasi trait yang metodenya dijalankan ketika nilai keluar dari lingkup.