Garbage collector membebaskan memori yang tidak lagi dirujuk, dan justru di situ letak masalahnya: dalam Go, kebocoran memori hampir selalu terjadi karena data masih dirujuk oleh sesuatu yang lupa kita bebaskan, bukan karena GC gagal bekerja. Kebocoran jarang meledak seketika — memori program naik perlahan setiap jam, lalu layanan tumbang di tengah malam. Chapter penutup section ini merangkum pola referensi tersembunyi yang paling sering menjadi biang keroknya, membuktikan dua pola besar dengan angka yang bisa diverifikasi, dan menunjukkan jalur deteksinya lewat profiling.

Pola Kebocoran yang Sering Muncul #

Subslice Menahan Backing Array #

Mengiris sepuluh elemen dari slice berisi satu juta elemen terasa seperti membuang sisanya, padahal slice kecil tersebut masih memegang pointer ke backing array penuh. Selama subslice hidup, GC tidak dapat membebaskan alokasi aslinya. Perbaikannya: salin data yang dibutuhkan ke slice baru dengan copy, sehingga array besar benar-benar terlepas — bukti angkanya kita lihat pada contoh utuh di bawah.

Goroutine yang Tak Pernah Pulang #

Goroutine yang menunggu channel yang tidak pernah dikirim atau ditutup akan menunggu selamanya, dan seluruh data yang ditangkap closure-nya ikut terjaga. Pola ini sering muncul pada worker yang dibuat per request tetapi tidak diberi jalur keluar. Perbaikannya: beri setiap goroutine channel penutupan atau context pembatal, lalu pastikan jalur tersebut benar-benar dipanggil sebelum pemanggil selesai.

Map Global yang Tumbuh Tanpa Batas #

go
var cache = make(map[string][]byte)

func simpan(kunci string, data []byte) {
	cache[kunci] = data // setiap kunci baru menambah isi map selamanya
}

Map di variabel global hidup seumur program, sehingga cache tanpa kebijakan penghapusan tumbuh tanpa hingga. Perbaikannya: tetapkan batas jumlah entri, hapus entri tua secara berkala, atau simpan data besar di penyimpanan eksternal dan biarkan map hanya menyimpan indeks.

Ticker Tanpa Stop #

go
func pantau() {
	t := time.NewTicker(time.Minute)
	defer t.Stop() // tanpa Stop, ticker terus berdetak seumur program
	for range t.C {
		// ... memproses sesuatu secara periodik ...
	}
}

Ticker yang dibuat lewat time.NewTicker tetap berdetak sampai Stop dipanggil. Ketika sebuah fungsi pembuat ticker kembali tanpa Stop, runtime timer dan channel-nya tetap hidup tanpa manfaat. Biasakan defer t.Stop() berdampingan dengan pembuatnya.

defer di Dalam Loop Panjang #

go
func prosesSemua(berkas []string) error {
	for _, nama := range berkas {
		f, err := os.Open(nama)
		if err != nil {
			return err
		}
		defer f.Close() // seluruh Close menumpuk sampai fungsi selesai

		// ... memproses isi f ...
	}
	return nil
}

Pernyataan defer baru dieksekusi ketika fungsi pembungkusnya kembali. Di dalam loop panjang, ratusan defer menumpuk dan ratusan berkas tetap terbuka sampai loop selesai. Perbaikannya: pindahkan isi loop menjadi fungsi tersendiri sehingga setiap defer dieksekusi per iterasi.

Contoh Utuh: Bukti dengan Angka #

Program berikut membuktikan dua pola secara terukur: kelima pekerja goroutine benar-benar pergi setelah sinyal penutupan dikirim, dan subslice menahan backing array penuh yang tak terlihat dari len-nya:

go
package main

import (
	"fmt"
	"runtime"
	"sync/atomic"
	"time"
)

// tahanBackingArray mengembalikan potongan kecil dari array besar.
// variabel kecil masih menahan backing array berukuran satu juta elemen,
// sedangkan salinan benar-benar berdiri sendiri.
func tahanBackingArray() (kecil, salinan []int) {
	besar := make([]int, 1_000_000) // ±8 MiB
	for i := range besar {
		besar[i] = i
	}
	kecil = besar[:10]
	salinan = make([]int, 10)
	copy(salinan, kecil)
	return kecil, salinan
}

func main() {
	fmt.Println("goroutine awal :", runtime.NumGoroutine())

	// pola pekerja: goroutine menunggu sinyal penutupan channel.
	// jika channel tidak pernah ditutup, goroutine menunggu selamanya.
	const jumlah = 5
	var siap atomic.Int32
	sinyal := make([]chan struct{}, jumlah)
	for i := range sinyal {
		sinyal[i] = make(chan struct{})
		go func() {
			siap.Add(1)
			<-sinyal[i]
		}()
	}

	// tunggu sampai seluruh pekerja tercipta dan menunggu
	for siap.Load() < jumlah {
		time.Sleep(time.Millisecond)
	}
	fmt.Println("pekerja menunggu:", runtime.NumGoroutine())

	// cleanup: tutup setiap channel sehingga pekerja boleh selesai
	for i := range sinyal {
		close(sinyal[i])
	}
	for runtime.NumGoroutine() > 1 {
		time.Sleep(time.Millisecond)
	}
	fmt.Println("setelah cleanup :", runtime.NumGoroutine())

	kecil, salinan := tahanBackingArray()
	fmt.Println("kecil   -> len:", len(kecil), "cap:", cap(kecil))
	fmt.Println("salinan -> len:", len(salinan), "cap:", cap(salinan))
}

Jalankan program tersebut dari direktori chapter dengan perintah berikut:

bash
go run memory-leak-demo.go

Output yang Dihasilkan:

text
goroutine awal : 1
pekerja menunggu: 6
setelah cleanup : 1
kecil   -> len: 10 cap: 1000000
salinan -> len: 10 cap: 10

Mari kita telaah baris demi baris program di atas:

  1. Baris 13-22 (tahanBackingArray): kecil = besar[:10] membuat subslice; copy(salinan, kecil) membuat salinan mandiri yang melepaskan diri dari array besar.
  2. Baris 29-38 (go func()): lima goroutine pekerja masing-masing menunggu channel miliknya — kalau penutupan pada baris 47-49 dihapus, kelimanya menunggu selamanya dan hitungan tidak pernah kembali ke 1.
  3. Baris 41-44 (siap.Load()): loop kecil menunggu seluruh pekerja tercipta, lalu NumGoroutine mencatat 6 — goroutine main ditambah lima pekerja.
  4. Baris 47-53 (close(sinyal[i])): setelah setiap channel ditutup, jumlah goroutine kembali ke 1; inilah alasan program ini sengaja melakukan cleanup, bukan membiarkan kebocoran sungguhan.
  5. Baris 55-57 (cap(kecil)): nilainya 1000000 — bukti bahwa backing array penuh masih ditahan meski len-nya hanya 10; salinan memiliki cap sepuluh dan tidak menahan apa pun di luar itu.

Mendeteksi dengan pprof #

Ketika kebocoran sudah terjadi di lapangan, dugaan tidak cukup — kita membutuhkan profil heap. Package runtime/pprof merekam alokasi ke berkas profil, dan perintah go tool pprof menampilkan fungsi-fungsi penghasil alokasi terbesar berikut ukurannya. Petunjuk praktis lengkap tersedia pada chapter Memory Profiling dan pprof . Untuk kebocoran goroutine, angka runtime.NumGoroutine yang terus naik pada metrik layanan adalah alarm paling murah sebelum profil diambil.

Referensi #

Section Pointer dan Memory kita tutup di sini: pointer memberi kontrol atas alamat dan masa hidup data, escape analysis menentukan rumahnya, stack dan heap menjadi ladangnya, GC menjaganya, dan disiplin referensi menjaga kebersihannya. Kemampuan ini menjadi bekal utama saat data mulai dibungkus struct — struktur data dan method-nya menyambut kita pada section berikutnya.

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

memory-leak-demo.go

Unduh berkas
go
package main

import (
	"fmt"
	"runtime"
	"sync/atomic"
	"time"
)

// tahanBackingArray mengembalikan potongan kecil dari array besar.
// variabel kecil masih menahan backing array berukuran satu juta elemen,
// sedangkan salinan benar-benar berdiri sendiri.
func tahanBackingArray() (kecil, salinan []int) {
	besar := make([]int, 1_000_000) // ±8 MiB
	for i := range besar {
		besar[i] = i
	}
	kecil = besar[:10]
	salinan = make([]int, 10)
	copy(salinan, kecil)
	return kecil, salinan
}

func main() {
	fmt.Println("goroutine awal :", runtime.NumGoroutine())

	// pola pekerja: goroutine menunggu sinyal penutupan channel.
	// jika channel tidak pernah ditutup, goroutine menunggu selamanya.
	const jumlah = 5
	var siap atomic.Int32
	sinyal := make([]chan struct{}, jumlah)
	for i := range sinyal {
		sinyal[i] = make(chan struct{})
		go func() {
			siap.Add(1)
			<-sinyal[i]
		}()
	}

	// tunggu sampai seluruh pekerja tercipta dan menunggu
	for siap.Load() < jumlah {
		time.Sleep(time.Millisecond)
	}
	fmt.Println("pekerja menunggu:", runtime.NumGoroutine())

	// cleanup: tutup setiap channel sehingga pekerja boleh selesai
	for i := range sinyal {
		close(sinyal[i])
	}
	for runtime.NumGoroutine() > 1 {
		time.Sleep(time.Millisecond)
	}
	fmt.Println("setelah cleanup :", runtime.NumGoroutine())

	kecil, salinan := tahanBackingArray()
	fmt.Println("kecil   -> len:", len(kecil), "cap:", cap(kecil))
	fmt.Println("salinan -> len:", len(salinan), "cap:", cap(salinan))
}