# Entretien technique Go : Goroutines, Channels et Concurrence > Questions d'entretien Go sur les goroutines, channels et patterns de concurrence. Exemples de code, pieges courants et reponses de niveau expert pour preparer les entretiens techniques Go en 2026. - Published: 2026-04-01 - Updated: 2026-04-01 - Author: Anthony Fillion-Maillet - Tags: go, interview, concurrency, goroutines, channels - Reading time: 10 min --- Les questions d'entretien Go portant sur les goroutines, les channels et la concurrence figurent parmi les sujets les plus exigeants auxquels les candidats sont confrontes. La maitrise approfondie de ces mecanismes constitue le facteur distinctif entre un ingenieur Go confirme et un developpeur encore en phase d'apprentissage. Ce guide couvre les questions exactes posees par les recruteurs en 2026, avec des exemples de code utilisables en production et le raisonnement derriere chaque reponse. > **Ce que les recruteurs evaluent reellement** > > Les entretiens de concurrence Go portent sur trois axes : la gestion du cycle de vie des goroutines, la semantique des channels (bufferises vs non-bufferises, types directionnels) et la composition de patterns (fan-out/fan-in, worker pools, annulation via context). Memoriser la syntaxe ne suffit pas — les recruteurs attendent des candidats qu'ils raisonnent sur les race conditions et les deadlocks. ## Fondamentaux des goroutines : les questions incontournables Le premier tour de questions vise a verifier si le candidat comprend ce que sont reellement les goroutines — pas simplement comment en lancer une. **Q : Qu'est-ce qu'une goroutine et en quoi differe-t-elle d'un thread OS ?** Une goroutine est une fonction concurrente legere, geree par l'ordonnanceur du runtime Go et non par le systeme d'exploitation. Le runtime Go multiplexe des milliers de goroutines sur un petit nombre de threads OS via un modele d'ordonnancement M:N (M goroutines mappees sur N threads OS). ```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") } ``` Differences cles a mentionner en entretien : les goroutines demarrent avec une pile de 2-8 Ko qui croit dynamiquement, contre la pile fixe de 1-8 Mo d'un thread OS. Le changement de contexte entre goroutines s'effectue en espace utilisateur par l'ordonnanceur Go, evitant ainsi les couteuses transitions en mode noyau des threads OS. Lancer 100 000 goroutines est parfaitement viable, alors que 100 000 threads OS epuiseraient les ressources systeme. **Q : Que se passe-t-il si une goroutine provoque un panic ?** Un panic non recupere dans n'importe quelle goroutine fait planter l'ensemble du programme. Contrairement aux exceptions en Java ou Python, un panic remonte la pile d'appels de sa propre goroutine — pas celle de la goroutine qui l'a lancee. Le seul moyen de l'intercepter est d'utiliser `recover()` dans une fonction differee au sein de la meme 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 {} } ``` Les recruteurs verifient la connaissance du fait que les services Go en production encapsulent les lancements de goroutines dans un pattern de recuperation. Des bibliotheques comme `errgroup` gerent cela de maniere plus elegante. ## Semantique des channels : bufferises, non-bufferises et directionnels Les questions sur les channels revelent si un candidat comprend veritablement le modele de concurrence de Go ou s'il se contente de memoriser des patterns. **Q : Quelle est la difference entre un channel bufferise et un channel non-bufferise ?** Un channel non-bufferise (`make(chan T)`) impose que l'emetteur et le recepteur soient prets simultanement — l'envoi bloque jusqu'a ce qu'une autre goroutine recoive la valeur. Un channel bufferise (`make(chan T, n)`) autorise l'envoi de jusqu'a `n` valeurs sans blocage. ```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 } ``` La question de suivi frequemment posee par les recruteurs : "Quand choisir l'un plutot que l'autre ?" Les channels non-bufferises imposent une synchronisation — utile quand l'emetteur doit avoir la garantie que le recepteur a traite la valeur. Les channels bufferises decouplent les temporalites de l'emetteur et du recepteur — utiles pour les files de travaux ou le rate limiting, la ou un certain decalage est acceptable. **Q : Que se passe-t-il quand un channel est ferme ?** Fermer un channel signale qu'aucune valeur supplementaire ne sera envoyee. Les lectures sur un channel ferme retournent immediatement la valeur zero du type. Envoyer sur un channel ferme provoque un panic. Une boucle `range` sur un channel se termine automatiquement lorsque le channel est ferme. ```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) } ``` Point fondamental : seul l'emetteur doit fermer un channel, jamais le recepteur. Fermer un channel sur lequel une autre goroutine ecrit encore provoque un panic. ## L'instruction select : multiplexer les channels **Q : Comment fonctionne `select`, et que se passe-t-il quand plusieurs cas sont prets ?** L'instruction `select` bloque jusqu'a ce qu'une de ses operations sur channel puisse s'executer. Lorsque plusieurs cas sont prets simultanement, Go en choisit un de maniere aleatoire — ce qui empeche la famine d'un cas particulier. ```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) } ``` Les recruteurs testent deux aspects avec `select` : la comprehension de la regle de selection aleatoire, et la capacite a combiner des channels avec `context.Context` pour les patterns de timeout et d'annulation. ## Patterns de concurrence frequemment poses en entretien Les entretiens Go de niveau senior incluent quasi-systematiquement une question demandant d'implementer l'un de ces patterns de zero. ### Pattern Fan-Out/Fan-In **Q : Implementer un pipeline fan-out/fan-in qui traite des elements de maniere concurrente.** Le fan-out distribue le travail sur plusieurs goroutines. Le fan-in collecte les resultats provenant de plusieurs goroutines dans un channel unique. ```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) } } ``` L'insight cle attendu par les recruteurs : le channel du `generator` est partage entre `c1` et `c2`, donc chaque valeur est traitee par exactement un worker (sans duplication). La fonction `fanIn` utilise un `WaitGroup` pour savoir quand tous les channels d'entree sont epuises avant de fermer le channel fusionne. ### Worker Pool avec errgroup **Q : Comment implementer un worker pool borne avec gestion d'erreurs ?** Le package `golang.org/x/sync/errgroup` (qui fait partie de la bibliotheque standard etendue de Go) resout ce probleme de maniere propre. Il gere le cycle de vie des goroutines, collecte la premiere erreur et s'integre avec `context` pour l'annulation. ```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) } } ``` Depuis Go 1.24 (la version stable courante debut 2026), ce pattern reste l'approche recommandee. La methode `SetLimit` a ete ajoutee dans Go 1.20 et evite la necessite d'implementer manuellement une limitation de concurrence basee sur des semaphores. ## Race conditions et primitives sync **Q : Comment detecter et prevenir les race conditions en Go ?** Go fournit un detecteur de race integre, active avec le flag `-race`. Il detecte les acces concurrents non-synchronises a la memoire partagee au moment de l'execution. ```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 } ``` La reponse en entretien doit couvrir trois strategies de synchronisation : `sync.Mutex` / `sync.RWMutex` pour l'etat partage complexe, `sync/atomic` pour les compteurs et flags simples, et les channels pour la communication entre goroutines ("partager la memoire en communiquant, ne pas communiquer en partageant la memoire"). L'execution de `go test -race ./...` doit faire partie de tout pipeline CI. ## Context et patterns d'annulation **Q : Comment context.Context controle-t-il le cycle de vie des goroutines ?** Le package `context` fournit un mecanisme pour propager les signaux d'annulation, les deadlines et les valeurs a portee de requete a travers les frontieres des goroutines. Toute goroutine a longue duree de vie doit accepter un `context.Context` comme premier parametre. ```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) } } ``` Les workers 1 et 2 terminent dans le delai de 250 ms. Les workers 3, 4 et 5 recoivent le signal d'annulation via `ctx.Done()`. Ce pattern est fondamental pour construire des serveurs HTTP et des microservices resilients en Go — chaque handler de requete recoit un context qui propage l'annulation lorsque le client se deconnecte. > **Piege classique en entretien** > > Ne jamais stocker un `context.Context` dans un champ de struct. La [documentation officielle de Go](https://pkg.go.dev/context) le stipule explicitement : "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." Les recruteurs testent ce point pour evaluer si le candidat respecte les conventions Go. ## Detection de deadlocks : les questions pieges **Q : Ce code provoquera-t-il un deadlock ? Pourquoi ?** Les questions de deadlock sont populaires car elles testent la capacite du candidat a raisonner sur l'ordonnancement des goroutines et les operations sur les channels. ```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" } ``` La correction est directe : soit rendre le channel bufferise (`make(chan int, 1)`), soit lancer une goroutine pour recevoir avant d'envoyer. Le runtime Go detecte les deadlocks lorsque toutes les goroutines sont bloquees — mais uniquement quand *toutes* les goroutines sont endormies. Si ne serait-ce qu'une seule goroutine est active (par exemple un serveur HTTP en arriere-plan), le runtime ne detectera pas un deadlock partiel. > **Les deadlocks partiels sont invisibles** > > Le runtime Go ne detecte les deadlocks que lorsque chaque goroutine du programme est bloquee. Dans les applications reelles avec des serveurs HTTP ou des workers en arriere-plan, les goroutines fuitees en situation de deadlock ne declenchent pas le detecteur du runtime. Des outils comme `pprof` et les dumps de goroutines (`runtime.Stack`) sont necessaires pour diagnostiquer ces problemes en production. ## Pattern avance : traitement concurrent avec limitation de debit **Q : Comment implementer des appels API concurrents avec limitation de debit ?** Cette question teste la capacite a combiner plusieurs primitives de concurrence en une solution coherente. ```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() } ``` Ce pattern combine un semaphore base sur des channels (pour limiter la concurrence) avec un ticker (pour limiter le debit). Le double `select` avec verification du context garantit un arret gracieux. C'est le type de reponse production-ready qui distingue les candidats seniors. ## Conclusion - Les goroutines sont des threads en espace utilisateur geres par le runtime Go avec un ordonnancement M:N ; toujours recuperer les panics dans les goroutines lancees - Les channels non-bufferises synchronisent emetteur et recepteur ; les channels bufferises decouplent les temporalites — le choix depend de la necessite pour l'emetteur d'obtenir une confirmation - L'instruction `select` multiplexe les operations sur channels avec selection aleatoire quand plusieurs sont prets ; a combiner avec `context.Context` pour les timeouts - Le pattern fan-out/fan-in et les worker pools (via `errgroup.SetLimit`) sont les deux patterns de concurrence les plus frequemment demandes - Utiliser `sync.Mutex` pour l'etat partage complexe, `sync/atomic` pour les compteurs simples, et les channels pour la communication entre goroutines - Toujours executer `go test -race` dans le pipeline CI pour detecter les data races ; les deadlocks partiels necessitent `pprof` pour etre diagnostiques - Ne jamais stocker un `context.Context` dans des structs — le passer comme premier parametre de fonction - La limitation de debit en Go combine des semaphores bases sur des channels avec des tickers, encapsules dans des instructions select conscientes du context --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/go/go-technical-interview-goroutines-channels-concurrency