BAB 27: Channel Timeout
Kendalikan durasi tunggu channel dengan time.After dan select untuk membangun sistem yang responsif dan tidak macet selamanya.
Di Bab 26, kamu mempelajari bahwa for range pada channel akan berjalan selamanya jika channel tidak pernah ditutup — goroutine leak yang paling umum di Go. Ada masalah serupa yang berbeda dimensinya: bagaimana jika channel memang valid dan terbuka, tapi datanya tidak kunjung datang? Program akan menunggu tanpa batas waktu, dan tidak ada cara untuk tahu kapan penantian itu harus dihentikan. Di sinilah timeout menjadi penting.
Masalah: Menunggu Tanpa Batas
Bayangkan sistem pemantau kualitas jaringan yang menerima laporan latensi dari berbagai node. Setiap node mengirim data secara periodik, tapi koneksi ke beberapa node bisa terputus kapan saja. Tanpa timeout, goroutine pemantau akan terus menunggu laporan yang mungkin tidak pernah datang.
// monitor.go — versi tanpa timeout (bermasalah)
package main
import (
"fmt"
"math/rand"
"time"
)
func kirimLatensi(ch chan<- int) {
for {
// simulasi: jeda acak 1–8 detik antar laporan
jeda := time.Duration(rand.Intn(8)+1) * time.Second
time.Sleep(jeda)
ch <- rand.Intn(200) + 10 // latensi 10–210ms
}
}
func main() {
latensiCh := make(chan int)
go kirimLatensi(latensiCh)
for {
nilai := <-latensiCh // blok selamanya jika node mati
fmt.Printf("latensi: %dms\n", nilai)
}
}
Jika kirimLatensi berhenti mengirim — misalnya karena node mati — baris nilai := <-latensiCh akan memblok selamanya. Program tampak berjalan normal tapi sebenarnya sudah macet.
time.After: Sumber Timeout
time.After adalah fungsi dari package time standar Go yang mengembalikan sebuah channel. Channel itu akan menerima satu nilai setelah durasi yang ditentukan berlalu. Tidak lebih dari sekali — hanya satu nilai, tepat setelah durasinya habis.
// Cara kerja time.After
//
// ch := time.After(5 * time.Second)
//
// t=0s t=1s t=2s t=3s t=4s t=5s
// ────────────────────────────────────────── ●
// ↑
// ch menerima nilai time.Time
// (pengirim internal Go runtime)
Karena ia mengembalikan channel, ia bisa langsung dipakai sebagai salah satu case di dalam select. Inilah kombinasi yang membuat timeout pada channel terasa alami di Go.
Timeout dengan Select
Solusi untuk masalah di atas adalah mengganti penerimaan blocking biasa dengan select yang punya dua case: satu untuk data yang ditunggu, satu lagi untuk timeout.
// monitor.go — dengan timeout
package main
import (
"fmt"
"math/rand"
"time"
)
func kirimLatensi(ch chan<- int) {
for {
jeda := time.Duration(rand.Intn(8)+1) * time.Second
time.Sleep(jeda)
ch <- rand.Intn(200) + 10
}
}
func pantauJaringan(latensiCh <-chan int) {
batasWaktu := 5 * time.Second
for {
select {
case nilai := <-latensiCh:
fmt.Printf("laporan masuk — latensi: %dms\n", nilai)
case <-time.After(batasWaktu):
fmt.Println("timeout: tidak ada laporan dalam 5 detik, hentikan pemantauan")
return
}
}
}
func main() {
latensiCh := make(chan int)
go kirimLatensi(latensiCh)
pantauJaringan(latensiCh)
fmt.Println("pemantauan selesai")
}
laporan masuk — latensi: 87ms
laporan masuk — latensi: 143ms
laporan masuk — latensi: 31ms
timeout: tidak ada laporan dalam 5 detik, hentikan pemantauan
pemantauan selesai
Setiap iterasi select memanggil time.After yang baru — artinya timer di-reset setiap kali iterasi dimulai. Jika data tiba sebelum 5 detik, case nilai := <-latensiCh yang berjalan dan timer dibuang. Jika tidak ada data dalam 5 detik sejak iterasi dimulai, case <-time.After(batasWaktu) yang berjalan dan fungsi keluar.
time.After membuat channel baru setiap kali dipanggil. Dalam loop yang intensif, ini bisa menambah tekanan pada garbage collector karena banyak channel timer yang dibuat dan dibuang. Untuk kasus seperti itu, time.NewTimer dengan timer.Reset() adalah alternatif yang lebih efisien secara memori.
Timeout Sekali vs Timeout Per Iterasi
Ada dua perilaku timeout yang berbeda, dan penting untuk memahami perbedaannya sebelum menulis kode.
Timeout per iterasi adalah perilaku yang sudah kita lihat: time.After dipanggil di dalam loop, jadi timer di-reset setiap putaran. Program berhenti jika satu jeda antar laporan melebihi batas waktu.
Timeout keseluruhan adalah batas waktu total untuk seluruh sesi pemantauan, bukan jeda antar laporan. Timer ini dibuat sekali sebelum loop dimulai dan tidak pernah di-reset.
// monitor.go — timeout keseluruhan 20 detik
package main
import (
"fmt"
"math/rand"
"time"
)
func kirimLatensi(ch chan<- int) {
for {
jeda := time.Duration(rand.Intn(4)+1) * time.Second
time.Sleep(jeda)
ch <- rand.Intn(200) + 10
}
}
func pantauDenganDeadline(latensiCh <-chan int, durasi time.Duration) {
// dibuat sekali, di luar loop — tidak di-reset setiap iterasi
selesaiCh := time.After(durasi)
for {
select {
case nilai := <-latensiCh:
fmt.Printf("latensi: %dms\n", nilai)
case <-selesaiCh:
fmt.Printf("sesi pemantauan %v selesai\n", durasi)
return
}
}
}
func main() {
latensiCh := make(chan int)
go kirimLatensi(latensiCh)
// pantau selama 20 detik, apapun yang terjadi
pantauDenganDeadline(latensiCh, 20*time.Second)
fmt.Println("program selesai")
}
latensi: 156ms
latensi: 44ms
latensi: 201ms
latensi: 78ms
latensi: 112ms
latensi: 67ms
sesi pemantauan 20s selesai
program selesai
Perbedaan kuncinya ada di posisi time.After: di luar loop berarti timeout keseluruhan, di dalam loop berarti timeout per iterasi.
Posisi time.After | Perilaku | Kapan digunakan |
|---|---|---|
| Di luar loop | Timeout keseluruhan sesi | Batas durasi total yang tidak boleh dilampaui |
| Di dalam loop | Timeout per jeda antar pesan | Deteksi node mati atau aliran data terhenti |
Timeout dengan Nilai Balik
Kadang fungsi yang menerima dari channel perlu mengembalikan sesuatu ke pemanggil — baik hasil yang berhasil didapat, maupun sinyal bahwa operasi habis waktu. Idiom Go untuk ini adalah mengembalikan dua nilai: data dan boolean yang menandakan keberhasilan.
// monitor.go — fungsi yang mengembalikan status timeout
package main
import (
"fmt"
"math/rand"
"time"
)
func ambilLaporanBerikutnya(latensiCh <-chan int, batas time.Duration) (int, bool) {
select {
case nilai := <-latensiCh:
return nilai, true
case <-time.After(batas):
return 0, false
}
}
func kirimLatensi(ch chan<- int) {
for {
jeda := time.Duration(rand.Intn(7)+1) * time.Second
time.Sleep(jeda)
ch <- rand.Intn(200) + 10
}
}
func main() {
latensiCh := make(chan int)
go kirimLatensi(latensiCh)
for i := 0; i < 10; i++ {
nilai, berhasil := ambilLaporanBerikutnya(latensiCh, 4*time.Second)
if !berhasil {
fmt.Printf("percobaan %d: timeout, tidak ada laporan\n", i+1)
continue
}
fmt.Printf("percobaan %d: latensi %dms\n", i+1, nilai)
}
}
percobaan 1: latensi 134ms
percobaan 2: timeout, tidak ada laporan
percobaan 3: latensi 67ms
percobaan 4: latensi 201ms
percobaan 5: timeout, tidak ada laporan
percobaan 6: latensi 88ms
percobaan 7: latensi 45ms
percobaan 8: timeout, tidak ada laporan
percobaan 9: latensi 156ms
percobaan 10: latensi 23ms
Pola ini memisahkan kapan menunggu dari apa yang harus dilakukan jika timeout — pemanggil (main) yang memutuskan apakah timeout adalah error fatal atau kondisi yang bisa di-retry.
Jangan gunakan time.After di dalam loop yang berjalan sangat cepat (ribuan iterasi per detik). Setiap pemanggilan membuat channel dan goroutine timer internal yang baru, dan channel itu tidak akan dibersihkan sampai durasinya habis — bahkan jika case lain yang berjalan duluan. Gunakan time.NewTimer dan panggil timer.Stop() lalu timer.Reset() secara eksplisit untuk kasus seperti itu.
Latihan
Latihan 1 — Retry dengan timeout:
Buat fungsi ambilDataDenganRetry(ch <-chan string, batas time.Duration, maksRetry int) (string, bool) yang mencoba menerima dari channel sebanyak maksRetry kali, masing-masing dengan timeout batas. Jika salah satu percobaan berhasil, kembalikan data dan true. Jika semua percobaan timeout, kembalikan string kosong dan false.
Latihan 2 — Dua sumber, satu timeout global:
Buat dua goroutine yang masing-masing mengirim string ke channel berbeda dengan jeda acak. Tulis fungsi yang menggunakan select untuk menerima dari keduanya secara bersamaan, dengan satu timeout global 15 detik. Catat dari channel mana setiap pesan datang, dan hentikan penerimaan saat timeout tercapai.
Latihan 3 — Timeout adaptif:
Modifikasi pantauJaringan agar timeout-nya adaptif: jika dua laporan berturut-turut datang dengan latensi di bawah 50ms, perkecil batas timeout menjadi 3 detik (node dianggap aktif dan responsif). Jika latensi di atas 150ms, perbesar batas timeout menjadi 8 detik (node dianggap lambat dan perlu waktu ekstra).
Kamu sekarang bisa membangun sistem yang tidak pernah macet menunggu selamanya — setiap penantian punya batas waktu yang jelas. Pola select dengan time.After ini adalah fondasi dari hampir semua timeout handling di Go, mulai dari HTTP client yang punya deadline sampai microservice yang perlu merespons dalam SLA tertentu. Tapi time.After hanya menangani satu dimensi waktu: durasi. Di ekosistem Go, ada mekanisme yang jauh lebih kaya untuk mengelola pembatalan dan deadline secara terstruktur lintas banyak goroutine — itu adalah package context, yang akan menjadi topik berikutnya.