Site menu Primeiras impressões com a linguagem Go

Primeiras impressões com a linguagem Go

O tempo passa. Minha primeira brincadeira com a linguagem Go foi em 2016, quando escrevi diversas versões de um mesmo comparador de imagens, nas linguagens Go, Rust, Javascript e Python, este último sendo a implementação de referência.

Então o título desta página é parcialmente mentiroso. Eu devia alegar "9 anos de experiência em Go" no LinkedIn, e quem vai dizer que não, se esse é o meu projeto GitHub com mais estrelas?

Mas a linguagem Go foi feita mesmo para serviços de rede, onde paralelismo e concorrência são os ingredientes principais. Por exemplo, MediaMTX é um aplicativo de que faço uso, que admiro muito, e é escrito em Go. Então portei outro utilitário meu, o VMonitor, um supervisor de redundância de links de rede.

Os resultados foram animadores, já estou cogitando reescrever outro projeto em Go.

Vou tentar não chover no molhado, não vou me aprofundar naquilo que se repete à exaustão sobre Go: linguagem compilada, tipagem estática, aposta na simplicidade, um "C moderno" na concepção dos autores.

Mas devo citar que, por qualquer motivo, Go me faz lembrar de Object Pascal, que podia fazer tudo que C faz, porém era muito mais ergonômico e menos sujeito a erros bobos que C. A compilação excepcionalmente rápida e o suporte bem raso a OOP são outras semelhanças bem-vindas.

C melhorado ou Python melhorado?

Apesar da pecha de "C moderno", alguns conhecidos já chamaram Go de "Python melhorado" ou "Python feito do jeito certo". Será?

Por um lado, Go não é a linguagem mais rápida do mercado. Em velocidade bruta, perde de C, C++, Rust e mesmo de Java. No final da página do comparador de imagens, há um benchmark simples que ilustra o ponto. (Claro, é um teste bem suspeito, porque a performance depende muito dos módulos subjacentes.)

Se tem uma linguagem mainstream de quem Go ganha na velocidade, é Python...

Apesar de compilada para linguagem de máquina, um programa Go roda num ambiente semi-gerenciado, quase uma máquina virtual. Isso se deve não só à coleta de lixo e a checagem de limites no acesso a vetores, mas também às goroutines (mais sobre elas depois). A filosofia de C/C++ é que as abstrações não utilizadas devem ter custo zero. Em Go, aceita-se que algumas abstrações úteis tenham custo recorrente.

Por outro lado, Go é uma linguagem ergonômica, com uma boa variedade de tipos primitivos, excelente biblioteca padrão e muitos módulos de terceiros. A tipagem estática elide muitos problemas que no Python só se poderia constatar na execução. A simplicidade da linguagem evita que os programadores cometam virtuosismos, como é comum em Python (*). A velocidade de compilação quase permite esquecer que a linguagem não é interpretada.

Outra enorme vantagem de Go sobre Python é o gerenciamento de dependências, principalmente na hora do deploy. O executável Go é um binário estático (monolítico) sem nenhuma dependência externa. Nem bibliotecas, nem módulos, nem interpretador.

Isto é a antítese de um programa em Python (ou Java, ou Node, ou Erlang/Elixir, ou Ruby, ou PHP, ou...) onde as dependências são sempre uma pedra no sapato. Mais ainda quando um programa antigo depende de versões antigas de interpretador e módulos de terceiros, impossíveis de achar ou de fazer funcionar num sistema atual. Mesmo programas em C/C++ enfrentam ocasionais problemas com dependências devido às DLLs compartilhadas.

Aliás, foi por isso que estou aqui a divagar sobre Go, não sobre Elixir. Quando fui tentar escrever o comparador de imagens em Elixir, já de saída ocorreu um daqueles problemas crípticos ao tentar instalar um módulo de manipulação de imagens (que por baixo dos panos é uma biblioteca C; a compilação in loco falhou). Aí larguei de mão por ora. Problema desse tipo já tenho que chega com Python e PIP...

O toolchain Go tem suporte embutido a compilação cruzada. Você está usando Mac, mas quer gerar um binário para Linux? Sem problemas, basta configurar uma variável de ambiente.

Concorrência e paralelismo embutidos

As pessoas vêem a simplicidade da sintaxe Go e presumem que é algo que poderia ser replicado em num fim-de-semana. Talvez até pudesse, não fosse pelas goroutines e canais, que por baixo dos panos são extremamente sofisticados.

Uma das curiosidades em portar o VMonitor para Go era ver se eu ia conseguir passar sem recriar um loop de eventos. E sim, dá pra usar apenas a linguagem para estabelecer concorrência em conexões de rede. É preciso se acostumar, mas funciona e fica esteticamente agradável. (Há até um comando select que espera por diversos canais ao mesmo tempo, uma provável barretada ao select() velho de guerra.)

Já não estamos mais em 2009; hoje em dia, quase toda linguagem relevante embute suporte a concorrência, em geral no estilo async/await. Sou meio antiquado e ainda "penso" concorrência em termos de event loop e callbacks. O esquema do Go fica no meio do caminho, é mais abstrato que um loop de eventos, porém mais explícito que async/await.

As goroutines oferecem tanto concorrência quanto paralelismo. Na filosofia "primeiro você começa, depois você melhora", a qualidade desse paralelismo evoluiu muito desde o lançamento: cooperativo em Go 1.0, semi-preemptivo em Go 1.2, 100% preemptivo a partir de Go 1.14.

Aquilo que Java nos prometeu nos anos 1990 com threads, Go finalmente nos entregou: obter concorrência e paralelismo com uma única ferramenta. Pode-se criar milhões de goroutines, que fazem uso de um número ótimo de threads de sistema. (Java finalmente cumpriu a promessa com suas virtual threads em 2023.)

Para chegar nesse ponto, o agendamento de goroutines faz o papel de um mini-sistema operacional embutido no aplicativo. Este vídeo explica os desafios de entregar as promessas das goroutines, e faz ver que um programa Go roda código de forma bastante tutelada, quase como se fosse uma máquina virtual.

A implementação dessa "tutela" já foi alterada muitas vezes desde a versão 1.0, e ainda é um trabalho em andamento. E os autores preferem manter a liberdade de fazê-lo, em vez de basear o compilador Go em LLVM. (Nem todo mundo concorda, e existe uma implementação alternativa da linguagem em LLVM.)

Isto também significa que Go simplesmente não pode ser tão rápido quanto C e Rust. E isso não é um problema para quem vê Go como um Python melhorado, porque a maior chateação do Python não é a velocidade (ele poderia ser 10x mais lento e a maioria dos usuários permaneceria), mas sim o gerenciamento de dependências.

Um aspecto que inicialmente ignorei na reescrita do VMonitor é que as goroutines podem realmente rodar em paralelo. Já consegui criar um par de race conditions envolvendo dados compartilhados. É o mesmo problema do Java, que tem suporte nativo a threads e sincronização, mas encoraja dar tiro no próprio pé.

Inicialmente, fiz uso de mutexes para proteger os dados compartilhados, é a solução "oficial" para esse tipo de problema, mas não desceu redondo. Acabei fazendo um esquema inspirado no pouco que sei sobre Elixir, onde uma goroutine fica dedicada a cuidar do estado compartilhado do sistema, recebendo atualizações unicamente via canais.

Essa possibilidade de criar race conditions com goroutines é algo que os egressos de linguagens interpretadas devem tomar cuidado, pois tais linguagens embutem proteções "invisíveis", como o GIL (Global Interpreter Lock) de que os Pythonistas tanto reclamam, ou a garantia do Node.JS de rodar código de usuário sempre na thread principal.

Go versus Elixir/Erlang

Go também é frequentemente comparado a Erlang e Elixir, por conta do estilo do suporte a concorrência e paralelismo, e talvez pela forma explícita de lidar com erros.

O nível de isolação entre processos Erlang é muito maior que entre goroutines. Cada processo Erlang tem seu próprio heap e seu próprio coletor de lixo. A linguagem funcional impede que se compartilhe memória entre processos, portanto é impossível cometer race conditions como os que cometi em Go logo de saída. Ainda assim, a promessa é que processos Erlang sejam extremamente baratos, e possam ser criados à vontade, assim como groutines.

Algo que eu gostaria de ter encontrado em Go — uma modalidade de goroutine "segura", totalmente isolada, sem acesso a nada senão através de mailboxes^Wcanais, ainda que com penalidade de desempenho. O esquema que desenvolvi no VMonitor, de delegar o compartilhamento de dados a uma goroutine isolada, foi inspirado no pouco que li sobre gerenciamento de estado em Elixir.

(Erlang também usa processos como unidades de tratamento de erros. O mantra é "let it crash", ou seja, erros não devem ser tratados in loco. O processo morre, o respectivo supervisor detecta a morte e trata o problema. Toda operação passível de falha é encapsulada num processo próprio, até mesmo a manipulação de um arquivo.)

Notas

(*) Essa tem endereço certo para um certo comunista safado aí da komunidade :D