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 memanggil Get(), goroutine tersebut akan leak selamanya. Buffer kapasitas 1 memastikan goroutine bisa menulis dan selesai meski Get() 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.Context untuk 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 →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact