Muitos programadores Go estão familiarizados com bytes.Buffer . Uma de suas vantagens é que ele permite que você evite alocar memória no heap da mesma maneira que a " pequena otimização de buffer / tamanho ":
type Buffer struct { bootstrap [64]byte
Existe apenas um problema. Essa otimização não funciona .
No final deste artigo, você descobrirá por que essa otimização não funciona e o que podemos fazer sobre isso.
Conforme pretendido, "otimização de buffer pequeno"
Vamos apresentar uma definição ligeiramente simplificada de bytes.Buffer
.
const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte }
Quando executamos ações no Buffer
, por exemplo, chamamos o método Buffer.Write
, o registro é sempre feito no buf
; no entanto, antes desse registro, Buffer.grow(n)
iniciado dentro de cada método semelhante, o que garante que haja espaço suficiente nessa fatia para próximos n
bytes.
Grow pode ser algo como isto:
func (b *Buffer) grow(n int) {
Pressupostos usados em nossa implementação do Buffer.grow
Assumimos que len(b.buf)
é o tamanho real dos dados no Buffer, o que exigiria que o Write
use métodos de adição para adicionar novos bytes à fatia. Esse não é o caso em bytes.Buffer
da biblioteca padrão, mas, por exemplo, este é um detalhe de implementação sem importância.
Se b
alocado na pilha, o bootstrap
dentro dela será alocado na pilha, o que significa que a fatia b.buf
reutilizará a memória dentro de b
sem exigir alocação adicional.
Quando o grow
revelar que a matriz de bootstrap
já é insuficiente, uma nova fatia "real" será criada, onde os elementos do armazenamento passado (do "pequeno buffer") serão copiados. Depois disso, o Buffer.bootstrap
perderá sua relevância. Se Buffer.Reset
for Buffer.Reset
, cap(b.buf)
permanecerá o mesmo e não haverá mais necessidade de uma matriz de bootstrap
.
Memória fugindo na pilha
Espera-se ainda que o leitor esteja pelo menos superficialmente familiarizado com o que é a análise de escape no Go.
Considere a seguinte situação:
func f() *Buffer { var b bytes.Buffer
Aqui b
será alocado na pilha. A razão para isso é o ponteiro vazando para b
:
$ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b
Terminologia
Neste artigo, "vazamento" e "escape" são usados quase como sinônimos.
Há alguma diferença no próprio compilador, por exemplo, o valor "escapa para a pilha", mas os parâmetros de função são "vazamento de parâmetro x".
Um parâmetro vazando significa que o argumento passado para esse parâmetro será alocado no heap. Em outras palavras, o parâmetro vazamento faz com que os argumentos escapem para um heap.
O acima foi um caso óbvio, mas e quanto a isso:
func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() }
Aqui precisamos de apenas 1 byte, tudo se encaixa no bootstrap
, o buffer em si é local e não "escapa" da função. Você pode se surpreender, mas o resultado será o mesmo, alocação b
na pilha.

Para ter certeza, você pode verificar isso usando a referência:
BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op
Listagem de benchmark
package p import ( "bytes" "testing" ) func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() } func BenchmarkLength(b *testing.B) { for i := 0; i < bN; i++ { _ = length() } }
Explicação 112 B / op
Quando o tempo de execução solicita N
bytes ao alocador, não é necessário que exatamente N
bytes sejam alocados.
Todos os resultados abaixo são para a combinação de GOOS=linux
e GOARCH=AMD64
.
package benchmark import "testing"
Se você executar, go test -bench=. -benchmem
go test -bench=. -benchmem
com este teste:
BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op
9 bytes solicitados, 16 alocados. Agora, de volta aos bytes.Buffer
:
fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104
Vejamos $ GOROOT / src / runtime / sizeclasses.go :
// class bytes/obj bytes/span objects tail waste max waste // 1 8 8192 1024 0 87.50% // 2 16 8192 512 0 43.75% // 3 32 8192 256 0 46.88% // 4 48 8192 170 32 31.52% // 5 64 8192 128 0 23.44% // 6 80 8192 102 32 19.07% // 7 96 8192 85 32 15.95% // 8 112 8192 73 16 13.56% // ...
Não cabe em 96 bytes, 112 está selecionado.
Mas por que isso está acontecendo?
O que está acontecendo e por quê
Alguma análise da situação pode ser encontrada na questão mencionada no início.
Há também um reprodutor simples .
O local do problema está apenas na atribuição b.buf = b.bootstrap[:]
. Esse código faz com que a análise de escape assuma que o b.bootstrap
"fugindo" e, como é uma matriz, ele é armazenado dentro do próprio objeto, o que significa que todo o b
deve ser alocado no heap.
Se o bootstrap fosse uma fatia, não uma matriz, isso não aconteceria, porque existe uma otimização adhoc para atribuir fatias do objeto ao próprio objeto:
A resposta por que essa otimização não funciona para matrizes já foi formulada acima, mas aqui está um aperto do próprio esc.go # L835-L866 (todo o código de otimização é destacado por referência):
Vale acrescentar aqui que, para o analisador de ponteiros, existem vários níveis de "vazamentos", os principais deles:
- O próprio objeto escapa (b escapa). Nesse caso, o próprio objeto precisa ser alocado no heap.
- Os elementos do objeto (escape de conteúdo b) escapam. Nesse caso, os ponteiros no objeto são considerados escapantes.
O caso da matriz é especial, pois se a matriz vazar, o objeto que a contém também deverá vazar.
A análise de escape exibe uma decisão sobre se é possível colocar um objeto na pilha ou não, baseando-se apenas nas informações disponíveis no corpo da função analisada. O método Buffer.grow
recebe b
por ponteiro, portanto, ele precisa calcular um local adequado para colocar. Como no caso de uma matriz não podemos distinguir entre "b escape"
e "b contents escape"
, temos que ser mais pessimistas e chegar à conclusão de que b
não b
seguro colocar na pilha.
Suponha o contrário, que o padrão de self-assignment
resolva o mesmo para matrizes e para fatias:
package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:]
A decisão de colocar b
na pilha nessa situação levará a um desastre: depois de sair da função na qual b
foi criado, a memória à qual o sink
se referirá será nada mais que lixo.
Ponteiros de matriz
Imagine que nosso Buffer
foi declarado um pouco diferente:
const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte }
Ao contrário de uma matriz regular, um ponteiro para uma matriz não armazena todos os elementos dentro do próprio Buffer
. Isso significa que se a alocação de bootstrap
no heap não envolver alocação de Buffer
no heap. Como a análise de escape pode alocar campos de ponteiro na pilha quando possível, podemos assumir que essa definição de Buffer
é mais bem-sucedida.
Mas isso é em teoria. Na prática, um ponteiro para uma matriz não possui muito processamento e se enquadra na mesma categoria que uma fatia de uma matriz regular, o que não está totalmente correto. CL133375: cmd / compile / internal / gc: handle auto-atribuir fatia em esc.go visa corrigir essa situação.
Suponha que essa alteração tenha sido aceita no compilador Go.
Valor zero que perdemos
Infelizmente, a transição de [64]byte
para *[64]byte
tem um problema: agora não podemos usar o bootstrap
sem inicializá-lo explicitamente, um valor zero de Buffer
deixa de ser útil, precisamos de um construtor.
func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} }
Retornamos o Buffer
, não o *Buffer
, para evitar problemas com a análise de ponteiros (é muito conservador no Go) e, levando em consideração o fato de que o NewBuffer
sempre NewBuffer
embutido no local de uma chamada, não haverá cópia desnecessária.
Após incorporar o corpo NewBuffer
no lugar da chamada, a análise de escape pode tentar provar que o new(*[smallBufSize]byte)
não excede a vida útil do quadro da função na qual é chamado. Nesse caso, a alocação estará na pilha.
Intel bytebuf
A otimização descrita acima é aplicada no pacote intel-go / bytebuf .
Essa biblioteca exporta o tipo bytebuf.Buffer
, que duplica 99,9% de bytes.Buffer
. Todas as alterações são reduzidas à introdução de um construtor ( bytebuf.New
) e um ponteiro para uma matriz em vez de uma matriz regular:
type Buffer struct { buf []byte // contents are the bytes buf[off : len(buf)] off int // read at &buf[off], write at &buf[len(buf)] - bootstrap [64]byte // helps small buffers avoid allocation. + bootstrap *[64]byte // helps small buffers avoid allocation. lastRead readOp // last read operation (for Unread*). }
Aqui está uma comparação de desempenho com bytes.Buffer
:
name old time/op new time/op delta String/empty-8 138ns ±13% 24ns ± 0% -82.94% (p=0.000 n=10+8) String/5-8 186ns ±11% 60ns ± 1% -67.82% (p=0.000 n=10+10) String/64-8 225ns ±10% 108ns ± 6% -52.26% (p=0.000 n=10+10) String/128-8 474ns ±17% 338ns ±13% -28.57% (p=0.000 n=10+10) String/1024-8 889ns ± 0% 740ns ± 1% -16.78% (p=0.000 n=9+10) name old alloc/op new alloc/op delta String/empty-8 112B ± 0% 0B -100.00% (p=0.000 n=10+10) String/5-8 117B ± 0% 5B ± 0% -95.73% (p=0.000 n=10+10) String/64-8 176B ± 0% 64B ± 0% -63.64% (p=0.000 n=10+10) String/128-8 368B ± 0% 256B ± 0% -30.43% (p=0.000 n=10+10) String/1024-8 2.16kB ± 0% 2.05kB ± 0% -5.19% (p=0.000 n=10+10) name old allocs/op new allocs/op delta String/empty-8 1.00 ± 0% 0.00 -100.00% (p=0.000 n=10+10) String/5-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/64-8 2.00 ± 0% 1.00 ± 0% -50.00% (p=0.000 n=10+10) String/128-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10) String/1024-8 3.00 ± 0% 2.00 ± 0% -33.33% (p=0.000 n=10+10)
Todas as outras informações estão disponíveis no README .
Devido à incapacidade de usar o valor zero e a ligação à função de construção New
, não é possível aplicar essa otimização a bytes.Buffer
.
Essa é a única maneira de gerar bytes.Buffer
mais bytes.Buffer
? A resposta é não. Mas esse é definitivamente um método que requer mudanças mínimas na implementação.
Planos de Análise de Escape
Em sua forma atual, a análise de escape no Go é bastante fraca. Quase qualquer operação com valores de ponteiro leva a alocações no heap, mesmo que essa não seja uma decisão razoável.
Tentarei direcionar a maior parte do tempo que dedico ao projeto golang / go para resolver esses problemas, portanto, algumas melhorias são possíveis na próxima versão (1.12).
Você pode ler sobre os resultados e detalhes da estrutura interna desta parte do compilador em um dos meus próximos artigos. Tentarei fornecer um conjunto de recomendações que ajudarão em alguns casos a estruturar o código para que ele tenha menos alocações de memória indesejadas.