Future–Promise Pattern #
Bayangkan kamu memesan makanan di restoran. Kasir menyerahkan struk nomor antrian — itu adalah promise bahwa makananmu sedang disiapkan. Kamu tidak perlu berdiri di depan dapur menunggu; kamu bisa duduk, ngobrol, atau baca buku. Saat makanan jadi, namamu dipanggil — itulah future yang terpenuhi. Analogi ini menangkap inti dari Future–Promise Pattern: memisahkan antara kapan sebuah pekerjaan dimulai dan kapan hasilnya digunakan, sehingga eksekusi tidak perlu berhenti hanya karena ada operasi yang lambat. Di sistem nyata, operasi seperti HTTP request ke API eksternal, query database berat, atau komputasi paralel semuanya adalah kandidat sempurna untuk pola ini — mereka membutuhkan waktu, dan memblokir seluruh thread hanya untuk menunggu mereka adalah pemborosan yang tidak perlu.
Apa itu Future–Promise Pattern? #
Future–Promise Pattern adalah concurrency pattern yang memisahkan dua peran berbeda dalam menghasilkan dan mengonsumsi nilai asinkron.
| Peran | Tanggung Jawab |
|---|---|
| Promise | Pihak yang menghasilkan nilai — menjalankan task dan menyimpan hasilnya |
| Future | Pihak yang mengonsumsi nilai — placeholder yang bisa di-query saat nilai siap |
Keduanya terhubung melalui sebuah channel: promise menulis ke channel saat task selesai, dan future membaca dari channel saat hasilnya dibutuhkan. Yang membuat pola ini powerful adalah bahwa pembacaan (blocking) hanya terjadi saat nilai benar-benar diperlukan, bukan saat task dimulai.
sequenceDiagram
participant Caller
participant Promise as Promise (goroutine)
participant Future as Future (channel)
participant Consumer
Caller->>Promise: NewFuture(task)
Promise-->>Future: goroutine mulai, channel dibuat
Caller->>Consumer: lanjutkan pekerjaan lain (non-blocking)
Note over Promise: task berjalan di background
Promise->>Future: kirim hasil ke channel
Consumer->>Future: future.Get() — blocking hanya di sini
Future-->>Consumer: nilai tersedia, dikembalikan
Di bahasa lain, konstruk ini sudah tersedia secara native: Java punya CompletableFuture, JavaScript punya Promise, Scala punya Future. Golang tidak menyediakan keyword khusus, tapi goroutine + channel adalah padanan idiomatiknya — dan justru lebih fleksibel karena kamu bisa mengontrol setiap aspeknya.
Mengapa Non-Blocking itu Penting? #
Untuk memahami nilai dari pola ini, perlu dilihat terlebih dahulu apa yang terjadi jika kita tidak menggunakannya.
// ANTI-PATTERN: eksekusi sekuensial — semua operasi saling menunggu
func fetchUserData(userID int) UserData {
profile := fetchProfile(userID) // 300ms
orders := fetchOrders(userID) // 400ms
reviews := fetchReviews(userID) // 200ms
// Total waktu tunggu: 900ms
return merge(profile, orders, reviews)
}
// BENAR: jalankan ketiganya secara paralel dengan Future
func fetchUserData(userID int) UserData {
fProfile := NewFuture(func() Profile { return fetchProfile(userID) })
fOrders := NewFuture(func() []Order { return fetchOrders(userID) })
fReviews := NewFuture(func() []Review { return fetchReviews(userID) })
// Ketiga task berjalan paralel — total waktu tunggu: ~400ms (task terlama)
return merge(fProfile.Get(), fOrders.Get(), fReviews.Get())
}
Perbedaannya signifikan: dari 900ms menjadi ~400ms hanya dengan mengubah pola eksekusi. Ini adalah keunggulan utama Future–Promise — latency keseluruhan ditentukan oleh task terlama, bukan jumlah semua task.
Implementasi Dasar #
Golang tidak punya tipe Future bawaan, jadi kita membangunnya sendiri menggunakan struct dan channel. Ini justru memberi kita kontrol penuh atas behaviornya.
package main
import (
"fmt"
"time"
)
// Future adalah placeholder untuk nilai yang akan tersedia di masa depan
type Future struct {
result chan int
}
// NewFuture bertindak sebagai Promise — menjalankan task di background
// dan mengembalikan Future yang bisa di-query nanti
func NewFuture(task func() int) *Future {
f := &Future{
result: make(chan int, 1), // BENAR: buffered agar goroutine tidak leak
}
go func() {
f.result <- task() // task berjalan di goroutine terpisah
}()
return f // dikembalikan segera, task masih berjalan di background
}
// Get memblokir sampai nilai tersedia, lalu mengembalikannya
func (f *Future) Get() int {
return <-f.result
}
func main() {
// Future dibuat — task langsung mulai di background
future := NewFuture(func() int {
fmt.Println("[Task] Mulai komputasi berat...")
time.Sleep(2 * time.Second)
return 42
})
// Kita bisa melakukan hal lain selagi task berjalan
fmt.Println("[Main] Task sedang berjalan di background")
fmt.Println("[Main] Melakukan pekerjaan lain...")
time.Sleep(500 * time.Millisecond)
fmt.Println("[Main] Pekerjaan lain selesai, sekarang ambil hasil future")
// Blocking hanya terjadi di sini, dan hanya jika task belum selesai
result := future.Get()
fmt.Printf("[Main] Hasil diterima: %d\n", result)
}
Kenapa channel harus buffered (make(chan int, 1))? Jika channel unbuffered (make(chan int)), goroutine task akan blocking saat mencoba menulis ke channel — dan jika pemanggil tidak pernah memanggilGet(), goroutine tersebut akan leak selamanya. Buffer kapasitas 1 memastikan goroutine bisa menulis dan selesai meskiGet()belum dipanggil.
Future dengan Error Handling #
Implementasi di atas hanya cocok untuk task yang tidak bisa gagal. Di dunia nyata, hampir semua operasi I/O bisa gagal — koneksi terputus, timeout, data tidak valid. Future yang baik harus membawa error bersama nilainya.
Dengan generics (tersedia sejak Go 1.18), kita bisa membuat Future yang type-safe untuk tipe apapun:
package main
import (
"errors"
"fmt"
"time"
)
// Result membungkus nilai dan error dalam satu struct
type Result[T any] struct {
Value T
Err error
}
// Future[T] adalah Future yang type-safe untuk tipe T
type Future[T any] struct {
result chan Result[T]
}
// NewFuture menjalankan task di background dan mengembalikan Future
func NewFuture[T any](task func() (T, error)) *Future[T] {
f := &Future[T]{
result: make(chan Result[T], 1), // BENAR: buffered
}
go func() {
val, err := task()
f.result <- Result[T]{Value: val, Err: err}
}()
return f
}
// Get memblokir sampai hasil tersedia dan mengembalikan (value, error)
func (f *Future[T]) Get() (T, error) {
res := <-f.result
return res.Value, res.Err
}
// fetchUser simulasi fetch ke database
func fetchUser(id int) (string, error) {
time.Sleep(300 * time.Millisecond)
if id <= 0 {
return "", errors.New("user ID tidak valid")
}
return fmt.Sprintf("User-%d", id), nil
}
func main() {
fUser := NewFuture(func() (string, error) {
return fetchUser(7)
})
fInvalid := NewFuture(func() (string, error) {
return fetchUser(-1) // ini akan gagal
})
// Kedua future berjalan paralel
user, err := fUser.Get()
if err != nil {
fmt.Println("Error:", err)
} else {
fmt.Println("User:", user)
}
_, err = fInvalid.Get()
if err != nil {
fmt.Println("Error (expected):", err)
}
}
Output:
User: User-7
Error (expected): user ID tidak valid
Fan-Out: Banyak Future Secara Paralel #
Salah satu use case paling kuat dari Future–Promise adalah fan-out — menjalankan banyak operasi secara paralel dan mengumpulkan hasilnya.
package main
import (
"fmt"
"time"
)
type Future[T any] struct {
result chan T
}
func NewFuture[T any](task func() T) *Future[T] {
f := &Future[T]{result: make(chan T, 1)}
go func() { f.result <- task() }()
return f
}
func (f *Future[T]) Get() T { return <-f.result }
// simulasi HTTP request ke endpoint berbeda
func fetchEndpoint(name string, delay time.Duration) string {
time.Sleep(delay)
return fmt.Sprintf("response dari %s", name)
}
func main() {
start := time.Now()
// Fan-out: semua request berjalan paralel
futures := []*Future[string]{
NewFuture(func() string { return fetchEndpoint("service-A", 400*time.Millisecond) }),
NewFuture(func() string { return fetchEndpoint("service-B", 250*time.Millisecond) }),
NewFuture(func() string { return fetchEndpoint("service-C", 600*time.Millisecond) }),
NewFuture(func() string { return fetchEndpoint("service-D", 150*time.Millisecond) }),
}
// Fan-in: kumpulkan semua hasil
results := make([]string, len(futures))
for i, f := range futures {
results[i] = f.Get()
}
elapsed := time.Since(start)
for _, r := range results {
fmt.Println(r)
}
fmt.Printf("Total waktu: %v (tanpa paralel: ~1400ms)\n", elapsed)
}
flowchart TD
Main([Main Goroutine]) --> FA[Future A\n400ms]
Main --> FB[Future B\n250ms]
Main --> FC[Future C\n600ms]
Main --> FD[Future D\n150ms]
FA --> Collect([Kumpulkan hasil])
FB --> Collect
FC --> Collect
FD --> Collect
Note[Total: ~600ms\nbukan 1400ms]
Collect --> Note
Cancellation dengan Context #
Untuk sistem yang berjalan lama atau dalam lingkungan production, Future harus bisa dibatalkan — misalnya jika request HTTP dari klien sudah timeout sebelum hasil Future siap.
package main
import (
"context"
"errors"
"fmt"
"time"
)
type Result[T any] struct {
Value T
Err error
}
type Future[T any] struct {
result chan Result[T]
}
// NewFutureWithContext membuat Future yang responsif terhadap cancellation
func NewFutureWithContext[T any](ctx context.Context, task func() (T, error)) *Future[T] {
f := &Future[T]{result: make(chan Result[T], 1)}
go func() {
// Jalankan task di goroutine terpisah agar bisa di-cancel
taskResult := make(chan Result[T], 1)
go func() {
val, err := task()
taskResult <- Result[T]{Value: val, Err: err}
}()
select {
case res := <-taskResult:
f.result <- res // task selesai normal
case <-ctx.Done():
var zero T
f.result <- Result[T]{
Value: zero,
Err: errors.New("future dibatalkan: " + ctx.Err().Error()),
}
}
}()
return f
}
func (f *Future[T]) Get() (T, error) {
res := <-f.result
return res.Value, res.Err
}
func longRunningTask() (string, error) {
time.Sleep(3 * time.Second) // simulasi task yang lama
return "hasil komputasi", nil
}
func main() {
// Context dengan timeout 1 detik — task butuh 3 detik, jadi akan di-cancel
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
defer cancel()
future := NewFutureWithContext(ctx, longRunningTask)
result, err := future.Get()
if err != nil {
fmt.Println("Future gagal:", err)
return
}
fmt.Println("Hasil:", result)
}
Output:
Future gagal: future dibatalkan: context deadline exceeded
stateDiagram-v2
[*] --> Running : NewFutureWithContext dipanggil
Running --> Completed : task selesai sebelum deadline
Running --> Cancelled : ctx.Done() menerima sinyal
Completed --> [*] : result dikirim ke channel
Cancelled --> [*] : error dikirim ke channel
WhenAll: Menunggu Semua Future Selesai #
Pola umum lainnya adalah menunggu semua future selesai sebelum melanjutkan — mirip Promise.all() di JavaScript.
package main
import (
"fmt"
"time"
)
type Result[T any] struct {
Value T
Err error
Index int
}
type Future[T any] struct {
result chan Result[T]
index int
}
func NewFuture[T any](index int, task func() (T, error)) *Future[T] {
f := &Future[T]{result: make(chan Result[T], 1), index: index}
go func() {
val, err := task()
f.result <- Result[T]{Value: val, Err: err, Index: index}
}()
return f
}
// WhenAll menunggu semua future selesai dan mengumpulkan hasilnya
// Urutan hasil sesuai urutan future yang diberikan
func WhenAll[T any](futures ...*Future[T]) []Result[T] {
results := make([]Result[T], len(futures))
for _, f := range futures {
res := <-f.result
results[res.Index] = res
}
return results
}
func main() {
futures := []*Future[string]{
NewFuture(0, func() (string, error) {
time.Sleep(300 * time.Millisecond)
return "data dari cache", nil
}),
NewFuture(1, func() (string, error) {
time.Sleep(600 * time.Millisecond)
return "data dari database", nil
}),
NewFuture(2, func() (string, error) {
time.Sleep(150 * time.Millisecond)
return "data dari config", nil
}),
}
results := WhenAll(futures...)
for _, r := range results {
if r.Err != nil {
fmt.Printf("Future[%d] error: %v\n", r.Index, r.Err)
} else {
fmt.Printf("Future[%d] hasil: %s\n", r.Index, r.Value)
}
}
}
Future–Promise vs Pola Concurrency Lain #
Sering muncul kebingungan kapan harus memilih Future–Promise dibanding pola lain. Tabel ini membantu memperjelas:
| Kriteria | Future–Promise | Producer-Consumer | Async Callback |
|---|---|---|---|
| Hasil | Satu nilai per future | Aliran data berkelanjutan | Dipanggil saat selesai |
| Blocking | Hanya saat Get() |
Consumer blocking di channel | Tidak ada blocking |
| Error handling | Dibawa bersama nilai | Perlu error channel terpisah | Lewat parameter callback |
| Composability | Mudah (WhenAll, WhenAny) | Sedang | Sulit (callback hell) |
| Ideal untuk | Satu hasil dari satu task | Streaming / antrian task | Event-driven sederhana |
flowchart TD
Q{Perlu hasil\ndari operasi async?} -- Ya --> Q2{Satu hasil\natau stream?}
Q -- Tidak --> CB[Async Callback\natau goroutine fire-and-forget]
Q2 -- Satu hasil --> FP[Future–Promise]
Q2 -- Stream berkelanjutan --> PC[Producer-Consumer]
FP --> Q3{Butuh\ncancellation?}
Q3 -- Ya --> CTX[Future + Context]
Q3 -- Tidak --> SF[Simple Future]
Kapan Tidak Menggunakan Future–Promise #
Tetap gunakan pendekatan sederhana jika:
✓ Operasi sangat cepat (< 1ms) — overhead goroutine tidak sebanding
✓ Hasilnya langsung dipakai setelah operasi selesai — blocking langsung lebih sederhana
✓ Codebase tidak familier dengan concurrency — abstraksi bisa menyulitkan debugging
✓ Hanya satu operasi asinkron — goroutine + channel langsung sudah cukup
Pertimbangkan Future–Promise jika:
✗ Ada banyak operasi I/O yang bisa berjalan paralel
✗ Kamu perlu komposisi (WhenAll, WhenAny, timeout per-future)
✗ Hasil dibutuhkan di titik yang berbeda dari titik task dimulai
✗ Sistem perlu cancellation yang granular per-task
Anti-Pattern yang Harus Dihindari #
// ✗ Channel unbuffered — goroutine leak jika Get() tidak dipanggil
type LeakyFuture struct {
result chan int
}
func NewLeakyFuture(task func() int) *LeakyFuture {
f := &LeakyFuture{result: make(chan int)} // ✗ unbuffered!
go func() {
f.result <- task() // akan blocking selamanya jika Get() tidak dipanggil
}()
return f
}
// ✓ Selalu gunakan buffered channel dengan kapasitas 1
type SafeFuture struct {
result chan int
}
func NewSafeFuture(task func() int) *SafeFuture {
f := &SafeFuture{result: make(chan int, 1)} // ✓ buffered
go func() {
f.result <- task()
}()
return f
}
// ✗ Get() dipanggil lebih dari sekali — akan blocking selamanya di panggilan kedua
future := NewSafeFuture(func() int { return 42 })
val1 := future.Get() // ✓ berhasil
val2 := future.Get() // ✗ blocking selamanya — channel sudah kosong
// ✓ Simpan hasil Get() di variabel jika perlu digunakan lebih dari sekali
result := future.Get() // ✓ panggil sekali
use(result)
use(result) // gunakan variabel, bukan future.Get() lagi
// ✗ Meluncurkan terlalu banyak Future tanpa batas
for i := 0; i < 1_000_000; i++ {
go func() { /* future */ }() // ✗ bisa exhausted memory dan scheduler
}
// ✓ Batasi dengan semaphore atau worker pool jika fan-out besar
sem := make(chan struct{}, 50) // maksimal 50 goroutine concurrent
for i := 0; i < 1_000_000; i++ {
sem <- struct{}{}
go func() {
defer func() { <-sem }()
/* future task */
}()
}
Checklist Review Future–Promise #
DESAIN FUTURE:
□ Channel di dalam Future selalu buffered (kapasitas minimal 1)
□ Get() hanya dipanggil sekali per Future — hasilnya disimpan ke variabel
□ Result struct membawa error bersama value (bukan value saja)
LIFECYCLE GOROUTINE:
□ Semua goroutine Future punya jalur exit yang jelas
□ Tidak ada goroutine yang bisa leak (channel unbuffered tanpa consumer)
□ Fan-out besar dibatasi dengan semaphore atau worker pool
CANCELLATION:
□ Sistem jangka panjang menggunakan context.Context untuk timeout/cancel
□ Cancellation dikomunikasikan sebagai error di Result, bukan panic
□ defer cancel() selalu ada setelah context.WithTimeout/WithCancel
ERROR HANDLING:
□ Error selalu dibawa bersama value dalam Result struct
□ Caller selalu memeriksa error dari Get()
□ Panic di dalam task di-recover dan dikonversi menjadi error
KOMPOSISI:
□ WhenAll digunakan saat semua hasil diperlukan sebelum melanjutkan
□ Urutan Get() mempertimbangkan task mana yang paling lama (hindari bottleneck)
□ Tidak ada penggunaan Future berlebihan untuk operasi yang bisa dilakukan sinkron
Ringkasan #
- Future–Promise memisahkan kapan task dimulai dan kapan hasilnya digunakan — ini yang membuat eksekusi bisa non-blocking dan paralel.
- Di Golang, Future diimplementasikan dengan goroutine + channel — tidak ada keyword khusus, tapi justru ini memberi kamu kontrol penuh atas behaviornya.
- Channel di dalam Future harus selalu buffered (kapasitas 1) — channel unbuffered menyebabkan goroutine leak jika
Get()tidak pernah dipanggil.- Gunakan generics (
Future[T]) untuk type-safe Future — tersedia sejak Go 1.18 dan mengeliminasi type assertion manual.- Result struct harus membawa error bersama value — Future yang hanya mengembalikan value tidak cukup untuk sistem production.
- Fan-out adalah use case utama — jalankan N operasi paralel dan tunggu semua selesai; latency ditentukan oleh task terlama, bukan jumlah semua task.
- Gunakan
context.Contextuntuk cancellation — future yang berjalan melewati deadline harus bisa dihentikan dengan bersih, bukan dibiarkan berjalan di background.Get()hanya boleh dipanggil sekali per Future — panggilan kedua akan blocking selamanya karena channel sudah kosong; simpan hasil ke variabel.- Batasi fan-out dengan semaphore — meluncurkan terlalu banyak goroutine sekaligus bisa membebani scheduler dan memory; gunakan semaphore channel untuk membatasi concurrency.
- Jangan over-engineering — jika operasi sederhana dan langsung, channel biasa atau goroutine langsung sudah cukup; Future menambah abstraksi yang perlu dipahami seluruh tim.
← Sebelumnya: Producer-Consumer Berikutnya: Async Callback →