Programador GO: agora não é cooperativo?

Se você leu as notas de versão da versão GO 1.14, provavelmente notou algumas mudanças bastante interessantes no tempo de execução do idioma. Por isso, fiquei muito interessado no item: "Agora, as goroutinas são preemptivamente assincrônicas". Acontece que o agendador GO (agendador) agora não é cooperativo? Bem, depois de ler a proposta correspondente na diagonal, a curiosidade foi satisfeita.

No entanto, depois de um tempo, decidi pesquisar as inovações em mais detalhes. Eu gostaria de compartilhar os resultados desses estudos.

imagem

requisitos de sistema


Os itens descritos abaixo exigem do leitor, além do conhecimento da linguagem GO, conhecimentos adicionais, a saber:

  • compreensão dos princípios do agendador (embora eu tentarei explicar abaixo, “nos dedos”)
  • entendendo como o coletor de lixo funciona
  • entender o que é o assembler GO

No final, deixarei alguns links que, na minha opinião, cobrem bem esses tópicos.

Brevemente sobre o planejador


Primeiro, deixe-me lembrá-lo do que é multitarefa cooperativa e não cooperativa.

Com a multitarefa não cooperativa (crowding out), todos conhecemos o exemplo do agendador do SO. Esse agendador trabalha em segundo plano, descarrega os threads com base em várias heurísticas e, em vez do tempo de CPU descarregado, outros threads começam a receber.

O planejador da cooperativa é caracterizado por um comportamento diferente - ele dorme até que uma das goroutines o acorde claramente com uma pitada de prontidão para dar seu lugar a outra. O planejador decidirá por si mesmo se é necessário remover a goroutina atual do contexto e, em caso afirmativo, quem colocar em seu lugar. Foi assim que o agendador GO funcionou.

Além disso, consideramos os pilares com os quais o agendador opera:

  • Processadores P - lógicos (podemos alterar seu número com a função runtime.GOMAXPROCS), em cada processador lógico uma goroutine pode ser executada independentemente de cada vez.
  • Threads do M - OS. Cada P é executado em um encadeamento de M. Observe que P nem sempre é igual a M; por exemplo, um encadeamento pode ser bloqueado por syscall e, em seguida, outro encadeamento será alocado para seu P. E há CGO e outras e outras nuances.
  • G - gorutinas. Bem, aqui está claro, G deve ser executado em cada P e o planejador monitora isso.

E a última coisa que você precisa saber e quando o agendador realmente chama goroutine? É simples, geralmente as instruções de chamada são inseridas pelo compilador no início do corpo (prólogo) da função (um pouco mais tarde falaremos sobre isso com mais detalhes).

E qual é o problema realmente?


imagem

Desde o início do artigo, você já percebeu que o princípio do trabalho do agendador mudou no GO, vamos considerar os motivos pelos quais essas alterações foram feitas. Dê uma olhada no código:

sob o spoiler
func main() {
	runtime.GOMAXPROCS(1)
	go func() {
		var u int
		for {
			u -= 2
			if u == 1 {
				break
			}
		}
	}()
	<-time.After(time.Millisecond * 5) //    main   ,         

	fmt.Println("go 1.13 has never been here")
}


Se você o compilar com a versão GO <1,14, a linha "go 1,13 nunca esteve aqui" não será exibida na tela. Isso acontece porque, assim que o agendador dá tempo ao processador para a goroutine com um loop infinito, ele captura completamente P, nenhuma chamada de função ocorre dentro dessa goroutine, o que significa que não vamos mais ativar o agendador. E apenas uma chamada explícita para runtime.Gosched () deixará nosso programa terminar.

Este é apenas um exemplo em que a goroutine captura P e, por um longo tempo, impede que outras goroutines sejam executadas neste P. Mais opções para quando esse comportamento causa problemas podem ser encontradas lendo a proposta.

Analisar proposta


A solução para esse problema é bastante simples. Vamos fazer o mesmo que no agendador do SO! Apenas deixe o GO executar a goroutine de P e colocar outra lá, e para isso usaremos as ferramentas do sistema operacional.

OK, como implementar isso? Permitiremos que o tempo de execução envie um sinal para o fluxo no qual a goroutine trabalha. Vamos registrar o processador deste sinal em cada fluxo de M, a tarefa do processador é determinar se a goroutina atual pode ser suplantada. Nesse caso, salvaremos seu estado atual (registradores e o estado da pilha) e forneceremos recursos para outro, caso contrário, continuaremos executando a goroutina atual. Vale ressaltar que o conceito com sinal é uma solução para sistemas baseados em UNIX, enquanto, por exemplo, a implementação para Windows é um pouco diferente. A propósito, o SIGURG foi selecionado como um sinal para o envio.

A parte mais difícil desta implementação é determinar se a goroutine pode ser forçada a sair. O fato é que alguns lugares em nosso código devem ser atômicos, do ponto de vista do coletor de lixo. Chamamos esses lugares de pontos inseguros. Se apertarmos a goroutina no momento da execução do código a partir de um ponto inseguro e, em seguida, o GC iniciar, ele substituirá o estado da nossa goroutina, inserida em um ponto inseguro, e poderá fazer as coisas. Vamos dar uma olhada no conceito de seguro / não seguro.

Você foi lá, GC?


imagem

Nas versões anteriores à 1.12, o tempo de execução Gosched usava pontos de segurança em locais onde você pode definitivamente chamar o agendador sem medo de que caíssemos na seção atômica do código do GC. Como já dissemos, os dados dos pontos de segurança estão localizados no prólogo de uma função (mas não de todas as funções, lembre-se). Se você desmontou o montador go-shn, pode se opor - nenhuma chamada óbvia ao planejador é visível lá. Sim, mas você pode encontrar a instrução de chamada runtime.morestack lá e, se você olhar dentro dessa função, uma chamada do planejador será encontrada. Sob o spoiler, ocultarei o comentário das fontes GO, ou você pode encontrar o montador para empilhar mais você mesmo.

encontrado na fonte
Synchronous safe-points are implemented by overloading the stack bound check in function prologues. To preempt a goroutine at the next synchronous safe-point, the runtime poisons the goroutine's stack bound to a value that will cause the next stack bound check to fail and enter the stack growth implementation, which will detect that it was actually a preemption and redirect to preemption handling.

Obviamente, ao mudar para um conceito de exclusão, um sinal de exclusão pode capturar nossa gorutina em qualquer lugar. Mas os autores do GO decidiram não deixar pontos seguros, mas declarar pontos seguros em todos os lugares! Bem, é claro, há um problema, quase em toda parte de fato. Como mencionado acima, existem alguns pontos inseguros em que não forçaremos ninguém a sair. Vamos escrever um ponto inseguro simples.


j := &someStruct{}
p := unsafe.Pointer(j)
// unsafe-point start
u := uintptr(p)
//do some stuff here
p = unsafe.Pointer(u)
// unsafe-point end

Para entender qual é o problema, vamos tentar a capa de um coletor de lixo. Cada vez que vamos trabalhar, precisamos descobrir os nós raiz (ponteiros na pilha e nos registros) com os quais começaremos a marcar. Como é impossível dizer em tempo de execução se 64 bytes de memória são um ponteiro ou apenas um número, recorremos à pilha e registramos cartões (alguns com cache de informações meta), gentilmente fornecidos pelo compilador GO. As informações nesses mapas nos permitem encontrar indicadores. Por isso, fomos acordados e enviados para o trabalho quando o GO executou a linha número 4. Chegando ao local e olhando para os cartões, descobrimos que ele estava vazio (e isso é verdade, porque o uintptr do ponto de vista do GC é um número e não um ponteiro). Bem, ontem ouvimos sobre a alocação de memória para j, já que agora não conseguimos acessar essa memória - precisamos limpá-la e, depois de remover a memória, vamos dormir.Qual é o próximo? Bem, as autoridades acordaram, à noite, gritando, bem, você mesmo entendeu.

Isso é tudo com a teoria, proponho considerar na prática como todos esses sinais, pontos inseguros e cartões de registro e pilhas funcionam.

Vamos seguir praticando


Executei duas vezes (vá 1,14 e vá 1,13) um exemplo do início do artigo pelo perf profiler para ver quais chamadas do sistema estão acontecendo e compará-las. O syscall necessário na versão 14 foi encontrado rapidamente:

15.652 ( 0.003 ms): main/29614 tgkill(tgid: 29613 (main), pid: 29613 (main), sig: URG                ) = 0

Bem, obviamente, o tempo de execução enviou o SIGURG para o segmento no qual a goroutine está girando. Tomando esse conhecimento como ponto de partida, fui examinar os commits no GO para descobrir para onde e por que motivo esse sinal é enviado, bem como para encontrar o local onde o manipulador de sinais está instalado. Vamos começar com o envio, encontraremos a função de envio de sinal em runtime / os_linux.go


func signalM(mp *m, sig int) {
	tgkill(getpid(), int(mp.procid), sig)
}

Agora, encontramos lugares no código de tempo de execução, de onde enviamos o sinal:

  1. quando a goroutine for suspensa, se estiver em um estado de execução. A solicitação de suspensão vem do coletor de lixo. Aqui, talvez, não adicionarei código, mas ele pode ser encontrado no arquivo runtime / preempt.go (suspendG)
  2. se o planejador decidir que a goroutine está funcionando por muito tempo, runtime / proc.go (retoke)
    
    if pd.schedwhen+forcePreemptNS <= now {
    	signalM(_p_)
    }
    

    forcePreemptNS - constante igual a 10ms, pd.schedwhen - horário em que o agendador do fluxo pd foi chamado pela última vez
  3. além de todos os fluxos, esse sinal é enviado durante um pânico, StopTheWorld (GC) e mais alguns casos (que eu tenho que ignorar, porque o tamanho do artigo já vai além dos limites)

Nós descobrimos como e quando o tempo de execução envia um sinal para M. Agora vamos encontrar o manipulador para esse sinal e ver o que o fluxo faz quando é recebido.


func doSigPreempt(gp *g, ctxt *sigctxt) {
	if wantAsyncPreempt(gp) && isAsyncSafePoint(gp, ctxt.sigpc(), ctxt.sigsp(), ctxt.siglr()) {
		// Inject a call to asyncPreempt.
		ctxt.pushCall(funcPC(asyncPreempt))
	}
}

Nesta função, fica claro que, para "travar", você precisa passar por 2 verificações:

  1. wantAsyncPreempt - verificamos se G quer ser forçado a sair, aqui, por exemplo, a validade do status atual da goroutina será verificada.
  2. isAsyncSafePoint - verifique se ele pode estar lotado no momento. A verificação mais interessante aqui é se G está em um ponto seguro ou não. Além disso, devemos ter certeza de que o encadeamento no qual G está executando também está pronto para antecipar G.

Se as duas verificações forem aprovadas, as instruções serão chamadas a partir do código executável que salva o estado G e chama o agendador.

E mais sobre inseguros


Proponho analisar um novo exemplo, ele ilustrará outro caso com um ponto inseguro:

outro programa sem fim

//go:nosplit
func infiniteLoop() {
	var u int
	for {
		u -= 2
		if u == 1 {
			break
		}
	}
}

func main() {
	runtime.GOMAXPROCS(1)
	go infiniteLoop()
	<-time.After(time.Millisecond * 5)

	fmt.Println("go 1.13 and 1.14 has never been here")
}


Como você pode imaginar, a inscrição “vá 1.13 e 1.14 nunca esteve aqui” não veremos no GO 1.14. Isso ocorre porque proibimos explicitamente interromper a função infiniteLoop (go: nosplit). Essa proibição é implementada apenas usando o ponto inseguro, que é todo o corpo da função. Vamos ver o que o compilador gerou para a função infiniteLoop.

Cuidado Montador

        0x0000 00000 (main.go:10)   TEXT    "".infiniteLoop(SB), NOSPLIT|ABIInternal, $0-0
        0x0000 00000 (main.go:10)   PCDATA  $0, $-2
        0x0000 00000 (main.go:10)   PCDATA  $1, $-2
        0x0000 00000 (main.go:10)   FUNCDATA        $0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   FUNCDATA        $2, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
        0x0000 00000 (main.go:10)   XORL    AX, AX
        0x0002 00002 (main.go:12)   JMP     8
        0x0004 00004 (main.go:13)   ADDQ    $-2, AX
        0x0008 00008 (main.go:14)   CMPQ    AX, $3
        0x000c 00012 (main.go:14)   JNE     4
        0x000e 00014 (main.go:15)   PCDATA  $0, $-1
        0x000e 00014 (main.go:15)   PCDATA  $1, $-1
        0x000e 00014 (main.go:15)   RET


No nosso caso, a instrução PCDATA é de interesse. Quando o vinculador vê essa instrução, não a converte em um assembler "real". Em vez disso, o valor do 2º argumento com a tecla igual ao contador do programa correspondente (o número que pode ser observado à esquerda do nome da função + linha) será colocado no mapa do registrador ou da pilha (determinado pelo 1º argumento).

Como vemos nas linhas 10 e 15, colocamos os valores $ 2 e -1 no mapa $ 0 e $ 1, respectivamente. Vamos lembrar desse momento e dar uma olhada na função isAsyncSafePoint, para a qual eu já chamei sua atenção. Lá veremos as seguintes linhas:

isAsyncSafePoint

	smi := pcdatavalue(f, _PCDATA_RegMapIndex, pc, nil)
	if smi == -2 {
		return false
	}


É neste local que verificamos se a goroutina está atualmente no ponto seguro. Passamos para o mapa de registradores (_PCDATA_RegMapIndex = 0) e, passando o pc atual, verificamos o valor, se -2, então G não está no ponto seguro, o que significa que não pode ser eliminado.

Conclusão


Parei minha "pesquisa" sobre isso, espero que o artigo tenha sido útil para você também.
Publico os links prometidos, mas tenha cuidado, pois algumas das informações desses artigos podem estar desatualizadas.

Agendador GO - uma e duas vezes .

Assembler GO.

All Articles