Os problemas programando em Golang
Eu adoro Go. É a linguagem de programação que escolho para muitos dos meus projetos. Simples e robusta, com muita documentação, muitos exemplos e uma biblioteca padrão grande e focada em atender problemas atuais. É, sem dúvida, uma ótima linguagem.
Entretanto, nada é perfeito, e o Golang tem seus problemas, assim como qualquer outra linguagem. Alguns deles serão resolvidos, conforme o compilador for ficando mais esperto. Outros, o programador precisa ficar sempre atento. Vamos ver alguns dos mais comuns. Alguns dos problemas que menciono aqui não são apenas do Go, são simplesmente design de código ruim, mas que já ví muitas vezes, o suficiente para valer a pena mencionar.
Shadow de variáveis
Shadow de variáveis é quando a declaração de uma variável sobrepõe uma declaração anterior com o mesmo nome. Por exemplo, uma declaração de uma variável em um bloco de código superior.
Este é um daqueles erros complicados de encontrar sem o compilador ajudando, emitindo algum aviso sobre o problema. É possível verificar seu código usando o go vet, escrevi mais sobre isso em “Shadow de variáveis em Golang”.
O que mais me surpreende é o compilador do Go não vir de fábrica com essa validação, já que é um problema conhecido e que tem validações em todos os compiladores C que eu já usei.
defer fazendo shadow
Qualquer bloco de código pode fazer shadow de variáveis, mas um que é o campeão, sem dúvida é o defer. Geralmente, o que ocorre nesse caso é que o programador cria uma pequena função para fazer alguma coisa como, por exemplo, fechar um arquivo. E como ele é um desenvolvedor responsável e segue minha recomendação de não ignorar erros (erros você trata, loga ou retorna, mas nunca ignora) o que ele faz é verificar se a função Close retornou um erro ao tentar fechar o arquivo. Mas com isso, acabou fazendo shadow da variável err da função principal. Isso porque a função chamada pelo defer está no mesmo escopo da função principal. Daí, como é muito raro dar um erro na função Close ela sempre retorna nil, e daí, sua função principal também sempre retorna nil no lugar de qualquer erro.
func xyz() error {
...
defer func(){
err := f.Close()
if err!=nil {
fmt.Println("error closing file:",err)
}
}
...
return err // esse erro sempre vai ser sobrescrito
}
Esse é um dos motivos porque é sempre importante criar testes para todos os retornos de erro. Se alguma coisa devia retornar um erro e misteriosamente está retornando nil pode ser um shadow causado pelo defer.
Inicialização ambígua
A função init() que, sem dúvida, é muito útil, vem com algumas armadilhas escondidas. Nos últimos anos trabalhando com Go, encontrei problemas de inicialização ambígua incontáveis vezes. Geralmente relacionado com coisas que não deveriam ser colocadas na função init em primeiro lugar.
Para aumentar a confusão, você pode ter mais de uma função init no mesmo arquivo (imagina como fiquei chocado).
package main
import "fmt"
func init() {
fmt.Println("init 1")
}
func init() {
fmt.Println("init 2")
}
func main() {
fmt.Println("main")
}
Esse código compila e executa normalmente.
$> go run main.go
init 1
init 2
main
Em um projeto grande, com muitos packages, você pode acabar em situações em que init acaba escondendo erros ou com coisas sendo inicializadas em uma ordem diferente da que o programador imaginou.
Hoje, evito usar a função init sempre que possível. Na verdade, a única justificativa real para usar init, na minha opinião, é para automatizar o registro de drivers, por exemplo, quando você importa um package usando _ (underline), como quando importamos o driver de um banco de dados. Logo depois de ter importado o package do SQL e o driver simplesmente registra a si mesmo.
No lugar da função init, prefiro criar uma função New, que pode receber parâmetros e sempre vai, no mínimo, retornar uma instância do objeto que estou criando. Com isso, o código fica muito mais flexível, fácil de testar e previsível.
defer em loop
Quando ouvimos sobre a palavra reservada defer pela primeira vez e aprendemos que ela adia a execução daquele trecho de código para o fim da função, e para nós, que somos programadores C, automaticamente relacionamos esse fim da função com o fim do bloco de código, que está errado. O fim de um bloco for por exemplo, não chama defer. O defer vai ser chamado apenas antes de realmente sair da função.
Então, o que acaba acontecendo é que, digamos que você vai processar centenas de arquivos em um loop, você abre o arquivo no início do loop e, imediatamente, chama defer f.Close() para fechar o arquivo. E como defer só será executado ao fim da função, todos os arquivos continuarão abertos até todos terem sido processados.
for {
...
defer func(){
err := f.Close()
if err!=nil {
fmt.Println("error closing file:",err)
}
}
...
}
return err
Esse cenário já é ruim o suficiente e bastante difícil de depurar, porque os sistemas atuais têm muita memória e aguentam muitos arquivos abertos ao mesmo tempo, e você provavelmente só vai perceber o problema quando tiver um pico de arquivos para abrir. Mas se seu loop for infinito, você vai ter problemas com o número de arquivos abertos. É só uma questão de tempo.
E claro que nada impede de seu defer, além de ser executado só no final ainda fazer shadow do erro.
Interface vazia
Uma interface vazia interface{} é, basicamente, um ponteiro void que aceita qualquer coisa e vem com os mesmos problemas. É bem raro você realmente precisar de uma interface vazia. Na pior das hipóteses como, por exemplo, fazendo parser de um JSON que você não faz ideia da estrutura, você pode usar map[string]interface{} e, daí, interagir nesse mapa. Neste caso, é importante sempre testar se o tipo da interface é o que se espera antes de usar.
Caso contrário você fica com um potencial panic nas mãos ao acessar interface pelo tipo errado ou que está nil.
Quando for projetar seu sistema, tente imaginar formas de evitar o uso da interface vazia, e aproveite para criar funções que sejam descritivas como, por exemplo, String(). Aposto que você não tem dificuldades em imaginar o retorno dessa função, mesmo sem eu falar nada além do nome.
Erro na ordem do switch
Mais um erro que o compilador não consegue detectar e nem mesmo emite um alerta é quando usamos interfaces em um switch case comparando pelo tipo. Se o tipo com escopo mais abrangente for testado antes na ordem dos cases, ele vai sempre retornar verdadeiro e os outros itens nunca serão acionados.
Escrevi com mais detalhes no tutorial “Erro difícil com interfaces e switch case em Go”.
HTTP timeout
Quando fazemos uma chamada HTTP por padrão o Go, não inclui timeout, ou seja, se não houver uma resposta ou um erro de algum tipo, a chamada vai ficar parada, esperando um retorno para sempre. Em um ambiente com várias goroutines é muito fácil algumas delas ficarem paradas, apenas ocupando espaço na memória, e você sem entender porque algum processo nunca chegou ao fim.
Para solucionar este problema, sempre defina um timeout quando for fazer qualquer chamada HTTP. Cinco segundos são mais que suficientes para qualquer API REST.
Veja o exemplo:
client := &http.Client{Timeout: 5 * time.Second}
Uma forma mais elaborada que é interessante de usar, principalmente se você recebe context como parâmetro da sua função, é demonstrado no exemplo a seguir.
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
...
resp, err := http.DefaultClient.Do(request.WithContext(ctx))
goroutines
Goroutines fazem com que criar código concorrente seja bem simples. Mas, ao mesmo tempo, vêm com seu próprio conjunto de problemas.
goroutine esperando canal indefinidamente
Uma das coisas que podem causar problemas são goroutines esperando a resposta de um canal que, por algum motivo, nunca vai responder.
flag := make(chan string)
...
go func() {
f := <-flag // fica esperando flag
...
}()
Isto pode acontecer por vários fatores, como a rotina que criou a goroutine terminar antes de enviar o canal, por exemplo.
Tem várias formas de resolver esse problema, usando timeout ou usando context, ou mesmo mandando uma mensagem de cancel, via defer.
canal com timeout via context
Este exemplo mostra o uso de select para receber o conteúdo de result. Ou caso o contexto termine porque foi cancelado, ele recebe o canal via ctx.Done().
select {
case <-ctx.Done():
...
case result := <-ch:
...
}
O exemplo a seguir mostra o uso de canais para fazer timeout depois de 5 segundos sem resposta e não ficar esperando um canal indefinidamente. Um detalhe importante é que mesmo que a goroutine não faça nada e apenas seja finalizada, precisamos fazer um log para saber que o sistema está com problemas de timeout.
select {
case <-time.After(5 * time.Second):
...
case result := <-ch:
...
}
goroutine precisa de tempo para iniciar
Carregar uma goroutine é bem rápido, mas não é instantâneo. O runtime do Go precisa de tempo para carregar e iniciar a goroutine, e também vai precisar de tempo para executar outras tarefas.
Então, na hora de projetar seu código, é necessário lembrar que, nas linhas logo após a criação da goroutine, ela pode não estar rodando ainda.
Todos os canais esperando
Canais em Go são um recurso fantástico. Nunca a comunicação entre processos foi tão simples e fácil. Mas, como tudo na vída tem um custo, um código que usa canais é mais difícil de depurar e dependendo de como o código foi escrito ainda há a possibilidade de você acabar em uma deadlock onde todas as goroutines estão esperando.
package main
func main() {
m := make(chan string)
go func(){}()
<-m // deadlock!
}
Esse problema só aparece em tempo de execução, mas, felizmente, hoje em dia, se o runtime do Go detectar que todas as goroutines estão paradas, ele emite um erro fatal error: all goroutines are asleep - deadlock!, o que é muito melhor que quando seu programa simplesmente não retornava e ficava parado para sempre. Mas ainda é ruim.
URL no binário
Esta foi uma daquelas coisas que me deixou muito surpreso quando eu vi pela primeira vez. O que acontece é que o compilador guarda muita informação no binário - muita mesmo - inclusive a URL de todos os packages que o projeto estiver usando.
Compile um de seus projetos que use algum package no GitHub e execute o seguinte comando:
strings nome-do-executável | grep github
Você vai ver o caminho de todos os packages usados, inclusive os que estão em repositórios privados. Há várias justificativas para essa informação estar lá. Mas, para mim, a falta de um parâmetro para remover é que complica as coisas, além de serem informações que talvez você não queira que ninguém fora da sua empresa veja, Será uma bela dica para quem for fazer engenharia reversa do seu código e, ainda, pode causar problemas com áreas de segurança de seus clientes, especialmente bancos que vão querer saber porque existem URLs dentro do código.
Existe como contornar esse problema, como usar go mod apenas com o nome do projeto, ou usar um compressor de executável como o UPX. Mas, cada uma dessas soluções vem com outros problemas. Por exemplo, alguns antivírus dão falso positivo com o UPX.
Panic tem ruído demais
Quando alguma coisa der errado e seu código emitir um panic, o Go segue a risca a ideia do UNIX, que diz que se algo vai dar errado, tem que dar errado da forma mais ruidosa possível. Daí, a mensagem de erro do panic tem, simplesmente, ruído demais, e acaba tornando a leitura demorada.
Talvez seja uma tática para você se acostumar a sempre tratar todos os possíveis erros do seu código para nunca encontrar um panic em produção.
Gerenciamento de dependências
O Gerenciamento de dependências do Go melhorou muito nas últimas versões. Hoje em dia, com go mod tudo ficou bem mais fácil. Mas tivemos tantos problemas com vendor antes de chegar aqui que não podia deixar de mencionar o quanto o caminho foi complicado.
Em grande parte, o problema das dependências era criado por nós mesmos. É tentador ter packages que resolvem várias coisas, ou que estão dentro de um projeto e são reaproveitados em outro gerando toda sorte de problemas.
Melhorando duas dependências
Então, vamos ver algumas coisas para melhorar a árvore de dependências dos seus projetos.
Dependências devem crescer apenas em uma direção
Basicamente se você tem um package em um projeto, e ele é útil em outro projeto você deve desmembrar o package, colocar ele em um repositório separado para ter certeza que a interface que você vai usar para se comunicar com as funcionalidades do package estão bem definidas e que está tudo bem testado e, então, usar ele nos dois projetos. Evite a tentação de usar um package de um projeto diretamente no outro. Isso leva a mudanças que quebram o segundo projeto sem você ficar sabendo.
Defina bem as responsabilidades do código
Para evitar mudanças constantes defina bem as responsabilidades do código de cada um dos seus packages. Invista algum tempo planejando uma interface que seja sólida e semântica. A ideia é evitar que essa interface mude muito conforme o tempo passar, facilitando manter retrocompatibilidade.
E falando em retrocompatibilidade, essa é uma preocupação que você sempre precisa ter quando for alterar seus packages. Outras equipes ficarão bem bravas com você se tiverem que fazer mudanças bruscas para ajustar o código sem um ótimo motivo.
Este exercício é mais interessante ainda para quem mantém código aberto. Imagina que pessoas no mundo todo podem usar seu código - e você, realmente, não quer quebrar projetos aleatoriamente de devs que nem conhece porque mudou alguma coisa.
Versione seus packages
Use os recursos do git para criar tags de versão dos seus packages. Isso vai permitir um controle melhor das alterações e também vai tornar o uso do go mod mais fácil, sobretudo se mantém código aberto. Sempre é uma boa ideia versionar seu código.
Otimização precoce e DOS
Estes não são problemas do Go, propriamente dito, mas está na categoria dos que foram tão marcantes que não dá para deixar de mencionar.
É divertido e relativamente fácil otimizar um código escrito em Go. Goroutines e canais fazem com que seja simples pensar em maneiras de subir várias threads e aproveitar ao máximo as máquinas modernas com muitos núcleos para brincar. Mas claro que também é fácil estourar os limites da máquina como, por exemplo, abrir centenas milhares de conexões ao mesmo tempo e esgotar o IO da máquina. (É, já fizemos isso)
Outra coisa que também já vi acontecer é que o Go é rápido, e se você escrever um sistema que vai pegar dados de outra API, precisa se preocupar em limitar o número de conexões por segundo. Caso contrário, você pode acidentalmente fazer um ataque DOS na própria API.
Código Altamente Acoplado
Outra coisa que não é uma falta do Go diretamente, mas acontece bastante devido às características da linguagem é escrever código altamente acoplado. É bem fácil perceber se seu código está com um acomodamento muito alto. Se você não consegue escrever testes unitários, ou se os seus arquivos-fonte tem milhares de linhas, ou se a lista de imports em um único arquivo tem mais de 10 itens, você não consegue reutilizar seu código. São todos sinais de que seu código está muito acoplado.
Boa parte desse problema acontece porque o Go é, basicamente, a boa e velha programação estruturada, que favorece esses problemas de design de código. Entretanto, pelo menos para mim, está mais próximo da forma como computadores funcionam.
A maneira de evitar alto acoplamento é dividir seu código em packages com responsabilidades bem definidas, conectados por interfaces que foram muito bem pensadas e com boa cobertura de testes (desculpe se isso pareceu repetitivo, mas é importante).
Conclusão
Golang tem problemas, alguns bastante sérios, e como ainda é uma linguagem jovem, cabe a nós programadores conhecer e tomar cuidado com cada um deles. Ainda não dá para deixar tudo por conta do compilador. E também tem alguns problemas de projeto, como no caso das URLs dentro do executável.
Conforme o compilador e as ferramentas do entorno forem ficando mais espertas, eu espero ver alguns desses erros desaparecerem, outros eu conto com a comunidade, que é bem ativa e ajuda a documentar as pedras do caminho.
Claro que não abordei todos os erros que podem acontecer. Alias, nunca se pode substituir a capacidade de um programador criativo de fazer alguma coisa que é um código totalmente válido do ponto de vista do compilador, mas faz algo completamente inesperado quando é executado. Vou tentar adicionar novos erros conforme for me lembrando deles ou cometendo novos.