Menu

select in Golang: timeout, default e canali di uscita

Come select attende più operazioni sui canali contemporaneamente: la scelta tra i casi pronti, invii e ricezioni non bloccanti con default, timeout con time.After e l'arresto dei cicli con un canale di uscita o un context.

Questa pagina include editor eseguibili: modifica, esegui e vedi subito l'output.

Attendere più canali

select somiglia a uno switch, ma ogni caso è un invio o una ricezione su un canale. Si blocca finché un caso non può procedere e poi esegue quello.

Con questi ritardi il primo select riceve il risultato veloce e il secondo quello lento. Nessuna delle due ricezioni deve aspettare l'altro canale. Un semplice <-slow seguito da <-fast li gestirebbe in un ordine fisso, indipendentemente da quale arrivi prima.

Come viene valutato select:

  1. Tutte le espressioni dei canali e i valori da inviare vengono valutati una sola volta, nell'ordine del sorgente, quando parte il select.
  2. Se uno o più casi sono pronti, ne viene scelto uno a caso.
  3. Se nessuno è pronto e c'è un default, viene eseguito il default.
  4. Altrimenti la goroutine si blocca finché un caso non diventa pronto.

Un select {} vuoto si blocca per sempre. Ogni tanto lo trovi alla fine di main in programmi il cui lavoro vero avviene in altre goroutine.

Scelta casuale tra i casi pronti

Quando più casi sono pronti nello stesso momento, select non preferisce il primo della lista. Questo programma riempie due canali con buffer e poi esegue select 1000 volte:

La ripartizione tra countA e countB cambia a ogni esecuzione e si aggira intorno a 500 ciascuno. La scelta casuale è voluta: impedisce che un canale molto attivo affami gli altri. Se ti serve una priorità, guarda lo schema più avanti.

Operazioni non bloccanti con default

Con un caso default, select non si blocca mai. Questo trasforma un invio o una ricezione in un'operazione di "tentativo":

Scartare il lavoro quando un buffer è pieno è il modo per ridurre il carico o emettere metriche senza mai bloccare il chiamante.

Non mettere un default in un select dentro un ciclo for solo per "controllare" i canali di continuo. Se non c'è niente di pronto, il ciclo gira a vuoto al 100% della CPU. Blocca invece, e aggiungi un caso di timeout se devi svegliarti periodicamente.

Timeout

time.After(d) restituisce un canale che riceve un valore una volta dopo d. Mettilo in gara con il lavoro vero:

La prima chiamata restituisce "data". La seconda restituisce l'errore di timeout dopo 50 ms, molto prima che il worker abbia finito. Il canale del risultato ha un buffer di 1 di proposito. Quando vince il timeout nessuno riceve mai da result; con un canale senza buffer la goroutine del worker resterebbe bloccata per sempre sul suo invio, causando un leak.

In un ciclo, time.After crea un nuovo timer a ogni iterazione, ed è esattamente quello che serve per un timeout di inattività per messaggio ("nessun messaggio per 1 secondo"). Per una scadenza complessiva su molte operazioni, crea un solo timer o context prima del ciclo. Da Go 1.23 i timer non più referenziati vengono raccolti dal garbage collector anche se non sono scattati, quindi time.After in un ciclo non trattiene più memoria finché ogni timer non scatta, come succedeva nelle versioni precedenti (serve go 1.23 o successivo in go.mod).

Cicli for-select e canali di uscita

Una goroutine che gira finché non le si dice di fermarsi è un ciclo for attorno a un select con un caso per il lavoro e uno per l'arresto:

Chiudere quit invece di inviarci un valore è l'idioma: una chiusura viene vista da ogni ricevente, ora e in futuro, quindi un solo close ferma qualsiasi numero di worker. Il canale done permette a main di aspettare che il worker sia davvero terminato.

Nel codice reale il canale di uscita di solito è un context.Context: case <-ctx.Done():. Funziona allo stesso modo (Done() restituisce un canale che viene chiuso alla cancellazione) e in più porta con sé scadenze e il motivo dell'arresto. La pagina su context lo tratta in dettaglio.

break dentro select

break in un caso di select esce dal select, non dal for che lo contiene. È una fonte comune di cicli che non finiscono mai. Usa return, oppure metti un'etichetta sul ciclo:

Priorità tra canali

Dato che select sceglie a caso, non puoi dare un ordine ai casi dentro una sola istruzione. Per far vincere un canale ogni volta che ha qualcosa, controllalo prima da solo:

for {
	select {
	case <-ctx.Done():
		return ctx.Err()
	default:
	}

	select {
	case <-ctx.Done():
		return ctx.Err()
	case job := <-jobs:
		handle(job)
	}
}

Il primo select ritorna subito se la cancellazione è già avvenuta. Senza di esso, un flusso costante di job potrebbe continuare a vincere la scelta casuale per un po' dopo la cancellazione di ctx.

I canali nil disattivano un caso

Un invio o una ricezione su un canale nil non è mai pronto, quindi un caso su un canale nil è di fatto spento. Impostare a nil un input chiuso è il modo per smettere di fare select su di esso continuando con gli altri; la pagina sui canali mostra un ciclo di merge costruito così. Lo stesso trucco accende e spegne un timeout: tieni var timeout <-chan time.Time a nil finché non ti serve, poi assegnagli time.After(d).

Errori comuni

  • Aspettarsi l'ordine del sorgente. Il primo caso della lista non ha la precedenza.
  • Un ciclo attivo con default. Un for { select { ... default: } } senza niente da fare consuma un core della CPU.
  • Lasciare in sospeso chi perde. Quando vince un timeout, la goroutine che avrebbe inviato il risultato deve comunque poter finire. Dai al suo canale un buffer di 1.
  • Un break che esce solo dal select. Usa un'etichetta o return.
  • Un time.After per iterazione usato come scadenza complessiva. Riparte a ogni iterazione; crea la scadenza una volta sola, fuori dal ciclo.

Domande frequenti

Cosa fa select in Go?

select aspetta finché una delle sue operazioni sui canali (un invio o una ricezione) può procedere, poi esegue quel caso. Se più casi sono pronti nello stesso momento ne sceglie uno a caso. Senza un caso default si blocca finché un caso non è pronto; con default non si blocca mai.

Come aggiungo un timeout alla ricezione da un canale in Go?

Metti la ricezione e un timer nello stesso select: select { case v := <-ch: use(v); case <-time.After(2 * time.Second): return errTimeout }. Vince chi arriva prima. Dentro un ciclo, o quando il chiamante ha già una scadenza, usa invece un context.Context con context.WithTimeout e fai select su ctx.Done().

select in Go sceglie i casi in ordine?

No. Quando più di un caso è pronto, Go sceglie in modo uniformemente casuale, così nessun caso può affamare gli altri. Se ti serve una priorità, controlla prima il canale prioritario in un suo select con un default, poi passa a un select su tutti i canali.

Perché break non esce dal mio ciclo for-select?

Dentro un select, break esce solo dall'istruzione select, non dal for che la contiene. Usa return, oppure metti un'etichetta sul ciclo (loop: for { select { case <-done: break loop } }).

Illustrazione dei linguaggi di programmazione di Coddy

Impara a programmare con Coddy

INIZIA