Async Callback Pattern #
Sebelum goroutine, sebelum channel, sebelum Future — ada callback. Pola ini adalah cara paling tua dan paling fundamental untuk menangani operasi asinkron: kamu menyerahkan sebuah fungsi kepada pemanggil, dan pemanggil berjanji akan menginvokasi fungsi itu saat pekerjaannya selesai. Kamu tidak menunggu. Kamu tidak polling. Kamu hanya bilang “kalau sudah selesai, panggil ini.” Callback menjadi fondasi dari hampir semua sistem event-driven yang ada hari ini — dari Node.js hingga framework GUI, dari network library hingga sistem operasi itu sendiri. Di Go, meskipun goroutine dan channel sering menjadi pilihan utama, memahami Async Callback Pattern tetap krusial: banyak library Go mengekspos API berbasis callback, dan mengetahui kapan callback lebih tepat dibanding channel adalah keahlian yang membedakan engineer yang baik dari yang biasa.
Apa itu Async Callback Pattern? #
Async Callback Pattern adalah pola di mana sebuah fungsi menerima fungsi lain sebagai parameter — disebut callback — dan memanggil callback tersebut ketika operasi asinkron selesai dikerjakan, baik berhasil maupun gagal.
Alur dasarnya selalu sama:
sequenceDiagram
participant Caller
participant AsyncFunc as Fungsi Async
participant BG as Background (goroutine)
participant CB as Callback
Caller->>AsyncFunc: panggil dengan callback
AsyncFunc->>BG: jalankan task di background
AsyncFunc-->>Caller: kembali segera (non-blocking)
Note over Caller: melanjutkan pekerjaan lain
BG->>BG: proses berjalan...
BG->>CB: panggil callback(result, err)
Note over CB: logika lanjutan dieksekusi
Tiga karakteristik yang mendefinisikan pola ini:
| Karakteristik | Penjelasan |
|---|---|
| Non-blocking | Fungsi async kembali segera tanpa menunggu proses selesai |
| Inversion of Control | Caller menyerahkan kontrol “apa yang terjadi selanjutnya” kepada fungsi async |
| Continuation | Logika lanjutan dikemas dalam callback, bukan ditulis setelah pemanggilan |
Di Go, callback diimplementasikan sebagai function value — first-class citizen yang bisa disimpan di variabel, dikirim sebagai argumen, atau dikembalikan dari fungsi lain.
Implementasi Dasar #
Bentuk paling sederhana dari Async Callback adalah fungsi yang menerima callback dan menjalankan task di goroutine terpisah.
package main
import (
"fmt"
"time"
)
// Definisikan tipe callback secara eksplisit — lebih mudah dibaca dan di-refactor
type Callback func(result string, err error)
// fetchDataAsync menjalankan fetch di background dan memanggil callback saat selesai
func fetchDataAsync(id int, cb Callback) {
go func() {
time.Sleep(2 * time.Second) // simulasi I/O
if id <= 0 {
cb("", fmt.Errorf("id tidak valid: %d", id))
return
}
cb(fmt.Sprintf("data untuk id %d", id), nil)
}()
// fungsi kembali segera — caller tidak blocking
}
func main() {
fmt.Println("[Main] Memulai fetch data...")
fetchDataAsync(1, func(result string, err error) {
if err != nil {
fmt.Println("[Callback] Error:", err)
return
}
fmt.Println("[Callback] Sukses:", result)
})
fmt.Println("[Main] Fetch dimulai, melanjutkan pekerjaan lain")
time.Sleep(3 * time.Second) // tunggu callback selesai sebelum program exit
}
Ada beberapa detail penting di sini yang sering diabaikan. Pertama, type alias Callback bukan sekadar kenyamanan — ia mendokumentasikan kontrak antara fungsi async dan pemanggilnya. Siapapun yang membaca kode ini langsung tahu bahwa callback menerima string dan error, dan harus menangani keduanya.
Kedua, go func() di dalam fetchDataAsync menjadikan seluruh eksekusi non-blocking — caller kembali ke baris berikutnya sebelum task selesai. Ini adalah bentuk paling murni dari Async Callback.
Callback dengan Multiple Event #
Di sistem nyata, sebuah operasi asinkron sering menghasilkan lebih dari satu jenis event — progress, sukses, error, atau cancelled. Alih-alih satu callback, kita bisa menggunakan struct yang berisi beberapa callback function.
package main
import (
"fmt"
"time"
)
// DownloadCallbacks mendefinisikan semua event yang bisa terjadi
type DownloadCallbacks struct {
OnProgress func(percent int)
OnSuccess func(filePath string)
OnError func(err error)
}
func downloadFileAsync(url string, cbs DownloadCallbacks) {
go func() {
// Simulasi progress download
for i := 20; i <= 100; i += 20 {
time.Sleep(300 * time.Millisecond)
if cbs.OnProgress != nil {
cbs.OnProgress(i)
}
}
// Simulasi hasil — url kosong dianggap error
if url == "" {
if cbs.OnError != nil {
cbs.OnError(fmt.Errorf("URL tidak boleh kosong"))
}
return
}
if cbs.OnSuccess != nil {
cbs.OnSuccess("/tmp/downloaded-file.zip")
}
}()
}
func main() {
downloadFileAsync("https://example.com/file.zip", DownloadCallbacks{
OnProgress: func(percent int) {
fmt.Printf("[Progress] %d%%\n", percent)
},
OnSuccess: func(filePath string) {
fmt.Printf("[Success] File tersimpan di: %s\n", filePath)
},
OnError: func(err error) {
fmt.Printf("[Error] Download gagal: %v\n", err)
},
})
time.Sleep(3 * time.Second)
}
Pola struct callback ini memberi fleksibilitas: caller bisa hanya mengisi callback yang diperlukan (field yang nil tidak akan dipanggil), dan penambahan event baru di masa depan tidak memecah signature yang sudah ada.
Masalah Callback Hell #
Sejarah panjang callback membawa satu pelajaran pahit yang berulang di setiap bahasa: callback hell. Ini terjadi ketika logika async yang berlapis memaksa callback bersarang satu di dalam lainnya, menciptakan kode yang sulit dibaca, diuji, dan di-maintain.
// ANTI-PATTERN: callback bersarang — "pyramid of doom"
func processOrder(orderID int) {
validateOrderAsync(orderID, func(valid bool, err error) {
if err != nil { handleError(err); return }
if !valid { handleInvalid(); return }
fetchInventoryAsync(orderID, func(stock int, err error) {
if err != nil { handleError(err); return }
if stock == 0 { handleOutOfStock(); return }
chargePaymentAsync(orderID, func(txID string, err error) {
if err != nil { handleError(err); return }
shipOrderAsync(orderID, txID, func(trackingNum string, err error) {
if err != nil { handleError(err); return }
// Empat level dalam — dan ini belum yang terburuk
fmt.Println("Order shipped:", trackingNum)
})
})
})
})
}
// BENAR: pisahkan setiap tahap menjadi fungsi bernama yang berdiri sendiri
func onValidated(orderID int) func(bool, error) {
return func(valid bool, err error) {
if err != nil { handleError(err); return }
if !valid { handleInvalid(); return }
fetchInventoryAsync(orderID, onInventoryFetched(orderID))
}
}
func onInventoryFetched(orderID int) func(int, error) {
return func(stock int, err error) {
if err != nil { handleError(err); return }
if stock == 0 { handleOutOfStock(); return }
chargePaymentAsync(orderID, onPaymentCharged(orderID))
}
}
func onPaymentCharged(orderID int) func(string, error) {
return func(txID string, err error) {
if err != nil { handleError(err); return }
shipOrderAsync(orderID, txID, onOrderShipped)
}
}
func onOrderShipped(trackingNum string, err error) {
if err != nil { handleError(err); return }
fmt.Println("Order shipped:", trackingNum)
}
func processOrder(orderID int) {
validateOrderAsync(orderID, onValidated(orderID)) // ✓ flat, mudah dibaca
}
Perbedaannya drastis: versi pertama adalah piramida kode yang membuat mata lelah dan logika sulit diikuti, sementara versi kedua adalah urutan pemanggilan yang flat dan setiap tahap mudah diuji secara independen.
flowchart TD
subgraph BAD["❌ Callback Hell"]
A1[validateOrder] --> B1[fetchInventory\n dalam callback A]
B1 --> C1[chargePayment\n dalam callback B]
C1 --> D1[shipOrder\n dalam callback C]
end
subgraph GOOD["✓ Named Callbacks"]
A2[validateOrder] --> B2[onValidated]
B2 --> C2[onInventoryFetched]
C2 --> D2[onPaymentCharged]
D2 --> E2[onOrderShipped]
end
Callback dengan Cancellation #
Untuk operasi yang berjalan lama, callback tanpa mekanisme pembatalan adalah resep untuk goroutine leak. Solusinya adalah mengintegrasikan context.Context ke dalam fungsi async.
package main
import (
"context"
"fmt"
"time"
)
type ProcessCallback func(result string, err error)
// processAsync menerima context — bisa dibatalkan dari luar
func processAsync(ctx context.Context, payload string, cb ProcessCallback) {
go func() {
// Simulasi pekerjaan yang bisa dibatalkan di tengah jalan
select {
case <-time.After(3 * time.Second):
// Pekerjaan selesai normal
cb(fmt.Sprintf("processed: %s", payload), nil)
case <-ctx.Done():
// Context dibatalkan sebelum selesai
cb("", fmt.Errorf("operasi dibatalkan: %w", ctx.Err()))
}
}()
}
func main() {
// Buat context yang akan timeout setelah 1 detik
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
done := make(chan struct{})
processAsync(ctx, "order-123", func(result string, err error) {
defer close(done)
if err != nil {
fmt.Println("[Callback] Error:", err)
return
}
fmt.Println("[Callback] Hasil:", result)
})
<-done // tunggu callback selesai
fmt.Println("[Main] Selesai")
}
Menggunakan select dengan ctx.Done() dan operasi utama memastikan goroutine selalu punya jalur keluar — baik selesai normal maupun dibatalkan. Tidak ada goroutine yang terjebak menunggu selamanya.
stateDiagram-v2
[*] --> Running : goroutine dimulai
Running --> Completed : operasi selesai sebelum deadline
Running --> Cancelled : ctx.Done() menerima sinyal
Completed --> [*] : callback(result, nil) dipanggil
Cancelled --> [*] : callback("", err) dipanggil
Menggabungkan Banyak Callback Async #
Ketika beberapa operasi async perlu berjalan paralel dan hasilnya dikumpulkan, kombinasi callback dengan sync.WaitGroup dan mutex adalah pola yang solid.
package main
import (
"fmt"
"sync"
"time"
)
type StringCallback func(result string, err error)
func fetchAsync(name string, delay time.Duration, cb StringCallback) {
go func() {
time.Sleep(delay)
cb(fmt.Sprintf("data dari %s", name), nil)
}()
}
func main() {
var (
mu sync.Mutex
results []string
wg sync.WaitGroup
)
sources := []struct {
name string
delay time.Duration
}{
{"cache", 100 * time.Millisecond},
{"database", 400 * time.Millisecond},
{"external-api", 250 * time.Millisecond},
}
for _, src := range sources {
wg.Add(1)
fetchAsync(src.name, src.delay, func(result string, err error) {
defer wg.Done()
if err != nil {
fmt.Println("Error:", err)
return
}
mu.Lock()
results = append(results, result) // ✓ lock saat akses shared state
mu.Unlock()
})
}
wg.Wait() // tunggu semua callback selesai
fmt.Println("Semua hasil terkumpul:")
for _, r := range results {
fmt.Println(" -", r)
}
}
Callback dipanggil dari goroutine berbeda. Ini berarti callback berjalan concurrently dengan goroutine lain. Setiap akses ke shared state di dalam callback — seperti slice results di atas — harus dilindungi dengan mutex atau mekanisme sinkronisasi lainnya. Mengabaikan ini menyebabkan data race yang sering hanya muncul di production, bukan saat development.
Async Callback vs Channel vs Future #
Ketiga pendekatan ini sering tertukar satu sama lain. Memahami trade-off masing-masing membantu kamu memilih yang tepat untuk setiap situasi.
| Aspek | Async Callback | Channel | Future–Promise |
|---|---|---|---|
| Model | Event-based | Message-based | Result-based |
| Blocking | Tidak ada | Saat send/receive | Hanya saat Get() |
| Error handling | Parameter callback | Error channel terpisah | Dibawa bersama nilai |
| Composability | Rendah (callback hell) | Sedang | Tinggi (WhenAll, dll) |
| Idiomatik Go | Tidak (tapi valid) | Ya | Ya (dengan generics) |
| Cocok untuk | Simple async, event API | Orchestration, pipeline | Satu hasil dari satu task |
| Goroutine management | Manual | Semi-otomatis | Semi-otomatis |
flowchart TD
Q{Perlu hasil\ndi titik tertentu?} -- Ya --> Q2{Satu hasil\natau banyak?}
Q -- Tidak --> Q3{Ada banyak\nevent type?}
Q2 -- Satu --> FP[Future–Promise]
Q2 -- Banyak / streaming --> CH[Channel]
Q3 -- Ya --> CB[Struct Callback\nmulti-event]
Q3 -- Tidak --> Q4{Banyak operasi\nparalel?}
Q4 -- Ya --> CH
Q4 -- Tidak --> CB2[Simple Callback]
Kapan Tidak Menggunakan Async Callback #
Tetap gunakan Async Callback jika:
✓ Mengintegrasikan library eksternal yang mengekspos API berbasis callback
✓ Operasi menghasilkan banyak event berbeda (progress, success, error)
✓ Tidak perlu menunggu hasil di titik tertentu
✓ Tim sudah familier dengan pattern ini dan codebase konsisten menggunakannya
Pertimbangkan Channel atau Future jika:
✗ Logika async berlapis lebih dari dua level — tanda callback hell akan datang
✗ Perlu chaining atau komposisi banyak operasi async
✗ Error handling menjadi rumit dengan callback
✗ Butuh cancellation yang mudah dan ekspresif
✗ Ini adalah kode Go baru tanpa constraint library eksternal
Anti-Pattern yang Harus Dihindari #
// ✗ Callback dipanggil lebih dari sekali — melanggar kontrak
func badAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // dipanggil sekali
if err != nil {
cb("", err) // ✗ dipanggil lagi — caller tidak siap untuk ini
}
}()
}
// ✓ Callback dipanggil tepat sekali, apapun kondisinya
func goodAsync(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ satu panggilan, selesai
}()
}
// ✗ Callback tanpa error — tidak ada cara untuk melaporkan kegagalan
func noErrorCallback(cb func(string)) {
go func() {
result, err := doWork()
if err != nil {
// error dibuang — caller tidak tahu ada yang salah
return
}
cb(result)
}()
}
// ✓ Callback selalu membawa error sebagai parameter kedua
func withErrorCallback(cb func(string, error)) {
go func() {
result, err := doWork()
cb(result, err) // ✓ caller bertanggung jawab menangani error
}()
}
// ✗ Goroutine leak — tidak ada mekanisme berhenti
func leakyAsync(cb func(string, error)) {
go func() {
time.Sleep(24 * time.Hour) // bagaimana jika caller sudah tidak peduli?
cb("selesai", nil) // goroutine ini hidup selamanya jika caller sudah exit
}()
}
// ✓ Gunakan context untuk lifecycle management
func safeAsync(ctx context.Context, cb func(string, error)) {
go func() {
select {
case <-time.After(24 * time.Hour):
cb("selesai", nil)
case <-ctx.Done():
cb("", ctx.Err()) // ✓ goroutine bisa berhenti saat diminta
}
}()
}
// ✗ Akses shared state tanpa sinkronisasi — data race
var sharedResults []string
func unsafeCallback(result string, err error) {
sharedResults = append(sharedResults, result) // ✗ race condition
}
// ✓ Lindungi shared state dengan mutex
var (
mu sync.Mutex
protectedResults []string
)
func safeCallback(result string, err error) {
mu.Lock()
defer mu.Unlock()
protectedResults = append(protectedResults, result) // ✓ aman dari race condition
}
Checklist Review Async Callback #
DESAIN CALLBACK:
□ Tipe callback didefinisikan sebagai type alias yang eksplisit
□ Callback selalu membawa error sebagai parameter (func(T, error))
□ Callback hanya dipanggil tepat sekali per eksekusi async
LIFECYCLE GOROUTINE:
□ Goroutine async punya jalur exit yang jelas (context atau done signal)
□ Tidak ada goroutine yang bisa leak karena tidak ada mekanisme berhenti
□ Callback yang mengelola multiple async menggunakan WaitGroup
KONKURENSI:
□ Akses shared state di dalam callback dilindungi dengan mutex
□ Callback tidak diasumsikan berjalan di goroutine yang sama dengan caller
□ Race condition diverifikasi dengan go test -race
KOMPLEKSITAS:
□ Callback tidak bersarang lebih dari dua level
□ Callback panjang dipecah menjadi named function
□ Pertimbangan untuk migrasi ke channel/Future jika logika berkembang
DOKUMENTASI:
□ Fungsi async mendokumentasikan kapan callback dipanggil
□ Fungsi async mendokumentasikan apakah callback dipanggil di goroutine terpisah
□ Kontrak "sekali panggil" didokumentasikan secara eksplisit
Ringkasan #
- Async Callback adalah pola concurrency paling fundamental — hampir semua abstraksi async modern (Future, Promise, async/await) dibangun di atasnya.
- Callback menerima kontrol atas “apa yang terjadi selanjutnya” — ini adalah Inversion of Control: caller menyerahkan logika lanjutan kepada fungsi async.
- Selalu definisikan tipe callback sebagai type alias — ini mendokumentasikan kontrak dan membuat kode lebih mudah di-refactor.
- Callback harus selalu membawa error —
func(T, error)adalah signature standar; callback tanpa error tidak bisa melaporkan kegagalan.- Callback dipanggil tepat sekali — memanggil callback lebih dari sekali melanggar kontrak dan bisa menyebabkan bug yang sulit dideteksi.
- Callback hell terjadi ketika callback bersarang — solusinya adalah memecah setiap tahap menjadi named function terpisah yang flat dan bisa diuji independen.
- Gunakan struct callback untuk multiple event — OnProgress, OnSuccess, OnError yang terpisah lebih ekspresif daripada satu callback dengan banyak kondisi.
- Callback berjalan di goroutine berbeda — akses shared state di dalam callback harus selindungi dengan mutex untuk mencegah data race.
- Integrasikan context untuk cancellation —
selectdenganctx.Done()memastikan goroutine async selalu punya jalur keluar.- Kenali batasnya — untuk operasi berlapis atau yang butuh komposisi, channel atau Future lebih idiomatik di Go; callback bersinar saat mengintegrasikan API eksternal atau event sederhana.