Систематическое использование указателей для передачи структур вместо их копирования для многих разработчиков 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

Комментарии (10)


  1. tyderh
    26.08.2021 16:36
    +2

    Как-то однобоко:

    • Тест производится на небольшой структуре. Очевидно, что после определенного размера структуры ситуация изменится. После какого?

    • Производительность - это не только скорость, но и потрабление памяти/аллокации


    1. khim
      26.08.2021 16:44
      -3

      Вот и выросло очередное поколение, заново открывающее для себя числа, которые обязан знать каждый.

      Да, понятно, что бывают пограничные случаи, когда сходу неясно что выгоднее — передача по значению или по ссылке. Но если вы будете помнить, что один проход по ссылке, которой не посчастливилось попасть ни в один из кешей эквивалентен пересылке 400-500 байт данных, то вы поймёте, что ссылки имеют смысл сильно реже, чам многим кажется.


      1. powerman
        26.08.2021 18:37

        Ссылка не открывается, вот альтернатива https://gist.github.com/jboner/2841832


        1. Sap_ru
          26.08.2021 19:35
          +1

          Очень смелое утверждение. Чтобы скопировать структуру её нужно прочитать, что приведет к её обновлению в кэше и той же потере времени, что и при обращении по ссылке. Но потом её ещё записать записать, что выпивает из кэша какие другие данные. И где профит?


          1. powerman
            26.08.2021 22:51

            Вы, вероятно, хотели ответить @khim


      1. Politura
        26.08.2021 20:06
        +4

        В данном случае проблема не в этих числах, а в том, что в куче по-одному создается миллион объектов, которые никак не используются и тут-же уничтожаются, что нагружает GC.

        В реальном коде объект создается и какое-то время живет и используется, в этом случае если нам надо работать с оным объектом из разных методов - выгоднее передавать его по ссылке. Однако если это кратковременный объект, чистое ДТО исключительно для передачи каких-то данных из одной функции в другую, то тут может быть действительно выгоднее эти данные передавать через стек, а не через кучу управляемую GC - здесь и выгода, что объект создается и уничтожается сам, без затрат на GC, и дает больше простора всяким оптимизациям во время сборки.

        И вот в статье взяли этот один единственный кейс, тестанули (причем даже это сделали неправильно: возвращаемый объект никак не используется, так что есть шанс, что оптимизатор более агрессивно соптимизировал код для случая возврата по-значению), ничего не пояснили и оставили читателя додумывать что ему захочется.


    1. Imobile
      27.08.2021 09:53

      В моих задачах еще бы пришлось решать вопрос обратного копирования. Даже подумать страшно, как это решать, интересно что бы сказал автор


  1. zuborg
    26.08.2021 18:09
    +4

    В первом тесте бенчится не передача структуры (в функцию) по значению или указателю, а возврат структуры из функции или указателя на неё. Разумеется, в первом случае (возврат структуры) можно (иногда) обойтись без аллокации (положить структуру в стек), а во втором (возврат указателя) без аллокации уже не обойтись.

    В то время как передавать структуру в функцию (последний тест, уже без страшных графиков) быстрее всего по указателю, что вполне очевидно.


  1. maxim_ge
    26.08.2021 19:04
    +2

    func BenchmarkMemoryHeap2(b *testing.B) {
    
        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()
            if s.a != 1 {
                panic("a!=1")
            }
        }
    
        trace.Stop()
        b.StopTimer()
    
    }

    BenchmarkMemoryStack-4 193849980 6.160 ns/op
    BenchmarkMemoryHeap-4 18460486 62.46 ns/op
    BenchmarkMemoryHeap2-4 195428566 6.141 ns/op


    Собственно, уже было


  1. JekaMas
    26.08.2021 23:04
    +1

    Говоря про типы по значению и по указателю в golang, я бы обязательно говорил, что эти понятия размыты донельзя, поскольку на типе по значению можно вызывать методы по указателю https://play.golang.org/p/IW2bLAfz1l8

    Эта "фича" и ещё больше ослабила систему типов, к сожалению.