Em que ponto uma goroutine pode ceder?
Estou tentando obter um melhor entendimento de como as goroutines são programadas nos programas Go, especialmente em quais pontos eles podem ceder a outras goroutines. Sabemos que uma goroutine cede em escalas que a bloqueariam, mas aparentemente essa não é toda a imagem.
Esta questão levanta uma preocupação semelhante, e as respostas mais avaliadas dizem que uma goroutine também pode ativar chamadas de função, pois isso chamaria o agendador para verificar se as pilhas precisam ser aumentadas, mas diz explicitamente que
Se você não tem nenhuma chamada de função, apenas um pouco de matemática, então sim, goroutine irá bloquear a thread até que ela saia ou acerte algo que poderia render a execução para outros.
Eu escrevi um programa simples para verificar e provar que:
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: Estou ciente de que colocar um valor na matriz e incrementar oisem sincronização não é totalmente correto, mas quero manter o código fácil e livre de coisas que podem causar a troca. Afinal, o pior que pode acontecer é colocar um valor sem avançar o índice (sobrescrever), o que não é grande coisa.
Ao contrário desta resposta , evitei usar qualquer chamada de função (incluindo embutida append()) da loop()função que é iniciada como uma goroutina, também estou definindo explicitamente de GOMAXPROCS=1acordo com a documentação :
limita o número de threads do sistema operacional que podem executar código Go no nível do usuário simultaneamente.
No entanto, na saída ainda vejo as mensagens Main/ Goroutine 1/ Goroutine 2intercaladas, o que significa uma das seguintes:
- a execução da goroutine interrompe e a goroutine abre mão do controle em alguns momentos;
GOMAXPROCSnão funciona conforme declarado na documentação, gerando mais threads de sistema operacional para agendar goroutines.
A resposta não está completa ou algumas coisas mudaram desde 2016 (testei no Go 1.13.5 e 1.15.2).
Lamento se a pergunta foi respondida, mas não consegui encontrar nenhuma explicação de por que este exemplo específico produz controle, nem sobre pontos onde goroutines geram controle em geral (exceto bloqueio de chamadas).
NOTA: Esta questão é puramente teórica, não estou tentando resolver nenhuma tarefa prática agora, mas em geral, presumo que conhecer pontos onde uma goroutine pode ceder e onde não pode nos permitir evitar o uso redundante de primitivas de sincronização.
Respostas
Go versão 1.14 introduziu preempção assíncrona:
Goroutines agora são preemptivas de forma assíncrona. Como resultado, os loops sem chamadas de função não bloqueiam mais potencialmente o planejador ou atrasam significativamente a coleta de lixo. Isto é suportado em todas as plataformas, exceto
windows/arm,darwin/arm,js/wasmeplan9/*.
Conforme respondido em Os canais de envio de pontos de preempção para agendamento de goroutine? , Os pontos de preempção do Go podem mudar de uma versão para a próxima. A preempção assíncrona apenas adiciona pontos de preempção possíveis em quase todos os lugares.
Suas gravações no outputarray não estão sincronizadas e seu oiíndice não é atômico, o que significa que não podemos ter certeza do que acontece em termos do array de saída. É claro que adicionar atomicidade a ele com um mutex introduz pontos de agendamento cooperativos. Embora essas não sejam a fonte de opções de agendamento cooperativo (que devem ocorrer com base em sua saída), elas bagunçam nossa compreensão do programa.
O outputarray contém strings e o uso de strings pode invocar o sistema de coleta de lixo, que pode usar bloqueios e causar a troca de agendamento. Portanto, esta é a causa mais provável da troca de agendamento em implementações anteriores ao Go-1.14.
Como @torek apontou, os ambientes de tempo de execução mais populares para GO têm usado o agendamento preventivo por alguns meses (desde 1.14). Caso contrário, os pontos em que uma goroutine pode ceder variam dependendo do ambiente de execução e da versão, mas William Kennedy fornece um bom resumo.
Também me lembro que havia uma opção adicionada ao compilador para adicionar pontos de rendimento a loops de longa execução alguns anos atrás, mas esta era uma opção experimental que normalmente não era acionada. (Claro que você pode fazer isso manualmente, chamando de runtime.GoSchedvez em quando no seu loop.)
Quanto ao seu teste, estou surpreso com o resultado que você obteve ao executar o Go 1.13.5. O comportamento não está exatamente definido devido às corridas de dados (eu sei que você evitou qualquer mecanismo de sincronização para evitar o acionamento de um rendimento), mas eu não esperava esse resultado. Uma coisa é que definir GOMAXPROCScomo 1 significa que apenas um goroutine está sendo executado simultaneamente, mas isso pode não significar necessariamente que, quando um goroutine diferente for executado, ele será executado no mesmo núcleo. Um núcleo diferente terá um cache diferente e (sem sincronização) opiniões diferentes sobre os valores de outpute oi.
Mas sugiro que você simplesmente esqueça a modificação de variáveis globais e apenas registre uma mensagem antes e depois do loop ocupado. Isso deve mostrar claramente (em GO <1.14) que apenas um lopp será executado por vez. (Eu estava tentando fazer o mesmo experimento que você há muitos anos e pareceu funcionar.)