# Go Technisch Interview: Goroutines, Channels en Concurrency [2026] > Go interviewvragen over goroutines, channels en concurrency-patronen. Codevoorbeelden en antwoorden voor technische Go-interviews. - Published: 2026-04-01 - Updated: 2026-04-01 - Author: Anthony Fillion-Maillet - Tags: go, interview, concurrency, goroutines, channels - Reading time: 12 min --- Go interviewvragen over goroutines, channels en concurrency behoren tot de zwaarste onderwerpen waarmee kandidaten in technische gesprekken te maken krijgen. Een diepgaand begrip van deze concepten onderscheidt ervaren Go-engineers van ontwikkelaars die de taal nog aan het leren zijn. Deze gids behandelt de exacte vragen die interviewers in 2026 stellen, met productieklare codevoorbeelden en de redenering achter elk antwoord. > **Waar interviewers daadwerkelijk op testen** > > Go concurrency-interviews richten zich op drie gebieden: goroutine-lifecycle management, channel-semantiek (buffered vs unbuffered, directionele types) en patrooncompositie (fan-out/fan-in, worker pools, context-cancellation). Syntaxis uit het hoofd kennen is onvoldoende -- interviewers verwachten dat de kandidaat kan redeneren over race conditions en deadlocks. ## Goroutine-basisprincipes: standaardvragen in elk interview De eerste ronde vragen peilt doorgaans of de kandidaat begrijpt wat goroutines werkelijk zijn, niet alleen hoe men ze start. **V: Wat is een goroutine en hoe verschilt deze van een OS-thread?** Een goroutine is een lichtgewicht concurrent functie die wordt beheerd door de Go runtime-scheduler, niet door het besturingssysteem. De Go runtime multiplext duizenden goroutines over een klein aantal OS-threads met behulp van een M:N-schedulingmodel (M goroutines toegewezen aan N OS-threads). ```go // goroutine_basics.go 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") } ``` Belangrijke verschillen om te benoemen in een interview: goroutines starten met een stack van 2-8KB die dynamisch groeit, tegenover de vaste stack van 1-8MB van een OS-thread. Context switching tussen goroutines wordt afgehandeld in user space door de Go-scheduler, waardoor de kostbare kernel-level context switches van OS-threads worden vermeden. Dit maakt het starten van 100.000 goroutines praktisch haalbaar, terwijl 100.000 OS-threads de systeembronnen zouden uitputten. **V: Wat gebeurt er als een goroutine in paniek raakt?** Een onafgevangen panic in een willekeurige goroutine crasht het volledige programma. Anders dan exceptions in Java of Python propageert een panic omhoog via de call stack van de goroutine zelf, niet via de stack van de goroutine die deze heeft gestart. De enige manier om de panic op te vangen is met `recover()` binnen een deferred functie in dezelfde goroutine. ```go // panic_recovery.go 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 {} } ``` Interviewers letten op het besef dat productie-Go-services goroutine-launches altijd inpakken in een recovery-patroon. Bibliotheken zoals `errgroup` behandelen dit op een elegantere manier. ## Channel-semantiek: buffered, unbuffered en directioneel Channelvragen onthullen of de kandidaat het concurrency-model van Go daadwerkelijk begrijpt of slechts patronen uit het hoofd heeft geleerd. **V: Wat is het verschil tussen een buffered en een unbuffered channel?** Een unbuffered channel (`make(chan T)`) vereist dat zowel verzender als ontvanger tegelijkertijd gereed zijn -- de verzending blokkeert totdat een andere goroutine ontvangt. Een buffered channel (`make(chan T, n)`) staat toe dat maximaal `n` waarden worden verzonden zonder te blokkeren. ```go // channel_semantics.go 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 } ``` De vervolgvraag die interviewers vaak stellen: "Wanneer kies je het ene boven het andere?" Unbuffered channels dwingen synchronisatie af -- nuttig wanneer de verzender moet weten dat de ontvanger de waarde heeft verwerkt. Buffered channels ontkoppelen de timing van verzender en ontvanger -- nuttig voor werkwachtrijen of rate limiting, waar enige speling acceptabel is. **V: Wat gebeurt er wanneer een channel wordt gesloten?** Het sluiten van een channel signaleert dat er geen waarden meer worden verzonden. Ontvangstoperaties op een gesloten channel retourneren onmiddellijk de zero value. Verzending op een gesloten channel veroorzaakt een panic. Een `range`-loop over een channel eindigt wanneer het channel wordt gesloten. ```go // close_channel.go 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) } ``` Een kritiek punt: alleen de verzender dient een channel te sluiten, nooit de ontvanger. Het sluiten van een channel waar een andere goroutine nog naar schrijft, veroorzaakt een panic. ## Het select-statement: channels multiplexen **V: Hoe werkt `select` en wat gebeurt er wanneer meerdere cases gereed zijn?** Het `select`-statement blokkeert totdat een van de channel-operaties kan doorgaan. Wanneer meerdere cases tegelijkertijd gereed zijn, kiest Go er willekeurig een -- dit voorkomt uithongering van een bepaalde case. ```go // select_multiplex.go 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) } ``` Interviewers testen twee zaken met `select`: begrip van de willekeurige selectieregel en het vermogen om channels te combineren met `context.Context` voor timeout- en cancellation-patronen. ## Veelvoorkomende concurrency-patronen in interviews Bij Go-interviews op seniorniveau komt vrijwel altijd de opdracht voor om een van deze patronen vanaf nul te implementeren. ### Het Fan-Out/Fan-In-patroon **V: Implementeer een fan-out/fan-in pipeline die items gelijktijdig verwerkt.** Fan-out verdeelt werk over meerdere goroutines. Fan-in verzamelt resultaten van meerdere goroutines in een enkel channel. ```go // fanout_fanin.go 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) } } ``` Het cruciale inzicht dat interviewers willen horen: het `generator`-channel wordt gedeeld tussen `c1` en `c2`, waardoor elke waarde door precies een worker wordt verwerkt (niet gedupliceerd). De `fanIn`-functie gebruikt een `WaitGroup` om te weten wanneer alle invoerchannels zijn leeggelezen voordat het samengevoegde channel wordt gesloten. ### Worker Pool met errgroup **V: Hoe zou men een begrensd worker pool met foutafhandeling implementeren?** Het pakket `golang.org/x/sync/errgroup` (onderdeel van de uitgebreide Go-standaardbibliotheek) lost dit op een nette manier op. Het beheert goroutine-lifecycles, verzamelt de eerste fout en integreert met `context` voor cancellation. ```go // worker_pool.go 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) } } ``` Vanaf Go 1.24 (de huidige stabiele release begin 2026) blijft dit patroon de aanbevolen aanpak. De `SetLimit`-methode werd toegevoegd in Go 1.20 en vervangt de noodzaak om handmatig semafoor-gebaseerde concurrency-begrenzing te implementeren. ## Race conditions en sync-primitieven **V: Hoe detecteert en voorkomt men race conditions in Go?** Go beschikt over een ingebouwde race detector die wordt geactiveerd met de `-race`-vlag. Deze detecteert ongesynchroniseerde gelijktijdige toegang tot gedeeld geheugen tijdens runtime. ```go // race_condition.go 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 } ``` Het interviewantwoord dient drie synchronisatiestrategieën te behandelen: `sync.Mutex` / `sync.RWMutex` voor complexe gedeelde state, `sync/atomic` voor eenvoudige tellers en vlaggen, en channels voor communicatie tussen goroutines ("share memory by communicating, don't communicate by sharing memory"). Het uitvoeren van `go test -race ./...` dient onderdeel te zijn van elke CI-pipeline. ## Context en cancellation-patronen **V: Leg uit hoe context.Context de levenscyclus van goroutines beheert.** Het `context`-pakket biedt een mechanisme om cancellation-signalen, deadlines en request-scoped waarden te propageren over goroutine-grenzen heen. Elke langlopende goroutine dient een `context.Context` als eerste parameter te accepteren. ```go // context_cancellation.go 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) } } ``` Workers 1 en 2 voltooien binnen de deadline van 250ms. Workers 3, 4 en 5 ontvangen het cancellation-signaal via `ctx.Done()`. Dit patroon is fundamenteel voor het bouwen van veerkrachtige HTTP-servers en microservices in Go -- elke request handler ontvangt een context die cancellation propageert wanneer de client de verbinding verbreekt. > **Veelvoorkomende interviewvalkuil** > > Sla een `context.Context` nooit op in een struct-veld. De [officiële Go-documentatie](https://pkg.go.dev/context) stelt expliciet: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." Interviewers testen dit om te beoordelen of de kandidaat Go-conventies volgt. ## Deadlock-detectie: lastige interviewvragen **V: Zal deze code een deadlock veroorzaken? Waarom?** Deadlockvragen zijn populair omdat ze testen of de kandidaat kan redeneren over goroutine-scheduling en channel-operaties. ```go // deadlock_example.go 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" } ``` De oplossing is eenvoudig: maak het channel buffered (`make(chan int, 1)`) of start een goroutine om te ontvangen voordat er wordt verzonden. De Go runtime detecteert deadlocks wanneer alle goroutines geblokkeerd zijn -- maar alleen wanneer *alle* goroutines in slaap zijn. Als zelfs maar één goroutine actief is (bijvoorbeeld een HTTP-server op de achtergrond), zal de runtime een partiële deadlock niet detecteren. > **Partiële deadlocks zijn onzichtbaar** > > De Go runtime detecteert deadlocks alleen wanneer elke goroutine in het programma geblokkeerd is. In echte applicaties met HTTP-servers of achtergrondwerkers worden gelekte goroutines die in een deadlock verkeren niet opgepikt door de runtime-detector. Tools zoals `pprof` en goroutine dumps (`runtime.Stack`) zijn noodzakelijk om deze problemen in productie te diagnosticeren. ## Rate-limited gelijktijdige verwerking **V: Hoe zou men rate-limited gelijktijdige API-aanroepen implementeren?** Deze vraag test het vermogen om meerdere concurrency-primitieven te combineren tot een samenhangende oplossing. ```go // rate_limited.go 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() } ``` Dit patroon combineert een channel-gebaseerde semafoor (voor concurrency-begrenzing) met een ticker (voor rate limiting). De dubbele `select` met context-controle zorgt voor een graceful shutdown. Dit is het type productieklaar antwoord dat senior kandidaten onderscheidt van minder ervaren ontwikkelaars. ## Conclusie - Goroutines zijn user-space threads beheerd door de Go runtime met M:N-scheduling; vang panics altijd op in gestarte goroutines - Unbuffered channels synchroniseren verzender en ontvanger; buffered channels ontkoppelen de timing -- kies op basis van of de verzender bevestiging nodig heeft - Het `select`-statement multiplext channel-operaties met willekeurige selectie wanneer meerdere cases gereed zijn; combineer met `context.Context` voor timeouts - Fan-out/fan-in en worker pools (via `errgroup.SetLimit`) zijn de twee meest gevraagde concurrency-patronen - Gebruik `sync.Mutex` voor complexe gedeelde state, `sync/atomic` voor eenvoudige tellers en channels voor goroutine-communicatie - Voer altijd `go test -race` uit in CI om data races op te sporen; partiële deadlocks vereisen `pprof` voor diagnose - Sla `context.Context` nooit op in structs -- geef het door als eerste functieparameter - Rate limiting in Go combineert channel-semaforen met tickers, gewrapped in context-aware select-statements --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/nl/blog/go/go-technical-interview-goroutines-channels-concurrency