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.
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)
Uma maneira segura de garantir sempre zero memória é fazer isso explicitamente:
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 )
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:
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:
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 {
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: