Gejala Masalah: Lonjakan Memori dan UI Freeze
Saat membangun antarmuka terminal (TUI) interaktif dengan framework Bubble Tea di Golang, responsivitas I/O adalah prioritas utama. Masalah performa sering muncul pada skenario navigasi direktori cepat (rapid file traversal). Ketika pengguna menekan tombol navigasi atas/bawah secara beruntun, konsumsi memori (RSS) melonjak drastis dan antarmuka mengalami frame drop atau UI freeze sementara.
Framework Bubble Tea mengeksekusi fungsi tea.Cmd di dalam goroutine terpisah. Jika lifecycle goroutine tersebut tidak dikelola dengan benar saat pembacaan filesystem (I/O traversal), goroutine lama akan tetap aktif di background meski hasilnya sudah tidak dibutuhkan lagi.
Reproduksi dan Analisis Root Cause
Penyebab utama dari masalah ini adalah kombinasi dari dua pola anti-pattern: penggunaan unbuffered channel di dalam asynchronous task dan ketiadaan pembatalan operasi via context.Context saat state TUI berganti.
Contoh Implementasi Bermasalah (Vulnerable Code)
Berikut pola kode yang memicu goroutine leak pada Bubble Tea command:
func fetchDirectoryContents(path string) tea.Cmd {
return func() tea.Msg {
ch := make(chan []os.DirEntry) // Unbuffered channel
go func() {
entries, _ := os.ReadDir(path)
// Terjebak selamanya jika receiver tidak membaca channel ini
ch <- entries
}()
// Jika timeout atau UI berpindah state sebelum baca selesai:
select {
case res := <-ch:
return DirReadMsg{Entries: res}
case <-time.After(100 * time.Millisecond):
// Receiver keluar, goroutine di atas block saat ch <- entries
return ErrTimeoutMsg{}
}
}
}Ketika pembacaan filesystem membutuhkan waktu lebih dari 100ms, fungsi tea.Cmd mengembalikan ErrTimeoutMsg. Goroutine internal mencoba mengirim data ke unbuffered channel ch, namun tidak ada receiver yang tersisa. Akibatnya, goroutine tersebut terblokir secara permanen di memori.
Identifikasi Menggunakan Runtime pprof
Untuk membuktikan kebocoran goroutine, pasang endpoint pprof atau dumping stack trace goroutine secara lokal ke terminal:
import (
"os"
"runtime/pprof"
)
func dumpGoroutines() {
f, _ := os.Create("goroutine.dump")
defer f.Close()
pprof.Lookup("goroutine").WriteTo(f, 2)
}Gunakan visualisasi CLI pprof untuk menganalisis tumpukan stack:
go tool pprof -top goroutine.dumpOutput pprof menunjukkan pola penumpukan goroutine pada channel write:
Flat Flat% Sum% Cum Cum%
542 98.19% 98.19% 542 98.19% runtime.gopark
0 0.00% 98.19% 542 98.19% runtime.chansend
0 0.00% 98.19% 542 98.19% main.fetchDirectoryContents.func1.1Ratusan goroutine tertahan di runtime.chansend menunggu penerima yang tidak pernah ada.
Solusi: Context Cancellation dan Bounded Worker Pool
Perbaikan membutuhkan tiga langkah: membatalkan operasi aktif saat terjadi pergantian direktori, menghindari channel unbuffered yang menggantung, dan mengimplementasikan graceful teardown model.
1. Model Refactoring dengan Context Cancellation
Simpan referensi context.CancelFunc pada Bubble Tea model untuk membatalkan proses pembacaan file sebelumnya:
type Model struct {
currentPath string
entries []os.DirEntry
cancelIO context.CancelFunc
}
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "down", "j":
// Batalkan I/O sebelumnya yang belum selesai
if m.cancelIO != nil {
m.cancelIO()
}
ctx, cancel := context.WithCancel(context.Background())
m.cancelIO = cancel
m.currentPath = "/target/path"
return m, readDirCmd(ctx, m.currentPath)
case "q", "ctrl+c":
if m.cancelIO != nil {
m.cancelIO()
}
return m, tea.Quit
}
case DirReadMsg:
m.entries = msg.Entries
return m, nil
}
return m, nil
}2. Implementasi Safe tea.Cmd
Gunakan bounded buffer atau kirim hasil langsung tanpa blocking anonymous goroutine:
type DirReadMsg struct {
Entries []os.DirEntry
Err error
}
func readDirCmd(ctx context.Context, path string) tea.Cmd {
return func() tea.Msg {
type result struct {
entries []os.DirEntry
err error
}
// Bounded channel kapasitas 1 mencegah sender goroutine terblokir
done := make(chan result, 1)
go func() {
entries, err := os.ReadDir(path)
done <- result{entries: entries, err: err}
}()
select {
case <-ctx.Done():
// Selesai jika dibatalkan oleh navigasi baru atau quit
return nil
case res := <-done:
return DirReadMsg{Entries: res.entries, Err: res.err}
}
}
}Verifikasi Unit Test Menggunakan Goleak
Gunakan library go.uber.org/goleak untuk mendeteksi goroutine leak secara otomatis di dalam pipeline automated testing.
package main
import (
"context"
"testing"
"time"
"go.uber.org/goleak"
)
func TestReadDirCmd_NoGoroutineLeak(t *testing.T) {
defer goleak.VerifyNone(t)
ctx, cancel := context.WithCancel(context.Background())
cmd := readDirCmd(ctx, ".")
// Simulasikan pembatalan cepat (rapid navigation)
cancel()
// Eksekusi tea.Cmd
_ = cmd()
// Beri jeda singkat untuk runtime scheduler menyelesaikan teardown
time.Sleep(10 * time.Millisecond)
}Jika masih ada goroutine yang tertinggal di background, goleak.VerifyNone akan menggagalkan unit test dan menampilkan ID goroutine beserta baris kode sumber yang bocor.
Ringkasan
Goroutine leak pada Bubble Tea TUI umumnya berakar dari tea.Cmd yang memanggil channel blocking tanpa memperhitungkan siklus hidup navigasi UI. Terapkan strategi berikut untuk menjaga aplikasi TUI tetap efisien:
- Selalu gunakan
context.WithCanceluntuk memutus async task saat state model berpindah. - Gunakan channel dengan buffer berkapasitas cukup (minimal
make(chan T, 1)) bila pengiriman data terjadi dari goroutine terpisah ke blokselect. - Pastikan cleanup logic terpanggil saat menangani key binding keluar seperti
tea.Quit. - Pasang
goleakpada unit test paket command untuk memvalidasi pembersihan goroutine.
Komentar
0 komentar
Masuk ke akun kamu untuk ikut berkomentar.
Belum ada komentar
Jadilah yang pertama ikut berdiskusi!