Kamu punya daftar 200 URL yang harus dicek statusnya. Loop biasa: tiap request makan sekitar 300 ms, total satu menit lebih, dan 199 request sisanya cuma nunggu giliran. Padahal request-request itu tidak saling bergantung — bisa jalan bareng-bareng. Di Go, solusinya bukan bikin thread manual, melainkan satu kata kunci: go. Tulisan ini membawamu dari goroutine pertama sampai worker pool yang rapi, semua kode bisa langsung kamu salin dan jalankan. Kalau sintaks dasar Go masih asing, siapkan dulu lewat Belajar Go (Golang) untuk Pemula.

Goroutine: Thread yang Hampir Gratis

Goroutine adalah fungsi yang berjalan secara konkuren. Tambahkan kata kunci go di depan pemanggilan fungsi, selesai. Go scheduler yang mengatur kapan tiap goroutine kebagian jatah CPU, jadi kamu tidak perlu repot mengelola thread sendiri.

go
package main

import (
	"fmt"
	"time"
)

func cetakPesan(pesan string) {
	for i := 0; i < 3; i++ {
		fmt.Println(pesan)
		time.Sleep(100 * time.Millisecond)
	}
}

func main() {
	go cetakPesan("goroutine")
	cetakPesan("main")
}

Jalankan dan perhatikan urutan output-nya — kemungkinan besar acak, karena kedua pemanggilan itu berjalan bergantian. Tapi ada jebakan: kalau fungsi main selesai duluan, program langsung keluar dan goroutine yang masih jalan ikut dibunuh. Di contoh di atas kebetulan cetakPesan("main") cukup lama sehingga goroutine-nya sempat jalan. Jangan andalkan keberuntungan itu.

Apa bedanya dengan thread OS? Satu goroutine mulai dengan stack sekitar 2 KB dan bisa tumbuh, sementara thread OS biasanya 1 MB. Artinya kamu bisa memunculkan ratusan ribu goroutine di laptop biasa tanpa kehabisan memori.

Masalah di atas — main keburu selesai — diselesaikan dengan WaitGroup. Ia cuma penghitung: Add menambah hitungan, Done menguranginya, dan Wait memblokir sampai hitungannya nol.

go
package main

import (
	"fmt"
	"sync"
	"time"
)

func ambilData(url string, wg *sync.WaitGroup) {
	defer wg.Done()
	time.Sleep(500 * time.Millisecond) // simulasi request
	fmt.Println("selesai:", url)
}

func main() {
	urls := []string{
		"https://example.com/a",
		"https://example.com/b",
		"https://example.com/c",
	}

	var wg sync.WaitGroup
	for _, u := range urls {
		wg.Add(1)
		go ambilData(u, &wg)
	}
	wg.Wait()
	fmt.Println("semua request selesai")
}

Dua hal yang sering bikin bug di sini. Pertama, Add(1) harus dipanggil sebelum goroutine dijalankan — kalau kamu panggil setelah go, ada jeda di mana Wait() sudah lewat padahal hitungannya belum naik. Kedua, defer wg.Done() memastikan hitungan berkurang walau fungsi return lebih awal karena error.

Channel: Goroutine Saling Kirim Data

Goroutine jalan sendiri-sendiri, jadi butuh cara untuk bertukar data. Channel adalah pipa bertipe: kamu bisa mengirim dan menerima nilai dengan operator <-. Konsepnya sederhana — kalau kamu pernah bekerja dengan task dan event loop lewat asyncio di Python, rasanya familiar.

go
package main

import "fmt"

func jumlah(a, b int, hasil chan int) {
	hasil <- a + b
}

func main() {
	ch := make(chan int)
	go jumlah(2, 3, ch)
	total := <-ch
	fmt.Println("total:", total)
}

hasil <- a + b mengirim nilai ke channel, dan <-ch menerimanya. Channel tanpa buffer itu sinkron: pengirim berhenti sampai ada yang menerima, dan sebaliknya. Itu sebabnya program di atas tetap aman walau tidak pakai WaitGroup — main menunggu di <-ch sampai goroutine mengirim hasil.

Mengirim ke channel yang tidak ada penerimanya akan memblokir selamanya, lalu Go melempar fatal error: all goroutines are asleep - deadlock!. Kalau kamu lihat pesan itu, hampir pasti ada goroutine yang mengirim (atau menunggu) channel tanpa pasangannya.

Buffered Channel: Antrean Berkapasitas

make(chan int) membuat channel tanpa buffer. Tambah angka kedua untuk memberi kapasitas: make(chan int, 3). Pengirim baru memblokir ketika buffer penuh.

go
package main

import "fmt"

func main() {
	ch := make(chan string, 2)

	ch <- "pertama"
	ch <- "kedua"
	// ch <- "ketiga" // memblokir karena buffer penuh

	fmt.Println(<-ch)
	fmt.Println(<-ch)
}

select: Menunggu Banyak Channel Sekaligus

Ketika satu goroutine harus bereaksi terhadap beberapa sumber sekaligus — hasil pekerjaan dan timer — pakai select. Ia menunggu case mana pun yang siap lebih dulu.

go
package main

import (
	"fmt"
	"time"
)

func main() {
	ch := make(chan string)

	go func() {
		time.Sleep(2 * time.Second)
		ch <- "hasil dari server"
	}()

	select {
	case pesan := <-ch:
		fmt.Println(pesan)
	case <-time.After(1 * time.Second):
		fmt.Println("timeout: server terlalu lambat")
	}
}

Contoh ini hampir selalu mencetak timeout, karena goroutine butuh 2 detik sedangkan time.After cuma 1 detik. time.After mengembalikan channel yang otomatis terisi setelah durasi tertentu — kombinasi ini jadi cara standar menambah batas waktu pada operasi apa pun.

Race Condition dan sync.Mutex

Dua goroutine menulis variabel yang sama tanpa pengaman = data race. Hasilnya tidak menentu dan bisa berbeda tiap kali dijalankan. Perhatikan counter di bawah.

go
package main

import (
	"fmt"
	"sync"
)

func main() {
	var counter int
	var mu sync.Mutex
	var wg sync.WaitGroup

	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			mu.Lock()
			counter++
			mu.Unlock()
		}()
	}
	wg.Wait()
	fmt.Println("counter:", counter)
}

mu.Lock() dan mu.Unlock() memastikan hanya satu goroutine yang masuk ke bagian kritis dalam satu waktu. Coba hapus keduanya lalu jalankan berulang kali — kadang dapat 1000, kadang kurang. Model ini beda jauh dengan GIL di Python; kalau kamu penasaran bagaimana Python menangani paralelisme, lihat panduan threading & multiprocessing ini.

Jangan menebak-nebak soal race. Jalankan dengan go run -race main.go atau go test -race — detektor bawaan Go akan melaporkan baris mana yang rawan. Aktifkan ini di CI, bukan cuma saat debugging.

Pola Worker Pool: Cek 200 URL Sekaligus

Gabungkan semua di atas jadi satu kasus nyata: mengecek status puluhan URL secara paralel, lalu mengumpulkan hasilnya lewat channel.

go
package main

import (
	"fmt"
	"net/http"
	"sync"
	"time"
)

type hasil struct {
	url    string
	status int
	err    error
}

func cekURL(url string, wg *sync.WaitGroup, hasilCh chan<- hasil) {
	defer wg.Done()
	client := http.Client{Timeout: 5 * time.Second}
	resp, err := client.Get(url)
	if err != nil {
		hasilCh <- hasil{url: url, err: err}
		return
	}
	defer resp.Body.Close()
	hasilCh <- hasil{url: url, status: resp.StatusCode}
}

func main() {
	urls := []string{
		"https://bengkelkode.com",
		"https://golang.org",
		"https://example.com",
	}

	hasilCh := make(chan hasil, len(urls))
	var wg sync.WaitGroup

	start := time.Now()
	for _, u := range urls {
		wg.Add(1)
		go cekURL(u, &wg, hasilCh)
	}
	wg.Wait()
	close(hasilCh)

	for h := range hasilCh {
		if h.err != nil {
			fmt.Printf("%s -> error: %v\n", h.url, h.err)
		} else {
			fmt.Printf("%s -> %d\n", h.url, h.status)
		}
	}
	fmt.Printf("selesai dalam %v\n", time.Since(start))
}

Ganti daftar urls dengan 200 situs yang benar-benar ingin kamu pantau, lalu bandingkan waktunya dengan versi loop biasa. Kamu akan lihat sendiri selisihnya: yang tadinya satu menit bisa jadi dua detik. Dari sini, coba tambah batas jumlah worker pakai channel semaphore, atau batalkan request yang terlalu lama lewat context.