golangMemahami Garbage Collector di Golang

Pengantar: Mengapa Garbage Collector Penting?

Bayangkan sebuah restoran yang sibuk. Setiap pelanggan memesan makanan, makan, lalu pergi β€” namun meja mereka tetap kotor dengan piring bekas. Jika tidak ada pelayan yang membersihkan, meja-meja itu akan penuh dan restoran tidak bisa melayani pelanggan baru. Inilah analogi yang tepat untuk menggambarkan masalah yang dipecahkan oleh Garbage Collector (GC): ia adalah "pelayan" yang membersihkan memori yang sudah tidak dibutuhkan, supaya program bisa terus berjalan dengan lancar.

Dalam bahasa pemrograman seperti C atau C++, programer harus mengalokasikan dan membebaskan memori secara manual. Ini memberi kontrol penuh, tetapi rawan kesalahan β€” lupa membebaskan memori menyebabkan memory leak, dan membebaskan memori yang masih dipakai menyebabkan crash. Go memilih jalan tengah: ia menyediakan Garbage Collector otomatis yang bekerja di belakang layar, sehingga programer bisa fokus pada logika bisnis tanpa khawatir soal manajemen memori secara manual.


Apa Itu Garbage Collection?

Garbage Collection adalah mekanisme manajemen memori otomatis. GC bertugas untuk:

  1. Melacak alokasi memori di heap (tumpukan memori dinamis).

  2. Mengidentifikasi objek-objek yang sudah tidak lagi digunakan oleh program.

  3. Membebaskan memori dari objek-objek tersebut agar bisa dipakai ulang.

Yang membedakan GC yang baik dari yang buruk adalah seberapa kecil gangguannya terhadap program yang sedang berjalan. GC yang naif akan menghentikan seluruh program sementara ia bekerja β€” ini disebut Stop-The-World (STW). Semakin lama jeda ini, semakin terasa oleh pengguna sebagai lag atau keterlambatan respons.


Sejarah Singkat GC di Go

Untuk memahami kenapa GC Go dirancang seperti sekarang, penting untuk mengenal perjalanannya:

Sebelum Go v1.3 menggunakan algoritma Mark-and-Sweep klasik. Caranya sederhana: hentikan seluruh program, tandai objek yang masih digunakan, hapus yang tidak bertanda, lalu lanjutkan program. Masalahnya adalah jeda STW bisa mencapai ratusan milidetik hingga detik β€” sangat tidak cocok untuk aplikasi server yang harus responsif.

Go v1.3 melakukan optimisasi kecil: fase sweep (pembersihan) dipindahkan ke luar STW, sehingga program tidak perlu berhenti terlalu lama. Namun masalah mendasar masih ada β€” fase mark (penandaan) tetap memerlukan STW penuh.

Go v1.5 adalah lompatan besar. Go memperkenalkan algoritma Tri-Color Mark-and-Sweep yang konkuren β€” artinya GC bisa berjalan bersamaan dengan program, bukan menghentikannya. Jeda STW terpangkas dari ratusan milidetik menjadi hanya beberapa milidetik.

Go v1.8 menyempurnakannya lagi dengan Hybrid Write Barrier, yang semakin mengurangi kebutuhan STW hingga jeda hanya ~500 mikrodetik per siklus GC.

Sampai hari ini, Go terus menyempurnakan GC-nya, dengan riset terbaru mengarah ke pendekatan berbasis span (disebut "Green Tea GC") untuk mendukung skalabilitas di sistem dengan banyak inti prosesor.


Arsitektur Memori Go: Stack vs Heap

Sebelum masuk ke cara kerja GC, kita perlu memahami dua wilayah memori utama di Go:

Stack adalah memori untuk variabel lokal dalam sebuah fungsi. Ketika fungsi dipanggil, stack tumbuh; ketika fungsi selesai, stack langsung dikecilkan secara otomatis. GC tidak perlu mengurusi stack β€” ia dikelola oleh mekanisme eksekusi fungsi itu sendiri. Go juga cerdas: melalui proses bernama escape analysis, compiler Go akan menentukan apakah sebuah variabel cukup hidup di stack atau harus "kabur" (escape) ke heap.

Heap adalah memori untuk objek-objek yang hidupnya lebih lama dari satu fungsi, misalnya objek yang dikembalikan dari fungsi, atau yang berbagi referensi antar goroutine. Di sinilah GC bekerja keras. Go menggunakan satu heap yang terpadu (unified heap) β€” tidak ada pemisahan berdasarkan umur objek seperti di JVM atau .NET.


Algoritma Inti: Tri-Color Mark-and-Sweep

Jantung dari GC Go adalah algoritma Tri-Color Mark-and-Sweep yang konkuren. Namanya terdengar rumit, tapi sebenarnya sangat intuitif jika dipecah per bagian.

Tiga Warna, Tiga Status

Setiap objek di heap diklasifikasikan ke dalam tiga "warna" yang mewakili statusnya:

Putih (White) adalah objek yang belum diperiksa sama sekali. Di awal siklus GC, semua objek adalah putih. Di akhir siklus, objek yang tetap putih berarti tidak terjangkau dari program β€” dan inilah objek yang akan dibebaskan memorinya.

Abu-abu (Gray) adalah objek yang sudah ditemukan sebagai terjangkau, tapi referensi-referensinya belum selesai ditelusuri. Abu-abu adalah status "sedang dalam antrian pemeriksaan". GC tahu objek ini perlu dipertahankan, tapi belum tahu apakah ia menunjuk ke objek-objek lain yang juga perlu diselamatkan.

Hitam (Black) adalah objek yang sudah sepenuhnya diperiksa β€” ia terjangkau, dan semua referensi yang ia miliki sudah ditelusuri. Objek hitam aman dan tidak akan disentuh lagi pada siklus ini.

Aturan Tak Tertulis yang Menjaga Keamanan

Ada satu aturan kritis yang menjamin tidak ada objek hidup yang salah dihapus: objek hitam tidak boleh secara langsung menunjuk ke objek putih. Mengapa? Karena GC sudah "selesai" dengan objek hitam dan tidak akan memeriksanya lagi. Jika objek hitam tiba-tiba menunjuk ke objek putih, objek putih itu bisa salah dihapus meski sebenarnya masih digunakan.

Inilah mengapa Write Barrier dibutuhkan β€” yang akan kita bahas sebentar lagi.

Fase-Fase Siklus GC

Fase 1 β€” Mark Setup (STW singkat): GC memulai dengan jeda STW yang sangat singkat untuk menyiapkan diri: mengaktifkan write barrier dan menandai semua root objects (variabel global dan variabel di stack semua goroutine) menjadi abu-abu. Jeda ini biasanya hanya puluhan hingga ratusan mikrodetik.

Fase 2 β€” Concurrent Marking: Inilah fase terlama, dan ia berjalan bersamaan dengan program. GC mengambil objek abu-abu satu per satu, menelusuri semua referensi yang dimilikinya, menandai referensi-referensi itu menjadi abu-abu juga, lalu menandai objek yang sudah selesai diperiksa menjadi hitam. Proses ini terus berulang hingga tidak ada lagi objek abu-abu β€” artinya semua objek yang terjangkau sudah berwarna hitam.

Fase 3 β€” Mark Termination (STW singkat): Satu lagi jeda STW singkat untuk memastikan tidak ada objek abu-abu yang tersisa dan menonaktifkan write barrier.

Fase 4 β€” Concurrent Sweeping: GC menyapu seluruh heap dan membebaskan memori dari objek-objek yang masih berwarna putih (objek tak terjangkau). Fase ini juga berjalan konkuren dan dilakukan secara lazy β€” memori dibersihkan sedikit demi sedikit saat goroutine mengalokasikan memori baru, bukan sekaligus.


Write Barrier: Penjaga Kebenaran GC Konkuren

Karena GC berjalan bersamaan dengan program, ada satu masalah rumit: program bisa mengubah referensi antar objek di saat GC sedang menandainya. Tanpa mekanisme tambahan, situasi berikut bisa terjadi:

  1. GC sudah menandai objek A sebagai hitam (selesai diperiksa).

  2. Program memindahkan referensi ke objek B dari objek abu-abu C ke objek hitam A.

  3. Karena C tidak lagi menunjuk B dan GC tidak akan memeriksa A lagi, objek B tidak pernah ditandai abu-abu.

  4. B tetap putih dan ikut dihapus β€” padahal B masih digunakan!

Write Barrier adalah solusinya. Ia adalah kode kecil yang otomatis disisipkan oleh compiler Go pada setiap operasi penulisan pointer. Ketika program mengubah referensi, write barrier "memberitahu" GC: "Hei, ada perubahan di sini, tandai objek ini abu-abu agar tidak terlewat."

Go v1.8 memperkenalkan Hybrid Write Barrier yang menggabungkan dua teknik sebelumnya: insertion barrier dan deletion barrier. Ini memungkinkan stack goroutine tidak memerlukan write barrier (lebih efisien), sementara heap tetap terlindungi sepenuhnya. Hasilnya adalah GC yang hampir tidak memerlukan STW sama sekali.


Kapan GC Berjalan? Memahami Pacer

GC tidak berjalan setiap saat β€” ada algoritma bernama Pacer yang menentukan kapan waktu yang tepat untuk memulai siklus GC baru. Pacing dimodelkan seperti masalah kontrol: GC mencoba menemukan waktu yang tepat agar heap tidak tumbuh terlalu besar sebelum dibersihkan, tapi juga tidak terlalu sering berjalan sehingga memakan CPU.

Secara default, Go akan memulai siklus GC baru setiap kali ukuran heap dua kali lipat dari ukurannya setelah siklus GC terakhir. Ini dikontrol oleh variabel lingkungan GOGC dengan nilai default 100 (artinya 100% pertumbuhan = dua kali lipat).

Misalnya jika setelah GC terakhir heap berukuran 10 MB, GC berikutnya akan dipicu ketika heap mencapai 20 MB. Meningkatkan GOGC (misalnya ke 200) berarti GC lebih jarang berjalan tapi mengonsumsi lebih banyak memori. Menurunkannya berarti GC lebih sering berjalan dengan konsumsi memori lebih rendah, tapi menggunakan lebih banyak CPU.

Go v1.19 juga memperkenalkan GOMEMLIMIT, yang memungkinkan kita menetapkan batas atas absolut penggunaan memori. Ini sangat berguna untuk program yang berjalan di dalam container dengan memori terbatas:


Go Bukan GC Generasional β€” dan Itu Disengaja

Banyak GC modern seperti di JVM atau .NET menggunakan pendekatan generasional: memori dibagi menjadi "generasi muda" dan "generasi tua", berdasarkan asumsi bahwa sebagian besar objek mati muda. Dengan fokus pada generasi muda yang lebih kecil, GC bisa bekerja lebih cepat dan lebih sering.

Go dengan sengaja tidak menggunakan pendekatan ini. Semua objek di Go diperlakukan sama dalam satu heap terpadu, tanpa pemisahan berdasarkan umur. Mengapa?

Pertama, model konkuren Go dengan goroutine yang saling berbagi referensi membuat analisis generasi menjadi sangat kompleks. Kedua, tim Go telah bereksperimen dengan GC generasional tetapi belum menemukan keuntungan yang konsisten dan signifikan untuk karakteristik program-program Go di dunia nyata. Ketiga, kesederhanaan model ini menghindari kompleksitas "promosi objek" antar generasi yang bisa menambah overhead tersendiri.


Alat untuk Memantau dan Mengoptimasi GC

Go menyediakan beberapa cara untuk mengintip perilaku GC dalam program kita.

Cara termudah adalah menggunakan variabel GODEBUG=gctrace=1 saat menjalankan program:

Ini akan mencetak baris seperti:

Baris ini memberi tahu kita: ini adalah siklus GC ke-7, membutuhkan total waktu tertentu, heap tumbuh dari 14 MB ke 8 MB setelah dibersihkan, dengan target 15 MB, dan ada 8 prosesor logis yang digunakan. Memahami output ini membantu kita mendiagnosis apakah GC menjadi bottleneck.

Selain itu, package runtime menyediakan runtime.ReadMemStats() untuk mendapatkan statistik memori secara programatik, dan Go Profiler (pprof) memiliki alat khusus untuk menganalisis penggunaan heap.


Tips Praktis: Menulis Kode Go yang "Bersahabat" dengan GC

Memahami cara kerja GC membantu kita menulis kode yang lebih efisien. Beberapa prinsip yang perlu diingat:

Kurangi alokasi heap yang tidak perlu. Setiap kali kita membuat objek dengan new() atau &Struct{}, ia berpotensi masuk ke heap. Gunakan variabel lokal biasa jika memungkinkan β€” compiler Go (escape analysis) cukup cerdas untuk menyimpannya di stack.

Gunakan sync.Pool untuk objek yang sering dibuat dan dihancurkan. Pool memungkinkan kita mendaur ulang objek mahal, misalnya buffer besar, tanpa harus mengalokasikan ulang setiap saat:

Hati-hati dengan pointer yang tidak perlu. Setiap pointer tambahan membuat GC harus menelusuri lebih banyak referensi. Kadang lebih baik menyalin nilai (value semantics) daripada selalu menggunakan pointer untuk struktur kecil.

Jangan panik soal GC. Untuk sebagian besar aplikasi, GC Go sudah sangat baik dan tidak perlu dioptimasi secara manual. Mulailah dengan mengukur (profiling) sebelum mengoptimasi β€” jangan berasumsi GC adalah masalahnya tanpa bukti.


Ringkasan

GC Go adalah hasil dari bertahun-tahun rekayasa yang cermat. Ia menggunakan algoritma Tri-Color Mark-and-Sweep yang konkuren, berjalan bersamaan dengan program untuk meminimalkan jeda. Dilindungi oleh Hybrid Write Barrier yang memastikan tidak ada objek hidup yang salah hapus, dan dikontrol oleh Pacer yang adaptif agar GC berjalan pada waktu yang tepat.

Desain non-generasional Go bukan kelemahan β€” melainkan pilihan sadar yang mengutamakan kesederhanaan dan prediktabilitas. Hasilnya adalah GC yang handal untuk server-server produksi dengan latensi rendah dan beban kerja konkuren tinggi.

Sebagai seorang developer Go, Anda tidak perlu memahami setiap detail implementasi untuk menulis kode yang baik. Namun mengerti mengapa GC dirancang seperti ini akan membantu Anda membuat keputusan yang lebih bijak dalam desain program, dan lebih percaya diri saat menghadapi isu performa memori di masa depan.


Referensi: Ardanlabs Go GC Semantics, Medium - Deep Dive into Go GC, Dev.to - Golang Triad GC Analysis, Go Optimization Guide (goperf.dev)

Last updated