Go: понимание дизайна Sync.Pool

Go

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

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

Лимиты пула

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

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%

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

Однако в реальном приложении ваш экземпляр может использоваться для выполнения тяжелых задач и выделения большого количества заголовков. В этом случае при увеличении памяти будет срабатывать GC. Мы также можем использовать командуruntime.GC()чтобы заставить сборщик мусора в тесте имитировать это поведение: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%

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

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

понять глубжеsync/pool.goИнициализация пакета может помочь с ответом на наш предыдущий вопрос:

func init() {
   runtime_registerPoolCleanup(poolCleanup)
}

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

func gcStart(trigger gcTrigger) {
   [...]
   // 在开始 GC 前调用 clearpools
   clearpools()

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

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

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

sync.Pool workflow in Go 1.12

для каждого мы создаемsync.Pool, go создает соединение с каждым процессором[P]poolLocal) внутреннего бассейнаpoolLocal. Структура состоит из двух свойств:privateа такжеshared. К первому может получить доступ только его владелец (Push и POP не нуждаются ни в каких блокировках), аsharedСвойства могут быть прочитаны любым другим процессором и должны быть защищены от параллелизма. На самом деле, пул — это не просто локальный кеш, он может использоваться любыми потоками/горутинами в нашем приложении.

Go версии 1.13 улучшитсяsharedдоступ, а также принесет новый кеш для решения проблем, связанных с сборкой мусора и очисткой пула.

Новый незаблокированный пул и кеш жертвы

Go версии 1.13 будетsharedиспользовать одинДвусвязный списокpoolChainЧто касается структуры хранения, это изменение снимает блокировки и улучшаетsharedДоступ. Ниже приведеныsharedНовый поток для доступа:

new shared pools in Go 1.13

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

Кроме того, теперьpoolLocalСтруктурам не нужны блокировки, а код может полагаться на атомарные операции.

О недавно добавленном кеше жертвы (Примечание переводчика: о введении кеша жертвыcommit, введение этого кеша должно решить проблему предыдущего теста), новая стратегия очень проста. Теперь есть два набора пулов: Активный и Архивный (Примечание переводчика:allPoolsа такжеoldPools). Когда сборщик мусора запускается, он сохраняет ссылку на каждый пул в новом атрибуте (жертве) в пуле, а затем превращает эту группу пулов в архивный пул перед очисткой текущего пула:

// 从所有 pool 中删除 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

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