1 pontos por GN⁺ 2024-08-29 | 1 comentários | Compartilhar no WhatsApp
  • A DoltHub criou um exemplo de brincadeira ao aninhar deliberadamente em excesso canais, comuns em concorrência em Go, enviando canais por canais
  • Em um código herdado de fato havia chan chan struct{}, usado em um padrão de fan-out que entregava um novo canal a uma goroutine worker, mas ele foi reescrito por ser difícil de raciocinar e gerenciar
  • O exemplo amplia para o chan do Go a piada do “4-star programmer” com int**** da família C, usando _4chan:= make(chan chan chan chan int) como canal de nível superior
  • Com factor = 3, ele ramifica produtores e consumidores em cada camada de canais e soma os valores finais int, imprimindo 243, ou seja, 3 elevado à 5ª potência
  • Na prática, é inadequado por causa da dificuldade de implementação e depuração, do tratamento de fechamento de canais, da necessidade de sync.WaitGroup e de vazamentos de goroutines; o exemplo depende de time.Sleep() em vez de lógica de encerramento

Caso de aninhamento de canais vindo do Dolt

  • A DoltHub está escrevendo em Go o Dolt, o primeiro banco de dados SQL com controle de versão do mundo
  • Como em bases de código Go comuns, usa channels e goroutines para implementar execução concorrente
  • Como programação concorrente já é difícil por si só, normalmente canais e goroutines são tratados de forma simples e intuitiva
  • Em certo momento, um código trazido de outro projeto open source tinha um canal que enviava canais, como abaixo
    • var c chan chan struct{}
  • Essa estrutura era uma forma de implementar um padrão de fan-out de goroutines worker, passando canais entre goroutines
    • O canal intermediário atuava como um mediador que entregava um canal recém-criado ao worker que fazia o trabalho real
    • Funcionava, mas era difícil de raciocinar e lidar, especialmente considerando até vazamentos de goroutines
    • O código em questão foi reescrito, e chan chan struct{} desapareceu

Versão em Go da piada do “4-star programmer”

  • Na época em que C e linguagens derivadas eram amplamente usadas, havia uma piada sobre iniciantes com dificuldade para entender ponteiros: o “4-star programmer”
  • O exemplo típico é um código que usa várias etapas de indireção de ponteiros, como int****
  • Como Go também deriva em grande parte de C, é possível escrever o mesmo tipo de código com ponteiros
    • Passando *int, **int, ***int e ****int em sequência
    • Se a última função executar ****i = 100, o programa imprime i is now 100
  • Como Go tem chan, que C não tem, a mesma piada pode ser ampliada para indireção por canais

Calculando a 5ª potência com canais em 4 níveis

  • O canal de nível superior é declarado assim
    • _4chan := make(chan chan chan chan int)
  • Identificadores em Go não podem começar com números, então o exemplo usa o nome _4chan
  • O valor enviado por _4chan é um canal de 3 níveis
    • _3chan := make(chan chan chan int)
  • Da mesma forma, ele desce pelas camadas até chegar, no fim, a um canal de valores chan int
  • Em cada camada de indireção, produtores são criados de acordo com a constante factor
    • No exemplo, const factor = 3
    • sendChanChanChan inicia um produtor de canais de 3 níveis como goroutine
  • No lado do consumidor, em cada camada ele recebe o canal recebido e inicia consumidores da próxima etapa factor vezes
    • receiveChanChanChan recebe _3chan de _4chan e inicia consumidores de canais de 3 níveis

Envio e soma de valores na última camada

  • Na camada mais baixa, não se enviam mais canais, mas sim valores int reais
  • A função send envia _1chan para _2chan e depois inicia produtores de int factor vezes
  • Cada produtor de int cria novamente factor goroutines para executar _1chan <- 1
  • O consumidor soma o inteiro recebido à variável global sum
    • sum é declarada como atomic.Int32
    • receive(c chan int) recebe um valor do canal e executa sum.Add(int32(s))

Resultado da execução e número de ramificações

  • O programa completo cria _4chan, inicia as camadas de envio e recebimento, cada uma em uma goroutine, e então espera por 500 * time.Millisecond
  • A saída do exemplo é a seguinte
    • 3 ^ 5: 243
  • Esse programa é um exemplo generalizado que calcula a 5ª potência de um número da forma mais distribuída possível
  • O exemplo executável pode ser visto no Go Playground, e a versão com realce de sintaxe está no GitHub Gist
  • Para usar um factor maior, talvez seja necessário aumentar o tempo de Sleep para que a execução consiga terminar
  • Ao ativar os logs, é possível conferir o número de ramificações de cada camada de produção e consumo de canais
    • starting 3chan producer: 3 vezes
    • starting 2chan producer: 9 vezes
    • starting 3chan consumer: 9 vezes
    • starting 2chan consumer: 27 vezes
    • starting chan producer: 27 vezes
    • starting 1chan consumer: 81 vezes
    • starting int producer: 81 vezes
    • sending int: 243 vezes
    • received int: 243 vezes

Por que evitar em código real

  • Essa abordagem é complicada de implementar e depurar em código real
  • Quando canais são enviados por canais, fica difícil decidir quando fechar cada canal
  • Em um caso de uso real, seria preciso fechar os canais, mas para adicionar a lógica de encerramento seria necessário rastrear se todos os envios por canais terminaram
  • Para implementar o encerramento, seria necessário adicionar sync.WaitGroup em vários pontos, o que tornaria o exemplo de brincadeira difícil de ler
  • O exemplo final foi simplificado para usar time.Sleep() em vez de lógica de encerramento, deixando muitos vazamentos de goroutines

1 comentários

 
GN⁺ 2024-08-29
Opiniões no Hacker News
  • Do ponto de vista de um cientista que trabalha de perto com engenheiros de software profissionais de verdade, muito do que eles fazem parece ser desse tipo, e é muito difícil entender por que fazem assim.
    Já vi uma única linha de código passar, em sequência, por 4 funções de interface antes de ser realmente chamada, com essas funções espalhadas por arquivos diferentes em pastas diferentes.
    Por isso, tentar ler o que o código faz fica cansativo; depois de entrar alguns níveis, você começa a duvidar se está olhando para o lugar certo e se algum dia vai chegar ao ponto onde o cálculo real acontece.

    • Isso é uma prática realmente ruim, e está mais para a forma como um engenheiro júnior excessivamente empolgado escreve software.
      A sensação de que parece excessivo e confuso não está errada; quando você escreve código “interessante” pela primeira vez, ele pode parecer tecnicamente complexo e até elegante, mas dentro de um software que precisa crescer de verdade isso vira um pesadelo técnico.
      Passei quase 2 anos limpando uso indevido de channels em código Go, e o problema é que channels raramente são de fato necessários, mas no começo parecem fáceis de usar para várias finalidades.
      O critério para saber se você está usando channels corretamente é conseguir responder “não” a “não dá para fazer com uma chamada direta de função?” e “não dá para fazer com wait group ou mutex?”, e “sim” a “o ganho de concorrência/paralelismo é grande o bastante para justificar a complexidade de depurar código concorrente?”.
    • Há coisas ainda piores. Este não é um exemplo tão exagerado assim: https://github.com/EnterpriseQualityCoding/FizzBuzzEnterpris...
      Às vezes há uma falta assustadora de compreensão e competência onde elas deveriam existir.
      Na graduação, fiz pair programming por uns 30 minutos com um doutor em Ciência da Computação, e foi bastante esclarecedor.
      Ele não entendia nada de software, a ponto de apontar que não estávamos verificando se o tamanho de uma estrutura de dados da biblioteca padrão era negativo.
      Dito isso, às vezes há motivos para esse tipo de estrutura. Às vezes ela é de fato razoável; outras vezes, é uma forma de lidar com uma base de código insana deixada por quem veio antes.
    • Em uma ponta do espectro dos coders estão os cientistas; na outra, os engenheiros de software. Só o equilíbrio pode nos salvar.
      Já li código usado em artigos de pesquisa, e, como a matemática teórica em geral está além da minha compreensão, quando entrava no código para tentar ver a lógica, muitas vezes era ainda pior e mais indecifrável.
      No fim, estamos acostumados ao nosso próprio jeito de fazer as coisas, e outros jeitos parecem estranhos.
    • Overengineering é uma causa comum. A solução simples muitas vezes fica escondida e difícil de encontrar.
      Ainda assim, camadas adicionais de indireção normalmente se justificam dentro da arquitetura geral; em casos razoáveis, pode ser difícil perceber seu valor olhando apenas localmente.
      “O toque leve e a quietude das primeiras linguagens de programação sempre dão prazer. Há pouco texto, mas muita coisa acontece. Programas antigos não se leem como uma discussão com o compilador, mas como uma conversa silenciosa entre um pesquisador eloquente e uma máquina colega bem treinada. Quem diria que a sofisticação compraria todo esse ruído?” — Dick Gabriel
    • Por outro lado, embora eu concorde que engenheiros de software levam a abstração longe demais quando não sabem bem o que estão fazendo, também não tenho uma visão especialmente elevada do código escrito por pessoas que não são engenheiras de software de profissão.
      Acabamos vendo os dois extremos: bases de código espalhadas demais por excesso de abstração e bases de código quase sem abstração nenhuma, mais próximas de scripts feitos apenas para atingir um objetivo; ambas são difíceis de trabalhar.
      Vi muitos scripts em Python, JS e PHP escritos com a atitude de “não importa, só me dê o resultado que eu quero”, e pessoas que trabalham com código todos os dias precisam de abstrações que ajudem na colaboração e na resiliência.
  • O meme do começo foi realmente engraçado para mim, como um programador C em recuperação.
    É divertido ver casos em que uma linguagem é distorcida desse jeito; em C há oportunidades de sobra para isso, e é interessante ver isso também em Go.

  • Dizem que é “uma piada antiga de programação dos tempos em que C e suas linguagens derivadas dominavam”, mas ainda vivemos nesses tempos.

  • É irônico que nesta thread estejam criticando isso como um exemplo clássico de excesso de abstração.
    O motivo de variáveis com três asteriscos serem raras em C não é que cadeias de ponteiros sejam raras, mas que raramente é preciso manipular mais de dois níveis ao mesmo tempo.
    Em linguagens como Python, Java e JavaScript, nas quais quase tudo é basicamente parecido com ponteiros por padrão, certamente existem cadeias de ponteiros muito maiores que 4.
    As partes profundas normalmente ficam escondidas dentro de estruturas cujos detalhes internos você não precisa se preocupar — ou seja, ficam abstraídas.
    Channels também exercem, em código concorrente, mais ou menos o papel que ponteiros exercem em código sequencial; então, se há uma falha fatal aqui, talvez ela não seja excesso de abstração, mas sim falta de abstração.
    Ainda assim, não conheço a base de código; talvez chan chan fosse exatamente a abstração certa para o que eles queriam escrever. Em linguagens com concorrência forte, como Erlang, é muito comum enviar um PID para outro PID para identificar o processo que deve receber a resposta.

    • Lembro que, no começo da carreira, levei bronca por causa de um commit com três asteriscos. Era algo como: quem precisa de um ponteiro para um ponteiro para um ponteiro?
      Mas não era bem isso: era o endereço de um array de strings. Era C, por isso foi assim, e ainda hoje, cerca de 20 anos depois, acho que era a solução mais intuitiva.
      Em defesa do meu colega, provavelmente não havia comentário. Essa parte foi responsabilidade minha.
  • Isso me lembra um clássico atemporal do Buena Vista Social Club: https://www.youtube.com/watch?v=o5cELP06Mik

  • chan chan Value ou chan struct{resp chan Value} são padrões que já usei de fato em situações bem específicas
    Eu poderia ter usado um barramento de mensagens, mas aí também teria que lidar com o barramento de mensagens

    • chan chan é usado com bastante frequência. É o caso de enviar uma mensagem a um servidor interno ou ator e incluir na mensagem também o canal pelo qual a resposta será recebida
      Na prática, em vez do chan chan literal que dá para encontrar com grep, acaba ficando algo como chan struct { ... alguma coisa que contém um canal ... }, mas o princípio é o mesmo
      É um padrão muito útil e considero uma das bases de Go
      Porém, até onde sei, nunca usei chan chan chan
      Transformar tudo isso em barramento de mensagens é exagero. As características de desempenho também são muito diferentes e, como os canais de Go são mais próximos de componentes internos de um processo do sistema operacional, não há valor em “promover” isso a um barramento de mensagens quando a comunicação é interna ao processo
    • Provavelmente é um padrão parecido com o servidor de chat concorrente do gopl. No começo surpreende um pouco, mas ainda assim é bastante legível
      [0]: https://github.com/adonovan/gopl.io/blob/master/ch8/chat/cha...
    • Eu também já usei. É útil para implementar em Go uma semântica parecida com Promise
  • Canal de canais é um padrão normal, mas geralmente aparece mais como um canal de valores de struct, em que esse tipo struct contém um campo de canal
    Pode ser usado para enviar solicitações que serão concluídas e esperar por esses valores independentemente da ordem, evitando bloqueio de cabeça de fila
    Por exemplo, algo como type request struct { params, reply chan response }: você envia a solicitação por um canal e, depois que o worker processa o trabalho, coloca o resultado no canal reply
    Mas o que vi ser útil foi até dois níveis; nunca vi um caso de uso que exigisse chan chan chan

  • Há um blog com um contraexemplo que implementa despacho dinâmico usando um canal que envia canais. Não é Go, é Limbo, mas o conceito é o mesmo. Talvez a complexidade acabe até comprovando o ponto https://ipn.caerwyn.com/2007/07/lab-78-dynamic-dispatch.html...

    • Para quem tiver curiosidade, Limbo é uma linguagem precursora de Go e era usada no sistema operacional Inferno, descendente do Plan 9
  • Isso me lembra “My favorite Erlang Program”, de Joe Armstrong
    https://joearms.github.io/published/2013-11-21-My-favorite-e...