A che punto una goroutine può cedere?
Sto cercando di ottenere una migliore comprensione di come sono programmate le goroutine nei programmi Go, specialmente in quali punti possono cedere ad altre goroutine. Sappiamo che una goroutine produce su scale di sistema che la bloccherebbero, ma a quanto pare questo non è l'intero quadro.
Questa domanda solleva un po 'di preoccupazione simile e le risposte più votate dicono che una goroutine può anche attivare le chiamate di funzione, in quanto ciò chiamerebbe lo scheduler per verificare se gli stack devono essere aumentati, ma dice esplicitamente che
Se non hai chiamate di funzione, solo un po 'di matematica, allora sì, goroutine bloccherà il thread finché non esce o colpisce qualcosa che potrebbe rendere l'esecuzione ad altri.
Ho scritto un semplice programma per verificare e provare che:
package main
import "fmt"
var output [30]string // 3 times, 10 iterations each.
var oi = 0
func main() {
runtime.GOMAXPROCS(1) // Or set it through env var GOMAXPROCS.
chanFinished1 := make(chan bool)
chanFinished2 := make(chan bool)
go loop("Goroutine 1", chanFinished1)
go loop("Goroutine 2", chanFinished2)
loop("Main", nil)
<- chanFinished1
<- chanFinished2
for _, l := range output {
fmt.Println(l)
}
}
func loop(name string, finished chan bool) {
for i := 0; i < 1000000000; i++ {
if i % 100000000 == 0 {
output[oi] = name
oi++
}
}
if finished != nil {
finished <- true
}
}
NOTA: sono consapevole del fatto che l'inserimento di un valore nell'array e l'incremento oisenza sincronizzazione non è del tutto corretto, ma voglio mantenere il codice semplice e privo di cose che potrebbero causare il passaggio. Dopotutto, la cosa peggiore che può accadere è dare un valore senza far avanzare l'indice (sovrascrittura), il che non è un grosso problema.
A differenza di questa risposta , ho evitato di utilizzare qualsiasi chiamata di funzione (incluso built-in append()) dalla loop()funzione che viene lanciata come goroutine, inoltre sto impostando esplicitamente GOMAXPROCS=1che secondo la documentazione :
limita il numero di thread del sistema operativo che possono eseguire simultaneamente codice Go a livello utente.
Tuttavia, nell'output vedo ancora i messaggi Main/ Goroutine 1/ Goroutine 2interleaved, ovvero uno dei seguenti:
- l'esecuzione della goroutine si interrompe e la goroutine cede il controllo in alcuni momenti;
GOMAXPROCSnon funziona come indicato nella documentazione, facendo girare più thread del sistema operativo per programmare le goroutine.
O la risposta non è completa o alcune cose sono cambiate dal 2016 (ho provato su Go 1.13.5 e 1.15.2).
Mi dispiace se alla domanda è stata data una risposta, ma non sono riuscito a trovare nessuna spiegazione del motivo per cui questo particolare esempio fornisce il controllo, né dei punti in cui le goroutine forniscono il controllo in generale (eccetto il blocco delle chiamate di sistema).
NOTA: Questa domanda è puramente teorica, non sto cercando di risolvere alcun compito pratico ora, ma in generale, presumo che conoscere i punti in cui una goroutine può cedere e dove non può ci consente di evitare l'uso ridondante delle primitive di sincronizzazione.
Risposte
La versione 1.14 della Go ha introdotto la prelazione asincrona:
Le Goroutine sono ora prerilasciabili in modo asincrono. Di conseguenza, i cicli senza chiamate di funzione non bloccano più potenzialmente lo scheduler o ritardano significativamente la garbage collection. Questo è supportato su tutte le piattaforme tranne
windows/arm,darwin/arm,js/wasm, eplan9/*.
Come risposto in Il canale invia punti di prelazione per la pianificazione goroutine? , I punti di prelazione di Go possono cambiare da una versione all'altra. La prelazione asincrona aggiunge solo possibili punti di prelazione quasi ovunque.
Le tue scritture outputsull'array non sono sincronizzate e il tuo oiindice non è atomico, il che significa che non possiamo essere veramente sicuri di cosa succede in termini di array di output. Ovviamente, aggiungendo atomicità ad esso con un mutex si introducono punti di schedulazione cooperativa. Sebbene queste non siano la fonte degli interruttori di pianificazione cooperativa (che devono verificarsi in base all'output), compromettono la nostra comprensione del programma.
L' outputarray contiene stringhe e l'utilizzo di stringhe può richiamare il sistema di raccolta dei rifiuti, che può utilizzare blocchi e causare il cambio di pianificazione. Quindi questa è la causa più probabile del cambio di pianificazione nelle implementazioni precedenti a Go-1.14.
Come ha sottolineato @torek, gli ambienti di runtime più popolari per GO utilizzano la pianificazione preventiva da alcuni mesi (dalla 1.14). Altrimenti i punti in cui una goroutine può produrre varia a seconda dell'ambiente di runtime e del rilascio, ma William Kennedy fornisce un buon riassunto.
Ricordo anche che qualche anno fa era stata aggiunta un'opzione al compilatore per aggiungere punti di rendimento a cicli di lunga durata, ma questa era un'opzione sperimentale normalmente non attivata. (Ovviamente puoi farlo manualmente chiamando runtime.GoSchedogni tanto sul tuo ciclo.)
Per quanto riguarda il tuo test, sono sorpreso dal risultato che hai ottenuto usando Go 1.13.5. Il comportamento non è esattamente definito a causa delle gare di dati (so che hai evitato meccanismi di sincronizzazione per evitare di innescare una resa) ma non mi sarei aspettato quel risultato. Una cosa è che l'impostazione GOMAXPROCSsu 1 significa che solo una goroutine è in esecuzione contemporaneamente, ma ciò potrebbe non significare necessariamente che quando viene eseguita una goroutine diversa verrà eseguita sullo stesso core. Un core diverso avrà una cache diversa e (senza sincronizzazione) opinioni diverse sui valori di outpute oi.
Ma posso suggerirti di dimenticarti semplicemente di modificare le variabili globali e di registrare un messaggio prima e dopo il ciclo occupato. Questo dovrebbe mostrare chiaramente (in GO <1.14) che verrà eseguito un solo lopp alla volta. (Stavo provando a fare lo stesso esperimento di te molti anni fa e sembrava funzionare.)