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 раз быстрее указателя.

Чтобы понять, почему, давайте взглянем на графики, сгенерированные с помощью 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

Источник: https://habr.com/ru/company/otus/blog/574838/


Интересные статьи

Интересные статьи

Вам когда-нибудь приходилось руководить командой? Той, где что-то идёт не по плану? Когда такое происходит, одно из мест, куда мы, как лидеры, идём, – это к своей собственной команде....
Как часто вы используете конструкцию Random.value или Random.Range()? А как много эту конструкцию использовали разработчики фреймворков или плагинов, которые вы встроили ...
Люди всех возрастов любят азарт, будь то Kinder Surprise, блэкджек или компьютерные игры. Представьте, у вас в игре есть две коробки: на одной написано "Вы получите 100 монет&quo...
В устройствах Apple есть прекрасная функция Airdrop — она сделана для пересылки данных между устройствами. При этом никакой настройки и предварительного сопряжения устройств не тр...
Привет, Хабр! Представляю вашему вниманию перевод статьи «5 Reasons You Should Stop Using System.Drawing from ASP.NET». Ну что ж, они таки сделали это. Команда corefx в конце концов соглас...