Program yang boros memori tidak selalu lambat di detik pertama — biayanya muncul pelan: garbage collector makin sering bekerja, latensi makin bergelombang, hingga OOM di jam sibuk. Dua profil pprof menjawab sisi memori: heap memotret objek yang masih hidup, allocs menjumlahkan seluruh alokasi sejak program mulai. Keduanya saling melengkapi karena masalah yang berbeda: kebocoran (banyak objek hidup) lawan pemborosan (banyak objek sekejap). Pada chapter ini kita merekam keduanya dari satu program, membaca statistik runtime.MemStats sebagai angka acuan, lalu membedah sumber alokasi dengan go tool pprof.

Counter Acuan dari runtime.MemStats #

Sebelum masuk ke profil, runtime.ReadMemStats memberi angka ringkas langsung dari runtime. Struktur runtime.MemStats memuat puluhan field; tiga yang paling penting untuk diagnosis adalah TotalAlloc (total byte yang pernah dialokasikan, kumulatif), Mallocs (jumlah operasi alokasi), dan HeapAlloc (byte yang sedang hidup). Berkas contoh.go di bawah halaman membangun laporan 100 ribu baris, mengukur selisih sebelum-sesudah, lalu menulis kedua profil:

go
package main

import (
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
	"strings"
)

// buatLaporan membangun laporan transaksi sebagai satu string panjang.
func buatLaporan(jumlahBaris int) string {
	var b strings.Builder
	for i := range jumlahBaris {
		fmt.Fprintf(&b, "trx-%06d status=sukses jumlah=%d\n", i, 1000+i%7)
	}
	return b.String()
}

func main() {
	var awal, akhir runtime.MemStats
	runtime.ReadMemStats(&awal)

	laporan := buatLaporan(100_000)
	runtime.GC() // paksakan GC sebelum statistik akhir dibaca
	runtime.ReadMemStats(&akhir)

	berkasHeap, err := os.CreateTemp("", "heap-*.prof")
	if err != nil {
		fmt.Println("gagal membuat berkas profil heap:", err)
		return
	}
	if err := pprof.WriteHeapProfile(berkasHeap); err != nil {
		fmt.Println("gagal menulis profil heap:", err)
		return
	}
	berkasHeap.Close()

	berkasAllocs, err := os.CreateTemp("", "allocs-*.prof")
	if err != nil {
		fmt.Println("gagal membuat berkas profil allocs:", err)
		return
	}
	if err := pprof.Lookup("allocs").WriteTo(berkasAllocs, 0); err != nil {
		fmt.Println("gagal menulis profil allocs:", err)
		return
	}
	berkasAllocs.Close()

	// Nilai mentah TotalAlloc bergeser beberapa ratus byte antar eksekusi
	// karena alokasi internal runtime; pembulatan MiB dan ribuan menjadikan
	// output program ini stabil untuk dibandingkan.
	// Cuplikan menjaga isi laporan tetap hidup sehingga terlihat di profil heap.
	fmt.Println("panjang laporan:", len(laporan), "byte")
	fmt.Printf("cuplikan laporan: %q\n", laporan[:24])
	fmt.Println("TotalAlloc bertambah:", (akhir.TotalAlloc-awal.TotalAlloc)/1024/1024, "MiB")
	fmt.Println("Mallocs bertambah:", (akhir.Mallocs-awal.Mallocs)/1000, "ribu alokasi")
	fmt.Println("profil heap ditulis: true")
	fmt.Println("profil allocs ditulis: true")
}

Output yang Dihasilkan:

text
panjang laporan: 3700000 byte
cuplikan laporan: "trx-000000 status=sukses"
TotalAlloc bertambah: 21 MiB
Mallocs bertambah: 199 ribu alokasi
profil heap ditulis: true
profil allocs ditulis: true

Angka-angka itu sudah bercerita: laporan jadi hanya 3,7 juta byte, tetapi runtime mengalokasikan 21 MiB — hampir enam kali lipatnya — dan 199 ribu operasi alokasi. Pembulatan ke MiB dan ribuan sengaja dipakai karena nilai byte persisnya bergeser beberapa ratus byte antar eksekusi (alokasi internal runtime), sedangkan angka yang dibulatkan stabil berhari-hari dan antar mesin; pola yang sama berlaku saat Anda membandingkan dua versi program.

Mari kita telaah baris demi baris program di atas:

  1. Baris 21–22: ReadMemStats membekukan dunia sejenak untuk mengambil statistik konsisten; pembacaan pertama menjadi titik nol pengukuran.
  2. Baris 24–26: beban kerja dijalankan, lalu runtime.GC() memaksa pembersihan sebelum statistik akhir — tanpa ini, objek sampah masih tercatat hidup di heap profile.
  3. Baris 33 (WriteHeapProfile): menulis profil heap ke berkas; secara internal sama dengan pprof.Lookup("heap").WriteTo.
  4. Baris 44 (Lookup("allocs").WriteTo): profil allocs memakai data yang sama dengan heap, hanya berbeda indeks default saat dibaca — inuse lawan alloc.
  5. Baris 55 (laporan[:24]): cuplikan menjaga isi string tetap hidup (terpakai di output) sehingga muncul di profil heap — tanpa pemakaian itu, GC boleh memungutnya.

Membaca Profil Heap (Objek Hidup) #

Ganti nama berkas sesuai keluaran di mesin Anda, lalu tampilkan peringkat alokasi yang masih hidup:

bash
go tool pprof -top /tmp/heap-XXXX.prof

Output yang Dihasilkan:

text
File: contoh
Build ID: 2019ec3b03dc40fa7671d8446e312fab72344e5e
Type: inuse_space
Time: 2026-10-13 10:55:58 UTC
Showing nodes accounting for 4729.17kB, 100% of 4729.17kB total
Showing top 6 nodes out of 9
      flat  flat%   sum%        cum   cum%
 4217.12kB 89.17% 89.17% 4217.12kB 89.17%  strings.(*Builder).Write
  512.05kB 10.83%  100%  512.05kB 10.83%  runtime.mallocgc
         0     0%   100% 4217.12kB 89.17%  fmt.Fprintf
         0     0%   100% 4217.12kB 89.17%  main.buatLaporan
         0     0%   100% 4217.12kB 89.17%  main.main
         0     0%   100%  512.05kB 10.83%  runtime.(*scavengerState).init

Setelah runtime.GC(), satu-satunya alokasi program yang masih hidup adalah buffer strings.Builder sebesar 4,2 MB — persis wadah yang menampung laporan 3,7 MB — ditambah sekitar 512 kB struktur internal runtime. Stack fmt.Fprintf → buatLaporan → main tampil dengan cum yang sama karena ketiganya berada di jalur pemanggilan menuju satu alokasi besar itu. Jika profil heap aplikasi production terus membesar antar dua snapshot yang jauh waktunya, itulah pola klasik kebocoran memori.

Membaca Profil Allocs (Total Alokasi) #

Profil heap menjawab “siapa yang menahan memori”, sementara profil allocs menjawab “siapa yang paling rajin mengalokasikan”. Aktifkan indeks alloc_space secara eksplisit:

bash
go tool pprof -top -sample_index=alloc_space /tmp/allocs-XXXX.prof

Output yang Dihasilkan:

text
File: contoh
Build ID: 2019ec3b03dc40fa7671d8446e312fab72344e5e
Type: alloc_space
Time: 2026-10-13 10:55:58 UTC
Showing nodes accounting for 23574.75kB, 100% of 23574.75kB total
Showing top 6 nodes out of 9
      flat  flat%   sum%        cum   cum%
21014.67kB 89.14% 89.14% 21014.67kB 89.14%  strings.(*Builder).Write
 2048.03kB  8.69% 97.83% 23062.70kB 97.83%  main.buatLaporan
  512.05kB  2.17%  100%  512.05kB  2.17%  runtime.mallocgc
         0     0%   100% 21014.67kB 89.14%  fmt.Fprintf
         0     0%   100% 23062.70kB 97.83%  main.main
         0     0%   100%  512.05kB  2.17%  runtime.(*scavengerState).init

Total alokasi 23,5 MB menjelaskan selisih enam kali lipat tadi: strings.Builder menumbuhkan buffer dengan menggandakan kapasitas, dan setiap pertumbuhan mengalokasikan blok baru sambil menyalin isi lama — 21 MB dari total datang dari pertumbuhan itu. Perbaikannya sudah jelas dari profil: alokasikan kapasitas awal b.Grow(...) sesuai perkiraan, atau tulis per baris ke bufio.Writer agar tidak ada penyalinan sama sekali. Bab Efisiensi Slice dan Map membahas pola pertumbuhan ini lebih jauh.

Setelah CPU dan memori, satu dimensi diagnosis masih tersembunyi: bagaimana penjadwal membagi waktu antar goroutine. Execution tracer membuka dimensi itu pada chapter berikutnya.

Referensi #

Berkas Sumber

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

contoh.go

Unduh berkas
go
package main

import (
	"fmt"
	"os"
	"runtime"
	"runtime/pprof"
	"strings"
)

// buatLaporan membangun laporan transaksi sebagai satu string panjang.
func buatLaporan(jumlahBaris int) string {
	var b strings.Builder
	for i := range jumlahBaris {
		fmt.Fprintf(&b, "trx-%06d status=sukses jumlah=%d\n", i, 1000+i%7)
	}
	return b.String()
}

func main() {
	var awal, akhir runtime.MemStats
	runtime.ReadMemStats(&awal)

	laporan := buatLaporan(100_000)
	runtime.GC() // paksakan GC sebelum statistik akhir dibaca
	runtime.ReadMemStats(&akhir)

	berkasHeap, err := os.CreateTemp("", "heap-*.prof")
	if err != nil {
		fmt.Println("gagal membuat berkas profil heap:", err)
		return
	}
	if err := pprof.WriteHeapProfile(berkasHeap); err != nil {
		fmt.Println("gagal menulis profil heap:", err)
		return
	}
	berkasHeap.Close()

	berkasAllocs, err := os.CreateTemp("", "allocs-*.prof")
	if err != nil {
		fmt.Println("gagal membuat berkas profil allocs:", err)
		return
	}
	if err := pprof.Lookup("allocs").WriteTo(berkasAllocs, 0); err != nil {
		fmt.Println("gagal menulis profil allocs:", err)
		return
	}
	berkasAllocs.Close()

	// Nilai mentah TotalAlloc bergeser beberapa ratus byte antar eksekusi
	// karena alokasi internal runtime; pembulatan MiB dan ribuan menjadikan
	// output program ini stabil untuk dibandingkan.
	// Cuplikan menjaga isi laporan tetap hidup sehingga terlihat di profil heap.
	fmt.Println("panjang laporan:", len(laporan), "byte")
	fmt.Printf("cuplikan laporan: %q\n", laporan[:24])
	fmt.Println("TotalAlloc bertambah:", (akhir.TotalAlloc-awal.TotalAlloc)/1024/1024, "MiB")
	fmt.Println("Mallocs bertambah:", (akhir.Mallocs-awal.Mallocs)/1000, "ribu alokasi")
	fmt.Println("profil heap ditulis: true")
	fmt.Println("profil allocs ditulis: true")
}