Panduan Lengkap Concurrency di Golang: Goroutine, Channel, dan Best Practice
Pelajari dasar concurrency Go yang terukur: goroutine, channel, sync.WaitGroup, worker pool, serta cara menelusuri race dan goroutine leak.
Saat backend menerima pekerjaan yang saling menunggu I/O—misalnya request HTTP, query database, atau pengiriman ke layanan lain—concurrency membantu program tetap menangani pekerjaan lain. Namun goroutine bukan alasan untuk melepas batas konkurensi; memori, CPU, koneksi, dan layanan downstream tetap punya kapasitas.
Catatan ini membahas goroutine, channel, sync.WaitGroup, worker pool, serta beberapa kegagalan yang sering muncul saat kode concurrent mulai masuk ke lingkungan nyata.
Jika Go belum terpasang di mesin lokal, lihat panduan instalasi Go di Linux, macOS, dan Windows terlebih dahulu.
Daftar Isi
- Concurrency dan parallelism
- Memulai goroutine
- Mengalirkan data dengan channel
- Menunggu pekerjaan dengan sync.WaitGroup
- Membatasi pekerjaan dengan worker pool
- Pola fan-out dan fan-in
- Jebakan umum dalam concurrency
- Penutup
Concurrency dan parallelism
Kedua istilah ini sering dipakai bergantian, padahal fokusnya berbeda. Concurrency adalah cara menyusun program agar banyak pekerjaan dapat berjalan maju; parallelism adalah menjalankan pekerjaan secara bersamaan pada sumber daya komputasi yang terpisah. Program concurrent tidak otomatis berjalan paralel, dan program paralel tetap perlu sinkronisasi yang benar.
Concurrency is about dealing with lots of things at once. Parallelism is about doing lots of things at once.
Gambaran sederhananya: satu koki dapat berganti antara menunggu air mendidih dan menyiapkan bumbu—itu concurrency. Beberapa koki yang benar-benar memasak bersamaan adalah parallelism. Runtime Go lah yang mengatur goroutine ke sejumlah OS thread; berapa banyak pekerjaan CPU-bound yang dapat berjalan paralel tetap dipengaruhi GOMAXPROCS dan CPU yang tersedia.
Memulai goroutine
Goroutine adalah fungsi yang berjalan concurrent dengan goroutine lain dalam address space yang sama. Runtime Go mengelolanya dan memultipleksnya ke OS thread. Stack goroutine dapat bertumbuh sesuai kebutuhan, mulai dari kecil sampai besar, sehingga biayanya umumnya lebih ringan daripada membuat satu OS thread per pekerjaan. Tetap ukur pada beban aplikasi sendiri; jumlah goroutine yang aman tidak punya angka universal.
package main
import (
"fmt"
"sync"
"time"
)
func printNumbers() {
for i := 1; i <= 5; i++ {
time.Sleep(100 * time.Millisecond) // Simulasi pekerjaan.
fmt.Printf("%d ", i)
}
}
func printLetters() {
for i := 'a'; i <= 'e'; i++ {
time.Sleep(150 * time.Millisecond) // Durasi berbeda agar output saling berselang.
fmt.Printf("%c ", i)
}
}
func main() {
var wg sync.WaitGroup
wg.Go(printNumbers) // Jalankan angka di goroutine baru.
wg.Go(printLetters) // Jalankan huruf di goroutine baru.
wg.Wait() // Tunggu dua goroutine selesai.
fmt.Println("\nEksekusi selesai.")
}Urutan output dapat berbeda di setiap eksekusi. Contoh ini sengaja memakai WaitGroup, bukan time.Sleep, untuk menunggu pekerjaan selesai. Sleep hanya cocok sebagai simulasi durasi, bukan kontrak sinkronisasi.
Komunikasi data dengan channel
Channel adalah jalur untuk mengirim data dari satu goroutine ke goroutine lain. Setiap channel membawa satu tipe data yang ditentukan saat dibuat. Misalnya, chan string hanya dapat mengirim dan menerima nilai string, sedangkan chan int hanya untuk int. Pengiriman dan penerimaan lewat channel juga dapat menjadi titik sinkronisasi antar-goroutine.
Channel dibuat dengan make. Bentuk paling dasar adalah make(chan T), dengan T diganti tipe data yang ingin dikirim. Contoh berikut membuat unbuffered channel untuk string:
package main
import "fmt"
func main() {
pesan := make(chan string) // Buat unbuffered channel untuk string.
go func() {
// Pengiriman menunggu sampai ada receiver.
pesan <- "Halo dari goroutine!"
}()
// Receive ini membuka jalan bagi pengiriman di atas.
fmt.Println(<-pesan)
}Unbuffered dan buffered channel
Channel pada Go umumnya dibedakan menjadi dua berdasarkan kapasitasnya: unbuffered dan buffered. Unbuffered channel dibuat tanpa kapasitas. Pengirim akan menunggu sampai ada receiver yang mengambil nilainya, dan receiver akan menunggu sampai ada nilai yang dikirim.
ch := make(chan string) // unbuffered channelBuffered channel memiliki antrean dengan kapasitas tertentu. Pengirim dapat memasukkan nilai selama antrean belum penuh. Setelah penuh, operasi kirim akan menunggu sampai ada nilai yang diterima.
ch := make(chan string, 2) // Buffer dapat menampung dua string.
ch <- "data-pertama" // Mengisi slot buffer pertama.
ch <- "data-kedua" // Mengisi slot buffer kedua.
fmt.Println(<-ch) // Membaca data-pertama.
fmt.Println(<-ch) // Membaca data-kedua.Buffered channel berguna sebagai antrean kecil atau untuk menampung burst sementara. Kapasitas buffer bukan solusi untuk consumer yang lebih lambat terus-menerus; saat penuh, pengirim tetap terblokir. Ini bukan larangan memakai mutex: bila beberapa goroutine memang perlu mengakses state bersama, sync.Mutex atau sync/atomic dapat menjadi rancangan yang lebih jelas.
Menutup dan memberi arah pada parameter channel
Untuk membaca sampai producer selesai, gunakan range dan tutup channel setelah tidak ada pengiriman lagi. Kesepakatan yang aman adalah pihak yang mengetahui bahwa semua pengiriman selesai-lah yang menutup channel—sering kali producer atau koordinatornya. Receiver tidak perlu menutup channel hanya untuk menandakan ia berhenti membaca.
Pada parameter function, arah channel dapat dibatasi agar maksudnya jelas: chan<- int hanya untuk mengirim int, sedangkan <-chan int hanya untuk menerima int. Channel biasa, chan int, dapat dipakai untuk keduanya.
func produceData(ch chan<- int) {
defer close(ch) // Producer menutup channel setelah semua nilai dikirim.
for i := 1; i <= 5; i++ {
ch <- i // Kirim satu angka ke receiver.
}
}
func main() {
numbers := make(chan int)
go produceData(numbers) // Jalankan producer.
for value := range numbers {
// range berhenti otomatis saat numbers ditutup.
fmt.Println("Menerima angka:", value)
}
}Mengirim ke channel yang sudah ditutup akan menyebabkan panic. Oleh karena itu, penutupan channel harus dilakukan satu pemilik atau koordinasi yang jelas; jangan menutup channel dari banyak goroutine yang masih mungkin mengirim.
Memeriksa channel yang sudah ditutup
Saat channel ditutup, operasi receive data akan langsung mengembalikan zero value tanpa memblokir eksekusi. Jika Anda melakukan receive di dalam loop tanpa pengecekan, ini bisa memicu infinite loop. Gunakan bentuk comma-ok (val, ok := <-ch) untuk memeriksa status keberadaan data secara pasti.
func readOne(ch <-chan string) {
value, ok := <-ch
if !ok {
// Tidak ada nilai lagi karena ch sudah ditutup.
return
}
fmt.Println(value)
}select, context.Done, dan timeout
Block select berguna untuk menunggu hingga salah satu operasi channel siap dieksekusi. Pola ini sangat cocok untuk memproses hasil pekerjaan sekaligus mendengarkan sinyal pembatalan melalui ctx.Done()—channel yang otomatis ditutup saat context dibatalkan atau batas waktunya (timeout) habis.
package main
import (
"context"
"fmt"
"time"
)
func fetch(ctx context.Context) (string, error) {
result := make(chan string, 1)
go func() {
// Simulasi pekerjaan yang selesai setelah dua detik.
time.Sleep(2 * time.Second)
result <- "data dari service"
}()
select {
case value := <-result:
return value, nil
case <-ctx.Done():
return "", ctx.Err()
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
value, err := fetch(ctx)
if err != nil {
fmt.Println("gagal:", err)
return
}
fmt.Println(value)
}Pada contoh tersebut timeout satu detik sudah terjadi lebih dulu, sehingga ctx.Done() dipilih dan fetch mengembalikan context deadline exceeded. Jangan lupa selalu panggil cancel() dengan defer agar resource context dilepas saat function selesai. Untuk HTTP client, database driver, atau library lain yang mendukung context, teruskan ctx ke operasi tersebut agar pekerjaan I/O-nya juga dapat dihentikan.

Timeout dengan time.After dan operasi non-blocking
Untuk timeout sederhana pada satu select, time.After mengembalikan channel yang menerima waktu setelah durasi berlalu. Bentuk berikut menunggu data selama paling lama lima detik.
select {
case message := <-ch:
// Data diterima sebelum batas waktu.
fmt.Println("pesan:", message)
case <-time.After(5 * time.Second):
// Tidak ada data yang diterima selama lima detik.
fmt.Println("timeout menunggu pesan")
}Jangan membuat time.After baru pada setiap iterasi loop yang berjalan lama. Jika timeout perlu diatur ulang atau dihentikan secara eksplisit, gunakan time.NewTimer dan kelola timer.C, Stop, atau Reset sesuai lifecycle operasi.
timer := time.NewTimer(5 * time.Second)
defer timer.Stop() // Hentikan timer bila function selesai lebih awal.
select {
case message := <-ch:
// Pesan datang sebelum timer berbunyi.
fmt.Println(message)
case <-timer.C:
// Timer mengirim nilai waktu setelah lima detik.
fmt.Println("timeout")
}Tambahkan default hanya saat memang ingin operasi non-blocking. Dengan default, select tidak menunggu channel mana pun yang belum siap.
select {
case message := <-ch:
// Jalankan saat data sudah tersedia.
fmt.Println(message)
default:
// Tidak ada data sekarang; lanjutkan tanpa menunggu.
fmt.Println("belum ada pesan")
}Ticker untuk pekerjaan berkala
time.NewTicker juga mengirim nilai melalui channel, yaitu ticker.C, pada interval tertentu. Gunakan Stop() saat ticker tidak lagi diperlukan.
func runScheduler(ctx context.Context) error {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop() // Hindari ticker tetap berjalan setelah function selesai.
for {
select {
case <-ticker.C:
// Jalankan pekerjaan berkala setiap menit.
refreshCache()
case <-ctx.Done():
// Hentikan loop saat aplikasi atau request dibatalkan.
return ctx.Err()
}
}
}Menunggu pekerjaan dengan sync.WaitGroup
sync.WaitGroup menunggu sekelompok task selesai. Untuk code baru yang menggunakan Go 1.25 atau lebih baru, gunakan wg.Go agar pendaftaran task dan goroutine dilakukan dalam satu pemanggilan.
WaitGroup.Go tersedia sejak Go 1.25 dan merangkum pola Add(1), go, serta Done() untuk fungsi tanpa nilai balik. Ia praktis bila versi Go proyek sudah mendukungnya.
var wg sync.WaitGroup
for i := 1; i <= 3; i++ {
id := i // Simpan ID untuk goroutine ini.
wg.Go(func() {
fmt.Println("Worker", id) // Kerjakan task worker.
})
}
wg.Wait() // Tunggu semua task yang didaftarkan lewat wg.Go selesai semuanya.Catatan: dokumentasisync.WaitGroupmenyatakan fungsi yang diberikan kewg.Gotidak boleh panic. Bila task dapat gagal, tangani error di dalam fungsi atau gunakan pola yang memang mengumpulkan error, misalnyaerrgroup.
Membatasi pekerjaan dengan worker pool
Worker pool digunakan untuk membatasi jumlah pekerjaan yang dapat diproses secara concuren. Worker pool ini sangat penting saat kapasitas resource dibatasi (CPU/RAM), contoh : koneksi database (misalkan di posgresql, ada setting max connection) atau API pihak ketiga. Jumlah worker bisa jadi bervariasi berdasarkan tipe pekerjaan-nya, misal : worker untuk I/O-bound dan CPU-bound biasanya membutuhkan angka yang berbeda.
package main
import (
"fmt"
"sync"
)
const (
totalWorker = 3
totalJobs = 8
)
type Job struct {
ID int
}
type Result struct {
JobID int
Nilai int
}
func worker(id int, jobs <-chan Job, results chan<- Result) {
for job := range jobs {
// Satu worker mengambil satu job dari channel bersama.
fmt.Printf("worker %d memproses job %d\n", id, job.ID)
results <- Result{
JobID: job.ID,
Nilai: job.ID * 10,
}
}
}
func main() {
jobs := make(chan Job, totalJobs)
results := make(chan Result, totalJobs)
var wg sync.WaitGroup
for id := 1; id <= totalWorker; id++ {
workerID := id
wg.Go(func() {
// Jalankan tiga worker yang membaca jobs yang sama.
worker(workerID, jobs, results)
})
}
for id := 1; id <= totalJobs; id++ {
jobs <- Job{ID: id} // Kirim delapan job ke antrean.
}
close(jobs) // Tidak ada job baru setelah ini.
go func() {
wg.Wait() // Semua worker berhenti setelah jobs habis.
close(results) // Receiver dapat berhenti melakukan range.
}()
for result := range results {
// Cetak semua result sampai channel results ditutup.
fmt.Printf("job %d menghasilkan %d\n", result.JobID, result.Nilai)
}
}Contoh ini memiliki 8 job dan 3 worker. Ketiga worker membaca dari channel jobs yang sama. Setiap job hanya diterima satu worker, tetapi satu worker dapat memproses lebih dari satu job. Distribusi job tidak tetap karena bergantung pada worker mana yang siap menerima job berikutnya.
Buffer pada jobs menentukan seberapa banyak pekerjaan yang boleh mengantre, bukan jaminan bahwa throughput akan selalu stabil. Jika diperlukan, pantau latency, error downstream, penggunaan koneksi, dan panjang antrean untuk menemukan batas yang sesuai.
Pola Fan-Out dan Fan-In
Fan-out berarti satu sumber pekerjaan dibagikan ke beberapa goroutine. Misalnya, jika ada 8 job dan 3 worker membaca channel jobs yang sama, setiap job akan diambil oleh tepat satu worker. Worker yang selesai lebih dahulu dapat mengambil job berikutnya, sehingga pembagiannya tidak harus sama rata. Pola ini mirip worker pool, tetapi istilah fan-out menekankan pembagian satu aliran pekerjaan ke banyak worker.
package main
import (
"fmt"
"sync"
)
func worker(id int, jobs <-chan int) {
for job := range jobs {
// Worker ini menerima salah satu job yang belum diambil worker lain.
fmt.Printf("worker %d memproses job %d\n", id, job)
}
}
func main() {
jobs := make(chan int)
var wg sync.WaitGroup
// Fan-out: satu channel jobs dibaca oleh tiga worker.
for id := 1; id <= 3; id++ {
workerID := id
wg.Go(func() {
worker(workerID, jobs)
})
}
for job := 1; job <= 6; job++ {
jobs <- job // Setiap job akan diterima tepat satu worker.
}
close(jobs) // Worker berhenti setelah semua job habis.
wg.Wait() // Tunggu ketiga worker selesai.
}Pada contoh ini, setiap job hanya diterima oleh satu worker. Urutan worker yang menerima job dapat berbeda di setiap eksekusi, jadi jangan mengandalkan urutan output untuk menentukan hasil.
Fan-in adalah kebalikannya: beberapa channel input digabungkan menjadi satu channel output. Pada contoh berikut, service-a dan service-b masing-masing mengirim data ke channel sendiri. Function fanIn meneruskan semua nilai dari dua channel tersebut ke satu channel out, sehingga pemanggil cukup melakukan satu range.
package main
import (
"fmt"
"sync"
)
func source(name string, values ...int) <-chan string {
out := make(chan string)
go func() {
defer close(out) // Source menutup channel setelah semua data dikirim.
for _, value := range values {
out <- fmt.Sprintf("%s: %d", name, value)
}
}()
return out
}
func fanIn(inputs ...<-chan string) <-chan string {
out := make(chan string)
var wg sync.WaitGroup
for _, input := range inputs {
input := input // Setiap goroutine membaca satu input.
wg.Go(func() {
for value := range input {
out <- value // Semua input diteruskan ke satu output.
}
})
}
go func() {
wg.Wait() // Tunggu semua input selesai dibaca.
close(out) // range di main berhenti setelah out ditutup.
}()
return out
}
func main() {
a := source("service-a", 1, 2)
b := source("service-b", 3, 4)
for value := range fanIn(a, b) {
// Semua nilai dari service-a dan service-b dibaca dari satu channel.
fmt.Println(value)
}
}fanIn membuat satu goroutine penerus untuk setiap input, lalu menutup out setelah semua input selesai dibaca. Nilai dari service-a dan service-b dapat keluar dalam urutan yang berbeda, tetapi semua nilai tetap diteruskan sebelum channel output ditutup.
Jebakan umum dalam concurrency
Goroutine leak
Goroutine leak adalah sebuah kondisi dimana terdapat goroutine yang terus aktif berjalan dan tertahan di memori (menggantung) karena tidak pernah menyelesaikan tugasnya dan tidak memiliki jalur untuk berhenti.
Leak biasanya terjadi ketika goroutine menunggu kirim/terima channel, lock, atau I/O yang tidak pernah selesai. Solusi : pastikan untuk memberi setiap goroutine jalur keluar: channel yang ditutup, context yang dibatalkan, atau deadline pada operasi I/O.
func waitForMessage(ctx context.Context, ch <-chan string) error {
select {
case message := <-ch:
// Pesan datang sebelum context dibatalkan.
fmt.Println(message)
return nil
case <-ctx.Done():
// Jangan menunggu channel tanpa batas.
return ctx.Err()
}
}Race condition
Race muncul saat beberapa goroutine mengakses resource yang sama secara bersamaan dan setidaknya satu akses adalah tulis tanpa sinkronisasi yang tepat.
package main
import (
"fmt"
"sync"
)
func main() {
var counter int
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
// wg.Go otomatis menangani registrasi Add(1) dan defer Done()
wg.Go(func() {
// RACE CONDITION AKAN TERJADI DI SINI:
// Beberapa goroutine membaca dan menulis 'counter' secara bersamaan.
counter++
})
}
wg.Wait()
fmt.Println("Hasil akhir counter:", counter)
}Jalankan test dengan go test -race ./...; race detector akan dapat menemukan kondisi race dari kode, sehingga pengembang akan dapat melakukan perbaikan hingga seluruh kode bebas race.
package main
import (
"fmt"
"sync"
)
func main() {
var (
counter int
mu sync.Mutex
wg sync.WaitGroup
)
for i := 0; i < 1000; i++ {
wg.Go(func() {
// lock resource untuk memastikan tidak ada goroutine lain yang bisa mengakses
mu.Lock()
counter++
mu.Unlock()
})
}
wg.Wait()
fmt.Println("Hasil akhir counter (Aman):", counter) // Selalu 1000
}Deadlock condition
Deadlock adalah kondisi saat dua atau lebih goroutine saling menunggu satu sama lain untuk melepaskan resource atau data, sehingga tidak ada satu pun goroutine yang dapat melanjutkan pekerjaannya. Karena semua goroutine terblokir, maka otomatis program akan berhenti bekerja (hang).
package main
func main() {
ch := make(chan int)
ch <- 42 // DEADLOCK! Tidak ada goroutine lain yang membaca channel ini.
}// Solusi A: Jalankan baca tulis channel di goroutine terpisah
ch := make(chan int)
go func() {
ch <- 42
}()
<-ch
// Solusi B: Gunakan buffered channel jika memang sesuai dengan kebutuhan
chBuf := make(chan int, 1)
chBuf <- 42 // Aman, karena ada slot buffer
// Solusi C: Gunakan select dengan default atau Timeout
select {
case ch <- 42:
// Berhasil dikirim
default:
// Batalkan/lewati jika channel sedang terblokir (tidak mau menunggu)
}Penutup
Dalam pengalaman saya, rancangan concurrency lebih mudah di-maintain ketika alur data dan kepemilikan resource didefinisikan dengan jelas: siapa yang membuat goroutine, siapa yang membatalkannya, siapa yang menutup channel, serta berapa batas pekerjaan bersamaan yang boleh masuk ke worker pool atau layanan downstream.
Terima kasih sudah membaca. Selamat mencoba, semoga catatan kecil ini bermanfaat.
