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.

Preparation entretien technique Go couvrant les goroutines, channels et patterns de concurrence

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).

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")
}

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.

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 {}
}

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.

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
}

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.

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)
}

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.

Prêt à réussir tes entretiens Go ?

Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.

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.

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)
}

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.

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)
	}
}

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.

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)
	}
}

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.

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
}

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.

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)
	}
}

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 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.

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"
}

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.

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()
}

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.

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

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

Passe à la pratique !

Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.

Tags

#go
#interview
#concurrency
#goroutines
#channels

Partager

Articles similaires