Систематическое использование указателей для передачи структур вместо их копирования для многих разработчиков Go кажется лучшим вариантом с точки зрения производительности.
Чтобы понять влияние использования указателя, как альтернативы передачи копии структуры, мы рассмотрим два юзкейса.
Интенсивная аллокация данных
Давайте рассмотрим простой пример, когда вам нужно поделиться структурой, чтобы передать куда-то ее значения:
type S struct {
a, b, c int64
d, e, f string
g, h, i float64
}
Вот небольшая структура, которую можно передать копией или по указателю:
func byCopy() S {
return S{
a: 1, b: 1, c: 1,
e: "foo", f: "foo",
g: 1.0, h: 1.0, i: 1.0,
}
}
func byPointer() *S {
return &S{
a: 1, b: 1, c: 1,
e: "foo", f: "foo",
g: 1.0, h: 1.0, i: 1.0,
}
}
На основе этих двух методов мы можем написать 2 теста, в одном из которых будет передаваться копия структуры:
func BenchmarkMemoryStack(b *testing.B) {
var s S
f, err := os.Create("stack.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
for i := 0; i < b.N; i++ {
s = byCopy()
}
trace.Stop()
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
И еще один, очень похожий, когда она передается по указателю:
func BenchmarkMemoryHeap(b *testing.B) {
var s *S
f, err := os.Create("heap.out")
if err != nil {
panic(err)
}
defer f.Close()
err = trace.Start(f)
if err != nil {
panic(err)
}
for i := 0; i < b.N; i++ {
s = byPointer()
}
trace.Stop()
b.StopTimer()
_ = fmt.Sprintf("%v", s.a)
}
Запустим тесты:
go test ./... -bench=BenchmarkMemoryHeap -benchmem -run=^$ -count=10 > head.txt && benchstat head.txt
go test ./... -bench=BenchmarkMemoryStack -benchmem -run=^$ -count=10 > stack.txt && benchstat stack.txt
И вот результаты:
name time/op
MemoryHeap-4 75.0ns ± 5%
name alloc/op
MemoryHeap-4 96.0B ± 0%
name allocs/op
MemoryHeap-4 1.00 ± 0%
name time/op
MemoryStack-4 8.93ns ± 4%
name alloc/op
MemoryStack-4 0.00B
name allocs/op
MemoryStack-4 0.00
Использование копии структуры здесь в 8 раз быстрее указателя.
Чтобы понять, почему, давайте взглянем на графики, сгенерированные с помощью trace:
График для структуры, переданной копированием
График для структуры, переданной по указателю
Первый график довольно прост, поскольку нет использования кучи, нет сборщика мусора и дополнительных горутин.
Что касается второго графика — использование указателей заставляет компилятор go переместить переменную в кучу и создать давление на сборщик мусора. Если мы увеличим масштаб графика, то увидим, что сборщик мусора играет важную роль во всем процессе:
На этом графике мы видим, что сборщик мусора должен отрабатывать каждые 4 мс.
Если мы увеличим масштаб еще немного, мы сможем получить подробную информацию о том, что именно происходит:
Синий, розовый и красный — это фазы сборки мусора, а коричневый — это выделение памяти в куче (на графике помечено как «runtime.bgsweep»):
Sweeping — это когда восстанавливается память, связанная со значениями в динамической памяти, которые не были помечены как используемые (in-use). Это действие происходит, когда приложение Goroutines пытается выделить новые значения в динамической памяти. Задержка свипинга добавляется к стоимости выделения памяти в куче и не связана ни с какими задержками, связанными со сборкой мусора.
https://www.ardanlabs.com/blog/2018/12/garbage-collection-in-go-part1-semantics.html
Даже если этот пример немного экстремален, мы все-равно можем наблюдать, насколько дорого может обходиться аллокация переменной в куче, а не в стеке. В нашем примере код гораздо быстрее аллоцирует структуру в стеке и копирует ее, нежели размещает ее в куче и передает ее адрес.
Если вы не знакомы со стеком/кучей и хотите больше узнать о внутреннем устройстве каждого из них, вы легко можете найти множество информации по этой теме в интернете (например статья Пола Гриббла).
Ситуация была бы еще хуже, если бы мы ограничили процессор до 1 с GOMAXPROCS = 1:
name time/op
MemoryHeap 114ns ± 4%
name alloc/op
MemoryHeap 96.0B ± 0%
name allocs/op
MemoryHeap 1.00 ± 0%
name time/op
MemoryStack 8.77ns ± 5%
name alloc/op
MemoryStack 0.00B
name allocs/op
MemoryStack 0.00
Если тест с аллокацией в стеке не меняется, то показатели для кучи ухудшились с 75нс/оп до 114нс/оп.
Интенсивные вызовы функций
В этом юзкейсе мы добавим в нашу структуру два пустых метода с небольшой адаптацией для наших тестов:
func (s S) stack(s1 S) {}
func (s *S) heap(s1 *S) {}
Тест с аллокацией в стеке создаст структуру и передаст ее копией:
func BenchmarkMemoryStack(b *testing.B) {
var s S
var s1 S
s = byCopy()
s1 = byCopy()
for i := 0; i < b.N; i++ {
for i := 0; i < 1000000; i++ {
s.stack(s1)
}
}
}
Тест с кучей передаст структуру по указателю:
func BenchmarkMemoryHeap(b *testing.B) {
var s *S
var s1 *S
s = byPointer()
s1 = byPointer()
for i := 0; i < b.N; i++ {
for i := 0; i < 1000000; i++ {
s.heap(s1)
}
}
}
Как и ожидалось, результаты теперь совсем другие:
name time/op
MemoryHeap-4 301µs ± 4%
name alloc/op
MemoryHeap-4 0.00B
name allocs/op
MemoryHeap-4 0.00
name time/op
MemoryStack-4 595µs ± 2%
name alloc/op
MemoryStack-4 0.00B
name allocs/op
MemoryStack-4 0.00
Заключение
Использование указателя в качестве альтернативы копированию структуры в go — не всегда хорошая идея.
Чтобы выбрать хорошую семантику для ваших данных, я настоятельно рекомендую прочитать статью о семантике значения/указателя, написанную Биллом Кеннеди. Это даст вам лучшее представление о стратегиях, которые вы можете использовать со своей структурой и встроенными типами.
Кроме того, профилирование использования памяти определенно поможет вам выяснить, что происходит с вашими аллокауиями и вашей кучей.
Материал подготовлен в преддверии старта курса Golang Developer. Professional