Меня зовут Максим Горозий. Я тимлид в Т-Банке, работаю над нашей образовательной платформой, которая служит для разных направлений бизнеса. В ИТ больше 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.

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


  1. smarthomeblog
    16.07.2024 16:39
    +2

    Спасибо за интересную и познавательную статью!

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

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


    1. mgorozii Автор
      16.07.2024 16:39
      +1

      Да, всё верно. После free обращаться к переменным из арены нельзя. Если же после использования арены нужно забрать какой‑то результат, то clone позволяет скопировать память из арены в хип.


  1. greg913
    16.07.2024 16:39
    +2

    Хорошая статья, толковая. Спасибо!


  1. slonopotamus
    16.07.2024 16:39
    +3

    Это в Java выделение объектов практически всегда приводит к их выделению в heap. В Go большинство короткоживущих объектов останутся на стеке и очистятся автоматически.

    Смелое заявление. А давайте я скажу наоборот: escape analysis в Go - детская поделка на фоне того что умеет JVM.


    1. mgorozii Автор
      16.07.2024 16:39
      +2

      Да, за счет JIT и большей истории escape analysis в Java бодрее, чем в Go. Но и там, и там далек от совершенства.

      Возможность работы с сущностями как значениями — полезная особенность, которая частично нивелирует отсутствие «поколений» в сборщике мусора. Но об этом часто забывают.


    1. Mikanor
      16.07.2024 16:39
      +2

      Смелое заявление. А давайте я скажу наоборот: escape analysis в Go - детская поделка на фоне того что умеет JVM.

      До JIT ещё надо дожить, а с учётом числа магии, которую использует тот же Spring под капотом, JIT ещё и очень консервативно может подойти к вопросу о размещении переменных.

      Но дело даже не в этом. В отличие от JVM-языков, Go — это язык, где значения, а не ссылки, правят бал. Слайс структур будет находиться в непрерывной памяти, а сами структуры с такими же полями будут представлены как непрерывный кусок в памяти. Всё это приводит к тому, что Go, при правильном использовании, генерирует меньшее число объектов на хипе. И даже наши дженерики следуют этому правилу — за счёт отсутствия type erasure я могу быть уверен, что в таком коде:

      type MyCollection[T any] struct {
         slc []T
      }
      

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

      И да, я в курсе про Project Valhalla. В курсе про него я с 2016 года, а воз и ныне там.

      P.S. А ещё у нас List[MyType] и List[MyAnotherType] — это разные типы, которые видны рантайму в момент исполнения. :)


      1. slonopotamus
        16.07.2024 16:39

        Вы вот это всё красиво рассказываете, только мы находимся в комментах статьи с текстом следующего содержания:

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

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

        Чего же GRPC на value-типах не сделали, а?


        1. Mikanor
          16.07.2024 16:39

          Ну давайте детально. Начну с конца.

          Да и в целом по рынку GRPC в Go значительно популярнее остальных протоколов вроде REST.

          Это неправда, я не знаю, откуда автор это взял. Основным протоколом по-прежнему остается HTTP+JSON практически во всех компаниях. И тут есть довольно понятный ответ: gRPC до сих пор не подружили с вебом. Да, есть всякие библиотеки от энтузиастов (последнюю, кстати, мы используем, но мы исключение, и у нас довольно специфичный стек). Но вот коробочного решения пока нет, поэтому делать сервис, который общается с пользователями на основе gRPC, не самая лучшая идея. А как гонять трафик между микросервисами — это уже отдельный вопрос

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

          Это правда, но не вся. Стандартный декодер gRPC очень активно использует reflect для кодирования и декодирования. Тут любой язык начнёт складываться, не говоря уж про то, что правила простановки полей из reflect довольно специфичные. Я не виню разработчиков gRPC, что они не стали вникать и сделали как проще. Но есть сторонние библиотеки, которые работают на основе кодогенерации, и у них ситуация намного лучше.

          Чего же GRPC на value-типах не сделали, а?

          Не было дженериков, из-за этого пришлось использовать указатели там, где их по-хорошему использовать не надо (у gRPC все поля optional, и возникает вопрос, как передать разницу между пустой структурой и структурой, которую не прислали). Это раз. Во-вторых, либу отвечающую за протобаф и gRPC писала (как минимум вначале) команда, которая была абсолютно не знакома с языком. По этой же причине Kubernetes, хоть и является крутым продуктом, но код его не сильно идиоматичный, ведь это перенос прототипа, который был написан на Java.


  1. demoth
    16.07.2024 16:39

    Ссылка на ristretto битая (ну для import она норм, но не для браузера :)).

    Видимо, имелось в виду либо
    https://github.com/dgraph-io/ristretto/
    либо
    https://pkg.go.dev/github.com/dgraph-io/ristretto/z


    1. mgorozii Автор
      16.07.2024 16:39

      Спасибо. Fixed.


  1. Mikanor
    16.07.2024 16:39
    +1

    Проблема кастомных аллокаторов, основанных на чём-либо, кроме того, о чём знает рантайм (включая вызов unix.Mmap), заключается в том, что внутри такой памяти нельзя размещать объекты из Go-хипа. Т.е. если у вас указатели, интерфейсы, слайсы, то без плясок с бубном разместить инстансы таких типов внутри объектов, которые лежат в памяти, полученной от аллокатора, не получится. А в случае мап и каналов не получится вообще.

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

    Но сама идея арен интересна, и рантайм Go, судя по дизайн-доку, должен ловить use-after-free баги и убивать программу. Просто текущий прототип этого не делает.

    The implementation is required to cause a run-time erro and terminate the Go program if the application accesses any object whose memory has already been freed.

    А sync.Pool, кстати, должен хорошо себя показать с итераторами. Т.к. можно после окончания работы итератора прозрачно для пользователя возвращать коллекцию обратно в пул.


  1. kivan_mih
    16.07.2024 16:39
    +2

    Чем больше мусора, тем больше CPU мы тратим

    Не знаю, как обстоит дело в go, но в java, например, это не так. Если взять любой коллектор на основе поколений, ему все равно сколько у вас мертвых объектов, поскольку он обойдет граф по живым, скопирует их все в соседний пустой регион (survivor space 1,2), а дальше просто объявит исходный регион пустым. Если бы это так не работало, то создавать что-то в куче было бы дорого и все поголовно пользовались бы пулами даже для простых объектов. Так делают крайне редко именно потому, что короткоживущие объекты на gc почти не влияют


  1. megasuperlexa
    16.07.2024 16:39

    Да и в целом по рынку GRPC в Go значительно популярнее остальных протоколов вроде REST.

    правда что ли?

    а в остальном познавательно, спасибо.