Banyak programmer Go yang akrab dengan byte . Buffer . Salah satu kelebihannya adalah memungkinkan Anda untuk menghindari mengalokasikan memori pada heap dengan cara yang sama seperti " optimasi buffer / ukuran kecil ":
type Buffer struct { bootstrap [64]byte
Hanya ada satu masalah. Pengoptimalan ini tidak berfungsi .
Pada akhir artikel ini, Anda akan mengetahui mengapa pengoptimalan ini tidak berfungsi dan apa yang dapat kami lakukan.
Seperti yang dimaksud, "optimasi buffer kecil"
Mari kita perkenalkan definisi bytes.Buffer
sedikit disederhanakan. bytes.Buffer
:
const smallBufSize int = 64 type Buffer struct { bootstrap [smallBufSize]byte buf []byte }
Ketika kita melakukan tindakan pada Buffer
, misalnya, memanggil metode Buffer.Write
, catatan selalu dibuat di buf
, namun, sebelum catatan ini, Buffer.grow(n)
diluncurkan di dalam setiap metode yang serupa, yang memastikan bahwa ada cukup ruang dalam irisan ini untuk n
byte berikutnya.
Grow mungkin terlihat seperti ini:
func (b *Buffer) grow(n int) {
Asumsi yang digunakan dalam implementasi Buffer.grow kami
Kami membuat asumsi bahwa len(b.buf)
adalah panjang data aktual dalam Buffer, yang akan membutuhkan Write
untuk menggunakan metode append untuk menambahkan byte baru ke slice. Ini bukan kasus dalam bytes.Buffer
dari perpustakaan standar, tetapi sebagai contoh, ini adalah detail implementasi yang tidak relevan.
Jika b
dialokasikan pada stack, maka bootstrap
di dalamnya dialokasikan pada stack, yang berarti bahwa slice b.buf
akan menggunakan kembali memori di dalam b
tanpa memerlukan alokasi tambahan.
Ketika grow
mengungkapkan bahwa array bootstrap
sudah tidak cukup, irisan "nyata" baru akan dibuat, di mana elemen dari penyimpanan sebelumnya (dari "buffer kecil") kemudian akan disalin. Setelah itu, Buffer.bootstrap
akan kehilangan relevansinya. Jika Buffer.Reset
, cap(b.buf)
akan tetap sama dan tidak akan ada lagi kebutuhan untuk array bootstrap
.
Memori hilang dalam tumpukan
Lebih lanjut diharapkan bahwa pembaca setidaknya secara dangkal akrab dengan apa analisis pelarian di Go.
Pertimbangkan situasi berikut:
func f() *Buffer { var b bytes.Buffer
Di sini b
akan dialokasikan pada heap. Alasan untuk ini adalah pointer bocor ke b
:
$ go tool compile -m leak.go leak.go:12:9: &b escapes to heap leak.go:11:6: moved to heap: b
Terminologi
Dalam artikel ini, "bocor" dan "melarikan diri" digunakan hampir secara sinonim.
Ada beberapa perbedaan dalam kompiler itu sendiri, misalnya, nilai "lolos ke tumpukan", tetapi parameter fungsi adalah "bocor param x".
Parameter bocor berarti bahwa argumen yang lolos untuk parameter ini akan dialokasikan pada heap. Dengan kata lain, parameter bocor menyebabkan argumen untuk melarikan diri ke tumpukan.
Di atas adalah kasus yang jelas, tetapi bagaimana dengan ini:
func length() int { var b bytes.Buffer b.WriteString("1") return b.Len() }
Di sini kita hanya perlu 1 byte, semuanya cocok dengan bootstrap
, buffer itu sendiri bersifat lokal dan tidak "lepas" dari fungsinya. Anda mungkin terkejut, tetapi hasilnya akan sama, alokasi b
di heap.

Yang pasti, Anda dapat memeriksa ini menggunakan patokan:
BenchmarkLength-8 20000000 90.1 ns/op 112 B/op 1 allocs/op
Daftar patokan
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() } }
Penjelasan 112 B / op
Ketika runtime meminta pengalokasi untuk N
byte, tidak perlu tepatnya N
byte dialokasikan.
Semua hasil di bawah ini adalah untuk kombinasi GOOS=linux
dan GOARCH=AMD64
.
package benchmark import "testing"
Jika Anda menjalankan go test -bench=. -benchmem
go test -bench=. -benchmem
dengan tes ini:
BenchmarkAlloc9-8 50000000 33.5 ns/op 16 B/op 1 allocs/op
9 byte diminta, dialokasikan 16. Sekarang kembali ke bytes.Buffer
:
fmt.Println(unsafe.Sizeof(bytes.Buffer{})) => 104
Mari kita lihat $ 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% // ...
Itu tidak masuk ke dalam 96 byte, 112 dipilih.
Tetapi mengapa ini terjadi?
Apa yang terjadi dan mengapa
Beberapa analisis situasi dapat ditemukan dalam masalah yang disebutkan di awal.
Ada juga alat reproduksi sederhana .
Tempat masalahnya hanya dalam penugasan b.buf = b.bootstrap[:]
. Kode ini membuat analisis melarikan diri berasumsi bahwa b.bootstrap
"melarikan diri", dan karena ini adalah array, maka ia disimpan di dalam objek itu sendiri, yang berarti bahwa semua b
harus dialokasikan pada heap.
Jika bootstrap adalah slice, bukan array, maka ini tidak akan terjadi, karena ada optimasi adhoc untuk menetapkan irisan dari objek ke objek itu sendiri:
Jawaban mengapa optimisasi ini tidak berfungsi untuk array telah dirumuskan di atas, tetapi di sini ada penekanan dari esc.go # L835-L866 itu sendiri (seluruh kode optimasi disorot oleh referensi):
Perlu ditambahkan di sini bahwa untuk penganalisa pointer ada beberapa level "kebocoran", utamanya:
- Objek itu sendiri lolos (b lolos). Dalam hal ini, objek itu sendiri perlu dialokasikan di heap.
- Unsur-unsur objek (b isi melarikan diri) melarikan diri. Dalam hal ini, pointer pada objek dianggap melarikan diri.
Kasing dengan array adalah spesial karena jika array bocor, objek yang memuatnya juga harus bocor.
melarikan diri analisis membuat keputusan tentang apakah mungkin untuk menempatkan objek pada tumpukan atau tidak, hanya mengandalkan informasi yang tersedia di tubuh fungsi yang dianalisis. Metode Buffer.grow
mengambil b
oleh pointer, sehingga perlu menghitung tempat yang sesuai untuk diletakkan. Karena dalam kasus array kita tidak dapat membedakan antara "b escape"
dan "b contents escape"
, kita harus lebih pesimis dan sampai pada kesimpulan bahwa b
tidak aman untuk ditempatkan pada stack.
Asumsikan sebaliknya, bahwa pola self-assignment
menyelesaikan yang sama untuk array seperti halnya untuk irisan:
package example var sink interface{} type bad struct { array [10]byte slice []byte } func (b *bad) bug() { b.slice = b.array[:]
Keputusan untuk menempatkan b
pada tumpukan dalam situasi ini akan menyebabkan bencana: setelah keluar dari fungsi di mana b
dibuat, memori yang akan sink
akan merujuk tidak lebih dari sampah.
Array pointer
Bayangkan Buffer
kita dinyatakan sedikit berbeda:
const smallBufSize int = 64 type Buffer struct { - bootstrap [smallBufSize]byte + bootstrap *[smallBufSize]byte buf []byte }
Tidak seperti array biasa, pointer ke array tidak akan menyimpan semua elemen di dalam Buffer
itu sendiri. Ini berarti bahwa jika alokasi bootstrap
di heap tidak memerlukan alokasi Buffer
di heap. Karena analisis pelarian dapat mengalokasikan bidang penunjuk pada tumpukan jika memungkinkan, kita dapat mengasumsikan bahwa definisi Buffer
seperti itu lebih berhasil.
Tapi ini dalam teori. Dalam praktiknya, penunjuk ke array tidak memiliki banyak pemrosesan dan jatuh ke dalam kategori yang sama dengan potongan dari array biasa, yang tidak sepenuhnya benar. CL133375: cmd / compile / internal / gc: handle slice yang ditugaskan sendiri di esc.go bertujuan untuk memperbaiki situasi ini.
Misalkan perubahan ini telah diterima ke kompiler Go.
Nilai nol kami hilang
Sayangnya, transisi dari [64]byte
ke *[64]byte
memiliki masalah: sekarang kita tidak dapat menggunakan bootstrap
tanpa menginisialisasi secara eksplisit, nilai nol Buffer
tidak lagi berguna, kita memerlukan konstruktor.
func NewBuffer() Buffer { return Buffer{bootstrap: new(*[smallBufSize]byte)} }
Kami mengembalikan Buffer
, bukan *Buffer
, untuk menghindari masalah dengan analisis pointer (sangat konservatif di Go), dan dengan mempertimbangkan fakta bahwa NewBuffer
selalu dibangun di tempat panggilan, tidak akan ada penyalinan yang tidak perlu.
Setelah menyematkan tubuh NewBuffer
di tempat panggilan, analisis pelarian dapat mencoba untuk membuktikan bahwa new(*[smallBufSize]byte)
tidak melebihi umur bingkai fungsi yang disebutnya. Jika demikian, maka alokasi akan berada di tumpukan.
Intel bytebuf
Optimasi yang dijelaskan di atas diterapkan dalam paket intel-go / bytebuf .
Perpustakaan ini mengekspor tipe bytebuf.Buffer
, yang menduplikasi 99,9% bytes.Buffer
. Semua perubahan bytebuf.New
untuk memperkenalkan konstruktor ( bytebuf.New
) dan pointer ke array bukan array biasa:
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*). }
Berikut ini adalah perbandingan kinerja dengan 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)
Semua informasi lain tersedia di README .
Karena ketidakmampuan untuk menggunakan nilai nol dan mengikat ke fungsi membangun New
, tidak mungkin untuk menerapkan optimasi ini ke bytes.Buffer
. bytes.Buffer
.
Apakah ini satu-satunya cara untuk membuat bytes.Buffer
lebih cepat. bytes.Buffer
? Jawabannya adalah tidak. Tapi ini jelas merupakan metode yang membutuhkan perubahan minimal dalam implementasi.
Rencana Analisis Pelarian
Dalam bentuknya saat ini, analisis pelarian di Go cukup lemah. Hampir semua operasi dengan nilai pointer mengarah ke alokasi di heap, bahkan jika ini bukan keputusan yang masuk akal.
Saya akan mencoba mengarahkan sebagian besar waktu yang saya curahkan untuk proyek golang / go untuk menyelesaikan masalah ini, sehingga beberapa perbaikan mungkin terjadi pada rilis mendatang (1.12).
Anda dapat membaca tentang hasil dan detail struktur internal bagian kompiler ini di salah satu artikel saya berikutnya. Saya akan mencoba untuk memberikan serangkaian rekomendasi yang akan membantu dalam beberapa kasus untuk menyusun kode sehingga memiliki alokasi memori yang kurang diinginkan.