golang

Работа с аренами: почти избавляемся от GC

  • среда, 17 июля 2024 г. в 00:00:10
https://habr.com/ru/companies/oleg-bunin/articles/828972/

Меня зовут Максим Горозий. Я тимлид в Т-Банке, работаю над нашей образовательной платформой, которая служит для разных направлений бизнеса. В ИТ больше 10 лет и успел поработать в двух GameDev-компаниях, где управление памятью занимало весомое время в оптимизации производительности кода. Люблю строить системы и взаимосвязи между ними, а также EdTech и преподавание, а еще больше — работать над инструментами обучения. Хотя начинал с C, я идеологический фанат Go, DDD и Agile.

«Оно тормозит» — классическая цитата разработчиков. Расскажу, как разобраться в причинах и научиться управлять памятью, медитируя над профайлингом, чтобы все работало быстро.

Memory Management: каким он бывает

Жизненный цикл управления памятью выглядит примерно так:

Наш флоу: выделить память, разместить в ней то, что нужно, а потом использовать
Наш флоу: выделить память, разместить в ней то, что нужно, а потом использовать

А дальше начинается самое интересное: как потом освободить этот кусочек памяти, чтобы переиспользовать либо передать обратно ОС? Для этого нужно понять, какие типы управления памятью существуют.

Manual — классическое ручное управление, где когнитивная сложность, как освободить нужный кусочек памяти, ложится на программиста. В большинстве случаев это работает быстрее всего. Но за скорость приходится платить той самой когнитивной сложностью и багами. Ведь неверное управление памятью — одна из основных причин багов и уязвимостей. Авторам аллокаторов приходится изобретать собственные решения. Например:

  • Частые syscall ↔ выделение лишней памяти для пуллинга. Это когда мы заранее просим у операционной системы больше памяти, чтобы не делать лишний syscall. В этом случае либо тратим лишнюю неиспользуемую память, либо, наоборот, вынуждены делать syscall чаще. Syscall в современных ОС не так дорог (1—2 мкс), но все равно обходится в 20 раз дороже, чем просто использование существующей памяти. 

  • Фрагментация ↔ затраты CPU. Мы либо допускаем фрагментацию, ускоряя программу, но при этом терпим, что программа будет выглядеть так, как будто она течет. Либо тратим CPU на хитрые алгоритмы распределения памяти, которые снижают фрагментацию, или даже на уплотнение памяти, что приводит еще к большим затратам CPU.

Reference Сounting. В этом случае Runtime подсчитывает количество ссылок на объекты. Они хранятся в памяти, пока их больше 0, а как только становится 0 — очищаются оттуда. На таком подходе до сих пор существует Objective-C с его ARC. В первых версиях Perl тоже был только Reference Counting и не было сборки мусора.

GC (сборщик мусора, garbage collector). Есть языки со сборщиками мусора, в которых разработчику вообще не нужно переживать об управлении памятью. Мы просто создаем столько объектов, сколько нужно, и верим, что память очистится самостоятельно.

В языках со сборкой мусора два первых пункта остаются такими же:

  • частые syscall ↔ выделение лишней памяти для пуллинга;

  • фрагментация ↔ затраты CPU.

Но еще добавляются затраты CPU на сборку мусора. Чем больше мусора, тем больше CPU мы тратим. А еще тратится RAM — на воркеры очистки и пометки, но это можно игнорировать.

В самых банальных реализациях языков с GC программа полностью тормозится на время, чтобы позволить GC очистить неиспользуемые объекты. В удачных реализациях тормозят не «весь world», а отдельные потоки. Чем дальше мы движемся в тюнинге GC, тем паузы становятся короче. Но избежать их полностью не удастся, так как они нужны.

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

  • Выделение больших спанов памяти — так избегаем лишних syscall.

  • Спаны выделяются под объекты одинаковых размеров — это позволяет избежать жесткой фрагментации и активно переиспользовать освобожденные участки памяти.

  • Ограничение до ~25% CPU. Авторы языка предупредили: если создавать много объектов, до четверти CPU уйдет на сборку мусора. Может, звучит и страшно, зато мы это знаем и всегда учитываем, что 25% CPU у нас будет сверху, с этим можно жить.

  • Минимизирован stop the world. Сложно найти язык с подобной минимизацией — разве что последние версии Java с их новым сборщиком мусора. Последние большие изменения произошли в Go 1.5. Как раз в этой версии ушли от достаточно банального сборщика мусора ранних версий. В современном Concurrent Mark Sweep постоянно работает background job, разделяющая объекты на достижимые и недостижимые. При маленьком heap хватит меньше миллисекунды, а для большого — несколько. GC в последующих версиях продолжили планомерно улучшать, и сейчас даже с большими heap stop the world всегда имеет субмиллисекундное значение.

  • Использование стековой памяти. GC в Golang ругают за то, что в нем нет использования поколений и в результате он постоянно обходит все объекты. Но в Go это и не нужно. Это в Java выделение объектов практически всегда приводит к их выделению в heap. В Go большинство короткоживущих объектов останутся на стеке и очистятся автоматически.

    Есть и другие менее известные подходы к управлению памятью. Например, уникальный подход в Rust c его концепцией владения. Некоторые могут усмотреть в ней схожесть со smart pointers в С++.

    Еще один пример подхода — миксовый, сочетающий сразу несколько описанных выше. Так, например, в классической тройке скриптовых языков Python, Ruby, Perl одновременно используется сборка мусора и reference counting.

Улучшить производительность можно с помощью Region-Based Memory Management, если в профайлинге мы обнаружили, что сборка мусора мешает.

Компьютер-сайентисты еще в 70-х активно исследовали управление памятью на основе регионов.

На основе подхода Region-Based Memory Management даже пытались делать отдельные языки, сделав его единственным способом управления и сборки памяти. Но до прода они так и не дошли, оставшись в академических работах.

В статьях об этом обычно не пишут, но Region-Based Memory Management мне очень напоминает такой стек.

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

Логика такая же: предсоздаем большие chunk памяти, в которых потом можем быстро выделить новую структуру. Для этого кладем ее по указателю, инкрементируем указатель, и готовы выделять дальше. Единственная дополнительная проверка: не вышли ли мы за размеры уже выделенного chunk. Если вышли, должны аллоцировать новый chunk.

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

Банальный пример, повсеместно используемый в разработке игр. Игроки ожидают, что фреймрейт будет 120+, и мы не хотим их подводить, а еще хотим избежать утечек памяти и вылета из игр. А для этого языки с автоматическим GC подходят хуже, потому что stop the world и прочие остановки могут настичь в любой случайный момент. Тогда пользователь точно заметит, что что-то не так. 

С ручным управлением тоже не все гладко. Если у нас будет syscall в самый активный момент игры, снова возникает риск просадки производительности. Пользователи будут жаловаться, что все тормозит, и увидят вместо игрового процесса кратковременное слайд-шоу. 

Остается вопрос: как после уровня очистить весь мусор, который успел накопиться? Ведь в играх даже простой уровень создает в памяти огромное количество объектов. И здесь подход с аренами очень применим. Рассмотрим на примере. 

Жизненный цикл уровня абстрактной игры
Жизненный цикл уровня абстрактной игры

Представим, что в игре начинается уровень, который занимает большой кусок памяти, и мы создаем арену. На игровых консолях арену часто специально делают фиксированного размера — на всю свободную память. В арене создаем столько объектов, сколько нужно. Ведь мы защищены от syscall, а значит, никаких просадок не будет. А в конце уровня, сколько бы объектов там ни оказалось, нам не придется их обходить — мы просто дропнем арену и начнем заново.

Go: Arena

С версии 1.20 в Go появились арены, а значит, теперь можем попробовать их в своем коде. Для включения арен есть специальная переменная окружения GOEXPERIMENT.

$ export GOEXPERIMENT=arenas

или

$ go run -tags goexperiment.arenas main.go

За GOEXPERIMENT разработчики языка скрывают еще не до конца стабильные фичи, которые хотят протестировать. Сейчас самое интересное — это арены и rangefunc. Но, может, скоро мы увидим и другие эксперименты.

Пакет arena лаконичен. Есть метод NewArena, чтобы создать арену. А еще есть метод Free, чтобы ее освободить.

package main

import "arena"

func main() {
  a := arena.NewArena()
  defer a.Free()
    
  // ...
}

Функция New помогает аллоцировать переменную на арене. А чтобы аллоцировать слайс, есть функция MakeSlice. Заметьте, используются современные дженерики, чтобы все было строго типизировано.

myStruct := arena.New[MyStruct](a)
myStructs := arena.MakeSlice[MyStruct](a)

Последний метод — Clone, который позволяет забрать что-то из арены обратно в heap даже после удаления. Ведь если обратиться к памяти арены после ее освобождения, данные будут битыми и все сломается.

myStruct := arena.Clone[MyStruct](myStruct)

Когда использовать арены целесообразно

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

Использовать аренды стоит, если:

  • Сборщик мусора тормозит работу программы. Просто так тащить арены в код не нужно — это экспериментальная практика.

  • Один из примеров, приближенных к разработке сервисов, — из-за GRPC. Особенность GRPC в том, что при парсинге протобафа в памяти возникает огромное количество мелких объектов, которые потом ложатся грузом на сборщик мусора. 

GRPC — это основной протокол, который используют в Google для разработки своих микросервисов. Да и в целом по рынку GRPC в Go значительно популярнее остальных протоколов вроде REST. Ребята практически на пустом месте получили рост 15% на парсинге протобафа.

Раньше парсинг протабафа с аренами был только в «плюсах». Там он тоже давал неплохой прирост, несмотря на ручное управление памятью. В других языках, особенно со сборкой мусора и динамических, такого не было, потому что там почти никто особо не задумывается о производительности. Для Google же GRPC оптимизировали. А значит, теперь и мы сможем этим пользоваться. 

Если возникла мысль, что нужно затюнить кучу мелких объектов с помощью арены, я скажу: не стоит, пока не появятся четкие данные профайлинга.

Что искать в профайлинге

runtime.gcBgMarkWorker. В профайлинге видно, что GC занимает значительное время. К примеру, можно поискать, сколько занимает gcBgMarkWorker. Он работает в бэкграунде, обходит все объекты в heap и раскрашивает их. На маленьком heap это будет даже незаметно. Но как только он у вас вообще появился или тем более стал красным, как в примере, на него стоит обратить внимание.

runtime.gcStart вызывается каждый раз, когда heap чувствительно увеличивается и начинается сборка мусора, очистка объектов. Его вы можете увидеть, когда создается очень много мелких объектов. В этом случае GC будет не успевать, поэтому станет запускаться все чаще.

Бенчмарк

Посмотрим как все работает в сравнении с созданием объектов на хипе. Создадим тестовую структуру на 2 int и конструктор для нее.

type MyStruct struct {
	a int
	b int
}

func NewMyStruct(a, b int) *MyStruct {
    return &MyStruct{a, b}
}

А затем небольшой бенчмарк, с помощью которого будем создавать эту структуру.

var N = 1_000_000


func BenchmarkHeap(b *testing.B) {
	for i := 0; i < b.N; i++ {
		slice := make([]*MyStruct, N)

		for j := 0; j < N; j++ {
			slice[j] = NewMyStruct(i, j)
		}
	}
}

Такие бенчмарки я не рекомендую писать никому, даже в уникальных кейсах, потому что в идеале в бенчмарке должен быть один цикл по b.N, чтобы он самостоятельно определял, сколько нужно сделать итераций, пока значения не стабилизируются. 

В этом примере — кейс, когда создание простой переменной обходится достаточно дешево, а время сильно плавает. Чтобы время оставалось стабильным, нужен внутренний цикл и slice. Это обеспечит достаточное количество объектов для GC, которые набираются в памяти.

Если мы запустим этот бенчмарк, увидим следующее.

$ go test -bench='BenchmarkHeap' -cpu=1 \
  -benchmem -benchtime=3s -cpuprofile=cpu.out

goos: darwin
goarch: amd64
pkg: code_examples/all
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkHeap 94 38049820 ns/op 24003781 B/op 1000001 allocs/op
PASS
ok      code_examples/all       6.852s

Теперь посмотрим то же самое в таблице.

В примере миллисекунды и объем выделенной памяти не так важны, но видно, что мы создаем миллион объектов во внутреннем цикле — в heap миллион аллокаций.

Теперь посмотрим на flamegraph того, что происходит.

Основная работа для нас — как раз создание объектов, а newobject занимает большую часть времени. Но можно увидеть, что GC MarkWorker занял больше 10% времени. А если посмотреть на длинные хвосты в создании, можно заметить, что аллокатору Go приходилось обращаться к ОС за памятью.

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

func NewMyStructWithArena(
	mem *arena.Arena, 
	a, b int,
) *MyStruct {
	s := arena.New[MyStruct](mem)
	s.a = a
	s.b = b

	return s
}

Бенчмарк с ареной выглядит абсолютно также.

func BenchmarkArena(b *testing.B) {
	for i := 0; i < b.N; i++ {
		mem := arena.NewArena()
		slice := arena.MakeSlice[*MyStruct](mem, N, N)

		for j := 0; j < N; j++ {
			slice[j] = NewMyStructWithArena(mem, i, j)
		}

		mem.Free()
	}
}

Сначала создали арену, затем на ней сделали slice, воспользовались новым конструктором, а в конце каждой итерации бенчмарка освободили арену.

В таблице приведено сравнение с процентами, которые дал benchstat. Посмотрим сравнения с benchstat.

Он также показывает среднее отклонение результатов при нескольких запусках.

Мы сэкономили CPU, и в этот раз потратили на 40% меньше времени на создание всех переменных. Потраченная память при этом примерно такая же. Интересно, что вместо миллиона аллокаций у нас теперь их семь — видимо, одна аллокация уходит непосредственно на саму арену и 6 chunk требуется, чтобы все это разместить.

Выглядит это так.

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

Arena vs sync.Pool

Раньше, когда мы сталкивались с проблемами со сборщиком мусора, практически всегда классическим решением было использовать sync.Pool. Он снижает давление сборщика мусора на аллокации и очистку. 

Идеологически sync.Pool предназначен для хранения объектов, чье создание достаточно дорого, и нужен:

  • для одинаковых объектов только одного типа;

  • разных горутин.

sync.Pool полезен, когда нужен один пул на множество горутин или потоков. Но если потоков мало или программа однопоточная, лишние lock и mutex могут чувствительно замедлить. Это зависит от того, сколько в sync.Pool будет объектов. 

Если объектов примерно столько же, сколько горутин, и нам этого хватает, все будет неплохо. А если rate создания объектов больше и в пуле их скопится достаточно много, на нас начнет давить бэкграунд gcBgMarkWorker, помечающий объекты. Так как он ничего не знает о поколениях, будет постоянно обходить все.

Посмотрим бенчмарк с пулом.

func NewMyStructWithPool(
	pool *sync.Pool,
	a, b int,
) *MyStruct {
	s := pool.Get().(*MyStruct)
	s.a = a
	s.b = b
	return s
}

Здесь практически все как в предыдущих примерах: снова поменяли конструктор, передаем туда инициализированный пул, из пула берем MyStruct. Сами пулы сделаем глобальными, один пул — непосредственно для MyStruct, один — для slice.

var (
	pool = &sync.Pool{
		New: func() any {
			return new(MyStruct)
		},
	}
	slicePool = &sync.Pool{
		New: func() any {
			return make([]*MyStruct, N)
		},
	}
)

Бенчмарк немного изменился.

func BenchmarkPool(b *testing.B) {
	for i := 0; i < b.N; i++ {
		slice := slicePool.Get().([]*MyStruct)

		for j := 0; j < N; j++ {
			slice[j] = NewMyStructWithPool(pool, i, j)
		}

		for j := range slice {
			pool.Put(slice[j])
			slice[j] = nil
		}
		slicePool.Put(slice)
	}
}

Как именно: slice получаем из пула, создаем с помощью конструктора, но нам приходится делать дополнительный цикл, чтобы в конце все-таки вернуть все объекты обратно в пул.

Если посмотреть на результаты этого бенчмарка, увидим, что пул значительно быстрее, чем просто выделение на heap.

Мы сэкономили 15% CPU, но это все равно не дотягивает до арены из-за того, что объектов было достаточно много. Зато мы практически не тратим память, так как постоянно переиспользуем одни и те же объекты.

На flamegraph видно, что GC снова практически нет, объектов было мизерное количество, а основное время заняла работа с самим пулом.

Проблемы в работе с аренами

Теперь поговорим о подводных камнях, которые могут встретиться при работе с аренами.

Issue#1

Сделаем практически тот же код, что был в начале, но добавим маленькое изменение — тип переменной, которая хранится внутри MyStruct (был int, теперь slice).

type MyStruct struct {
	a int
	b []int
}

func NewMyStruct(a int, b []int) *MyStruct {
    return &MyStruct{a, b}
}

То же самое я сделал в самом бенчмарке.

func BenchmarkArena(b *testing.B) {
	for i := 0; i < b.N; i++ {
		mem := arena.NewArena()
		slice := arena.MakeSlice[*MyStruct](mem, N, N)
		for j := 0; j < N; j++ {
			slice[j] = NewMyStructWithArena(
				mem,
				i,
				[]int{j},
			)
		}
		mem.Free()
	}
}

Теперь у нас j попадает в конструктор и есть int slice. Если мы запустим бенчмарк, с ужасом увидим два миллиона аллокаций, хотя на арене их быть вообще не должно.

$ go test -benchmem -cpuprofile=cpu.out -benchtime=3s -bench=.
goos: darwin
goarch: amd64
pkg: code_examples/issue_1
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkArena-16  76  50 ms/op  45.77 MiB/op  2000001 allocs/op
PASS

2000001 🤔 упс, оно утекло в heap

Что-то пошло не так, значит, попробуем явно создать slice в арене.

func BenchmarkArena(b *testing.B) {
	for i := 0; i < b.N; i++ {
		// ...

		for j := 0; j < N; j++ {
			jSlice := arena.MakeSlice[int](mem, 1, 1)

			slice[j] = NewMyStructWithArena(
				mem,
				i,
				jSlice,
			)
		}

После этого все нормализовалось и стало снова только 11 аллокаций. Разберемся, в чем было дело.

$ go test -benchmem -cpuprofile=cpu.out -benchtime=3s -bench=.
goos: darwin
goarch: amd64
pkg: code_examples/issue_1_fixed
cpu: Intel(R) Core(TM) i9-9980HK CPU @ 2.40GHz
BenchmarkArena-16 45 70.9 ms/op 45.86 MiB/op 11 allocs/op
PASS
ok      code_examples/issue_1_fixed     3.843s

11 allocs/op 👍

Арены не будут создавать все ссылочное автоматически. По всему дереву вложенных объектов нужно вручную создать pointer — типы структур арены, — иначе они утекут в heap, в том числе slice. Но для slice есть специальный метод, а строки и maps в арене создать вообще не получится. 

Придется искать обходные пути. Для строк можно предложить использовать в SliceByte, а для maps остается написать свою реализацию. Только в этом случае получится ее туда вместить.

Issue#2

И вот мы создали в арене slice с capacity 5, добавив туда 5 переменных. Все замечательно работает.

mem := arena.NewArena()
defer mem.Free()

s := arena.NewSlice[int](mem, 0, 5)
s = append(s, 1, 2, 3, 4, 5) // ✅

// ...

Но если добавим шестой элемент, нижележащий массив снова утечет в heap.

mem := arena.NewArena()
defer mem.Free()

s := arena.NewSlice[int](mem, 0, 5)
s = append(s, 1, 2, 3, 4, 5) // ✅

// ...

s = append(s, 6) // 😞 => убежит в heap

Нам даже понятно, почему, ведь мы не можем убрать предыдущий slice, а расти ему некуда: в следующей памяти уже может быть что-то записано. Поэтому, если вам нужно использовать slice, лучше заранее выделить весь необходимый capacity или вручную пересоздать новый.

Issue#3

Третья проблема самая очевидная: у нас появилось ручное управление памятью. Вот банальный пример с ошибкой.

func main() {
	mem := arena.NewArena()
	s := NewMyStruct(mem, 42, 32)
	mem.Free()

	fmt.Println(s)
}

Мы создали арену, переменную, очистили арену и пытаемся распечатать MyStruct. В этом примере — классический вариант Use-After-Free, который может привести к неочевидным ошибкам. Здесь это кажется очевидным, но в реальном коде все может быть совсем иначе. В C++, например, почти наверняка был бы segfault. Но Go нас о таком не предупредит.

Все отработало нормально, потому что сборка мусора не успела очистить chunk арены и по этому адресу по-прежнему была нормальная структура.

func main() {
	mem := arena.NewArena()
	s := NewMyStruct(mem, 42, 32)
	mem.Free()

	fmt.Println(s)
}
$ go run main.go
&{42 32}

А еще нам помогут санитайзеры, но они поддерживаются только в Linux.

$ go help build
    ...
    -msan
            Link with C/C++ memory sanitizer support.
    -asan
            Link with C/C++ address sanitizer support.
    ...

Еще в Go 1.5 Google добавил memory sanitizer, а в Go 1.18 address sanitizer. Так же как с нашим race-детектором, ребята из скора разработки решили не придумывать велосипеды, а взяли то, что уже используется для LLVM. Тогда это был thread sanitizer, а теперь — memory и address sanitizer.

  • Memory sanitizer нужен, чтобы найти использование неинициализированной памяти.

  • Address sanitizer немного хитрее. Он следит за обращением к адресам, которые сейчас недоступны, не инициализированы, выходят за выделенные области.

Изначально их добавляли, чтобы удобнее работать с биндингами с C/C++-кодом. Сейчас туда интегрировали арены. Пока оба инструмента работают только в Linux, поэтому любителям маков придется в запускать их Docker-контейнерах.

Если мы запустим ту же программу с memory sanitizer, он тут же упадет с информацией, что мы используем неинициализированное значение.

$ go run -msan main.go
Uninitialized bytes in __msan_check_mem_is_initialized at offset 0 inside [0x51c0007ffff0, 16)
==2645==WARNING: MemorySanitizer: use-of-uninitialized-value
    #0 0x506f2c  (/tmp/go-build2039100842/b001/exe/main+0x506f2c) (BuildId: 175a63c9410f482f9a7c98184ed3a818f3eec3b7)

SUMMARY: MemorySanitizer: use-of-uninitialized-value (/tmp/go-build2039100842/b001/exe/main+0x506f2c) (BuildId: 175a63c9410f482f9a7c98184ed3a818f3eec3b7) 
Exiting
exit status 1

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

$ go run -asan main.go
=================================================================
==2776==ERROR: AddressSanitizer: use-after-poison on address 0x40c0007ff7f0 at pc 0x00000053819d bp 0x000000000000 sp 0x10c0000477c0
READ of size 16 at 0x40c0007ff7f0 thread T0
    #0 0x53819c  (/tmp/go-build2097485829/b001/exe/main+0x53819c) (BuildId: d4c6575e6a95dbd9d8c9da71c3ec1f54bdaedc2f)

Address 0x40c0007ff7f0 is a wild pointer inside of access range of size 0x000000000010.
SUMMARY: AddressSanitizer: use-after-poison (/tmp/go-build2097485829/b001/exe/main+0x53819c) (BuildId: d4c6575e6a95dbd9d8c9da71c3ec1f54bdaedc2f) 
Shadow bytes around the buggy address:
  0x0818800f7ea0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
  0x0818800f7eb0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
  0x0818800f7ec0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
  0x0818800f7ed0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
  0x0818800f7ee0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7
=>0x0818800f7ef0: f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7 f7[f7]=2776==ABORTING
exit status 1

Готовность арен к использованию

Рано еще использовать арены или нет — вопрос спорный. Получить 40% можно только при очень большом количестве объектов. 

Можно посмотреть другой реальный пример из жизни — когда к нам приходят батчевые запросы с Kafka на 4—8 МБ и достаточно много объектов в целом. В этом случае мы можем создать арену в памяти, чтобы обработать их батчем и затем куда-то переложить. Но тогда их, скорее всего, будет отнюдь не миллион. Если уменьшить количество создаваемых объектов до 256 тысяч, арены начинают отставать даже от heap, а тот же пул продолжает сохранять свое превосходство.

256 тысяч — это не какое-то магическое число, а скорее примерное. Я много раз запускал свои бенчмарки с разными значениями, и примерно на этом порядке значений происходит надлом, когда больше начинают выигрывать арены, а меньше — просто heap.

Проблемы со сборщиками мусора

Проблемы со сборщиками мусора с нами давно. Мы не только сейчас столкнулись с тем, что GC иногда мешает. Думаю, многие помнят статью Discord 2020 года, в которой ребята пожаловались, как не справились со сборщиком мусора в Go в их сервисе. Сервис кэшировал количество непрочитанных сообщений, и им даже пришлось переписать этот сервис на Rust.

В том же 2019 году не только Discord столкнулся с большим кэшем, которому слегка мешает GC. Есть компания Dgraph, которая пилит одноименную графовую базу данных. Внутри этой базы есть кэш ristretto, который намного популярнее самой базы. Уверен, многие его использовали. 

Но ребята из Dgraph разобрались, как можно улучшить кэш, чтобы безопасно и быстро хранить объекты в памяти. Для этого написали свою маленькую обертку, которую назвали z. По сути, это тончайшая Go-прослойка над «плюсовым» аллокатором jemalloc. Внутри этой библиотеки есть аллокатор, который практически целиком такой же, как арена, только использует jemalloc, а не какие-то собственные внутренние штуки.

По использованию он тоже похож на арены. 

func NewMyStructWithZ(
	mem *z.Allocator,
	a, b int,
) *MyStruct {
	data := mem.Allocate(size)
	s := (*MyStruct)(unsafe.Pointer(&data[0]))
	s.a = a
	s.b = b
	return s
}

Так выглядит еще один конструктор, который в этот раз принимает z.Allocator. Только в то время еще не было дженериков, поэтому приходится сначала выделить память. Создается указатель на первый элемент, этот указатель — к unsafe.Pointer, unsafe.Pointer — к указателю на структуру. 

Бенчмарк тоже примерно такой же, как с аренами, кроме того что slice опять же создаем с помощью магии unsafe.

func BenchmarkZ(b *testing.B) {
	for i := 0; i < b.N; i++ {
		sz := z.NewAllocator(8<<20, "benchmark")

		arrData := sz.Allocate(N * ptrSize)
		slice := (*[N]*MyStruct)(unsafe.Pointer(
			&arrData,
		))[:]

		for j := 0; j < N; j++ {
			slice[j] = NewMyStructWithZ(sz, i, j)
		}

		sz.Release()
	}
}

Финальная таблица сравнения

Выводы 

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

Мне приходилось использовать z.Allocator, он работает достаточно быстро, для отладки ASan и MSan также подходят. Если заметили, что в бенчмарках, профайлингах и тому подобном вам мешают GC, можно использовать эти решения. Если, конечно, не боитесь unsafe.