[Перевод] Go: Должен ли я использовать указатель вместо копии моей структуры?
Иллюстрация, созданная для «A Journey With Go», из оригинального гофера, созданного Рене Френч.
С точки зрения производительности систематическое использование указателей вместо копирования самой структуры для совместного использования структур многим 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 раз быстрее, чем использование указателя на нее!
Чтобы понять почему, давайте посмотрим на графики, генерируемые трассировкой:
график для структуры, переданной копией
график для структуры, переданной указателем
Первый график довольно прост. Поскольку не используется куча, нет сборщика мусора и лишней горутины.
Во втором случае использование указателей заставляет компилятор Go перемещать переменную в кучу и работать сборщику мусора. Если мы увеличим масштаб графика, то увидим, что сборщик мусора занимает важную часть процесса:
На этом графике видно, что сборщик мусора запускается каждые 4 мс.
Если мы снова увеличим масштаб, мы можем получить подробную информацию о том, что именно происходит:
Синие, розовые и красные полосы являются фазами сборщика мусора, а коричневые связаны с аллоцированием в куче (на графике помечено как «runtime.bgsweep»):
Sweeping — это освобождение из кучи связанных с данными участков памяти, не помеченных как используемые. Это действие происходит, когда горутины пытаются выделить новые значения в памяти кучи. Задержка Sweeping добавляется к стоимости выполнения выделения в памяти кучи и не относится к каким-либо задержкам, связанным со сборкой мусора.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
Если бенчмарк размещения в стеке не изменился, то показатель в куче уменьшился с 75ns/op до 114ns/op.
Интенсивные вызовы функций
Мы добавим два пустых метода в нашу структуру и немного адаптируем наши бенчмарки:
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 не всегда хорошо. Чтобы выбрать хорошую семантику для ваших данных, я настоятельно рекомендую прочитать пост о семантике значения/указателя, написанной Биллом Кеннеди. Это даст вам лучшее представление о стратегиях, которые вы можете использовать со своими структурами и встроенными типами. Кроме того, профилирование использования памяти определенно поможет вам понять, что происходит с вашими аллокациями и кучей.