DevGang
Авторизоваться

Go: понять дизайн Sync.Pool 

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

Эта статья основана на Go 1.12 и 1.13 и объясняет эволюцию sync / pool.go между этими двумя версиями.

Ограничение pool

Давайте возьмем простой пример, чтобы увидеть, как это работает в довольно простом контексте с выделениями 1k:

type Small struct {
  a int
}

var pool = sync.Pool{
  New: func() interface{} { return new(Small) },
}

//go:noinline
func inc(s *Small) { s.a++ }

func BenchmarkWithoutPool(b *testing.B) {
  var s *Small
  for i := 0; i < b.N; i++ {
     for j := 0; j < 10000; j++ {
        s = &Small{ a: 1, }
        b.StopTimer(); inc(s); b.StartTimer()
     }
  }
}

func BenchmarkWithPool(b *testing.B) {
  var s *Small
  for i := 0; i < b.N; i++ {
     for j := 0; j < 10000; j++ {
        s = pool.Get().(*Small)
        s.a = 1
        b.StopTimer(); inc(s); b.StartTimer()
        pool.Put(s)
     }
  }
}

Вот два теста, один из которых не использует sync.Pool а другой его использует:

name           time/op        alloc/op        allocs/op
WithoutPool-8  3.02ms ± 1%    160kB ± 0%      1.05kB ± 1%
WithPool-8     1.36ms ± 6%   1.05kB ± 0%        3.00 ± 0%

Поскольку цикл имеет 10k итераций, бенчмарк, который не использует пул, выделил 10k в куче против только 3 для бенчмарка с пулом. 3 распределения выполняются пулом, но выделен только один экземпляр структуры. Пока все хорошо; с помощью sync.Pool работает намного быстрее и потребляет меньше памяти.

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

name           time/op        alloc/op        allocs/op
WithoutPool-8  993ms ± 1%    249kB ± 2%      10.9k ± 0%
WithPool-8     1.03s ± 4%    10.6MB ± 0%     31.0k ± 0%

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

Внутренний рабочий процесс

Покопавшись в sync/pool.go я нашел инициализацию пакета, который мог бы ответить на нашу вопрос:

func init() {
  runtime_registerPoolCleanup(poolCleanup)
}

Он регистрируется во время выполнения как метод очистки пулов. И этот же метод будет вызван сборщиком мусора в своем выделенном файле runtime/mgc.go:

func gcStart(trigger gcTrigger) {
  [...]
  // clearpools before we start the GC
  clearpools()

Это объясняет, почему при вызове сборщика мусора производительность была ниже. Пулы очищаются каждый раз, когда запускается сборщик мусора. Документация также предупреждает нас об этом:

Любой элемент, хранящийся в пуле, может быть удален автоматически в любое время без уведомления

Теперь давайте создадим рабочий процесс, чтобы понять, как управляются элементы:

sync.Pool рабочий процесс в Go 1.12

Для каждой sync.Pool мы создаем, go генерирует внутренний пул, прикрепленный poolLocal к каждому процессору. Этот внутренний пул состоит из двух атрибутов: private и shared. Первый доступен только его владельцу (push и pop - и, следовательно, не нуждается в блокировке), а атрибут shared может быть прочитан любым другим процессором и должен быть безопасным для параллелизма. Действительно, пул - это не простой локальный кеш, он может использоваться любым потоком / программами в нашем приложении.

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

Новый пул без блокировки и кэш victim

Go версии 1.13 содержит новый двусвязный список в виде общего пула, который снимает блокировку и улучшает общий доступ. Это основа для улучшения кеша. Вот новый рабочий процесс общего доступа:

новые общие пулы в Go 1.13

С этим новым цепочечным пулом каждый процессор с push и pop во главе своей очереди, в то время как общий доступ выскочит из хвоста. Глава очереди может расти, выделяя новую структуру в два раза больше, которая будет связана с предыдущей благодаря атрибутам next / prev. Размер по умолчанию для исходной структуры составляет 8 элементов. Это означает, что вторая структура будет содержать 16 элементов, третья - 32 и так далее. 
Кроме того, блокировка теперь не нужна, и код может полагаться на атомарные операции.

Что касается нового кэша, новая стратегия довольно проста. Теперь есть 2 набора пулов: активные и архивные. Когда сборщик мусора запускается, он сохраняет ссылку каждого пула на новый атрибут в этом пуле, а затем копирует набор пулов в архивированные, прежде чем очищать текущие пулы:

// Удалите кэши victim из всех пулов. 
for _, p := range oldPools {
  p.victim = nil
  p.victimSize = 0
}

// Переместить основной кэш в кэш-память victim.
for _, p := range allPools {
  p.victim = p.local
  p.victimSize = p.localSize
  p.local = nil
  p.localSize = 0
}

// Кэши victim и без пулов имеют первичные кэши. 
oldPools, allPools = allPools, nil
 

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

#Golang
Комментарии 1
Andrii Malik 14.01.2021 в 14:51

Спасибо, статья помогла оптимизировать код со структурой более чем на 20%

Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу