Storia
Il framework di test di Go ha introdotto t.Parallel() per affrontare la crescente durata delle pipeline CI in ampie codebase. Prima dell'adozione diffusa dei processori multicore, i test venivano eseguiti in modo sequenziale per impostazione predefinita. Man mano che i progetti scalavano fino a migliaia di test, l'esecuzione puramente sequenziale diventava un collo di bottiglia, mentre un parallelismo illimitato rischiava di esaurire le risorse di processo come i descrittori di file o le connessioni al database. L'obiettivo di design era fornire un modello di concorrenza incorporato e facoltativo che rispettasse un limite globale senza richiedere agli sviluppatori di orchestrare manualmente pool di lavoro o sincronizzazioni complesse per ogni suite di test.
Problema
Quando uno sviluppatore chiama t.Parallel(), il test deve segnalare al runner che può essere eseguito in modo concorrente con altri test. Tuttavia, il framework deve imporre un rigoroso tetto alla concorrenza (che di default è GOMAXPROCS ma configurabile tramite il flag -parallel) per prevenire la carenza di risorse. La sfida si intensifica con i sottotest annidati: un test padre potrebbe invocare t.Run più volte, e ogni sottotest potrebbe chiamare indipendentemente t.Parallel(). La soluzione deve impedire al genitore di rilasciare il proprio slot di esecuzione prima che tutti i suoi discendenti terminino, garantendo al contempo che i sottotest paralleli profondamente annidati acquisiscano correttamente gli slot dallo stesso pool globale senza bloccare il genitore o superare il limite.
Soluzione
Il pacchetto testing utilizza un semaforo implementato come un canale bufferizzato di strutture vuote (chan struct{}) dimensionato rispetto al valore del flag -parallel. Questo canale è condiviso tra tutti i test in un pacchetto. Ogni istanza di T mantiene un riferimento a questo canale parallel e a un canale signal interno per coordinarsi con il proprio genitore.
Quando viene invocato t.Parallel():
signal, sbloccando la chiamata t.Run del genitore in modo che il genitore possa continuare o terminare mentre il sottotest viene eseguito in modo concorrente.parallel, acquisendo uno slot di esecuzione.parallel una volta che la funzione di test restituisce e tutti i ganci t.Cleanup vengono eseguiti.Per le gerarchie, t.Run blocca la goroutine padre utilizzando un sync.WaitGroup fino al completamento completo del sottotest, anche se il sottotest viene eseguito in parallelo. Questo garantisce che il genitore mantenga il suo slot (o aspetti) fino a quando l'intero albero di sottotest non termina, impedendo che il limite globale venga superato da un picco di test paralleli profondamente annidati.
// Modello concettuale degli interni del pacchetto di test type T struct { parallel chan struct{} // Semaforo condiviso signal chan struct{} // Segnala al genitore che è stato chiamato Parallel() parent *T wg sync.WaitGroup // Aspetta i sottotest } func (t *T) Parallel() { // Rilascia il genitore per continuare close(t.signal) // Acquisisce uno slot dal pool globale t.parallel <- struct{}{} // La pulizia rilascia lo slot quando il test termina t.Cleanup(func() { <-t.parallel }) } func (t *T) Run(name string, f func(t *T)) bool { t.wg.Add(1) sub := &T{parallel: t.parallel, signal: make(chan struct{})} go func() { defer t.wg.Done() f(sub) }() <-sub.signal // Aspetta che il sottotest inizi o chiami Parallel t.wg.Wait() // Aspetta il completamento return !sub.Failed() }
Contesto
Un team di piattaforma gestiva un monorepo contenente 2.000 test di integrazione per un'architettura a microservizi. Ogni test avviava contenitori ephemera di Docker per Postgres e Redis. Eseguire i test sequenzialmente richiedeva 45 minuti, rendendo impossibile un feedback rapido. Tuttavia, eseguire go test -parallel 100 causava l'esaurimento del limite max_user_namespaces del kernel per i runner CI, causando il crash dell'host e la corruzione della cache di build.
Problema
Il team doveva limitare i test intensivi per i contenitori a cinque istanze concorrenti per rispettare i limiti del kernel, consentendo al contempo ai test unitari puri di essere eseguiti con -parallel 32 per un massimo throughput. Il pacchetto di test standard di Go accetta solo un singolo valore globale -parallel per invocazione, non offrendo modo integrato per applicare limiti diversi a diverse categorie di test all'interno della stessa esecuzione.
Soluzioni considerate
Orchestrazione esterna con Bazel.
È stata proposta la migrazione a Bazel perché supporta il test sharding e il tagging delle risorse (ad esempio, tags = ["resources:postgres:1"]). Questo avrebbe consentito al pianificatore di limitare esattamente i test sui database concorrenti. Tuttavia, ciò richiedeva di riscrivere l'intero sistema di build e di perdere la semplicità di go test. La curva di apprendimento era ripida e i flussi di lavoro di sviluppo locale sarebbero cambiati drasticamente, rallentando gli sviluppatori non familiarizzati con il linguaggio di query di Bazel.
Semaphore manuale all'interno delle suite di test.
Gli sviluppatori hanno considerato di aggiungere un var dbSem = make(chan struct{}, 5) a livello di pacchetto e di far acquisire manualmente ogni test di integrazione all'inizio. Ciò forniva un controllo fine ma introdurrebbe un'importante quantità di boilerplate e il rischio di deadlock se un test andava in panico mentre deteneva il semaforo. Ha anche frammentato il modello di concorrenza: alcuni test rispettavano il flag -parallel, altri rispettavano il semaforo personalizzato, rendendo difficile il debug e portando a un'incoerenza nel conteggio delle risorse.
Separazione dei tag di build con fasi di CI.
Il team ha scelto di segregare i test utilizzando i tag di build. Hanno aggiunto //go:build integration a tutti i test containerizzati e hanno lasciato i test unitari non contrassegnati. La pipeline CI ha eseguito prima go test -short -parallel 32 ./... per i test unitari, quindi ha eseguito separatamente go test -tags=integration -parallel 5 ./.... Ciò ha sfruttato le funzionalità esistenti della toolchain di Go senza modificare la logica del test. Lo svantaggio era la perdita di parallelismo interpacchetti tra test unitari e di integrazione; le fasi venivano eseguite sequenzialmente. Tuttavia, poiché i test unitari venivano completati in tre minuti, il tempo totale (3m + 20m) era accettabile e stabile.
Soluzione scelta e risultato
Hanno scelto la separazione dei tag di build. Ha richiesto modifiche minime al codice: solo aggiungere tag agli header dei file e ha utilizzato naturalmente il semaforo del pacchetto standard testing senza sincronizzazione personalizzata. La CI è diventata stabile, i limiti del kernel sono stati rispettati e gli sviluppatori potevano comunque eseguire go test -tags=integration -parallel 4 localmente per il debug. Il tempo totale della CI è sceso da 45 minuti a 23 minuti e i crash dell'host sono cessati del tutto.
Perché chiamare t.Parallel() dopo aver avviato una goroutine a volte risulta nella scrittura di quella goroutine nel formato di output errato del test o nel panico?
Quando viene invocato t.Parallel(), la goroutine di test corrente viene bloccata sul semaforo, e il runner di test padre continua con il test successivo. La goroutine avviata, tuttavia, eredita l'istanza di T. Se la funzione di test principale restituisce mentre la goroutine è ancora in esecuzione, il pacchetto di test contrassegna T come terminato e chiude i suoi buffer di output. Le chiamate successive a t.Log o t.Error dalla goroutine orfana possono andare in panico con "Log in goroutine dopo che TestX è stato completato". L'approccio corretto è sincronizzare il completamento della goroutine utilizzando sync.WaitGroup o assicurarsi che t.Cleanup attenda per essa, poiché t.Parallel() non attende automaticamente goroutine distaccate; coordina solo il ciclo di vita della funzione di test con il runner.
Come impedisce il pacchetto di test a un test genitore di rilasciare il proprio slot di parallelismo prima che tutti i suoi sottotest—alcuni dei quali potrebbero anche chiamare t.Parallel()—siano terminati?
La struct T incorpora un sync.WaitGroup. Quando viene chiamato t.Run per creare un sottotest, il genitore chiama t.wg.Add(1) prima di avviare la goroutine del sottotest, e il sottotest chiama t.wg.Done() in una funzione deferred al termine. Fondamentalmente, quando un sottotest stesso chiama t.Parallel(), decrementa immediatamente il WaitGroup del genitore (consentendo così al genitore di potenzialmente terminare il proprio corpo della funzione), ma il completamento complessivo del test del genitore—e quindi il rilascio del proprio token semaforo—è bloccato da un finale t.wg.Wait() nella catena di pulizia. Questo crea un'attesa a struttura ad albero dove il test parallelo radice mantiene lo slot fino a quando l'intero sotto-albero di sottotest seriali e paralleli non termina, garantendo che il limite -parallel rifletta accuratamente il numero degli alberi di test attivi, non solo le goroutine attive.
Perché t.Setenv potrebbe andare in panico se chiamato dopo t.Parallel(), e cosa rivela questo sul modello di isolamento dei test paralleli in Go?
t.Setenv va in panico quando viene chiamato dopo t.Parallel() perché le variabili di ambiente sono uno stato globale del processo. I test paralleli vengono eseguiti simultaneamente nello stesso processo; se un test modifica PATH mentre un altro lo legge, il risultato sarebbe una corsa sui dati e un comportamento non deterministico. Per prevenire questo, il pacchetto di test di Go segna l'ambiente come "congelato" una volta che un test diventa parallelo, e qualsiasi tentativo di mutarlo tramite t.Setenv o os.Setenv provoca un panico. Questo rivela che i test paralleli sono progettati per la concorrenza all'interno di uno stesso spazio degli indirizzi ma presumono uno stato condiviso immutabile o una sincronizzazione esplicita. I candidati spesso trascurano che t.Parallel() implica un rigido contratto di "nessuna mutazione dello stato globale del processo", necessitando dell'uso di t.Cleanup per ripristinare lo stato solo se il test non era parallelo, o progettando test per evitare completamente lo stato globale.