Métodos simples para otimizar programas Go

Eu sempre me preocupo com desempenho. Eu não sei exatamente o porquê. Mas fico chateado com serviços e programas lentos. Parece que não estou sozinho .

Nos testes A / B, tentamos diminuir a produção de páginas em incrementos de 100 milissegundos e descobrimos que mesmo atrasos muito pequenos levam a uma queda significativa na receita. - Greg Linden, Amazon.com

Por experiência, a baixa produtividade se manifesta de duas maneiras:

  • Operações com bom desempenho em pequena escala tornam-se inviáveis ​​com um número crescente de usuários. Geralmente, essas são operações O (N) ou O (N²). Quando a base de usuários é pequena, tudo funciona bem. O produto tem pressa de trazer para o mercado. À medida que a base cresce, surgem cada vez mais situações patológicas inesperadas - e o serviço para.
  • Muitas fontes individuais de trabalho subótimo, "morte por mil cortes".

Durante a maior parte da minha carreira, estudei ciência de dados com Python ou criei serviços no Go. No segundo caso, tenho muito mais experiência em otimização. Go geralmente não é um gargalo nos serviços que eu escrevo - os programas de banco de dados geralmente são limitados por E / S. No entanto, nos pipelines de lote de aprendizado de máquina que desenvolvi, o programa geralmente é limitado pela CPU. Se o Go usar muito o processador, existem várias estratégias.

Este artigo explica alguns métodos que podem ser usados ​​para aumentar significativamente a produtividade sem muito esforço. Eu deliberadamente ignoro métodos que exigem esforço significativo ou grandes alterações na estrutura do programa.

Antes de começar


Antes de fazer alterações no programa, reserve um tempo para criar uma linha de base adequada para comparação. Caso contrário, você vagará no escuro, se perguntando se há algum benefício com as alterações feitas. Primeiro, escreva benchmarks e obtenha perfis para uso no pprof. É melhor escrever a referência também no Go : isso facilita o uso de perfis de pprof e memória. Use também o benchcmp: uma ferramenta útil para comparar as diferenças de desempenho entre os testes.

Se o código não for muito compatível com os benchmarks, comece com algo que possa ser medido. Você pode criar um perfil manual do código com runtime / pprof .

Então, vamos começar!

Use sync.Pool para reutilizar objetos selecionados anteriormente


O sync.Pool implementa uma lista de liberação . Isso permite reutilizar estruturas alocadas anteriormente e amortiza a distribuição do objeto em vários usos, reduzindo o trabalho do coletor de lixo. A API é muito simples. Implemente uma função que aloque uma nova instância de um objeto. A API retornará o tipo de ponteiro.

var bufpool = sync.Pool{ New: func() interface{} { buf := make([]byte, 512) return &buf }} 

Depois disso, você pode fazer objetos Get() do pool e Put() los de volta quando terminar.

 // sync.Pool returns a interface{}: you must cast it to the underlying type // before you use it. b := *bufpool.Get().(*[]byte) defer bufpool.Put(&b) // Now, go do interesting things with your byte buffer. buf := bytes.NewBuffer(b) 

Existem nuances. Antes do Go 1.13, a piscina era limpa a cada coleta de lixo. Isso pode afetar adversamente o desempenho de programas que alocam muita memória. A partir de 1.13, parece que mais objetos sobrevivem após o GC .

!!! Antes de retornar um objeto ao pool, certifique-se de redefinir os campos da estrutura.

Caso contrário, você pode obter um objeto sujo do pool que contém dados de uso anterior. Este é um sério risco de segurança!

 type AuthenticationResponse { Token string UserID string } rsp := authPool.Get().(*AuthenticationResponse) defer authPool.Put(rsp) // If we don't hit this if statement, we might return data from other users! if blah { rsp.UserID = "user-1" rsp.Token = "super-secret" } return rsp 

Uma maneira segura de garantir sempre zero memória é fazer isso explicitamente:

 // reset resets all fields of the AuthenticationResponse before pooling it. func (a* AuthenticationResponse) reset() { a.Token = "" a.UserID = "" } rsp := authPool.Get().(*AuthenticationResponse) defer func() { rsp.reset() authPool.Put(rsp) }() 

O único caso em que isso não ocorre é quando você usa a memória exata para a qual escreveu. Por exemplo:

 var ( r io.Reader w io.Writer ) // Obtain a buffer from the pool. buf := *bufPool.Get().(*[]byte) defer bufPool.Put(&buf) // We only write to w exactly what we read from r, and no more. nr, er := r.Read(buf) if nr > 0 { nw, ew := w.Write(buf[0:nr]) } 

Evite usar estruturas que contenham ponteiros como chaves para um mapa grande


Fuh, eu era muito detalhado. Me desculpe Eles costumavam conversar (incluindo meu ex-colega Phil Pearl ) sobre o desempenho do Go com um tamanho grande de pilha . Durante a coleta de lixo, o tempo de execução verifica os objetos com ponteiros e os rastreia. Se você tem um mapa de map[string]int muito grande map[string]int , o GC deve verificar cada linha. Isso acontece com toda coleta de lixo, porque as linhas contêm ponteiros.

Neste exemplo, escrevemos 10 milhões de elementos para map[string]int e medir a duração da coleta de lixo. Alocamos nosso mapa na área do pacote para garantir a alocação de memória do heap.

 package main import ( "fmt" "runtime" "strconv" "time" ) const ( numElements = 10000000 ) var foo = map[string]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[strconv.Itoa(i)] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Executando o programa, veremos o seguinte:

  inthash → vá instalar && inthash
 gc levou: 98.726321ms
 gc levou: 105.524633ms
 gc levou: 102.829451ms
 gc levou: 102.71908ms
 gc levou: 103.084104ms
 gc levou: 104.821989ms 

Isso é bastante tempo em um país de computadores!

O que pode ser feito para otimizar? Remover os ponteiros de todos os lugares é uma boa ideia, para não carregar o coletor de lixo. Existem indicadores nas linhas ; então vamos implementar isso como map[int]int .

 package main import ( "fmt" "runtime" "time" ) const ( numElements = 10000000 ) var foo = map[int]int{} func timeGC() { t := time.Now() runtime.GC() fmt.Printf("gc took: %s\n", time.Since(t)) } func main() { for i := 0; i < numElements; i++ { foo[i] = i } for { timeGC() time.Sleep(1 * time.Second) } } 

Executando o programa novamente, vemos:

  inthash → vá instalar && inthash
 gc levou: 3.608993ms
 gc levou: 3.926913ms
 gc levou: 3.955706ms
 gc levou: 4.063795ms
 gc levou: 3.91519ms
 gc levou: 3.75226ms 

Muito melhor Aceleramos a coleta de lixo em 35 vezes. Quando usado na produção, será necessário colocar as seqüências de caracteres em números inteiros antes de inseri-las no cartão.

A propósito, existem muitas outras maneiras de evitar o GC. Se você alocar matrizes gigantescas de estruturas, ints ou bytes sem sentido, o GC não verificará isso : ou seja, você economizará tempo no GC. Tais métodos geralmente requerem uma revisão substancial do programa, portanto hoje não vamos nos aprofundar neste tópico.

Como em qualquer otimização, o efeito pode variar. Veja a lista de tweets de Damian Gryski para um exemplo interessante de como a exclusão de linhas de um mapa grande em favor de uma estrutura de dados mais inteligente realmente aumentou o consumo de memória. Em geral, leia tudo o que ele publica.

Geração de código Marshaling para evitar a reflexão em tempo de execução


Organizar e desorganizar sua estrutura em vários formatos de serialização, como JSON, é uma operação típica, especialmente ao criar microsserviços. Para muitos microsserviços, esse geralmente é o único trabalho. Funções como json.Marshal e json.Unmarshal dependem da reflexão no tempo de execução para serializar os campos da estrutura em bytes e vice-versa. Isso pode funcionar lentamente: a reflexão não é tão eficiente quanto o código explícito.

No entanto, existem opções de otimização. A mecânica de empacotamento JSON é mais ou menos assim:

 package json // Marshal take an object and returns its representation in JSON. func Marshal(obj interface{}) ([]byte, error) { // Check if this object knows how to marshal itself to JSON // by satisfying the Marshaller interface. if m, is := obj.(json.Marshaller); is { return m.MarshalJSON() } // It doesn't know how to marshal itself. Do default reflection based marshallling. return marshal(obj) } 

Se conhecemos o processo de marshalization no JSON, temos uma pista para evitar a reflexão no tempo de execução. Mas não queremos escrever manualmente todo o código de marshalization, então o que devemos fazer? Deixe o computador gerar esse código! Geradores de código como o easyjson examinam a estrutura e geram código altamente otimizado que é totalmente compatível com as interfaces de empacotamento existentes, como json.Marshaller .

Faça o download do pacote e escreva o seguinte comando em $file.go , que contém as estruturas para as quais você deseja gerar código.

  easyjson -all $ file.go 

O arquivo $file_easyjson.go deve ser gerado. Como o easyjson implementou a interface json.Marshaller para você, essas funções serão chamadas por padrão, e não por reflexão. Parabéns: você acabou de acelerar seu código JSON três vezes. Existem muitos truques para aumentar ainda mais a produtividade.

Eu recomendo este pacote porque já o usei antes e com êxito. Mas tenha cuidado. Por favor, não tome isso como um convite para iniciar debates agressivos comigo sobre os pacotes JSON mais rápidos.

Certifique-se de gerar novamente o código de empacotamento quando a estrutura mudar. Se você esquecer de fazer isso, os novos campos adicionados não serão serializados, o que causará confusão! Você pode usar go generate para essas tarefas. Para manter a sincronização com as estruturas, prefiro colocar generate.go na raiz do pacote, o que faz com que go generate para todos os arquivos do pacote: isso pode ajudar quando você tem muitos arquivos que precisam gerar esse código. A principal dica: para garantir que as estruturas sejam atualizadas, ligue go generate no CI e verifique se não há diferença com o código registrado.

Use strings.Builder para criar strings


No Go, as strings são imutáveis: pense nelas como bytes somente leitura. Isso significa que toda vez que você cria uma cadeia, aloca memória e potencialmente cria mais trabalho para o coletor de lixo.

O Go 1.10 implementou strings.Builder como uma maneira eficiente de criar strings. Internamente, ele grava em um buffer de bytes. Somente ao chamar String() no construtor realmente cria uma string. Ele conta com alguns truques inseguros para retornar os bytes base como uma sequência com uma alocação zero: consulte este blog para um estudo mais aprofundado sobre como isso funciona.

Compare o desempenho das duas abordagens:

 // main.go package main import "strings" var strs = []string{ "here's", "a", "some", "long", "list", "of", "strings", "for", "you", } func buildStrNaive() string { var s string for _, v := range strs { s += v } return s } func buildStrBuilder() string { b := strings.Builder{} // Grow the buffer to a decent length, so we don't have to continually // re-allocate. b.Grow(60) for _, v := range strs { b.WriteString(v) } return b.String() } 

 // main_test.go package main import ( "testing" ) var str string func BenchmarkStringBuildNaive(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrNaive() } } func BenchmarkStringBuildBuilder(b *testing.B) { for i := 0; i < bN; i++ { str = buildStrBuilder() } 

Aqui estão os resultados no meu Macbook Pro:

  strbuild -> faça o teste -bench =.  -benchmem
 goos: darwin
 goarch: amd64
 pkg: github.com/sjwhitworth/perfblog/strbuild
 BenchmarkStringBuildNaive-8 5.000.000 255 ns / op 216 B / op 8 alocações / op
 BenchmarkStringBuildBuilder-8 20.000.000 54,9 ns / op 64 B / op 1 alocações / op 

Como você pode ver, o strings.Builder é 4,7 vezes mais rápido, causa oito vezes menos alocações e ocupa quatro vezes menos memória.

Quando o desempenho importa, use strings.Builder . Em geral, eu recomendo usá-lo em qualquer lugar, exceto nos casos mais triviais de construção de strings.

Use strconv em vez de fmt


O fmt é um dos pacotes mais famosos do Go. Você provavelmente o usou no seu primeiro programa para exibir "olá, mundo". Mas quando se trata de converter números inteiros e flutuantes em strings, não é tão eficiente quanto o irmão mais novo strconv . Este pacote mostra um desempenho decente com muito poucas alterações na API.

fmt basicamente considera a interface{} como argumentos de função. Existem duas desvantagens:

  • Você está perdendo a segurança do tipo. Para mim é muito importante.
  • Isso pode aumentar a quantidade de secreções necessárias. Passar um tipo sem ponteiro como interface{} geralmente resulta em uma alocação de heap. Esta postagem no blog explica por que isso acontece.
  • O programa a seguir mostra a diferença de desempenho:

     // main.go package main import ( "fmt" "strconv" ) func strconvFmt(a string, b int) string { return a + ":" + strconv.Itoa(b) } func fmtFmt(a string, b int) string { return fmt.Sprintf("%s:%d", a, b) } func main() {} 

     // main_test.go package main import ( "testing" ) var ( a = "boo" blah = 42 box = "" ) func BenchmarkStrconv(b *testing.B) { for i := 0; i < bN; i++ { box = strconvFmt(a, blah) } a = box } func BenchmarkFmt(b *testing.B) { for i := 0; i < bN; i++ { box = fmtFmt(a, blah) } a = box } 

    Benchmarks no Macbook Pro:

      strfmt → teste -bench =.  -benchmem
     goos: darwin
     goarch: amd64
     pkg: github.com/sjwhitworth/perfblog/strfmt
     BenchmarkStrconv-8 30.000.000 39,5 ns / op 32 B / op 1 alocações / op
     BenchmarkFmt-8 10.000.000 143 ns / op 72 B / op 3 alocações / op 

    Como você pode ver, a opção strconv é 3,5 vezes mais rápida, causa três vezes menos alocações e ocupa metade da memória.

    Aloque o tanque de fatia com make para evitar redistribuição


    Antes de avançar para melhorar o desempenho, vamos atualizar rapidamente as informações fatiadas na memória. Uma fatia é uma construção muito útil no Go. Ele fornece uma matriz escalável com a capacidade de aceitar visualizações diferentes na mesma memória base sem realocação. Se você olhar por baixo do capô, a fatia será composta por três elementos:

     type slice struct { // pointer to underlying data in the slice. data uintptr // the number of elements in the slice. len int // the number of elements that the slice can // grow to before a new underlying array // is allocated. cap int } 

    Quais são esses campos?

    • data : ponteiro para os dados subjacentes na fatia
    • len : número atual de elementos na fatia
    • cap : número de elementos para os quais uma fatia pode crescer antes de redistribuir

    Seções sob o capô são matrizes de comprimento fixo. Quando o valor máximo ( cap ) é atingido, uma nova matriz com um valor duplo é alocada, a memória é copiada da fatia antiga para a nova e a matriz antiga é descartada.

    Muitas vezes vejo algo como esse código em que uma fatia com capacidade limite zero é alocada se a capacidade da fatia é conhecida antecipadamente:

     var userIDs []string for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Nesse caso, a fatia começa com o tamanho zero len e o limite de capacidade limite zero. Depois de receber a resposta, adicionamos os elementos à fatia, ao mesmo tempo em que atingimos a capacidade limite: uma nova matriz de base é selecionada, onde o cap duplicado e os dados são copiados para ela. Se obtivermos 8 elementos na resposta, isso resultará em 5 redistribuições.

    O método a seguir é muito mais eficiente:

     userIDs := make([]string, 0, len(rsp.Users)) for _, bar := range rsp.Users { userIDs = append(userIDs, bar.ID) } 

    Aqui, alocamos explicitamente a capacidade da fatia usando make. Agora podemos adicionar dados com segurança lá, sem redistribuição e cópia adicionais.

    Se você não souber quanta memória alocar, porque a capacidade é dinâmica ou posteriormente calculada no programa, meça a distribuição final do tamanho da fatia após a execução do programa. Normalmente, pego o percentil 90 ou 99 e codifico o valor do programa. Nos casos em que a CPU é mais cara que a RAM, defina esse valor mais alto do que você acha necessário.

    A dica também se aplica aos mapas: make(map[string]string, len(foo)) alocará memória suficiente para evitar redistribuição.

    Veja este artigo sobre como as fatias realmente funcionam.

    Use métodos para transferir fatias de bytes


    Ao usar pacotes, use métodos que permitam a transmissão de uma fatia de bytes: esses métodos geralmente oferecem mais controle sobre a distribuição.

    Um bom exemplo é comparar time.Format e time.AppendFormat . O primeiro retorna uma string. Sob o capô, isso seleciona uma nova fatia de bytes e chama time.AppendFormat nela. O segundo pega um buffer de bytes, grava uma representação de hora formatada e retorna uma fatia de bytes estendida. Isso geralmente é encontrado em outros pacotes da biblioteca padrão: consulte strconv.AppendFloat ou bytes.NewBuffer .

    Por que isso aumenta a produtividade? Bem, agora você pode passar as fatias de bytes que recebeu do sync.Pool , em vez de alocar um novo buffer a cada vez. Ou você pode aumentar o tamanho do buffer inicial para um valor mais adequado ao seu programa para reduzir o número de cópias repetidas da fatia.

    Sumário


    Você pode aplicar todos esses métodos à sua base de código. Com o tempo, você criará um modelo mental para raciocinar sobre o desempenho nos programas Go. Isso ajudará muito em seu design.

    Mas use-os dependendo da situação. Estes são conselhos, não o evangelho. Meça e verifique tudo com referências.

    E saiba quando parar. Aumentar a produtividade é um bom exercício: a tarefa é interessante e os resultados são imediatamente visíveis. No entanto, a utilidade do aumento da produtividade é altamente dependente da situação. Se o seu serviço responder em 10 ms e o atraso da rede for 90 ms, você provavelmente não deve tentar reduzir esses 10 ms para 5 ms: você ainda tem 95 ms. Mesmo se você otimizar o serviço até o máximo de 1 ms, o atraso total ainda será de 91 ms. Provavelmente coma peixe maior.

    Otimize com sabedoria!

    Referências


    Se você quiser obter mais informações, aqui estão ótimas fontes de inspiração:

Source: https://habr.com/ru/post/pt457004/


All Articles