2 pontos por GN⁺ 2023-07-18 | 1 comentários | Compartilhar no WhatsApp
  • Em Go, há padrões de concorrência que ficam estranhos usando apenas goroutines e canais, e corrotinas são uma proposta para ajudar a estruturar programas passando explicitamente o fluxo de execução, sem paralelismo
  • Corrotinas trocam o controle de execução com resume e yield; como apenas uma roda por vez, evitam corridas em dados compartilhados, e os pontos de troca se tornam pontos de sincronização
  • Generators do Python e iterators do CLU se parecem com corrotinas, mas a posição de yield é limitada; por isso, ao transpor diretamente uma travessia de árvore aninhada no estilo Lua, alguns valores desaparecem
  • O coro.New do Go também pode ser expresso com canais e goroutines, e coro.Pull transforma um push iterator em um pull iterator que extrai um valor a cada chamada
  • Uma implementação baseada em canais levava cerca de 190 ns por troca, enquanto a troca direta no runtime reduziu isso para cerca de 20 ns por troca e cerca de 40 ns por valor em coro.Pull, com o objetivo de evitar gargalos no uso real

Modelo de execução das corrotinas

  • Corrotinas parecem chamadas de função, mas rodam em stacks diferentes e não executam ao mesmo tempo
    • Mesmo que F inicie G, G não roda imediatamente; só executa quando F faz resume explicitamente
    • Durante a execução, G pode devolver o controle de execução a F a qualquer momento com yield
    • Quando G retorna, ela é finalizada, e F recebe um sinal de que não deve mais fazer resume em G
  • Nesse modelo, apenas uma corrotina por vez executa, enquanto o chamador espera em outro stack
  • Como as trocas de execução só acontecem em pontos específicos do programa, vários fluxos executam alternadamente de forma coordenada

Corrotinas vistas por um exemplo em Lua

  • O exemplo em Lua 5 compara se duas árvores binárias com estruturas diferentes têm a mesma sequência de valores
    • t1 e t2 contêm 1, 2, 3, 4, 5
    • t3 contém 1, 2, 3, 4, 6
  • visit(t) percorre a árvore em ordem e emite cada valor com coroutine.yield(t.value)
  • A função de comparação cria duas corrotinas de visit e lê o próximo valor alternando chamadas a coroutine.resume
    • Se o estado de término ou o valor das duas corrotinas for diferente, retorna false
    • Se ambas terminarem, retorna true
  • Um código Lua mais idiomático usa coroutine.wrap para obter uma função next que esconde o objeto de corrotina
    • Quando a corrotina termina, a função next retorna nil
    • O código completo está no Gist

Limitações dos generators do Python e dos iterators do CLU

  • Generators do Python parecem corrotinas de Lua, mas não seguem o mesmo modelo
  • Se o exemplo em Lua for traduzido diretamente para Python, visit(t['left']) não executa a travessia de fato; apenas cria um objeto generator e o descarta
    • Se há yield no corpo da função, def visit não define uma função comum, e sim um generator
    • O exemplo traduzido de forma simples imprime apenas 4 da árvore e perde 1, 2, 3 e 5
  • O código Python correto precisa iterar explicitamente sobre os generators aninhados e fazer yield novamente
    • O yield from do Python 3.3 simplifica esse padrão
  • Um objeto generator do Python contém apenas o estado de uma única chamada a visit
    • Os valores das variáveis locais e a linha em execução ficam armazenados no objeto generator
    • Quando retomado, esse estado sobe para a pilha de chamadas e, no yield, sai de volta para o objeto generator
    • yield só pode ocorrer no frame de chamada mais externo
  • O CLU chamava essa abstração de iterator e distinguia estaticamente iter de proc
    • Graças às informações de tipo, o compilador conseguia diagnosticar usos em que um iterator era chamado por engano como uma função comum
    • O artigo de 1977 de Barbara Liskov e outros, “Abstraction Mechanisms in CLU”, explica que o iterator é uma forma restrita de corrotina implementada apenas com o stack do programa

Diferenças entre corrotinas, threads e generators

  • Os três conceitos oferecem alguma forma de concorrência, mas diferem em poder e custo
  • Corrotinas

    • Oferecem concorrência sem paralelismo
    • Enquanto uma corrotina está executando, a corrotina que a retomou ou para a qual ela cedeu o controle não executa
    • Como os pontos de troca são explícitos, não surgem corridas ao compartilhar dados
    • Trocas como chamadas a coroutine.resume ou next viram pontos de sincronização e criam uma aresta happens-before
    • Como o escalonamento é explícito, sem o sistema operacional, as trocas podem chegar a aproximadamente 10 ns ou menos
  • Threads

    • São mais poderosas que corrotinas, e esse poder adicional é o paralelismo
    • O custo disso é overhead de escalonamento, trocas de contexto mais caras e a necessidade de alguma forma de preempção
    • Uma troca típica de thread fica na casa de alguns microssegundos
  • Goroutines do Go

    • Nesta classificação, são mais próximas de threads baratas
    • O runtime do Go assume parte do escalonamento, e as trocas ficam mais perto de algumas centenas de ns
    • Como threads, oferecem paralelismo e preempção
    • As novas lightweight threads do Java são basicamente iguais às goroutines
  • Generators

Casos em que corrotinas são necessárias em Go

  • As bibliotecas de concorrência existentes em Go não oferecem diretamente o padrão de corrotinas
  • Goroutines muitas vezes são parecidas o suficiente, mas por causa do paralelismo e da preempção podem produzir resultados diferentes dos de corrotinas
  • A apresentação de 2011 de Rob Pike, “Lexical Scanning in Go”, trata do projeto inicial do lexer e do parser do pacote text/template
    • O lexer e o parser eram executados em goroutines separadas e conectados por canais
    • Essa era uma estrutura que imitava de forma imperfeita um par de corrotinas
    • O lexer olhava antecipadamente o próximo token enquanto o parser processava o token mais recente
    • Generators não eram suficientes para um lexer que precisava fazer yield de valores a partir de várias funções
    • O paralelismo das goroutines criou corridas, e o projeto acabou mudando para armazenar o estado do lexer em um objeto
    • Se houvesse corrotinas adequadas, elas teriam evitado as corridas e sido mais eficientes que goroutines
  • Um caso de uso futuro é a iteração por coleções genéricas
    • Em Go, já houve discussões sobre suporte a range sobre funções
    • Isso poderia incentivar autores de coleções e abstrações a fornecer funções iterator no estilo CLU
  • Em Go, hoje já é possível implementar push iterators usando valores de função
    • Exemplo: func (t *Tree[V]) All(yield func(v V))
    • Atualmente, é possível chamar como t.All(func(v V) { fmt.Println(v) })
    • No futuro, uma forma como for v := range t.All poderia ser possível
  • O problema são travessias que não cabem em um único loop for
    • Há casos, como a comparação de árvores binárias, em que duas travessias precisam ser intercaladas
    • Corrotinas podem transformar um push iterator como (*Tree).All em um pull iterator que retorna um valor por chamada

coro.New expresso em Go puro

  • Se corrotinas fossem adicionadas ao Go, isso deveria ser possível sem mudanças na linguagem, e elas deveriam poder ser entendidas e implementadas como código Go comum
  • Um coro.New simples é expresso com channels e goroutines
    • cin entrega valores de entrada
    • cout devolve valores de saída
    • resume envia um valor para cin e espera o resultado em cout
    • A nova goroutine fica inicialmente bloqueada em <-cin, portanto não há oportunidade de execução paralela
  • Ao adicionar yield, f pode emitir um valor durante a execução, e o chamador pode inserir novamente um valor no próximo resume
    • yield(out) envia um valor para cout e espera a próxima entrada em cin
    • Isso também é um par send-receive, portanto não há paralelismo
  • Esse padrão de comunicação restringe a goroutine a se comportar como uma corrotina
    • Na prática, ainda é uma goroutine, mas resume e yield atuam como operações de troca de contexto

Exemplo de parser de strings

  • O problema em “Storing Data in Control Flow” é executar func parseQuoted(read func() byte) bool em um fluxo de controle separado e fornecer bytes um por um por meio do método Write
  • Usando coro.New, é possível escrever em um nível mais alto do que a implementação temporária baseada em channels do artigo anterior
    • Init define a função coparse
    • read faz yield de NeedMoreInput e depois retorna o byte enviado pelo chamador
    • O resultado booleano de parseQuoted(read) é convertido em BadInput ou Success
    • p.resume(0) avança até o primeiro read de parseQuoted
    • Write(c byte) se torna um wrapper fino que chama p.resume(c)
  • O código completo está no Go Playground

Exemplo do crivo de números primos

  • O crivo concorrente de números primos, de Doug McIlroy, é um pipeline com uma corrotina para cada primo p
    • Cada filtro recebe números do vizinho à esquerda e, se não forem divisíveis por p, os repassa ao vizinho à direita
    • O counter na extremidade esquerda fornece 2, 3, 4, ...
    • A corrotina de saída na extremidade direita lê e imprime os primos e cria uma nova corrotina de filtro
  • counter é uma função que envolve com coro.New um loop que faz yield dos valores
    • more bool informa se deve continuar gerando
    • yield(i) emite o valor e recebe a próxima indicação de continuidade
  • filter(p, next) obtém valores de next(true), a corrotina à esquerda, e só faz yield(n) quando n%p != 0
  • main mantém em next a saída atual do pipeline
    • Lê o primo p
    • Imprime p
    • Adiciona à direita do pipeline um novo filtro que remove os múltiplos de p
  • A relação de chamadas entre as corrotinas pode mudar durante a execução
    • O primeiro yield do counter vai para main, mas os yields seguintes vão para o filtro de 2
    • A primeira saída de cada filtro de p vai para main como o próximo primo, e as saídas seguintes vão para o próximo filtro
  • O código completo está no Go Playground

Relação entre goroutines e corrotinas

  • O fluxo de controle criado aqui é, estritamente falando, uma goroutine
    • Ele pode fazer tudo que uma goroutine comum pode fazer, como esperar em mutexes, channels e system calls
  • coro.New cria uma goroutine que pode usar operações de troca de corrotina dentro de yield e resume
  • A instrução go cria um novo fluxo de controle concorrente e paralelo, mas coro.New cria um novo fluxo de controle concorrente e não paralelo
    • Se você executar 10 instruções go, 11 goroutines, incluindo main, poderão ser executadas simultaneamente
    • Se você chamar coro.New 10 vezes, haverá 11 fluxos de controle, mas o paralelismo do programa permanecerá o mesmo, com apenas um executando por vez
  • Qual goroutine desempenha o papel de corrotina “não paralela” pode mudar durante a execução
    • Isso é igual ao fato de que qual goroutine está enviando ou recebendo em um channel pode mudar durante a execução

Um resume mais robusto

  • O coro.New inicial causa deadlock se resume for chamado depois que a função termina
  • Para corrigir isso, resume retorna um bool junto com o resultado
    • true significa que o resultado veio de yield
    • Quando a função retorna, resume retorna o valor de retorno e false
    • Se resume for chamado depois que a corrotina terminou, ele retorna o zero value e false
  • A variável running acompanha se f está em execução
    • Como resume e a corrotina executam alternadamente, o compartilhamento de running não é uma race
  • O exemplo imprime "hello" true, "world" true, "done" false, "" false

Conversão de iterator com coro.Pull

  • coro.Pull transforma um push iterator em um pull iterator
  • O formato do push iterator de entrada é o seguinte
    • push func(yield func(V) bool)
    • O retorno booleano de yield indica se deve continuar
  • O formato do pull iterator desejado é o seguinte
    • pull func() (V, bool)
    • Ele retorna um valor e se a iteração terminou, como um receive de channel ou uma busca em map
  • Para interrupção antecipada, Pull retorna não apenas pull, mas também stop
  • Para implementar, basta criar um pequeno wrapper que executa o push iterator com coro.New
    • pull chama resume(true)
    • stop chama resume(false)
  • O método All da árvore é alterado para usar o resultado bool de yield
    • Ele encadeia a travessia à esquerda, o yield do valor atual e a travessia à direita com &&, propagando a interrupção antecipada
  • A função de comparação de árvores cria dois coro.Pull e compara os valores um a um
    • Usa defer stop1() e defer stop2() para parar as corrotinas em caso de término antecipado
    • Se o valor ou o estado de término forem diferentes, retorna false
    • Se ambas terminarem, retorna true
  • O código completo está no Go Playground

Propagação de panic e cancelamento

  • Um panic ocorrido em uma corrotina pode ser devolvido ao chamador que mais recentemente deu resume nessa corrotina
    • Em uma goroutine comum, é difícil saber para qual goroutine avisar e se essa goroutine está pronta para receber o aviso
    • Em uma corrotina, o chamador está bloqueado esperando em resume, então o destino para entregar o panic é claro
  • A implementação transmite uma mensagem contendo um valor ou um panic por cout
    • O defer da nova corrotina captura o panic
    • O resume que está esperando dá panic novamente com o mesmo valor de panic
  • No exemplo, a corrotina faz yield de "hello" e depois dá panic com "world"
    • O panic é propagado para a goroutine main e, na stack, parece ter ocorrido na chamada a resume
    • O código completo está no Go Playground
  • Para avisar a corrotina quando o chamador encerra antes do tempo, foi adicionada uma função cancel
    • cancel é parecida com resume, mas faz com que yield dê panic em vez de retornar um valor
    • O panic de cancelamento usa um wrapper de erro exclusivo que satisfaz ErrCanceled
    • Um panic causado por cancel não é propagado de volta, mas, se a corrotina produzir outro panic durante o cancelamento, ele é propagado
    • Se resume ainda não tiver sido chamado, cancel impede que f seja executada
  • Para interromper um iterator, um bool explícito é mais claro do que panic, portanto Pull mantém a interrupção baseada em bool

Revisitando a peneira de primos: limpeza e propagação de erros

  • Na nova API, counter e filter retornam juntos a função resume e a função cancel
  • primes(n int) cria um counter e registra defer cancel()
    • Lê e imprime cada número primo
    • Sempre que adiciona um novo filter, também registra o cancel desse filter com defer
  • Quando a função obtém n primos e retorna, as chamadas cancel adiadas limpam as corrotinas criadas
  • Se alguma corrotina der panic, ele é propagado para a corrotina que estava esperando
    • Se for uma corrotina retomada diretamente por primes com next, o panic volta para primes
    • Se for uma corrotina retomada por um filter com next, o panic sobe pela cadeia de filters até p := next(true) em primes
    • Depois disso, os cancel adiados de primes limpam as corrotinas restantes
  • O código completo está no Go Playground

Forma final da API

  • New cria uma nova corrotina suspensa e prepara a execução da função f
    • A nova corrotina é uma goroutine, mas não executa por conta própria
    • Ela executa apenas enquanto outra goroutine chama resume ou cancel e espera
  • resume(in) pausa a goroutine chamadora e alterna para a nova corrotina
    • A primeira chamada inicia f(in, yield)
    • resume fica bloqueado até que f chame yield(out) ou retorne out
    • Quando yield é chamado, resume retorna out, true
    • Quando f retorna, resume retorna out, false
    • O próximo resume(in) faz com que o yield que estava bloqueado retorne in
  • cancel interrompe a execução de f e encerra a corrotina
    • Se resume nunca tiver sido chamado, f não é executada
    • Caso contrário, o yield que estava bloqueado dá panic com um erro que satisfaz ErrCanceled
  • Se f gerar um panic que não conseguir recuperar, esse panic se move para a goroutine que está esperando em resume ou cancel e dá panic novamente com o mesmo valor
    • Porém, cancel não dá panic novamente com o panic de cancelamento que ele próprio causou
  • Se f retornar ou der panic, a corrotina deixa de existir
    • Chamadas posteriores a resume retornam o zero value e false
    • Chamadas posteriores a cancel simplesmente retornam
  • resume, cancel e yield podem ser passados para outra goroutine e usados lá
    • Como resultado, qual goroutine é a “corrotina” pode mudar dinamicamente
  • New cria uma nova goroutine, mas mantém a invariante de que sempre há uma goroutine bloqueada em resume, cancel, yield ou no estado inicial de espera
    • Essa invariante é mantida até que f retorne
    • Como resultado, coro.New cria nova concorrência, mas não cria novo paralelismo
  • A assinatura final é a seguinte
func New[In, Out any](f func(in In, yield func(Out) In) Out) (resume func(In) (Out, bool), cancel func())

Eficiência

  • Deve ser possível definir corrotinas com uma implementação em Go puro, mas o uso real exige uma implementação otimizada no runtime
  • Em um MacBook Pro de 2019, o coro.New baseado em canais leva cerca de 190ns por troca em uma ida e volta de valor
    • Em coro.Pull, isso equivale a cerca de 380ns por valor
  • coro.Pull não é a forma padrão de usar um iterator
    • A forma padrão é chamar o iterator diretamente; nesse caso, não há overhead de corrotina
    • coro.Pull é necessário quando os valores precisam ser processados incrementalmente, e não em um único loop for
  • A primeira tentativa de otimização foi fazer o compilador marcar pares send-receive e deixar uma dica para que o runtime os combinasse em uma única operação
    • O runtime de canais pode contornar o scheduler e saltar diretamente para outra corrotina
    • Leva cerca de 118ns por troca e cerca de 236ns por valor pulled
    • É 38% mais rápido que a implementação original com canais
  • A segunda implementação evita completamente canais e adiciona a troca de corrotinas diretamente ao runtime
    • A troca de corrotinas é reduzida a 3 operações atomic compare-and-swap
    • Uma é usada para a estrutura de dados da corrotina, uma para o status no scheduler da corrotina que será bloqueada e uma para o status no scheduler da corrotina que será retomada
    • Leva cerca de 20ns por troca e cerca de 40ns por valor pulled
    • É cerca de 10 vezes mais rápida que a implementação original com canais
  • 40ns por valor é considerado um custo absoluto pequeno o bastante para não se tornar gargalo em código que precisa de coro.Pull

1 comentários

 
GN⁺ 2023-07-18
Opiniões no Hacker News
  • Parece que muita gente aqui está perdendo o ponto central. É verdade que uma biblioteca de corrotinas é uma forma pior e mais trabalhosa de lidar com concorrência do que a palavra-chave go
    O caso de uso real que traz essa complexidade são os iteradores de função, permitindo usar range em funções do tipo func() (T, bool). Isso já foi discutido por muito tempo na comunidade Go, e o significado deve ser intuitivo para a maioria dos programadores Go
    O texto trata do problema seguinte: se iteradores de função forem adicionados à linguagem, como escrever iteradores para usar em loops for. Ele começa pelo ponto de que iteradores push muitas vezes são fáceis de escrever, expande para um adaptador push-pull, e esse adaptador é construído sobre corrotinas
    Se tudo isso entrar, acho que usar corrotinas para algo que não seja iteração será uma má prática, como usar canais/goroutines onde um mutex seria suficiente

    • Também vale dizer que, em certos casos de uso, corrotinas são muito mais eficientes do que goroutines completas. Isso porque, ao alternar para uma corrotina, não é necessária uma troca de contexto nem um reescalonamento
      Quando duas tarefas cooperam logicamente de forma síncrona, por exemplo no caso de um iterador, é muito mais eficiente executar tudo na mesma CPU. O kernel não precisa reescalonar nada nem desligar e acordar núcleos de CPU, e os dados continuam ajustados ao cache da CPU, melhorando a latência de cache e a taxa de acerto
      Com goroutines isso pode acontecer por acaso, mas não é garantido, e no mínimo há o custo de passar pelo escalonador de goroutines do runtime do Go. Ele é rápido, mas não tão rápido quanto executar outro contexto de código dentro da mesma goroutine
      Corrotinas permitem saber que a tarefa A alterna diretamente para a tarefa B, então o comportamento de escalonamento é mais previsível. No fim do texto, Russ mostra que uma implementação otimizada de corrotinas no runtime é 10 vezes mais rápida do que uma emulada com goroutines
      O Google tem internamente um patch de kernel que implementa esse tipo de multithreading cooperativo, chamado internamente de fibers. Ele existe para obter melhor latência e escalonamento previsível. Paul Turner também fez uma apresentação no LPC, cerca de 10 anos atrás, explicando essa motivação: https://www.youtube.com/watch?v=KXuZi9aeGTw
    • Não vejo qual é o problema de escrever for { next := getNext(); ... }. Fico curioso para saber qual é a vantagem de escrever for next := range getNext { ... }
    • As corrotinas em Go parecem poder permitir usar Go como linguagem hospedeira para definir de forma limpa simulações de eventos discretos. Hoje, a forma de ceder aos atores é estranha
    • Não entendo por que não dá para usar range ou switch com canais e rodar uma goroutine que empurra valores para o canal. Ainda não estou convencido de por que corrotinas são necessárias
    • Mesmo assim, no fim das contas, parece uma adição do tipo pia de cozinha
      Desta vez, não parece um raciocínio cuidadoso com uma boa solução 80/20, mas algo como: “para fazer isso direito, vamos precisar de corrotinas; então vamos simplesmente colocá-las”
      Ao adicionar generics, houve uma reflexão realmente longa e profunda, e foi apresentada uma solução de compromisso inovadora e muito bem equilibrada
      Aqui, eu esperava uma abordagem como “adicionar um recurso às goroutines para permitir controle em certas situações”. Isso teria parecido melhor do que “vamos seguir o Rust de forma abrangente neste problema e simplesmente adicionar isso”
  • Uso Go profissionalmente há vários anos, mas não quero que ele vire algo parecido com Twisted / Tornado / outros frameworks do Python
    A palavra-chave go evita bem o bastante o doloroso problema da coloração de funções
    Em contextos de alta performance, às vezes você quer fazer coisas como sharding de dados por núcleo de CPU, mas esta proposta não coça essa coceira

    • Corrotinas e goroutines atendem nichos diferentes. Goroutines já preenchem o espaço que coisas como Twisted ocupavam. Não há nada aqui tentando trazer algo parecido com async/await para Go
      Corrotinas preencheriam outro espaço, mais próximo dos geradores do Python. Muitas vezes podem reduzir bastante o uso de memória e a complexidade do código em lugares onde é preciso ligar pipelines de componentes componíveis. O design parece totalmente síncrono
    • Em nenhum lugar do texto há uma proposta que corresponda ao problema da coloração de funções
    • Se você precisa escrever uma goroutine e usar Context, isso é literalmente coloração de funções
    • O “problema” de outros frameworks assíncronos/de threading talvez seja que eles constroem threading sobre iteradores/corrotinas. Neste caso, as coisas são ortogonais, então talvez não seja tão ruim quanto parece
      Claro, é quase certo que algum desenvolvedor inteligente em algum lugar do mundo vai criar algo idiota com isso, e que isso vai ficar muito popular. Meu palpite é uma abstração única para goroutines e corrotinas
    • Fico curioso se alguém pode compartilhar dicas sobre melhores padrões de gerenciamento de canais ou frameworks
      Em geral, código relacionado a goroutines parece bagunçado por causa dos canais, e não sei como deixá-lo mais “limpo”. Eu realmente agradeceria dicas de padrões já validados como manuteníveis
  • Sistemas multitarefa nos deram processos
    Mas eles eram pesados demais
    Então surgiram as threads, processos que compartilham o espaço de endereçamento, a tabela de arquivos e algumas outras coisas. O escalonador consegue alternar entre threads com mais facilidade do que entre processos, e o compartilhamento de dados entre threads não exige serialização
    Mas isso também era pesado demais
    Então surgiram as threads em espaço de usuário. São threads lógicas de execução conduzidas inteiramente pelo runtime no espaço de usuário. O runtime coloca hooks de escalonamento em todas as funções de entrada/saída da biblioteca padrão ou preempta threads lógicas por meio de APIs do sistema, como sinais Unix. Não exigem troca de contexto em nível de sistema e podem ser bem pequenas
    Mas isso também era pesado demais
    Então surgiram as corrotinas. Elas permitem que o programador defina “threads” lógicas que interagem cooperativamente entre si. Não presumem a existência de um escalonador. O programador escreve o próprio loop de eventos ou chama o loop de eventos de uma biblioteca a partir de uma thread lógica “de verdade”
    Fico curioso para saber o que virá a seguir. Do ponto de vista de [processos sequenciais comunicantes][1], talvez corrotinas cooperativas sejam o nível mais baixo a que se pode chegar
    [1]: https://www.cs.cmu.edu/~crary/819-f09/Hoare78.pdf

    • Corrotinas também não salvam o programador do horror de ter que explicar ao computador todos os detalhes possíveis. Em especial, é preciso explicar como fazer a execução não durar para sempre quando a entrada é só um pouco diferente do que o programador imaginou
    • Acho um pouco surpreendente que existam poucas linguagens que paralelizem código automaticamente tanto quanto possível, mas só até o ponto em que o desempenho medido mostre ganho
      Seriam necessárias semânticas que deem suporte à concorrência. Por exemplo, iterações deveriam ser, por padrão, não ordenadas; isso também depende de análise de fluxo do programa inteiro, mas não vejo nenhum obstáculo de princípio
      A Microsoft não pesquisou uma linguagem assim alguns anos atrás? Também há o ParaSail, feito por alguém da comunidade Ada. Fico curioso sobre o que aconteceu com esses projetos e se ninguém os usa
    • Não acho que haja para onde descer mais. Em vez disso, talvez haja espaço para subir rumo à computação distribuída. Se me lembro bem, em uma das primeiras versões alfa de Go, canais também funcionavam entre máquinas
    • O próximo passo pode ser algo como “processe esta coleção de itens de trabalho da maneira que quiser”. Se você olhar de esguelha, toda execução concorrente pode ser vista como uma sequência ordenada de tarefas, e a própria tarefa também pode ser uma coleção. Isso vale mesmo que haja só uma ou duas tarefas
  • Eu achava que o ponto central de green threads era obter um bom escalonamento cooperativo sem usar uma palavra-chave como yield do Python
    Achei que a decisão de design de Go de inserir pontos de retomada nos pontos de chamada e em locais específicos era um compromisso muito bom
    Estão expondo cada vez mais controle próximo do hardware. A partir de certo ponto, não sei se isso não é apenas recriar Zig. O próximo passo é um coletor de lixo opcional?

    • Aqui, yield e resume não são palavras-chave, mas variáveis comuns. São apenas referências a closures comuns nomeadas assim para fins didáticos
      O ponto peculiar é a parte de criar e usar callbacks de cancelamento. Não usei muito Go, então não sei se é uma melhoria de desempenho para coletar mais rapidamente o estado de iteradores descartados, ou se é necessário porque Go não faz garbage collection de uma goroutine esperando em um canal quando essa goroutine tem a única referência a esse canal
      Em Lua, nada disso é necessário. Corrotinas/threads também passam por garbage collection como qualquer outro objeto, então, quando todas as referências desaparecem, elas são coletadas mesmo que a última operação tenha sido yield, e não o retorno da função de entrada
    • O coletor de lixo de Go é opcional. Você pode desativar o garbage collection definindo a variável de ambiente GOGC=off
      Mais detalhes sobre GOGC: https://dave.cheney.net/tag/gogc
  • Acho que eu preferiria que corrotinas entrassem como suporte da linguagem em vez de biblioteca
    Imagino algo como x := co func(){ var z int; for { z++; yield z } }, ou algo equivalente
    É legal que isso seja possível só com Go puro, e entendo a atração de oferecê-lo como um pacote da biblioteca padrão com um runtime otimizado, em vez de complicar a especificação da linguagem. Afinal, se é possível em Go puro, outras implementações também podem fazer bootstrap rapidamente
    Como alguém que usa Go todos os dias no $work, eu receberia bem qualquer uma das duas opções, mas preferiria que fosse embutido na linguagem. As primitivas de concorrência de Go sempre foram um ponto forte, então faz sentido seguir nessa direção

    • Corrotinas precisam de suporte da linguagem. O que fazer se uma corrotina sofrer esgotamento de pilha?
      Em uma solução de biblioteca, seria preciso matar a corrotina ou matar o programa. Se você quer que a pilha cresça de forma transparente, só o código gerado pode fazer isso, porque ele precisa monitorar o uso da pilha e aumentá-la quando necessário. Pelo que sei, goroutines têm algo assim
      Talvez uma solução de biblioteca também possa colocar uma página de guarda no fim da pilha. Ao atingi-la, o tratador de erro poderia tentar expandir a pilha. Mas, se houver ponteiros guardados para variáveis de pilha, provavelmente não funcionaria
    • Eu preferiria adicionar um parâmetro yield às funções para interagir melhor com o sistema de tipos atual
      A ideia seria que uma função que cede valores tenha um chamado parâmetro yield, e que só possa ceder dentro de funções com a mesma assinatura de cessão ou sem assinatura
      O exemplo poderia virar algo como x := func(:z int) { for { z++; :- z } }. : adiciona a assinatura de cessão, e :- cede o valor
      Uma função que só cede valores X precisaria ter apenas : X ou : name X. Para receber, ao ser retomada, um valor do tipo Y, a assinatura mudaria para :[Y] X ou :[Y] name X
      A aceitação deve ser fraca. Em um lugar que espera uma função que é retomada com Y e cede X, também se deve aceitar uma função que apenas cede X, sem retomada
      Se funções especiais do pacote co fornecerem os recursos de resume e New, daria para manter o estilo de Go. A sintaxe range poderia ser estendida para passar valores de retomada com -:, e, se nenhum valor for passado, a retomada ocorreria com o valor zero padrão
    • Isso é realmente horrível. Não é nada intuitivo e não combina com um dos pontos fortes de Go. Para escrever ou ler código assim, seria preciso aprender separadamente a semântica de corrotinas de Go
  • Não gostei muito. Pelos exemplos, parece que a linguagem fica muito mais difícil de ler e acompanhar. Claro, talvez seja culpa da minha cabeça e dos meus preconceitos
    Além disso, também não parece permitir fazer algo que não seja possível com os canais bloqueantes ou estado atuais

    • Exatamente como você disse. Quem defende isso parece só enxergar o que está logo à frente
    • Não sei de qual mudança na linguagem você está falando. Isto é apenas uma proposta para formalizar e tornar eficiente algo que as pessoas já fazem com “estado”
      Já usei iteradores parecidos com os deste texto para evitar alocações em caminhos críticos de código. Com esta abordagem, esse tipo de código ficaria muito menos estranho. Especialmente junto com a mudança de linguagem para iteradores com range que deve chegar em breve
    • Também não é mais fácil ter dezenas de implementações de iteradores incompatíveis entre si, como acontece na biblioteca-padrão atual
      Canais são lentos sem muito motivo quando não estão encapsulando operações bloqueantes
  • Fico amargurado ao ler os comentários
    Muita gente considera corrotinas e green threads quase a mesma coisa, mas ambas têm vantagens e desvantagens
    É triste que, na comunidade Go, a ausência de iteradores possa ser aceita. Parece que, em nome da simplicidade, rejeitam deliberadamente qualquer recurso que possa tornar a linguagem sequer um pouco mais complexa. Ainda assim, pelo menos voltaram atrás na posição sobre genéricos
    Isso reforça a ideia de que Go realmente não é a minha linguagem

    • Uso Go todos os dias, mas, sendo sincero, genéricos não mudaram muito meu código
      Hoje eu os uso um pouco, porque a conveniência sintática no ponto de chamada é um pouco melhor. No ponto de definição, como esperado, continuam feios; ainda assim, a sintaxe de Go é uma das melhores que já vi entre outras linguagens
      No fim, acho que se perdeu bastante simplicidade só para calar as reclamações de que “não há genéricos”. Não foi uma boa troca
    • Não se deve confundir o HN com a “comunidade Go”. A pessoa que escreveu este texto é o líder da equipe Go
      O pacote coro proposto no texto será adicionado exatamente como está? Pode ser, mas provavelmente não exatamente assim. Algo parecido será adicionado? Se eu pudesse apostar dinheiro, diria que sim
      Quanto tempo vai levar? Acho que no mínimo 1 ano, ou seja, por volta do lançamento do Go 1.23, em agosto de 2024. Pode demorar um pouco mais. Acho difícil ser muito menos que isso
    • Eles não voltaram atrás na posição sobre genéricos
      Genéricos foram aceitos pela comunidade porque são totalmente retrocompatíveis com o código existente e podem ser ignorados com segurança se você não precisar deles
      Sem surpresa, a maior parte do código Go continua fazendo exatamente isso. Exceto por vários tipos de “coleções”, não é tão fácil encontrar usos práticos para genéricos. Para começo de conversa, a maior parte do código não precisa lidar com mais de um tipo; passar de dois tipos já é algo raro
      Na verdade, a adição de genéricos mostrou para a comunidade mais ampla como esses recursos aclamados que as pessoas exigem como “essenciais” são, na prática, muito menos necessários em uma linguagem excelente e amplamente aceita
    • Genéricos não mudaram nem 20% da minha base de código, e dentro desses 20% havia bibliotecas. Não sei se é hábito de Go ou de C, mas genéricos me parecem só mais uma biblioteca em que algum programador, em algum lugar, resolveu algum problema
    • Eu achava que a comunidade Go receberia bem uma interface unificada de iteração
  • Parece positivo que só agora estejam prestando atenção a linguagens de programação como CLU
    Por outro lado, pela minha experiência com corrotinas em .NET e C++, e com Active Object em Symbian C++ e Active Oberon, não tenho certeza de que isso realmente valha a pena ser adicionado ao Go
    Como a equipe .NET também reconheceu na BUILD deste ano, se pudessem voltar no tempo, teria sido melhor deixar o runtime cuidar disso no estilo Go. Muitos desenvolvedores ainda têm dificuldade para entender async/await

  • Não sei se isso é realmente necessário. Na maioria dos casos em Go, goroutines dão conta, e para a semântica de yield/resume bastam 2 canais bloqueantes
    Parece adicionar complexidade pela complexidade, e nem está claro se isso realmente acrescenta alguma capacidade nova que já não existisse em Go

    • Goroutines e canais acrescentam um overhead enorme. Usá-los como iteradores praticamente não faz sentido
    • O texto aborda isso
  • Como comparação, em uma apresentação recente fiz uma demonstração iniciando 1 milhão de threads em Elixir (BEAM VM), enviando uma mensagem "Hello!" para todas elas e, depois, cada thread esperava por um tempo aleatório entre 0 e 2 segundos antes de devolver "Process received message !"
    Ao mesmo tempo, deixei o Erlang observer aberto ao lado para observar consumo de CPU e memória, e a rapidez da recuperação após a coleta de lixo
    O maior gargalo aqui é a capacidade do terminal de acompanhar, mas o observer parece refletir bem a situação real
    https://www.youtube.com/watch?v=yxyYKnashR0
    Código usado: https://gist.github.com/pmarreck/4cc8f2f55a561ebce2012085a3a...
    Esse recurso existe embutido em Erlang, e portanto também em Elixir, desde os anos 1980. Muita gente já ouviu falar do modelo de atores ou da implementação “lendária” de Erlang, mas não sei quantas pessoas já viram isso na prática com ferramentas de monitoramento abertas ao lado
    Seria bom se Go oferecesse esse tipo de suporte no nível da linguagem, mas a implementação de threads da BEAM VM é extremamente eficiente em recursos tanto na criação quanto no consumo em tempo de execução, e ainda combina isso com a facilidade de concorrência que vem de permitir apenas valores imutáveis; acho que será muito difícil alcançá-la