Go Performance: Pequenas mudanças que ajudam a melhorar o desempenho do seu app

rflpazini

Rafael Pazini

Posted on July 31, 2024

Go Performance: Pequenas mudanças que ajudam a melhorar o desempenho do seu app

Fala, dev! Se você está aqui, é porque curte um bom desafio e quer deixar seu app Go voando baixo, né? Bora mergulhar em algumas técnicas marotas pra otimizar o desempenho do seu código Go e fazer seu app brilhar como nunca.

Como todos já ouviram falar, Go é uma linguagem de programação que tem se destacado pela sua simplicidade e eficiência. No entanto, como qualquer outra linguagem, há diversas formas de otimizar o desempenho das aplicações escritas em Go. Vamos falar desde ajustes finos no código até configurações mais avançadas no ambiente de execução.

Então coloque seu capacete, aperte o cinto e vamos acelerar para descobrir o que podemos melhorar em nossos apps!

Utilização Eficiente da Memória

1. Ordem dos campos da struct

Quase tudo em Go são structs, e a ordem dos campos influenciam diretamente na memória usada do nosso app. Vamos de exemplo:

type Car struct {
    isAvailable bool    // Disponibilidade do carro, 1 byte
    year        int     // Ano de fabricação, 8 bytes
    pricePerDay float64 // Preço por dia de aluguel, 8 bytes
    isElectric  bool    // Indica se o carro é elétrico, 1 byte
    mileage     float64 // Quilometragem do carro, 8 bytes
}
Enter fullscreen mode Exit fullscreen mode

Você deve estar pensando que essa struct tem 26 bytes, correto? Mas esta errado. Vou explicar o motivo e para vermos o real tamanho da nossa struct podemos executar o seguinte comando:

func main() {
    c := Car{}
    fmt.Println(unsafe.Sizeof(c)) // 40 bytes
}
Enter fullscreen mode Exit fullscreen mode

Como utilizamos sistemas 64 bits, a memória é organizada de forma que os tipos de dados são alinhados em múltiplos de 8 bytes. Isso significa que tipos de dados como int e float64 são colocados em endereços de memória múltiplos de 8, garantindo acesso eficiente e evitando penalidades de desempenho por desalinhamento. Caso você queira se aprofundar um pouco mais sobre o assunto, você pode ler esse artigo que explica essa alocação de memória nos detalhes.

misaligned memory

E como podemos melhorar isso? Podemos alinhar nosso struct de acordo com o preenchimento de memória:

type Car struct {
    year        int     // 8 bytes
    pricePerDay float64 // 8 bytes
    mileage     float64 // 8 bytes
    isAvailable bool    // 1 byte
    isElectric  bool    // 1 byte
}

func main() {
    c := Car{}
    fmt.Println(unsafe.Sizeof(c)) // 32 bytes
}
Enter fullscreen mode Exit fullscreen mode

Agora você deve estar se perguntando, poxa mudamos de 40 para 32... são só 8 bytes? Sim, mas imagina que você tem uma cache em memória com todo o estoque da Volkswagen... 8 bytes multiplicados pelo número de elementos pode ser uma perda gigantesca de memória, afetando custo e performance. Otimizar o uso da memória pode resultar em economia significativa, especialmente em sistemas de grande escala, onde cada byte conta para manter a eficiência e reduzir os custos operacionais.

aligned memory

Rafa, preciso me preocupar com isso e fazer isso na mão toda vez? Se preocupar com isso durante o desenvolvimento é sempre bom e é o que diferencia programadores de programadores... mas não precisamos fazer na mão, você pode usar a ferramenta fieldalignment para automatizar essa otimização:

$ fieldalignment -fix ./...
Enter fullscreen mode Exit fullscreen mode

fieldalignment result

2. Uso de Slices e Arrays
Slices são como os pneus do seu carro. Se você não escolher a melhor configuração, pode acabar com um desempenho abaixo do esperado.

Quando você cria um slice sem uma capacidade inicial adequada e continua a adicionar elementos, o runtime do Go precisa realocar um novo array maior para acomodar os novos elementos. Cada realocação copia os elementos do array antigo para o novo, o que pode ser custoso em termos de desempenho.

func createSlice() []int {
    s := make([]int, 0)
    for i := 0; i < 10; i++ {
        s = append(s, i)
    }
    return s
}
Enter fullscreen mode Exit fullscreen mode

Neste exemplo, s é inicializado com capacidade 0. A cada chamada append, o Go pode precisar realocar um array maior, resultando em múltiplas realocações.

Para evitar os problemas como esse, é importante inicializar seus slices com uma capacidade que se aproxime da quantidade real de dados que você espera armazenar. Por exemplo, s imagine que temos que guardar 50 carros em uma garagem, então nosso slice pode ser inicializado com capacidade para 50 elementos. Isso evita realocações desnecessárias, melhorando o desempenho.

func createSlice() []int {
    s := make([]int, 0, 50) // Predefine a capacidade para 50 elementos
    for i := 0; i < 50; i++ {
        s = append(s, i)
    }
    return s
}
Enter fullscreen mode Exit fullscreen mode

Bibliotecas de JSON mais eficientes

Quando se trata de serialização e desserialização de JSON em Go, a biblioteca padrão encoding/json é a escolha mais comum. No entanto, para aplicativos de alta performance, pode ser interessante considerar o uso da biblioteca jsoniter, que é significativamente mais rápida que a padrão.

Vantagens de jsoniter

  • Desempenho: jsoniter é projetado para ser uma alternativa de alta performance ao encoding/json, oferecendo uma codificação e decodificação de JSON mais rápidas.
  • Compatibilidade: jsoniter é totalmente compatível com a API de encoding/json, o que facilita a migração sem grandes alterações no código.

Aqui está um exemplo de benchmark fornecido por eles:

benchmark jsoniter

E o bacana é que é quase esforço zero para adotar essa lib em seu código. Vou mostrar um exemplo com a lib padrão encoding/json:

// encoding/json
package main

import (
    "encoding/json"
    "fmt"
)

type Car struct {
    Year        int     `json:"year"`
    PricePerDay float64 `json:"pricePerDay"`
    Mileage     float64 `json:"mileage"`
    IsAvailable bool    `json:"isAvailable"`
    IsElectric  bool    `json:"isElectric"`
}

func main() {
    car := &Car{
        Year:        2021,
        PricePerDay: 29.99,
        Mileage:     15000.50,
        IsAvailable: true,
        IsElectric:  false,
    }

    jsonData, err := json.Marshal(car)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println(string(jsonData))
}
Enter fullscreen mode Exit fullscreen mode

Agora como fica o código com a lib jsoniter:

// jsoniter
package main

import (
    "fmt"

    jsoniter "github.com/json-iterator/go"
)

type Car struct {
    Year        int     `json:"year"`
    PricePerDay float64 `json:"pricePerDay"`
    Mileage     float64 `json:"mileage"`
    IsAvailable bool    `json:"isAvailable"`
    IsElectric  bool    `json:"isElectric"`
}

func main() {
    json := jsoniter.ConfigCompatibleWithStandardLibrary // apenas chamamos a config da lib

    car := &Car{
        Year:        2021,
        PricePerDay: 29.99,
        Mileage:     15000.50,
        IsAvailable: true,
        IsElectric:  false,
    }

    jsonData, err := json.Marshal(car)
    if err != nil {
        fmt.Println(err)
    }
    fmt.Println(string(jsonData))
}
Enter fullscreen mode Exit fullscreen mode

Use o pacote unsafe

Para casos onde o desempenho é crítico e você precisa manipular diretamente a memória, o pacote unsafe pode ser uma ferramenta poderosa. No entanto, ele deve ser usado com cautela, pois pode comprometer a segurança e a portabilidade do seu código.

Vantagens

  • Desempenho: Permite manipular diretamente ponteiros e bytes na memória, eliminando sobrecargas associadas a operações de alto nível.
  • Flexibilidade: Facilita conversões e manipulações de tipos que seriam difíceis ou impossíveis com os mecanismos seguros de Go.

Riscos:

  • Segurança: Pode levar a corrupção de memória se não for usado corretamente.
  • Portabilidade: Código que usa unsafe pode não ser portátil entre diferentes arquiteturas de hardware.

Vamos ver como isso funciona na prática, vamos obter um ponteiro e manipular diretamente o valor dele.

Poxa Rafa, mas por que usar um ponteiro direto para a memória? Usar ponteiros diretos para a memória pode ser necessário em situações onde a performance é crítica e cada milissegundo conta. Isso é especialmente relevante em sistemas de alta performance, como jogos, sistemas de trading de alta frequência, e, claro, sistemas de corrida de carros onde decisões em tempo real são cruciais.

No exemplo a seguir, iremos aplicar essa ideia em um contexto de corrida de carros. Suponha que temos uma estrutura que representa o status de um carro de corrida CarStatus e queremos manipular diretamente a velocidade Speed do carro para simular um aumento de velocidade durante uma corrida:

package main

import (
    "fmt"
    "unsafe"
)

type CarStatus struct {
    Speed     int32
    FuelLevel int64
}

func main() {
    status := CarStatus{
        Speed:     250,
        FuelLevel: 50,
    }

    // Obter um ponteiro para o campo Speed (velocidade)
    speedPointer := (*int32)(unsafe.Pointer(uintptr(unsafe.Pointer(&status)) + unsafe.Offsetof(status.Speed)))
    fmt.Println("Velocidade atual:", *speedPointer, "km/h")

    // Aumentar a velocidade do carro
    *speedPointer = 300
    fmt.Println("Nova velocidade:", status.Speed, "km/h")
}
Enter fullscreen mode Exit fullscreen mode

Usamos o pacote unsafe para manipular diretamente a memória e modificar a velocidade de um carro de corrida. Isso elimina a sobrecarga de funções de alto nível e permite um acesso mais rápido e eficiente à memória.

Outro exemplo pode ser converter uma string para byte e vice-versa. Quando fazemos isso da forma "clássica", estamos copiando os valores das variáveis. Mas podemos utilizar o usafe e não alocar memória extra durante o processo:

func StringToBytes(s string) []byte {
 return unsafe.Slice(unsafe.StringData(s), len(s))
}

func BytesToString(b []byte) string {
 return unsafe.String(unsafe.SliceData(b), len(b))
}
Enter fullscreen mode Exit fullscreen mode

Isso é possível porque internamente, em Go, esses dois tipos utilizam valores de StringHeader e SliceHeader frequentemente. Libs como o fasthttp usam essas conversões internamente.

GC Tuning

Ajustar o coletor de lixo (GC) pode melhorar a performance, especialmente em aplicações que fazem muito uso de concorrência. É como calibrar o turbo do seu carro pra extrair cada gota de potência.

GOMAXPROCS é uma configuração que define o número máximo de threads do sistema operacional que podem ser executadas simultaneamente. É como ajustar o número de cilindros do motor do seu carro pra maximizar a performance. Por padrão, Go ajusta o valor de GOMAXPROCS para o número de CPUs disponíveis na máquina.

import "runtime"

func main() {
    runtime.GOMAXPROCS(4)
    // Resto do seu código...
}
Enter fullscreen mode Exit fullscreen mode

Definir runtime.GOMAXPROCS(4) faz com que sua aplicação use até 4 threads do SO ao mesmo tempo. Isso pode ser útil pra tirar o máximo proveito de sistemas com múltiplos núcleos.

Dica: Se você está rodando seu app dentro do Kubernetes, temos a opção de definir automaticamente o número de cores usando a lib automaxprocs. O número de threads que o Go utilizará será igual ao limite de CPU limit que você definiu no arquivo k8s yaml.

SetGCPercent controla a frequência com que o garbage collector (GC) é acionado. Em outras palavras, é como ajustar a sensibilidade dos freios do seu carro pra encontrar um equilíbrio entre performance e segurança. O valor padrão é 100, o que significa que o GC será acionado quando o heap duplicar de tamanho.

import (
    "runtime/debug"
)

func main() {
    runtime.GOMAXPROCS(4)
    debug.SetGCPercent(20)
    // Resto do seu código...
}
Enter fullscreen mode Exit fullscreen mode

debug.SetGCPercent(20) faz com que o GC seja mais agressivo, acionando-o quando o heap crescer 20% em vez de 100%. Isso reduz o uso de memória, mas pode aumentar a frequência de pausas do GC, então tome cuidado e teste bem antes de sair colocando números milagrosos aqui pois pode prejudicar o desempenho ao invés de ajudar.

Benchmark e Profiler

O mais importante de todos! Antes de sair modificando seu app sem necessidade ou saber se aquela modificação é realmente necessária, faça testes e o profiling da sua aplicação para ter dados e saber se essas mudanças serão boas, ou apenas perda de tempo (desculpem a sinceridade 😅)

1. Ferramentas de Perfilamento

Para otimizar o desempenho do seu código Go, é essencial entender onde estão os gargalos. Ferramentas de perfilamento, como pprof, são como engenheiros de corrida analisando dados do carro para fazer ajustes. Elas ajudam a identificar partes do código que estão consumindo mais CPU ou memória do que deveriam.

import (
    "net/http"
    _ "net/http/pprof"
)

func main() {
    go func() {
        log.Println(http.ListenAndServe("localhost:6060", nil))
    }()

    // Resto do seu código...
}
Enter fullscreen mode Exit fullscreen mode

Com isso, você acessa perfis de CPU, heap e goroutine em http://localhost:6060/debug/pprof/, ajustando seu código como um carro na garagem e vendo os resultados da mudança.

  • CPU Profile: Mostra onde seu programa está gastando a maior parte do tempo de CPU.
  • Heap Profile: Mostra como a memória está sendo alocada e onde estão os maiores consumos de memória.
  • Goroutine Profile: Ajuda a entender o uso de goroutines e a detectar possíveis leaks.

pprof webpage

2. Benchmarking

Benchmarks são essenciais para medir o desempenho das suas funções. É como medir o tempo de volta do seu carro em uma pista de corrida. Em Go, você pode usar o pacote testing para escrever benchmarks e medir o desempenho de suas funções de maneira sistemática.

// main_test.go
package main

import (
    "strings"
    "testing"
)

func concatStrings(a, b string) string {
    var sb strings.Builder
    sb.WriteString(a)
    sb.WriteString(b)
    return sb.String()
}

func BenchmarkConcatStrings(b *testing.B) {
    for i := 0; i < b.N; i++ {
        concatStrings("hello", "world")
    }
}
Enter fullscreen mode Exit fullscreen mode

Benchmark numbers

Conclusão

Otimizar aplicações Go é como preparar um carro de corrida: uma mistura de boas práticas de codificação e ajustes no ambiente de execução. Seguindo essas dicas e sempre medindo e ajustando o desempenho, você pode garantir que suas aplicações Go fiquem rápidas e eficientes, prontas para ganhar o grande prêmio.

Este é um pouco do conhecimento que tenho e gostaria de compartilhar com vocês. Sei que o campo da otimização de desempenho é enorme, então, se você tem opiniões, sugestões ou experiências que gostaria de compartilhar, seus comentários são mais do que bem-vindos!

É isso e até a próxima... 🚀🏁

💖 💪 🙅 🚩
rflpazini
Rafael Pazini

Posted on July 31, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related