Colloquio Tecnico Go: Goroutine, Channel e Concorrenza nel 2026

Domande di colloquio Go su goroutine, channel e concorrenza con esempi di codice. Preparazione completa per il colloquio tecnico Golang.

Go Technical Interview: Goroutines, Channels and Concurrency

Le domande di colloquio Go su goroutine, channel e concorrenza rappresentano uno degli ostacoli tecnici più impegnativi per chi affronta un processo di selezione nel 2026. Padroneggiare questi concetti in profondità distingue gli sviluppatori Go senior da chi si limita a conoscere la sintassi di base. Questa guida copre le esatte go interview questions poste dai selezionatori, con esempi di codice di livello produttivo e il ragionamento alla base di ciascuna risposta.

Cosa valutano davvero i selezionatori

I colloqui tecnici sulla concorrenza in Go si concentrano su tre aree: la gestione del ciclo di vita delle goroutine, la semantica dei channel (buffered vs unbuffered, tipi direzionali) e la composizione di pattern (fan-out/fan-in, worker pool, cancellazione tramite context). Memorizzare la sintassi non basta: il candidato deve dimostrare la capacità di ragionare su race condition e deadlock.

Fondamenti delle Goroutine: le Domande Ricorrenti

La prima fase di un colloquio tecnico Go verifica se il candidato comprende cosa sono realmente le goroutine, non soltanto come lanciarle.

D: Che cos'è una goroutine e in cosa differisce da un thread del sistema operativo?

Una goroutine è una funzione concorrente leggera gestita dallo scheduler del runtime Go, non dal sistema operativo. Il runtime Go multiplexa migliaia di goroutine su un numero ridotto di thread OS attraverso un modello di scheduling M:N (M goroutine mappate su N thread 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")
}

Differenze chiave da menzionare durante un colloquio: le goroutine partono con uno stack di 2-8KB che cresce dinamicamente, contro lo stack fisso di 1-8MB di un thread OS. Il cambio di contesto tra goroutine avviene in user space tramite lo scheduler di Go, evitando i costosi context switch a livello kernel. Questo rende pratico l'avvio di 100.000 goroutine simultanee, mentre altrettanti thread OS esaurirebbero le risorse di sistema.

D: Cosa succede se una goroutine va in panic?

Un panic non recuperato in qualsiasi goroutine provoca il crash dell'intero programma. A differenza delle eccezioni in Java o Python, un panic risale lo stack della goroutine in cui si verifica, non quello della goroutine che l'ha lanciata. L'unico modo per intercettarlo è utilizzare recover() all'interno di una funzione differita nella stessa 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 {}
}

I selezionatori cercano la consapevolezza che nei servizi Go in produzione ogni lancio di goroutine viene avvolto in un pattern di recovery. Librerie come errgroup gestiscono questo aspetto in modo più elegante e strutturato.

Semantica dei Channel: Buffered, Unbuffered e Direzionali

Le domande sui channel rivelano se il candidato comprende realmente il modello di concorrenza di Go o se si limita a ripetere pattern memorizzati.

D: Qual è la differenza tra un channel buffered e uno unbuffered?

Un channel unbuffered (make(chan T)) richiede che mittente e destinatario siano pronti simultaneamente: l'invio blocca fino a quando un'altra goroutine riceve. Un channel buffered (make(chan T, n)) consente l'invio di fino a n valori senza blocco.

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 domanda di approfondimento che segue frequentemente: "Quando si sceglie l'uno rispetto all'altro?" I channel unbuffered impongono la sincronizzazione ed è utile quando il mittente deve sapere che il destinatario ha elaborato il valore. I channel buffered disaccoppiano la temporizzazione tra mittente e destinatario, risultando ideali per code di lavoro o rate limiting in cui una certa flessibilità è accettabile.

D: Cosa succede quando si chiude un channel?

La chiusura di un channel segnala che non verranno inviati altri valori. Le operazioni di ricezione su un channel chiuso restituiscono immediatamente il valore zero del tipo. L'invio su un channel chiuso provoca un panic. Un ciclo range su un channel termina automaticamente alla chiusura del channel stesso.

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

Un punto critico: solo il mittente dovrebbe chiudere un channel, mai il destinatario. Chiudere un channel su cui un'altra goroutine sta ancora scrivendo provoca un panic immediato.

Pronto a superare i tuoi colloqui su Go?

Pratica con i nostri simulatori interattivi, flashcards e test tecnici.

L'istruzione Select: Multiplexing tra Channel

D: Come funziona select e cosa succede quando più casi sono pronti contemporaneamente?

L'istruzione select blocca fino a quando una delle sue operazioni su channel può procedere. Quando più casi sono pronti simultaneamente, Go ne seleziona uno in modo casuale, impedendo così la starvation di qualsiasi caso specifico.

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

I selezionatori verificano due aspetti tramite select: la comprensione della regola di selezione casuale e la capacità di combinare i channel con context.Context per implementare pattern di timeout e cancellazione.

Pattern di Concorrenza Frequenti nei Colloqui

I colloqui Go di livello senior includono quasi sempre la richiesta di implementare uno di questi go concurrency patterns da zero.

Il Pattern Fan-Out/Fan-In

D: Implementare una pipeline fan-out/fan-in che elabora elementi in modo concorrente.

Il fan-out distribuisce il lavoro su più goroutine. Il fan-in raccoglie i risultati da più goroutine in un singolo 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)
	}
}

L'aspetto fondamentale che i selezionatori cercano: il channel del generator è condiviso tra c1 e c2, quindi ogni valore viene elaborato da esattamente un worker (senza duplicazione). La funzione fanIn utilizza un WaitGroup per sapere quando tutti i channel di input sono stati svuotati prima di chiudere il channel unificato.

Worker Pool con errgroup

D: Come si implementa un worker pool a concorrenza limitata con gestione degli errori?

Il pacchetto golang.org/x/sync/errgroup (parte della libreria standard estesa di Go) risolve questo problema in modo pulito. Gestisce il ciclo di vita delle goroutine, raccoglie il primo errore e si integra con context per la cancellazione.

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

Con Go 1.24 (la release stabile corrente a inizio 2026), questo pattern resta l'approccio raccomandato. Il metodo SetLimit, introdotto in Go 1.20, elimina la necessità di implementare manualmente un semaforo per limitare la concorrenza.

Race Condition e Primitive sync

D: Come si rilevano e prevengono le race condition in Go?

Go fornisce un race detector integrato, attivabile con il flag -race. Questo strumento rileva a runtime gli accessi concorrenti non sincronizzati alla memoria condivisa.

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 risposta da colloquio dovrebbe coprire tre strategie di sincronizzazione: sync.Mutex / sync.RWMutex per lo stato condiviso complesso, sync/atomic per contatori e flag semplici, e i channel per la comunicazione tra goroutine ("share memory by communicating, don't communicate by sharing memory"). L'esecuzione di go test -race ./... dovrebbe far parte di ogni pipeline CI.

Context e Pattern di Cancellazione

D: Spiegare come context.Context controlla il ciclo di vita delle goroutine.

Il pacchetto context fornisce un meccanismo per propagare segnali di cancellazione, scadenze e valori legati alla richiesta attraverso i confini delle goroutine. Ogni goroutine a lunga esecuzione dovrebbe accettare un context.Context come primo parametro.

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

I worker 1 e 2 completano l'esecuzione entro la scadenza di 250ms. I worker 3, 4 e 5 ricevono il segnale di cancellazione tramite ctx.Done(). Questo pattern è fondamentale per la costruzione di server HTTP e microservizi resilienti in Go: ogni handler di richiesta riceve un context che propaga la cancellazione quando il client si disconnette.

Trappola frequente nei colloqui

Non si deve mai memorizzare un context.Context in un campo di una struct. La documentazione ufficiale di Go afferma esplicitamente: "Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it." I selezionatori usano questa domanda per verificare se il candidato segue le convenzioni idiomatiche del linguaggio.

Rilevamento dei Deadlock: Domande Insidiose

D: Questo codice provoca un deadlock? Perché?

Le domande sui deadlock sono molto diffuse perché testano la capacità del candidato di ragionare sullo scheduling delle goroutine e sulle operazioni dei 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"
}

La correzione è immediata: rendere il channel buffered (make(chan int, 1)) oppure lanciare una goroutine che riceve prima dell'invio. Il runtime Go rileva i deadlock quando tutte le goroutine sono bloccate, ma solo quando tutte le goroutine sono in stato di attesa. Se anche una sola goroutine è in esecuzione (ad esempio un server HTTP in background), il runtime non rileva un deadlock parziale.

I deadlock parziali sono invisibili

Il runtime Go rileva i deadlock solo quando ogni goroutine nel programma è bloccata. Nelle applicazioni reali con server HTTP o worker in background, le goroutine in leak che si trovano in deadlock non attivano il rilevatore del runtime. Strumenti come pprof e i dump delle goroutine (runtime.Stack) sono necessari per diagnosticare questi problemi in produzione.

Elaborazione Concorrente con Rate Limiting

D: Come si implementano chiamate API concorrenti con rate limiting?

Questa domanda verifica la capacità di combinare più primitive di concorrenza in una soluzione coerente. Il pattern richiede sia il controllo del numero massimo di operazioni simultanee sia la regolazione della frequenza temporale delle richieste.

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

Questo pattern combina un semaforo basato su channel (per limitare la concorrenza) con un ticker (per limitare la frequenza). Il doppio select con verifica del context garantisce uno shutdown ordinato. Si tratta del tipo di risposta production-ready che distingue i candidati senior durante un colloquio tecnico su golang goroutines e go channels.

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Conclusione

  • Le goroutine sono thread in user space gestiti dal runtime Go con scheduling M:N; si consiglia di recuperare sempre i panic nelle goroutine lanciate
  • I channel unbuffered sincronizzano mittente e destinatario; i channel buffered disaccoppiano la temporizzazione — la scelta dipende dalla necessità di conferma da parte del mittente
  • L'istruzione select multiplexa le operazioni sui channel con selezione casuale quando più casi sono pronti; va combinata con context.Context per timeout e cancellazione
  • Fan-out/fan-in e worker pool (tramite errgroup.SetLimit) sono i due pattern di concorrenza più richiesti nei colloqui
  • Per lo stato condiviso complesso si utilizza sync.Mutex, per contatori semplici sync/atomic, e i channel per la comunicazione tra goroutine
  • L'esecuzione di go test -race nella pipeline CI è indispensabile per intercettare le data race; i deadlock parziali richiedono pprof per la diagnosi
  • Il context.Context non va mai memorizzato in una struct: si passa come primo parametro di funzione
  • Il rate limiting in Go combina semafori a channel con ticker, incapsulati in istruzioni select consapevoli del context

Inizia a praticare!

Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.

Tag

#go
#interview
#concurrency
#goroutines
#channels

Condividi

Articoli correlati