Memahami Bagaimana Garbage Collector di Go Bekerja
16 min read

Memahami Bagaimana Garbage Collector di Go Bekerja

Sebagian besar Go developer menulis kode tanpa pernah memikirkan GC secara eksplisit — dan itu adalah desain yang disengaja oleh tim Go. Tapi ada momen di mana pemahaman tentang cara GC bekerja menjadi tidak opsional: ketika aplikasi mengalami latency spike yang tidak bisa dijelaskan, ketika memory usage terus naik meskipun tidak ada memory leak yang jelas, atau ketika profiling menunjukkan bahwa GC memakan persentase CPU yang tidak proporsional. Di titik itu, GC bukan lagi detail implementasi yang bisa diabaikan — ia adalah variabel yang harus dipahami untuk membuat keputusan yang tepat. Artikel ini membahas cara kerja GC Go dari level algoritma hingga level praktis: bagaimana ia menemukan objek yang tidak lagi dibutuhkan, bagaimana ia menghindari stop-the-world yang lama, apa yang menyebabkannya bekerja keras, dan bagaimana menulis kode yang bekerja harmonis dengannya.

Mengapa Go Memilih Tracing GC

Ada dua pendekatan besar dalam automatic memory management: reference counting dan tracing garbage collection. Python dan Swift menggunakan reference counting — setiap objek menyimpan hitungan berapa banyak reference yang menunjuk ke dirinya, dan ketika hitungan mencapai nol, objek langsung dibebaskan. Go memilih tracing GC dengan alasan yang sangat konkret.

Reference counting punya dua masalah fundamental yang sulit diselesaikan. Pertama, ia tidak bisa mendeteksi reference cycle — dua objek yang saling menunjuk satu sama lain tidak akan pernah mencapai hitungan nol meskipun keduanya sudah tidak bisa diakses dari program. Ini memaksa adanya mekanisme cycle detector terpisah yang menambah kompleksitas. Kedua, setiap update reference harus atomic untuk thread safety, yang menambah overhead di setiap assignment — biaya yang tidak terlihat per-operasi tapi signifikan secara agregat di program dengan banyak goroutine.

Tracing GC menyelesaikan kedua masalah itu: ia bisa mendeteksi cycle secara alami karena berjalan dari root, dan tidak ada overhead per-assignment untuk menghitung reference. Trade-off-nya adalah GC butuh waktu untuk berjalan secara periodik — dan inilah yang menentukan karakteristik latency Go.


Tri-Color Mark-and-Sweep: Algoritma Utama

GC Go menggunakan algoritma tri-color mark-and-sweep yang dirancang untuk bisa berjalan secara concurrent dengan goroutine aplikasi. Memahami tiga warna ini adalah kunci untuk memahami seluruh mekanisme GC.

Tiga Warna dan Maknanya

Setiap objek di heap Go pada setiap saat berada di salah satu dari tiga warna:

Putih — objek yang belum diperiksa oleh GC. Di awal siklus GC, semua objek berwarna putih. Di akhir siklus, objek yang masih putih adalah objek yang tidak bisa dicapai dari root — mereka adalah garbage yang akan di-sweep.

Abu-abu — objek yang sudah ditemukan oleh GC (bisa dicapai dari root), tapi referensi-referensi yang dimilikinya belum sepenuhnya diperiksa. Objek abu-abu adalah “antrian pekerjaan” GC.

Hitam — objek yang sudah diperiksa sepenuhnya beserta semua referensinya. Objek hitam dijamin masih hidup dan tidak akan disentuh lagi di siklus ini.

stateDiagram-v2
    [*] --> Putih: Awal siklus GC\nSemua objek dimulai putih

    Putih --> AbuAbu: Ditemukan dari root\natau dari objek abu-abu
    AbuAbu --> Hitam: Semua referensi\nsudah diperiksa

    Putih --> Freed: Akhir siklus\nObjek putih = garbage
    Hitam --> Putih: Awal siklus berikutnya\nReset semua ke putih

    note right of AbuAbu: Worklist GC\nMenunggu referensinya\ndiperiksa
    note right of Hitam: Dijamin masih hidup\nTidak bisa menunjuk\nke objek putih

Invariant yang Menjamin Kebenaran

Algoritma tri-color bekerja dengan satu invariant yang tidak boleh dilanggar: objek hitam tidak boleh memiliki referensi langsung ke objek putih. Jika invariant ini terjaga, maka di akhir fase marking, semua objek putih yang tersisa pasti tidak bisa dicapai dari root dan aman untuk dibebaskan.

Invariant inilah yang membuat concurrent GC bisa berjalan bersama goroutine aplikasi — tapi juga yang membuat write barrier menjadi keharusan.

Fase-fase dalam Satu Siklus GC

sequenceDiagram
    participant App as Goroutine Aplikasi
    participant GC as GC Goroutine
    participant Heap as Heap Memory

    Note over App,Heap: Fase 1: Mark Setup (Stop-The-World, sangat singkat)
    GC->>App: STW — pause semua goroutine
    GC->>GC: Aktifkan write barrier
    GC->>GC: Scan root (stack, globals, heap pointers)
    GC->>App: Resume — goroutine jalan lagi

    Note over App,Heap: Fase 2: Concurrent Mark (berjalan bersamaan dengan aplikasi)
    GC->>Heap: Scan objek abu-abu, temukan referensi ke putih
    GC->>Heap: Warnai objek putih yang ditemukan menjadi abu-abu
    GC->>Heap: Warnai objek abu-abu yang selesai di-scan menjadi hitam
    App->>Heap: Goroutine aplikasi terus berjalan dan mengalokasi memori
    App->>GC: Write barrier melaporkan perubahan pointer ke GC

    Note over App,Heap: Fase 3: Mark Termination (Stop-The-World, sangat singkat)
    GC->>App: STW — pause semua goroutine
    GC->>GC: Drain sisa worklist abu-abu
    GC->>GC: Nonaktifkan write barrier
    GC->>App: Resume

    Note over App,Heap: Fase 4: Concurrent Sweep (berjalan bersamaan dengan aplikasi)
    GC->>Heap: Bebaskan semua span yang hanya berisi objek putih
    App->>Heap: Goroutine aplikasi terus berjalan normal

Yang paling penting dari diagram ini: stop-the-world (STW) hanya terjadi dua kali dan sangat singkat — biasanya di bawah 1 milidetik untuk program yang dikonfigurasi dengan baik. Sebagian besar pekerjaan GC (marking dan sweeping) berjalan secara concurrent.


Write Barrier: Menjaga Invariant saat Concurrent

Write barrier adalah mekanisme yang paling sering disalahpahami dalam GC Go. Banyak developer tahu bahwa write barrier “ada” tapi tidak paham mengapa ia diperlukan.

Masalah yang Diselesaikan Write Barrier

Tanpa write barrier, skenario berikut bisa merusak invariant tri-color:

sequenceDiagram
    participant App as Goroutine Aplikasi
    participant GC as GC (sedang marking)

    Note over App,GC: Kondisi: A (hitam), B (abu-abu), C (putih)
    Note over App,GC: GC sudah selesai marking A — A tidak akan diperiksa lagi

    App->>App: A.ref = C  (objek hitam A sekarang menunjuk ke putih C)
    App->>App: B.ref = nil (objek abu-abu B melepas referensi ke C)

    Note over GC: GC memproses B — tidak menemukan C
    Note over GC: C tidak pernah di-marking menjadi abu-abu
    Note over GC: Di akhir siklus: C masih putih → dianggap garbage!
    Note over GC: BUG: C masih dipakai oleh A tapi sudah dibebaskan

Tanpa write barrier, GC bisa membebaskan objek yang masih aktif digunakan — ini adalah use-after-free bug yang sangat serius.

Cara Write Barrier Bekerja

Go menggunakan hybrid write barrier (sejak Go 1.17) yang menangkap dua jenis operasi:

Saat pointer baru ditulis — jika goroutine aplikasi menulis pointer ke slot memori, write barrier memastikan objek yang ditunjuk masuk ke worklist abu-abu GC.

Saat pointer lama dihapus — nilai lama dari slot memori juga dimasukkan ke worklist abu-abu, memastikan objek yang dilepas tidak hilang begitu saja sebelum GC sempat memeriksanya.

// Apa yang terlihat di kode Go:
a.field = c

// Apa yang sebenarnya terjadi saat write barrier aktif:
// (pseudocode — ini diimplementasikan di level runtime, bukan kode Go biasa)
//
// oldValue := a.field   // simpan nilai lama
// shade(oldValue)        // masukkan nilai lama ke worklist abu-abu
// a.field = c            // lakukan assignment
// shade(c)               // masukkan nilai baru ke worklist abu-abu

Write barrier hanya aktif selama fase marking. Ini punya konsekuensi penting: ada overhead kecil di setiap pointer write selama GC berjalan, tapi tidak ada overhead sama sekali di antara siklus GC. Berbeda dengan reference counting yang selalu punya overhead.

Write barrier hanya berlaku untuk pointer yang disimpan di heap — pointer di stack goroutine ditangani secara terpisah dengan cara yang lebih murah karena stack di-scan saat STW mark setup. Ini adalah salah satu alasan mengapa mengalokasi di stack (bukan heap) lebih efisien dalam konteks GC.

Escape Analysis: Apa yang Masuk Heap

Sebelum membahas lebih jauh tentang cara GC mengelola heap, penting untuk memahami apa yang memutuskan apakah sebuah alokasi masuk ke stack atau heap. Keputusan ini dibuat oleh compiler Go melalui proses yang disebut escape analysis.

Objek yang dialokasikan di stack jauh lebih murah: tidak perlu di-track oleh GC, dibebaskan otomatis ketika fungsi return, dan tidak menambah tekanan ke GC. Objek yang “escape” ke heap adalah objek yang harus di-track oleh GC.

// ✓ Tidak escape ke heap — dialokasikan di stack
func sum(a, b int) int {
    result := a + b  // result ada di stack, hilang saat fungsi return
    return result
}

// ✓ Tidak escape — compiler bisa membuktikan pointer tidak keluar
func process() {
    x := 42
    helper(&x)  // jika helper tidak menyimpan pointer ke x, x tetap di stack
}

// ✗ Escape ke heap — pointer dikembalikan, bisa diakses setelah fungsi return
func newUser(name string) *User {
    u := User{Name: name}  // u harus di heap karena pointernya keluar dari fungsi
    return &u
}

// ✗ Escape ke heap — disimpan di interface{}
func store(v interface{}) {
    cache[key] = v  // nilai konkret di-box ke interface, masuk heap
}

// ✗ Escape ke heap — ukuran tidak diketahui saat compile time
func makeSlice(n int) []int {
    return make([]int, n)  // n dinamis, tidak bisa di stack
}

Kamu bisa melihat apa yang di-escape oleh compiler dengan flag -gcflags="-m":

# Lihat escape analysis untuk satu file
go build -gcflags="-m" ./main.go

# Output contoh:
# ./main.go:15:6: moved to heap: u
# ./main.go:22:14: make([]int, n) escapes to heap
# ./main.go:8:14: result does not escape

Memahami escape analysis membantu menulis kode yang menghasilkan lebih sedikit alokasi heap — yang berarti lebih sedikit tekanan ke GC.


Memicu GC: Kapan dan Mengapa

Go tidak menjalankan GC pada interval waktu yang tetap. GC dipicu berdasarkan pertumbuhan heap, diatur oleh parameter yang bisa dikontrol.

GOGC: Target Ratio Pertumbuhan Heap

Parameter GOGC (default 100) mendefinisikan berapa persen pertumbuhan heap yang diizinkan sebelum GC berikutnya dipicu. Dengan GOGC=100, GC akan berjalan ketika ukuran heap saat ini mencapai dua kali ukuran heap yang tersisa setelah GC sebelumnya.

flowchart LR
    GC1["GC Selesai\nLive heap: 50 MB"] --> CALC["Target next GC:\n50 MB × (1 + GOGC/100)\n= 50 MB × 2\n= 100 MB"]
    CALC --> ALLOC["Aplikasi mengalokasi\nmemori baru..."]
    ALLOC --> TRIGGER{"Heap mencapai\n100 MB?"}
    TRIGGER -- Ya --> GC2["GC berikutnya\ndipicu"]
    TRIGGER -- Tidak --> ALLOC
    GC2 --> GC1

Konsekuensi dari formula ini:

  • GOGC lebih tinggi (misal 200) → GC lebih jarang dipicu, overhead CPU GC lebih rendah, tapi penggunaan memory lebih tinggi
  • GOGC lebih rendah (misal 50) → GC lebih sering dipicu, memory footprint lebih kecil, tapi overhead CPU GC lebih tinggi
  • GOGC=off → GC tidak pernah dipicu secara otomatis (hanya bisa dipicu manual dengan runtime.GC())

GOMEMLIMIT: Batas Atas yang Lebih Aman (Go 1.19+)

GOMEMLIMIT adalah parameter yang lebih baru dan sangat berguna untuk deployment di container. Ia mendefinisikan batas atas memori yang boleh digunakan oleh Go runtime — termasuk heap, stack, dan overhead runtime.

flowchart TD
    ALLOC[Aplikasi mengalokasi memori] --> CHECK{Heap mendekati\nGOMEMLIMIT?}
    CHECK -- Tidak --> NORMAL[Lanjut normal\nGOGC mengontrol frekuensi GC]
    CHECK -- Ya --> AGGRESSIVE[GC berjalan lebih agresif\nmeski target GOGC belum tercapai]
    AGGRESSIVE --> FREED{Cukup memory\ndibebaskan?}
    FREED -- Ya --> NORMAL
    FREED -- Tidak --> OOM[OOM — batas sudah tercapai\nruntime tidak bisa berbuat lebih]

Kombinasi GOGC dan GOMEMLIMIT memberikan kontrol yang lebih presisi:

# Di container dengan limit 512 MB, set GOMEMLIMIT sedikit di bawah limit container
# agar GC bisa bekerja sebelum container OOM-killed
GOMEMLIMIT=450MiB go run main.go

# Atau via kode:
import "runtime/debug"
debug.SetMemoryLimit(450 * 1024 * 1024)
// Contoh: konfigurasi GC via kode untuk microservice di container
func init() {
    // Set GOMEMLIMIT ke 90% dari container memory limit
    // untuk memberikan buffer sebelum OOM killer aktif
    containerMemLimit := int64(512 * 1024 * 1024) // 512 MB
    debug.SetMemoryLimit(int64(float64(containerMemLimit) * 0.9))

    // GOGC=100 adalah default yang wajar untuk kebanyakan service
    // Naikkan jika CPU overhead GC terlalu tinggi
    // Turunkan jika memory footprint perlu dikecilkan
    debug.SetGCPercent(100)
}
Jangan set GOMEMLIMIT sama persis dengan container memory limit. GC butuh sedikit ruang untuk bekerja — jika heap sudah di batas saat GC dimulai, GC tidak punya ruang untuk alokasi sementara yang dibutuhkan selama marking. Gunakan 85–90% dari container limit sebagai nilai yang aman.

Membaca GC Trace

Sebelum melakukan tuning apapun, kamu perlu bisa membaca sinyal yang diberikan GC. GODEBUG=gctrace=1 adalah cara paling langsung untuk melihat apa yang GC lakukan.

GODEBUG=gctrace=1 go run main.go

# Output contoh:
# gc 1 @0.012s 3%: 0.024+2.1+0.018 ms clock, 0.19+0.44/2.0/0+0.14 ms cpu,
#    4->4->2 MB, 5 MB goal, 0 MB stacks, 0 MB globals, 8 P

Format output ini terlihat kriptik tapi sangat informatif jika dipahami:

gc 1        → nomor siklus GC (kumulatif sejak program start)
@0.012s     → waktu sejak program start
3%          → persentase waktu CPU yang dihabiskan GC (idealnya < 5%)

0.024       → durasi STW mark setup (milidetik)
2.1         → durasi concurrent mark (milidetik)
0.018       → durasi STW mark termination (milidetik)

4->4->2 MB  → ukuran heap: sebelum GC → setelah marking → setelah sweep
5 MB goal   → target ukuran heap untuk GC berikutnya (berdasarkan GOGC)
8 P         → jumlah processor (GOMAXPROCS)
// Program sederhana untuk mengamati GC trace
package main

import (
    "fmt"
    "runtime"
    "time"
)

func allocate() []byte {
    // Alokasi yang akan di-GC
    return make([]byte, 1*1024*1024) // 1 MB per panggilan
}

func main() {
    var slices [][]byte

    for i := 0; i < 20; i++ {
        s := allocate()
        slices = append(slices, s)

        if i%5 == 4 {
            // Lepaskan referensi agar GC bisa bekerja
            slices = nil
        }

        var stats runtime.MemStats
        runtime.ReadMemStats(&stats)
        fmt.Printf("Iterasi %2d: Heap=%5d KB, NumGC=%d\n",
            i, stats.HeapAlloc/1024, stats.NumGC)

        time.Sleep(100 * time.Millisecond)
    }
}

Jalankan dengan GODEBUG=gctrace=1 go run main.go dan perhatikan pola GC yang muncul.


Pola Kode yang Ramah GC

Memahami GC memungkinkan kamu menulis kode yang lebih efisien — bukan dengan menghindari alokasi sama sekali (yang seringkali tidak mungkin), tapi dengan membuat pola alokasi yang lebih mudah dikelola oleh GC.

Gunakan sync.Pool untuk Objek Berumur Pendek

sync.Pool adalah mekanisme untuk me-reuse objek yang sering dibuat dan dibuang. GC memahami pool secara khusus — objek di pool bisa dibebaskan saat GC berjalan tapi tidak diperlakukan sebagai garbage yang “perlu segera dibebaskan”.

import (
    "bytes"
    "sync"
)

// BENAR: pool untuk buffer yang sering dipakai dan dibuang
var bufPool = sync.Pool{
    New: func() interface{} {
        return new(bytes.Buffer)
    },
}

func processRequest(data []byte) string {
    // Ambil dari pool — reuse jika tersedia, buat baru jika tidak
    buf := bufPool.Get().(*bytes.Buffer)
    buf.Reset() // penting: reset sebelum dipakai
    defer bufPool.Put(buf) // kembalikan ke pool setelah selesai

    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
}

// ANTI-PATTERN: buat buffer baru setiap request
func processRequestNaive(data []byte) string {
    buf := new(bytes.Buffer) // ✗ alokasi baru setiap panggilan
    buf.Write(data)
    buf.WriteString(" processed")
    return buf.String()
    // buf di-GC setelah fungsi return — tekanan GC tinggi jika request banyak
}

Minimalkan Alokasi di Hot Path

Alokasi di hot path — kode yang dieksekusi ribuan atau jutaan kali per detik — adalah kontributor utama tekanan GC. Beberapa pola untuk menguranginya:

// ANTI-PATTERN: alokasi slice baru untuk setiap operasi filter
func filterActiveUsers(users []User) []User {
    result := []User{} // ✗ alokasi baru setiap panggilan
    for _, u := range users {
        if u.Active {
            result = append(result, u)
        }
    }
    return result
}

// BENAR: pre-alokasi dengan kapasitas yang diperkirakan
func filterActiveUsers(users []User) []User {
    result := make([]User, 0, len(users)) // ✓ pre-alokasi, tidak perlu realokasi
    for _, u := range users {
        if u.Active {
            result = append(result, u)
        }
    }
    return result
}

// LEBIH BAIK: gunakan slice yang sudah ada sebagai destination (zero allocation)
func filterActiveUsersInto(users []User, dst []User) []User {
    dst = dst[:0] // reset length tapi pertahankan capacity
    for _, u := range users {
        if u.Active {
            dst = append(dst, u)
        }
    }
    return dst
}
// ANTI-PATTERN: konversi string-to-[]byte yang tidak perlu
func containsKeyword(content string, keyword string) bool {
    return bytes.Contains([]byte(content), []byte(keyword)) // ✗ dua alokasi per panggilan
}

// BENAR: gunakan strings package yang tidak perlu alokasi
func containsKeyword(content string, keyword string) bool {
    return strings.Contains(content, keyword) // ✓ zero allocation
}

Hindari Pointer yang Tidak Perlu untuk Struct Kecil

Pointer selalu masuk heap — bahkan untuk struct yang sangat kecil. Untuk struct kecil yang sering dibuat, nilai (bukan pointer) bisa lebih efisien karena bisa dialokasikan di stack.

// ANTI-PATTERN: pointer ke struct kecil yang tidak perlu hidup di luar fungsi
type Point struct {
    X, Y float64
}

func computeDistance(p1, p2 *Point) float64 { // ✗ kedua point di heap
    dx := p2.X - p1.X
    dy := p2.Y - p1.Y
    return math.Sqrt(dx*dx + dy*dy)
}

// BENAR: nilai, bukan pointer — compiler bisa letakkan di stack
func computeDistance(p1, p2 Point) float64 { // ✓ p1 dan p2 di stack
    dx := p2.X - p1.X
    dy := p2.Y - p1.Y
    return math.Sqrt(dx*dx + dy*dy)
}

Gunakan Slice of Struct, Bukan Slice of Pointer

Salah satu pola yang paling berpengaruh pada performa GC tapi sering tidak disadari adalah perbedaan antara []T dan []*T.

// ANTI-PATTERN: slice of pointer — GC harus scan setiap pointer
type Record struct {
    ID   int64
    Data [256]byte
}

records := make([]*Record, 1000) // ✗ 1000 pointer → 1000 objek terpisah di heap
for i := range records {
    records[i] = &Record{ID: int64(i)}
}
// GC harus mengikuti 1000 pointer untuk memeriksa apakah Record masih hidup

// BENAR: slice of value — GC hanya perlu scan satu objek
records := make([]Record, 1000) // ✓ satu alokasi, satu objek di heap
for i := range records {
    records[i].ID = int64(i)
}
// GC hanya perlu memeriksa satu span memori yang berisi semua Record

Perbedaan ini sangat signifikan untuk program yang memiliki banyak objek kecil dalam koleksi besar. []T memberikan GC pekerjaan yang jauh lebih sedikit karena semua data tersimpan contigu di memori.


Menggunakan pprof untuk Investigasi GC

GODEBUG=gctrace=1 memberikan sinyal tingkat tinggi, tapi untuk investigasi yang lebih dalam kamu butuh pprof.

import (
    "net/http"
    _ "net/http/pprof" // import ini untuk sisi efeknya — mendaftarkan HTTP handler
    "runtime"
)

func main() {
    // Aktifkan pprof endpoint di port terpisah
    go func() {
        http.ListenAndServe("localhost:6060", nil)
    }()

    // ... kode aplikasi
}
# Ambil heap profile — snapshot alokasi saat ini
go tool pprof http://localhost:6060/debug/pprof/heap

# Ambil allocation profile — semua alokasi sejak program start
go tool pprof http://localhost:6060/debug/pprof/allocs

# Di dalam pprof interactive shell:
# top     → tampilkan fungsi dengan alokasi terbesar
# list    → tampilkan kode sumber dengan anotasi alokasi
# web     → buka grafik di browser (butuh graphviz)

# Bandingkan dua snapshot untuk menemukan memory leak
go tool pprof -base heap1.pb.gz heap2.pb.gz
# Benchmark dengan pengukuran alokasi — sangat berguna untuk hot path
go test -bench=. -benchmem ./...

# Output contoh:
# BenchmarkProcessRequest-8   1000000   1234 ns/op   256 B/op   3 allocs/op
# ─────────────────────────────────────────────────────────────────────────
# 256 B/op   → byte yang dialokasikan per operasi
# 3 allocs/op → jumlah alokasi heap per operasi
# Tujuan: kurangi kedua angka ini untuk hot path

Kapan Tuning GC Diperlukan dan Tidak

Mengetahui kapan melakukan tuning sama pentingnya dengan mengetahui cara melakukannya. Tuning prematur pada parameter GC bisa menyembunyikan masalah yang sebenarnya atau membuat sistem lebih tidak stabil.

TUNING GC DIPERLUKAN jika:
  ✓ GC menggunakan > 5% CPU secara konsisten (lihat dari gctrace)
  ✓ GC pause (STW) > 1ms dan memengaruhi latency SLA
  ✓ Memory footprint lebih besar dari yang diharapkan
  ✓ Throughput menurun saat load naik karena GC thrashing
  ✓ Sudah diverifikasi via profiling bahwa GC adalah bottleneck nyata

JANGAN TUNING GC jika:
  ✗ Performa belum diukur — profiling dulu sebelum menyentuh GOGC
  ✗ Masalah sebenarnya ada di algoritma atau pola alokasi
  ✗ Hanya berdasarkan "terasa lambat" tanpa data konkret
  ✗ Aplikasi sudah memiliki memory usage yang sehat dan GC < 3% CPU

LANGKAH TUNING YANG BENAR:
  1. Ukur dengan GODEBUG=gctrace=1 selama load testing
  2. Profile dengan pprof untuk menemukan sumber alokasi terbesar
  3. Perbaiki pola alokasi di kode (sync.Pool, pre-alloc, struct layout)
  4. Ukur ulang — bandingkan sebelum dan sesudah
  5. Jika masih perlu: sesuaikan GOGC atau GOMEMLIMIT
  6. Dokumentasikan alasan dan nilai yang dipilih

Ringkasan

  • Go menggunakan tri-color concurrent mark-and-sweep — tiga warna (putih, abu-abu, hitam) memungkinkan GC berjalan bersamaan dengan goroutine aplikasi dengan hanya dua pause STW yang sangat singkat.
  • Write barrier menjaga invariant “objek hitam tidak menunjuk ke objek putih” — ia aktif selama fase marking dan menambah overhead kecil per pointer write, tapi tidak ada overhead di antara siklus GC.
  • Escape analysis menentukan apakah alokasi masuk stack atau heap — objek di stack gratis dari perspektif GC; minimasi escape ke heap dengan menghindari pointer yang tidak perlu dan ukuran dinamis di fungsi kritis.
  • GOGC mengontrol trade-off CPU vs memory — nilai lebih tinggi berarti GC lebih jarang (CPU lebih rendah, memory lebih tinggi); nilai lebih rendah berarti sebaliknya. Default 100 cocok untuk kebanyakan aplikasi.
  • GOMEMLIMIT adalah cara modern untuk deployment di container — set ke 85–90% dari container memory limit untuk mencegah OOM kill sebelum GC sempat bekerja.
  • sync.Pool adalah alat paling efektif untuk mengurangi tekanan GC — gunakan untuk objek berumur pendek yang sering dibuat dan dibuang di hot path seperti buffer dan parser.
  • Slice of struct lebih baik dari slice of pointer untuk koleksi besar[]T adalah satu objek di heap; []*T adalah N+1 objek yang masing-masing harus di-trace oleh GC.
  • Profil sebelum tuningGODEBUG=gctrace=1 untuk melihat frekuensi dan durasi GC, pprof untuk menemukan sumber alokasi terbesar. Jangan sentuh GOGC sebelum ada data konkret yang menunjukkan GC adalah bottleneck.

Portofolio