Wawancara Teknis Go: Goroutine, Channel, dan Concurrency

Pertanyaan wawancara teknis Go tentang goroutine, channel, dan pola concurrency. Contoh kode, jebakan umum, dan jawaban tingkat ahli untuk persiapan wawancara teknis Go pada tahun 2026.

Persiapan wawancara teknis Go mencakup goroutine channel dan pola concurrency

Pertanyaan wawancara Go tentang goroutine, channel, dan concurrency secara konsisten menjadi topik paling menantang yang dihadapi kandidat. Pemahaman mendalam terhadap konsep-konsep ini membedakan engineer Go senior dari mereka yang masih mempelajari bahasa tersebut. Panduan ini membahas pertanyaan yang sering ditanyakan pewawancara pada tahun 2026, dilengkapi contoh kode siap produksi dan penjelasan di balik setiap jawaban.

Yang sebenarnya diuji pewawancara

Wawancara concurrency Go berfokus pada tiga area: manajemen siklus hidup goroutine, semantik channel (buffered vs unbuffered, tipe directional), dan komposisi pola (fan-out/fan-in, worker pool, context cancellation). Menghafal sintaks saja tidak cukup — pewawancara mengharapkan kandidat mampu menganalisis race condition dan deadlock.

Dasar-Dasar Goroutine: Pertanyaan yang Selalu Ditanyakan

Putaran pertama pertanyaan biasanya menguji apakah kandidat memahami apa itu goroutine sebenarnya — bukan sekadar cara membuatnya.

T: Apa itu goroutine, dan apa bedanya dengan OS thread?

Goroutine adalah fungsi concurrent ringan yang dikelola oleh Go runtime scheduler, bukan oleh sistem operasi. Go runtime melakukan multiplexing ribuan goroutine ke sejumlah kecil OS thread menggunakan model penjadwalan M:N (M goroutine dipetakan ke N OS thread).

goroutine_basics.gogo
package main

import (
	"fmt"
	"runtime"
	"sync"
)

func main() {
	// Print the number of OS threads available
	fmt.Println("GOMAXPROCS:", runtime.GOMAXPROCS(0))

	var wg sync.WaitGroup

	for i := 0; i < 10000; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			// Each goroutine starts with ~2-8KB stack
			// OS threads typically start with 1-8MB
			_ = id
		}(i)
	}

	wg.Wait()
	fmt.Println("All 10,000 goroutines completed")
}

Perbedaan utama yang perlu disebutkan dalam wawancara: goroutine dimulai dengan stack 2-8KB yang tumbuh secara dinamis, dibandingkan stack tetap 1-8MB pada OS thread. Context switching antar goroutine ditangani di user space oleh Go scheduler, menghindari context switch tingkat kernel yang mahal pada OS thread. Hal ini membuat pembuatan 100.000 goroutine sangat praktis, sementara 100.000 OS thread akan menghabiskan sumber daya sistem.

T: Apa yang terjadi jika sebuah goroutine mengalami panic?

Panic yang tidak ter-recover pada goroutine mana pun akan menghentikan seluruh program. Berbeda dengan exception di Java atau Python, panic merambat ke atas call stack goroutine itu sendiri — bukan stack goroutine yang membuatnya. Satu-satunya cara menangkapnya adalah dengan recover() di dalam deferred function pada goroutine yang sama.

panic_recovery.gogo
package main

import "fmt"

func safeGo(fn func()) {
	go func() {
		defer func() {
			if r := recover(); r != nil {
				fmt.Println("recovered from panic:", r)
			}
		}()
		fn() // execute the actual work
	}()
}

func main() {
	safeGo(func() {
		panic("something went wrong")
	})

	// Give goroutine time to complete
	select {}
}

Pewawancara mencari kesadaran bahwa layanan Go produksi membungkus peluncuran goroutine dalam pola recovery. Library seperti errgroup menangani hal ini dengan lebih elegan.

Semantik Channel: Buffered, Unbuffered, dan Directional

Pertanyaan tentang channel mengungkapkan apakah kandidat benar-benar memahami model concurrency Go atau hanya menghafal pola.

T: Apa perbedaan antara buffered dan unbuffered channel?

Unbuffered channel (make(chan T)) mengharuskan pengirim dan penerima siap secara bersamaan — pengiriman memblokir sampai goroutine lain menerima. Buffered channel (make(chan T, n)) mengizinkan hingga n nilai dikirim tanpa memblokir.

channel_semantics.gogo
package main

import "fmt"

func main() {
	// Unbuffered: send blocks until receive is ready
	ch := make(chan string)
	go func() {
		ch <- "hello" // blocks here until main reads
	}()
	msg := <-ch
	fmt.Println(msg)

	// Buffered: send does not block until buffer is full
	buf := make(chan int, 3)
	buf <- 1 // does not block (buffer has space)
	buf <- 2 // does not block
	buf <- 3 // does not block
	// buf <- 4 would block — buffer is full

	fmt.Println(<-buf, <-buf, <-buf) // 1 2 3
}

Pertanyaan lanjutan yang sering diajukan pewawancara: "Kapan memilih salah satu di antara keduanya?" Unbuffered channel memaksakan sinkronisasi — berguna ketika pengirim harus mengetahui bahwa penerima telah memproses nilai. Buffered channel memisahkan waktu pengirim dan penerima — berguna untuk work queue atau rate limiting di mana toleransi tertentu dapat diterima.

T: Apa yang terjadi ketika channel ditutup?

Menutup channel menandakan bahwa tidak ada lagi nilai yang akan dikirim. Operasi receive pada channel yang sudah ditutup langsung mengembalikan zero value. Mengirim ke channel yang sudah ditutup menyebabkan panic. Loop range pada channel berhenti ketika channel ditutup.

close_channel.gogo
package main

import "fmt"

func producer(ch chan<- int, count int) {
	for i := 0; i < count; i++ {
		ch <- i
	}
	close(ch) // signal: no more values
}

func main() {
	ch := make(chan int, 5)
	go producer(ch, 5)

	// range exits automatically when channel closes
	for val := range ch {
		fmt.Println("received:", val)
	}

	// Reading from closed channel returns zero value + false
	val, ok := <-ch
	fmt.Printf("after close: val=%d, ok=%v\n", val, ok)
}

Poin kritis: hanya pengirim yang boleh menutup channel, tidak pernah penerima. Menutup channel yang masih ditulis oleh goroutine lain menyebabkan panic.

Siap menguasai wawancara Go Anda?

Berlatih dengan simulator interaktif, flashcards, dan tes teknis kami.

Pernyataan Select: Multiplexing Channel

T: Bagaimana select bekerja, dan apa yang terjadi ketika beberapa case siap bersamaan?

Pernyataan select memblokir sampai salah satu operasi channel dapat dilanjutkan. Ketika beberapa case siap secara bersamaan, Go memilih secara acak — ini mencegah starvation pada case tertentu.

select_multiplex.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

func fetchFromAPI(ctx context.Context, url string) (string, error) {
	resultCh := make(chan string, 1)
	errCh := make(chan error, 1)

	go func() {
		// Simulate API call
		time.Sleep(200 * time.Millisecond)
		resultCh <- fmt.Sprintf("data from %s", url)
	}()

	select {
	case result := <-resultCh:
		return result, nil
	case err := <-errCh:
		return "", err
	case <-ctx.Done():
		// Context cancelled or timed out
		return "", ctx.Err()
	}
}

func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
	defer cancel()

	result, err := fetchFromAPI(ctx, "https://api.example.com/data")
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(result)
}

Pewawancara menguji dua hal dengan select: pemahaman tentang aturan pemilihan acak, dan kemampuan mengombinasikan channel dengan context.Context untuk pola timeout dan pembatalan.

Pola Concurrency Umum dalam Wawancara

Wawancara Go tingkat senior hampir selalu menyertakan pertanyaan tentang implementasi salah satu pola berikut dari awal.

Pola Fan-Out/Fan-In

T: Implementasikan pipeline fan-out/fan-in yang memproses item secara concurrent.

Fan-out mendistribusikan pekerjaan ke beberapa goroutine. Fan-in mengumpulkan hasil dari beberapa goroutine ke satu channel.

fanout_fanin.gogo
package main

import (
	"fmt"
	"sync"
)

// generator produces values on a channel
func generator(nums ...int) <-chan int {
	out := make(chan int)
	go func() {
		for _, n := range nums {
			out <- n
		}
		close(out)
	}()
	return out
}

// square reads from input, squares each value
func square(in <-chan int) <-chan int {
	out := make(chan int)
	go func() {
		for n := range in {
			out <- n * n
		}
		close(out)
	}()
	return out
}

// fanIn merges multiple channels into one
func fanIn(channels ...<-chan int) <-chan int {
	var wg sync.WaitGroup
	merged := make(chan int)

	for _, ch := range channels {
		wg.Add(1)
		go func(c <-chan int) {
			defer wg.Done()
			for val := range c {
				merged <- val
			}
		}(ch)
	}

	go func() {
		wg.Wait()
		close(merged) // close after all inputs are drained
	}()

	return merged
}

func main() {
	in := generator(2, 3, 4, 5, 6)

	// Fan out: two goroutines reading from same channel
	c1 := square(in)
	c2 := square(in)

	// Fan in: merge results
	for result := range fanIn(c1, c2) {
		fmt.Println(result)
	}
}

Insight utama yang dicari pewawancara: channel generator dibagi antara c1 dan c2, sehingga setiap nilai diproses oleh tepat satu worker (tidak diduplikasi). Fungsi fanIn menggunakan WaitGroup untuk mengetahui kapan semua input channel telah habis sebelum menutup channel gabungan.

Worker Pool dengan errgroup

T: Bagaimana cara mengimplementasikan bounded worker pool dengan penanganan error?

Package golang.org/x/sync/errgroup (bagian dari Go extended standard library) menyelesaikan masalah ini secara elegan. Package ini mengelola siklus hidup goroutine, mengumpulkan error pertama, dan terintegrasi dengan context untuk pembatalan.

worker_pool.gogo
package main

import (
	"context"
	"fmt"
	"golang.org/x/sync/errgroup"
)

func processItem(ctx context.Context, id int) error {
	// Check for cancellation before heavy work
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	if id == 7 {
		return fmt.Errorf("failed to process item %d", id)
	}
	fmt.Printf("processed item %d\n", id)
	return nil
}

func main() {
	g, ctx := errgroup.WithContext(context.Background())
	g.SetLimit(3) // maximum 3 concurrent goroutines

	for i := 0; i < 10; i++ {
		id := i
		g.Go(func() error {
			return processItem(ctx, id)
		})
	}

	// Wait blocks until all goroutines finish
	// Returns the first non-nil error
	if err := g.Wait(); err != nil {
		fmt.Println("pipeline error:", err)
	}
}

Sejak Go 1.24 (rilis stabil terbaru pada awal 2026), pola ini tetap menjadi pendekatan yang direkomendasikan. Metode SetLimit ditambahkan pada Go 1.20 dan menghilangkan kebutuhan untuk mengimplementasikan pembatasan concurrency berbasis semaphore secara manual.

Race Condition dan Primitif sync

T: Bagaimana cara mendeteksi dan mencegah race condition di Go?

Go menyediakan race detector bawaan yang diaktifkan dengan flag -race. Alat ini mendeteksi akses concurrent tidak tersinkronisasi ke shared memory pada saat runtime.

race_condition.gogo
package main

import (
	"fmt"
	"sync"
	"sync/atomic"
)

// BAD: race condition — do not use in production
func unsafeCounter() int {
	counter := 0
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter++ // DATA RACE: concurrent read/write
		}()
	}
	wg.Wait()
	return counter // result is non-deterministic
}

// GOOD: atomic operations for simple counters
func atomicCounter() int64 {
	var counter atomic.Int64
	var wg sync.WaitGroup
	for i := 0; i < 1000; i++ {
		wg.Add(1)
		go func() {
			defer wg.Done()
			counter.Add(1) // thread-safe atomic increment
		}()
	}
	wg.Wait()
	return counter.Load() // always 1000
}

// GOOD: mutex for complex shared state
type SafeMap struct {
	mu sync.RWMutex
	data map[string]int
}

func (m *SafeMap) Set(key string, val int) {
	m.mu.Lock()         // exclusive lock for writes
	defer m.mu.Unlock()
	m.data[key] = val
}

func (m *SafeMap) Get(key string) (int, bool) {
	m.mu.RLock()         // shared lock for reads
	defer m.mu.RUnlock()
	v, ok := m.data[key]
	return v, ok
}

func main() {
	fmt.Println("unsafe:", unsafeCounter())  // unpredictable
	fmt.Println("atomic:", atomicCounter())  // always 1000
}

Jawaban wawancara harus mencakup tiga strategi sinkronisasi: sync.Mutex / sync.RWMutex untuk shared state yang kompleks, sync/atomic untuk counter dan flag sederhana, dan channel untuk komunikasi antar goroutine ("share memory by communicating, don't communicate by sharing memory"). Menjalankan go test -race ./... harus menjadi bagian dari setiap CI pipeline.

Context dan Pola Pembatalan

T: Jelaskan bagaimana context.Context mengontrol siklus hidup goroutine.

Package context menyediakan mekanisme untuk menyebarkan sinyal pembatalan, deadline, dan nilai berskala request ke seluruh batas goroutine. Setiap goroutine yang berjalan lama harus menerima context.Context sebagai parameter pertamanya.

context_cancellation.gogo
package main

import (
	"context"
	"fmt"
	"time"
)

// worker simulates a long-running task
func worker(ctx context.Context, id int, results chan<- string) {
	select {
	case <-time.After(time.Duration(id*100) * time.Millisecond):
		results <- fmt.Sprintf("worker %d: done", id)
	case <-ctx.Done():
		results <- fmt.Sprintf("worker %d: cancelled (%v)", id, ctx.Err())
	}
}

func main() {
	// Parent context with 250ms deadline
	ctx, cancel := context.WithTimeout(context.Background(), 250*time.Millisecond)
	defer cancel()

	results := make(chan string, 5)

	// Launch 5 workers with increasing durations
	for i := 1; i <= 5; i++ {
		go worker(ctx, i, results)
	}

	// Collect all results
	for i := 0; i < 5; i++ {
		fmt.Println(<-results)
	}
}

Worker 1 dan 2 selesai dalam deadline 250ms. Worker 3, 4, dan 5 menerima sinyal pembatalan melalui ctx.Done(). Pola ini fundamental untuk membangun HTTP server dan microservice yang tangguh di Go — setiap request handler menerima context yang menyebarkan pembatalan ketika klien terputus.

Jebakan umum dalam wawancara

Jangan pernah menyimpan context.Context di dalam field struct. Dokumentasi resmi Go secara eksplisit menyatakan: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." Pewawancara menguji hal ini untuk mengukur apakah kandidat mengikuti konvensi Go.

Deteksi Deadlock: Pertanyaan Wawancara yang Menjebak

T: Apakah kode ini akan deadlock? Mengapa?

Pertanyaan deadlock populer karena menguji kemampuan kandidat menganalisis penjadwalan goroutine dan operasi channel.

deadlock_example.gogo
package main

func main() {
	ch := make(chan int)
	ch <- 42 // DEADLOCK: unbuffered send with no receiver
	// The main goroutine blocks here forever
	// Go runtime detects this: "fatal error: all goroutines are asleep"
}

Solusinya sederhana: buat channel menjadi buffered (make(chan int, 1)) atau buat goroutine untuk menerima sebelum mengirim. Go runtime mendeteksi deadlock ketika semua goroutine terblokir — tetapi hanya ketika semua goroutine tertidur. Jika bahkan satu goroutine masih berjalan (misalnya, HTTP server background), runtime tidak akan mendeteksi deadlock parsial.

Deadlock parsial tidak terdeteksi

Go runtime hanya mendeteksi deadlock ketika setiap goroutine dalam program terblokir. Pada aplikasi nyata dengan HTTP server atau background worker, goroutine yang bocor dan mengalami deadlock tidak akan memicu detektor runtime. Tool seperti pprof dan goroutine dump (runtime.Stack) diperlukan untuk mendiagnosis masalah ini di produksi.

Pola Lanjutan: Pemrosesan Concurrent dengan Rate Limiting

T: Bagaimana cara mengimplementasikan rate-limited concurrent API call?

Pertanyaan ini menguji kemampuan mengombinasikan beberapa primitif concurrency menjadi solusi yang kohesif.

rate_limited.gogo
package main

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

// RateLimiter controls concurrent and temporal access
type RateLimiter struct {
	semaphore chan struct{}   // limits concurrency
	ticker    *time.Ticker    // limits rate
}

func NewRateLimiter(maxConcurrent int, interval time.Duration) *RateLimiter {
	return &RateLimiter{
		semaphore: make(chan struct{}, maxConcurrent),
		ticker:    time.NewTicker(interval),
	}
}

func (rl *RateLimiter) Execute(ctx context.Context, fn func() error) error {
	// Wait for rate limit tick
	select {
	case <-rl.ticker.C:
	case <-ctx.Done():
		return ctx.Err()
	}

	// Acquire concurrency slot
	select {
	case rl.semaphore <- struct{}{}:
	case <-ctx.Done():
		return ctx.Err()
	}

	defer func() { <-rl.semaphore }() // release slot
	return fn()
}

func main() {
	rl := NewRateLimiter(3, 100*time.Millisecond)
	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
	defer cancel()

	var wg sync.WaitGroup
	for i := 0; i < 10; i++ {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			err := rl.Execute(ctx, func() error {
				fmt.Printf("[%v] processing %d\n", time.Now().Format("04:05.000"), id)
				time.Sleep(150 * time.Millisecond) // simulate work
				return nil
			})
			if err != nil {
				fmt.Printf("item %d: %v\n", id, err)
			}
		}(i)
	}
	wg.Wait()
}

Pola ini mengombinasikan semaphore berbasis channel (untuk pembatasan concurrency) dengan ticker (untuk rate limiting). Double select dengan pengecekan context memastikan shutdown yang graceful. Jawaban siap produksi seperti inilah yang membedakan kandidat senior.

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Kesimpulan

  • Goroutine adalah thread user-space yang dikelola Go runtime dengan penjadwalan M:N; selalu recover panic pada goroutine yang di-spawn
  • Unbuffered channel mensinkronisasi pengirim dan penerima; buffered channel memisahkan waktu — pilih berdasarkan apakah pengirim memerlukan konfirmasi
  • Pernyataan select melakukan multiplexing operasi channel dengan pemilihan acak ketika beberapa case siap; kombinasikan dengan context.Context untuk timeout
  • Fan-out/fan-in dan worker pool (melalui errgroup.SetLimit) adalah dua pola concurrency yang paling sering ditanyakan
  • Gunakan sync.Mutex untuk shared state kompleks, sync/atomic untuk counter sederhana, dan channel untuk komunikasi goroutine
  • Selalu jalankan go test -race di CI untuk menangkap data race; deadlock parsial memerlukan pprof untuk diagnosa
  • Jangan simpan context.Context di struct — kirimkan sebagai parameter pertama fungsi
  • Rate limiting di Go mengombinasikan semaphore channel dengan ticker, dibungkus dalam select statement yang sadar context

Mulai berlatih!

Uji pengetahuan Anda dengan simulator wawancara dan tes teknis kami.

Tag

#go
#golang
#interview
#goroutines
#channels
#concurrency

Bagikan

Artikel terkait