Planejando no Go: Parte II - O Agendador Go

Olá Habr! Este é o segundo post de uma série de três partes que fornecerá uma idéia da mecânica e da semântica do trabalho do planejador no Go. Esta postagem é sobre o planejador Go.

Na primeira parte desta série, expliquei aspectos do agendador do sistema operacional que, na minha opinião, são importantes para entender e avaliar a semântica do agendador Go. Neste post, explicarei em um nível semântico como o agendador Go funciona. O Go Scheduler é um sistema complexo e pequenos detalhes mecânicos não são importantes. É importante ter um bom modelo de como tudo funciona e se comporta. Isso permitirá que você tome as melhores decisões de engenharia.

Seu programa está iniciando


Quando o seu programa Go é iniciado, é atribuído um processador lógico (P) para cada núcleo virtual definido na máquina host. Se você tiver um processador com vários threads de hardware por núcleo físico (Hyper-Threading), cada thread de hardware será apresentado ao seu programa como um núcleo virtual. Para entender melhor isso, dê uma olhada no relatório do sistema para o meu MacBook Pro.

imagem

Você pode ver que eu tenho um processador com 4 núcleos físicos. Este relatório não divulga o número de threads de hardware por núcleo físico. O processador Intel Core i7 possui a tecnologia Hyper-Threading, o que significa que o núcleo físico possui 2 threads de hardware. Isso indica ao Go que 8 núcleos virtuais estão disponíveis para executar threads do SO em paralelo. Para verificar isso, considere o seguinte programa:

package main

import (
	"fmt"
	"runtime"
)

func main() {

    // NumCPU returns the number of logical
    // CPUs usable by the current process.
    fmt.Println(runtime.NumCPU())
}

Quando executo este programa no meu computador, o resultado da chamada da função NumCPU () será 8. Qualquer programa Go executado no meu computador terá 8 (P).
Cada P recebe um fluxo do SO ( M ). Esse encadeamento ainda é gerenciado pelo sistema operacional, e o SO ainda é responsável por colocar o encadeamento no kernel para execução. Isso significa que, quando executo o Go no meu computador, tenho 8 threads disponíveis para realizar meu trabalho, cada um vinculado individualmente ao P.

Cada programa Go também recebe uma Goroutine inicial ( G) A Goroutine é essencialmente a Coroutine, mas é Go, por isso substituímos a letra C por G e obtemos a palavra Goroutine. Você pode pensar nas Goroutines como threads no nível do aplicativo e são muito parecidos com os threads do SO. Assim como os threads do sistema operacional são ativados e desativados pelo kernel, os programas de contexto são ativados e desativados pelo contexto.

O último quebra-cabeça são as filas de execução. Existem duas filas de execução diferentes no planejador Go: a fila de execução global (GRQ) e a fila de execução local (LRQ). A cada P é atribuído um LRQ que controla as goroutinas atribuídas para execução no contexto de P. Essas goroutines são ativadas e desativadas no contexto M atribuído a esse P. GRQ é para goroutines que não foram atribuídas a P. Existe um processo para mover as goroutinas do GRQ para o LRQ, que discutiremos mais adiante.

A imagem mostra todos esses componentes juntos.

imagem

Planejador cooperativo


Como dissemos no primeiro post, o agendador do SO é um agendador de preferência. Essencialmente, isso significa que você não pode prever o que o planejador fará a qualquer momento. O kernel toma decisões e tudo é não determinístico. Os aplicativos executados no topo do sistema operacional não controlam o que acontece dentro do kernel com agendamento, a menos que usem primitivas de sincronização, como instruções atômicas e chamadas mutex.

O Go Scheduler faz parte do Go Runtime e o Go Runtime é incorporado ao seu aplicativo. Isso significa que o planejador Go funciona no espaço do usuário no kernel. A implementação atual do planejador Go não é preemptiva, mas um planejador interativo. Ser um planejador cooperativo significa que o planejador precisa de eventos claramente definidos no espaço do usuário que ocorrem em pontos seguros do código para tomar decisões de planejamento.

O que é bom no planejador colaborativo da Go é que ele parece e parece proativo. Você não pode prever o que o agendador Go fará. Isso se deve ao fato de que a tomada de decisão para esse planejador não depende dos desenvolvedores, mas do tempo de execução do Go. É importante pensar no agendador Go como um agendador proativo e, como o agendador é não determinístico, não é muito difícil.

Estados de Gorutin


Assim como os fluxos, as goroutines têm os mesmos três estados de alto nível. Eles determinam o papel que o planejador Go desempenha com qualquer goroutine. Goroutin pode estar em um dos três estados: Aguardando, Pronto ou Cumprindo.

Aguardando : Isso significa que a goroutine está parada e aguardando que algo continue. Isso pode ocorrer por motivos como espera pelo sistema operacional (chamadas do sistema) ou sincronização de chamadas (operações atômicas e mutex). Esses tipos de atrasos são a principal causa de baixo desempenho.

Prontidão: isso significa que a goroutine deseja tempo para seguir as instruções atribuídas. Se você tiver muitas goroutines que precisam de tempo, elas precisarão esperar mais para obter tempo. Além disso, a quantidade de tempo individual que qualquer goroutine recebe é reduzida à medida que mais goroutines competem pelo tempo. Esse tipo de atraso de agendamento também pode causar desempenho ruim.

Cumprimento : significa que a goroutina foi colocada em M e está seguindo suas instruções. O trabalho associado ao aplicativo foi concluído. É isso que todo mundo quer.

Mudança de contexto


O Go Scheduler requer eventos de espaço do usuário bem definidos que ocorrem em pontos seguros no código para alternar o contexto. Esses eventos e pontos seguros aparecem nas chamadas de função. As chamadas de função são críticas para o desempenho do Go Scheduler. Se você executar qualquer loop estreito que não faça chamadas de função, causará atrasos no planejador e na coleta de lixo. É imperativo que as chamadas de função ocorram dentro de um período de tempo razoável.

Existem quatro classes de eventos que ocorrem nos seus programas Go que permitem ao planejador tomar decisões de planejamento. Isso não significa que isso sempre aconteça em um desses eventos. Isso significa que o planejador obtém a oportunidade.

  • Usando a palavra-chave go
  • Coletor de lixo
  • Chamadas do sistema
  • Sincronização

Usando a
palavra-chave go A palavra-chave go é como você cria goroutine. Assim que uma nova goroutine é criada, ela oferece ao planejador a oportunidade de tomar uma decisão de planejamento.

Garbage Collector (GC)
Como o GC trabalha com seu próprio conjunto de goroutines, essas gorutinas precisam de tempo em M para serem executadas. Isso força o GC a criar muito caos no planejamento. No entanto, o planejador é muito inteligente no que a goroutine faz, e ele a usará para tomar decisões. Uma solução razoável é mudar o contexto para a goroutine, que deseja acessar o recurso do sistema, e mais ninguém além dele durante a coleta de lixo. Quando o GC funciona, muitas decisões de planejamento são tomadas.

Chamadas do sistema
Se a goroutine fizer uma chamada do sistema que o fará bloquear M, o agendador poderá alternar o contexto para outra goroutine, para o mesmo M.

Sincronização
Se uma chamada para uma operação atômica, um mutex ou um canal fizer com que a goroutine seja bloqueada, o agendador poderá alternar o contexto para iniciar uma nova goroutine. Uma vez que a goroutine possa funcionar novamente, ela poderá ser colocada na fila e, eventualmente, voltar para M.

Chamadas assíncronas do sistema


Quando o sistema operacional em que você está trabalhando tem a capacidade de processar uma chamada do sistema de forma assíncrona, o que é chamado de poller de rede pode ser usado para processar a chamada do sistema com mais eficiência. Isso é feito usando o kqueue (MacOS), epoll (Linux) ou iocp (Windows) nesses respectivos sistemas operacionais.

As chamadas do sistema de rede podem ser tratadas de forma assíncrona por muitos dos sistemas operacionais que usamos hoje. É aqui que o pesquisador da rede se mostra, pois seu principal objetivo é processar as operações da rede. Usando o pesquisador de rede para chamadas do sistema de rede, o planejador pode impedir que goroutines bloqueiem M durante essas chamadas do sistema. Isso ajuda a manter M disponível para executar outras goroutines no LRQ P sem a necessidade de criar um novo M. Isso ajuda a reduzir a carga de planejamento no sistema operacional.

A melhor maneira de ver como isso funciona é ver um exemplo. A figura mostra nosso esquema de planejamento básico. Gorutin-1 é executado em M, e mais 3 Gorutins estão esperando no LRQ para se dedicar a M. O pesquisador da rede está ocioso e ele não tem nada para fazer.

imagem

Na figura a seguir, Gorutin-1 (G1) deseja fazer uma chamada ao sistema de rede, de modo que G1 se move para o pesquisador de rede e é tratado como uma chamada de sistema de rede assíncrona. Depois que o G1 foi movido para o pesquisador de rede, M agora está disponível para executar outra goroutine do LRQ. Nesse caso, Gorutin-2 muda para M.

imagem

Na figura a seguir, a chamada de rede do sistema termina com uma chamada de rede assíncrona e G1 volta para LRQ para P. Depois que G1 pode ser retornado para M, o código associado a Go, para o qual Ele responde pode executar novamente. A grande vitória é que nenhuma Sra. Adicional é necessária para fazer chamadas ao sistema de rede. O pesquisador de rede possui um encadeamento do SO e processa através de um loop de eventos.

Chamadas síncronas do sistema


O que acontece quando a goroutine deseja fazer uma chamada do sistema que não pode ser executada de forma assíncrona? Nesse caso, o pesquisador da rede não pode ser usado e a goroutine que faz a chamada do sistema bloqueará M. Isso é ruim, mas não há como evitar isso. Um exemplo de chamada de sistema que não pode ser feita de forma assíncrona são as chamadas de sistema baseadas em arquivo. Se você usar o CGO, poderá haver outras situações em que a chamada de funções C também bloqueie M.
O sistema operacional Windows pode fazer chamadas assíncronas ao sistema com base em arquivo. Tecnicamente, ao trabalhar no Windows, você pode usar o Poller de rede.
Vamos ver o que acontece com uma chamada de sistema síncrona (por exemplo, arquivo de E / S) que bloqueia M. A figura mostra nosso diagrama de planejamento básico, mas desta vez G1 fará uma chamada de sistema síncrona que bloqueará M1.

imagem

Na figura a seguir, o planejador pode determinar que G1 causou um bloqueio M. Nesse momento, o planejador desconecta M1 de P com um G1 de bloqueio ainda anexado. O planejador introduz um novo M2 para servir P. Nesse ponto, o G2 pode ser selecionado no LRQ e incluído no contexto do M2. Se M já existe devido a uma troca anterior, essa transição é mais rápida que a necessidade de criar um novo M.

imagem

A próxima etapa completa a chamada do sistema de bloqueio feita pelo G1. Nesse ponto, G1 pode retornar ao LRQ e ser atendido novamente por P. M1, depois será retirado para uso futuro se esse cenário for repetido.

imagem

Roubo de trabalho


Outro aspecto do planejador é que ele é um planejador de roubo de goroutine. Isso ajuda em várias áreas a apoiar um planejamento eficaz. Primeiramente, a última coisa que você precisa é que o M entre no estado de espera, porque assim que isso acontecer, o sistema operacional alternará o M do kernel usando o contexto. Isso significa que P não pode fazer nenhum trabalho, mesmo que haja uma Goroutine em um estado íntegro, até que M volte para o kernel. O Gorutin Theft também ajuda a equilibrar os intervalos de tempo entre todos os Ps, para que o trabalho seja melhor distribuído e executado com mais eficiência.

Na figura, temos um programa Go multiencadeado com dois Ps servindo quatro Gs cada e um G no GRQ. O que acontece se um de P serve rapidamente todo o seu G?

imagem

Além disso, P1 não possui mais goroutines para executar. Mas existem goroutines em condições de trabalho, tanto no LRQ para P2 quanto no GRQ. Este é o momento em que P1 precisa roubar goroutine.

imagem

As regras para roubar goroutines são as seguintes. Todo o código pode ser visualizado nas fontes de tempo de execução.

runtime.schedule() {
    // only 1/61 of the time, check the global runnable queue for a G.
    // if not found, check the local queue.
    // if not found,
    //     try to steal from other Ps.
    //     if not, check the global runnable queue.
    //     if not found, poll network.
}

Assim, com base nessas regras, P1 deve verificar P2 quanto à presença de goroutines em seu LRQ e pegar metade do que encontrar.

imagem

O que acontece se o P2 terminar de servir todos os seus programas e o P1 não tiver mais nada no LRQ?

P2 concluiu todo o seu trabalho e agora deve roubar as goroutines. Primeiro, ele examinará o LRQ P1, mas não encontrará nenhuma Goroutines. Em seguida, ele analisará o GRQ. Lá ele encontrará o G9.

imagem

P2 rouba G9 do GRQ e começa a fazer o trabalho. O bom de todo esse roubo é que ele permite que M permaneça ocupado e não fique inativo.

imagem

Exemplo prático


Com mecânica e semântica, quero mostrar como tudo isso se ajusta para que o planejador Go possa fazer mais trabalho ao longo do tempo. Imagine um aplicativo multithread escrito em C, no qual o programa gerencia dois threads do SO que enviam mensagens um para o outro. Existem 2 tópicos na imagem que enviam a mensagem para frente e para trás. O segmento 1 recebe o núcleo 1 com alternância de contexto e agora está em execução, o que permite que o segmento 1 envie sua mensagem ao segmento 2.

imagem

Além disso, quando o encadeamento 1 termina de enviar a mensagem, agora é necessário aguardar uma resposta. Isso fará com que o thread 1 seja desconectado do contexto do kernel 1 e colocado em um estado de espera. Assim que o segmento 2 recebe uma notificação de mensagem, ele entra em um estado íntegro. Agora o sistema operacional pode executar uma alternância de contexto e executar o thread 2 no kernel, que acaba sendo o kernel 2. Em seguida, o thread 2 processa a mensagem e envia uma nova mensagem de volta ao thread 1.

imagem

Em seguida, o fluxo retorna ao contexto quando a mensagem do fluxo 2 é recebida pelo fluxo 1. Agora, o fluxo 2 alterna do estado de execução para o estado em espera e o fluxo 1 alterna do estado de espera para o estado pronto e finalmente retorna ao estado de execução, o que permite processar e envie uma nova mensagem de volta. Todas essas alternâncias de contexto e alterações de estado levam tempo para serem concluídas, o que limita a velocidade do trabalho. Como cada alternância de contexto implica um atraso de ~ 1000 nanossegundos, e esperamos que o hardware execute 12 instruções por nanossegundo, observe 12.000 instruções que são mais ou menos executadas durante essas alternâncias de contexto. Como esses fluxos também se cruzam entre diferentes núcleos,A probabilidade de um atraso adicional na linha de cache também é alta.

imagem

Na figura há dois gorutins que estão em harmonia um com o outro, passando a mensagem para frente e para trás. O G1 obtém a chave de contexto M1, que é executada no Core 1, o que permite que o G1 faça seu trabalho.

imagem

Além disso, quando G1 termina de enviar a mensagem, agora ele precisa aguardar uma resposta. Isso fará com que G1 seja desconectado do contexto M1 e colocado no estado ocioso. Assim que o G2 é notificado da mensagem, ela entra em um estado íntegro. Agora, o planejador Go pode executar a alternância de contexto e executar G2 no M1, que ainda é executado no Core 1. Em seguida, o G2 processa a mensagem e envia uma nova mensagem de volta ao G1.

imagem

Na próxima etapa, tudo muda novamente quando a mensagem enviada por G2 é recebida por G1. Agora, o contexto G2 alterna do estado de execução para o estado de espera, e o contexto G1 alterna do estado de espera para o estado de execução e, finalmente, volta ao estado de execução, o que permite processar e enviar uma nova mensagem de volta.

imagem

As coisas na superfície não parecem ser diferentes. Todas as mesmas mudanças de contexto e de estado ocorrem independentemente de você usar Streams ou Goroutines. No entanto, existe uma grande diferença entre o uso de Streams e Gorutin, o que pode não ser óbvio à primeira vista.

Se a goroutine for usada, os mesmos threads e kernel do sistema operacional serão usados ​​para todo o processamento. Isso significa que, do ponto de vista do SO, o OS Flow nunca entra em um estado de espera; Nunca. Como resultado, todas as instruções que perdemos ao alternar contextos ao usar fluxos não são perdidas ao usar goroutin.

Essencialmente, o Go transformou o trabalho de E / S / Bloqueio em um trabalho vinculado ao processador no nível do SO. Como toda alternância de contexto ocorre no nível do aplicativo, não perdemos as mesmas ~ 12 mil instruções (em média) na alternância de contexto que perdemos ao usar fluxos. No Go, as mesmas opções de contexto custam ~ 200 nanossegundos ou ~ 2,4 mil comandos. O planejador também ajuda a melhorar o desempenho das seqüências de cache e do NUMA. É por isso que não precisamos de mais threads do que os núcleos virtuais. O Go pode fazer mais trabalho com o tempo, porque o planejador Go tenta usar menos threads e fazer mais em cada thread, o que ajuda a reduzir a carga no SO e no hardware.

Conclusão


O Go Scheduler é realmente incrível na maneira como leva em consideração os meandros do sistema operacional e do hardware. A capacidade de transformar a operação de E / S / bloqueio em uma operação ligada ao processador no nível do sistema operacional é onde obtemos grandes ganhos no uso de mais energia do processador ao longo do tempo. É por isso que você não precisa de mais threads do SO do que dos kernels virtuais. Você pode esperar razoavelmente que todo o seu trabalho seja realizado (com ligação à CPU e E / S / bloqueios) com um encadeamento do SO por kernel virtual. Isso é possível para aplicativos de rede e outros aplicativos que não precisam de chamadas do sistema que bloqueiam threads do SO.

Como desenvolvedor, você ainda deve entender o que seu aplicativo faz em termos de tipo de trabalho. Você não pode criar uma quantidade ilimitada de goroutines e esperar um desempenho incrível. Menos é sempre mais, mas com um entendimento dessa semântica do planejador Go, você pode tomar melhores decisões de engenharia.

All Articles