Os problemas programando em Golang

Eu amo Go. Escolho Go para muitos dos meus projetos. Ela é simples e robusta, com ampla documentação, diversos exemplos e uma biblioteca padrão extensa, focada em resolver problemas atuais. Sem dúvida, é uma excelente linguagem.

Entretanto, nada é perfeito. Golang tem seus problemas, assim como qualquer outra linguagem. Alguns serão resolvidos conforme o compilador evolui. Outros, o programador precisa estar atento. Vamos ver alguns dos mais comuns. Alguns problemas mencionados aqui não são exclusivos do Go, mas sim decorrentes de um design de código ruim, que já encontrei diversas vezes e vale a pena mencionar.

Shadow de Variáveis

Shadow de variáveis ocorre quando a declaração de uma variável sobrepõe uma declaração anterior com o mesmo nome, por exemplo, em um bloco de código superior.

Esse erro é difícil de encontrar sem a ajuda do compilador, que pode emitir avisos sobre o problema. É possível verificar o código usando o go vet. Escrevi mais sobre isso em “Shadow de variáveis em Golang”.

Surpreende-me que o compilador do Go não inclua essa validação por padrão, já que é um problema conhecido e presente em todos os compiladores C que já utilizei.

defer Fazendo Shadow

Qualquer bloco de código pode causar shadow de variáveis, mas o defer é o principal responsável. Geralmente, o programador cria uma pequena função para, por exemplo, fechar um arquivo. Seguindo a recomendação de não ignorar erros (erros devem ser tratados, registrados ou retornados), ele verifica se a função Close retornou um erro ao fechar o arquivo. Isso resulta no shadow da variável err da função principal, pois a função chamada pelo defer está no mesmo escopo. Como é raro ocorrer um erro na função Close, ela sempre retorna nil, fazendo com que a função principal também retorne 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 para sempre criar testes para todos os retornos de erro. Se algo deveria retornar um erro e misteriosamente retorna nil, pode ser um shadow causado pelo defer.

Inicialização Ambígua

A função init() é muito útil, mas possui armadilhas. Nos últimos anos trabalhando com Go, encontrei inúmeros problemas de inicialização ambígua, geralmente relacionados a elementos que não deveriam estar na função init.

Para aumentar a confusão, é possível ter mais de uma função init no mesmo arquivo (imagina meu choque).

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 projetos grandes, com muitos packages, pode ocorrer de init esconder erros ou inicializar coisas em uma ordem diferente da esperada.

Hoje, evito usar a função init sempre que possível. A única justificativa real para usá-la, na minha opinião, é automatizar o registro de drivers, por exemplo, ao importar um package com _ (underline), como ao importar o driver de um banco de dados. Após importar o package do SQL, o driver se registra automaticamente.

Prefiro criar uma função New, que recebe parâmetros e sempre retorna, no mínimo, uma instância do objeto criado. Isso torna o código mais flexível, fácil de testar e previsível.

defer em Loop

Quando aprendemos que a palavra reservada defer adia a execução de um trecho de código para o fim da função, programadores de C frequentemente associam esse “fim da função” com o “fim do bloco de código”, o que está errado. O fim de um bloco for, por exemplo, não chama defer. O defer é executado apenas antes de sair da função.

Assim, ao processar centenas de arquivos em um loop, ao abrir um arquivo e imediatamente chamar defer f.Close(), todos os arquivos permanecem abertos até que a função termine.

for {
	...
	defer func(){
		err := f.Close()
		if err!=nil {
			fmt.Println("error closing file:", err)
		}
	}()
	...
}
return err

Esse cenário é ruim e difícil de depurar, pois o sistema pode ficar com muitos arquivos abertos até processar todos. Em loops infinitos, isso causa problemas no número de arquivos abertos. É apenas uma questão de tempo.

Além disso, o defer pode causar shadow do erro.

Interface Vazia

Uma interface vazia interface{} é basicamente um ponteiro void que aceita qualquer coisa, trazendo os mesmos problemas. Raramente é necessário usar uma interface vazia. No pior dos casos, como ao fazer parse de um JSON desconhecido, você pode usar map[string]interface{} e interagir com esse mapa. É importante sempre verificar se o tipo da interface é o esperado antes de usá-la.

Caso contrário, você pode ter um panic ao acessar a interface com o tipo errado ou nil.

Ao projetar seu sistema, evite usar a interface vazia e crie funções descritivas, como String(). Você não tem dificuldade em imaginar o retorno dessa função apenas pelo nome.

Erro na Ordem do switch

Outro erro que o compilador não detecta é ao usar interfaces em um switch case comparando pelo tipo. Se o tipo mais abrangente for testado primeiro nos cases, ele sempre será verdadeiro e os demais casos nunca serão acionados.

Escrevi mais detalhes no tutorial “Erro difícil com interfaces e switch case em Go”.

HTTP Timeout

Por padrão, ao fazer uma chamada HTTP no Go, não há timeout. Se não houver resposta ou erro, a chamada fica esperando indefinidamente. Em ambientes com várias goroutines, é fácil que algumas fiquem paradas, ocupando memória, e você não entende por que um processo nunca termina.

Para resolver, sempre defina um timeout em chamadas HTTP. Cinco segundos são suficientes para qualquer API REST.

Veja o exemplo:

client := &http.Client{Timeout: 5 * time.Second}

Uma forma mais elaborada, especialmente se você recebe context como parâmetro, é:

ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
...
resp, err := http.DefaultClient.Do(request.WithContext(ctx))

Goroutines

Goroutines facilitam a criação de código concorrente, mas trazem seus próprios problemas.

Goroutine Esperando Canal Indefinidamente

Uma goroutine pode esperar a resposta de um canal que nunca responderá.

flag := make(chan string)
...
go func() {
		f := <-flag // espera por flag
		...
}()

Isso pode ocorrer se a rotina que criou a goroutine termina antes de enviar no canal.

Existem várias soluções, como usar timeout, context ou enviar uma mensagem de cancel via defer.

Canal com Timeout via context

Este exemplo usa select para receber do canal result ou do ctx.Done() se o contexto for cancelado.

select {
	case <-ctx.Done():
		...
	case result := <-ch:
		...
}

O exemplo a seguir usa time.After para fazer timeout após 5 segundos sem resposta, evitando esperar indefinidamente. Mesmo que a goroutine não faça nada e seja finalizada, é necessário registrar um log para identificar problemas de timeout.

select {
   case <-time.After(5 * time.Second):
		...
   case result := <-ch:
		...
}

Goroutine Precisa de Tempo para Iniciar

Carregar uma goroutine é rápido, mas não instantâneo. O runtime do Go precisa de tempo para iniciar a goroutine e executar outras tarefas.

Ao projetar seu código, lembre-se de que, logo após a criação da goroutine, ela pode ainda não estar em execução.

Todos os Canais Esperando

Canais em Go são fantásticos. A comunicação entre processos ficou simples e fácil. Mas, como tudo na vida, tem custo. Um código que usa canais é mais difícil de depurar e pode causar deadlocks, onde todas as goroutines estão esperando.

package main

func main() {
	m := make(chan string)
	go func(){}()
	<-m // deadlock!
}

Esse problema aparece em tempo de execução. Felizmente, se o runtime do Go detectar que todas as goroutines estão paradas, ele emite um erro fatal error: all goroutines are asleep - deadlock!. Isso é melhor do que o programa ficar parado para sempre, mas ainda é ruim.

URL no Binário

Fiquei surpreso ao descobrir que o compilador guarda muita informação no binário, incluindo a URL de todos os packages usados no projeto.

Compile um projeto que usa um package do GitHub e execute:

strings nome-do-executável | grep github

Você verá os caminhos de todos os packages utilizados, inclusive os de repositórios privados. Isso complica, pois não há um parâmetro para remover essa informação. Além disso, expõe URLs que talvez não queiram ser vistas externamente, facilitando engenharia reversa e podendo causar problemas de segurança, especialmente em áreas como bancos que não aceitam URLs no código.

É possível contornar esse problema usando go mod apenas com o nome do projeto ou comprimindo o executável com UPX. Porém, cada solução traz outros problemas, como falsos positivos em antivírus com o UPX.

Panic Tem Ruído Demais

Quando ocorre um panic, o Go segue a filosofia UNIX de que “se algo vai dar errado, deve dar errado de forma ruidosa”. A mensagem de erro do panic é muito detalhada, tornando a leitura demorada.

Isso pode ser uma tática para que você se acostume a tratar todos os possíveis erros, evitando panics em produção.

Gerenciamento de Dependências

O gerenciamento de dependências do Go melhorou muito com go mod. Antes, com vendor, enfrentamos muitos problemas que não posso deixar de mencionar.

Grande parte dos problemas de dependências eram criados por nós mesmos. É tentador ter packages que resolvem várias coisas ou que são reaproveitados em diferentes projetos, gerando diversos problemas.

Melhorando as Dependências

Vamos ver como melhorar a árvore de dependências dos seus projetos.

Dependências Devem Crescer Apenas em Uma Direção

Se você tem um package em um projeto e ele é útil em outro, desmembre o package e coloque-o em um repositório separado. Assim, você garante que a interface para se comunicar com as funcionalidades do package está bem definida e testada, podendo usá-lo em ambos os projetos. Evite usar diretamente um package de um projeto em outro, pois isso leva a mudanças que podem quebrar o segundo projeto sem aviso.

Defina Bem as Responsabilidades do Código

Para evitar mudanças constantes, defina claramente as responsabilidades de cada package. Invista tempo planejando uma interface sólida e semântica. A ideia é evitar que a interface mude muito com o tempo, facilitando a manutenção da retrocompatibilidade.

Ao alterar seus packages, sempre considere a retrocompatibilidade. Outras equipes ficarão frustradas se tiverem que fazer mudanças bruscas no código sem um bom motivo.

Isso é ainda mais importante para quem mantém código aberto. Imagine que pessoas no mundo todo usam seu código. Você não quer quebrar projetos aleatoriamente de desenvolvedores desconhecidos por mudanças não planejadas.

Versione seus Packages

Use os recursos do Git para criar tags de versão nos seus packages. Isso permite um controle melhor das alterações e facilita o uso do go mod, especialmente em código aberto. Sempre é uma boa ideia versionar seu código.

Otimização Precoce e DOS

Esses não são problemas específicos do Go, mas são relevantes.

É divertido e relativamente fácil otimizar um código em Go. Goroutines e canais facilitam a criação de várias threads e o aproveitamento máximo das máquinas modernas com muitos núcleos. Mas também é fácil exceder os limites da máquina, como abrir centenas de milhares de conexões simultaneamente e esgotar o IO da máquina. (Já fizemos isso)

Outra questão é que o Go é rápido. Se você escrever um sistema que acessa dados de outra API, precisa limitar o número de conexões por segundo. Caso contrário, você pode causar acidentalmente um ataque DOS na própria API.

Código Altamente Acoplado

Embora não seja uma falha do Go diretamente, é comum escrever código altamente acoplado devido às características da linguagem. É fácil perceber se seu código está muito acoplado: dificuldade em escrever testes unitários, arquivos-fonte com milhares de linhas ou uma lista de imports com mais de 10 itens indicam alto acoplamento.

Esse problema ocorre porque o Go favorece a programação estruturada, que pode levar a problemas de design de código. Entretanto, para mim, está mais próximo da forma como os computadores funcionam.

Para evitar alto acoplamento, divida seu código em packages com responsabilidades bem definidas, conectados por interfaces bem pensadas e com boa cobertura de testes (desculpe se isso pareceu repetitivo, mas é importante).

Conclusão

Golang tem problemas, alguns sérios, e como ainda é uma linguagem jovem, cabe a nós programadores conhecê-los e tomar cuidado com cada um. Ainda não dá para deixar tudo por conta do compilador. Existem também problemas de projeto, como as URLs no executável.

Conforme o compilador e as ferramentas evoluem, espero ver alguns desses erros desaparecerem. Outros, conto com a comunidade ativa para ajudar a documentar os obstáculos.

Claro que não abordei todos os erros possíveis. Nunca se pode substituir a capacidade de um programador criativo de escrever código válido para o compilador, mas que executa algo completamente inesperado. Vou tentar adicionar novos erros conforme os lembrar ou os encontrar.

Cesar Gimenes

Última modificação