Жонглирование памятью: арены в Golang
- понедельник, 30 июня 2025 г. в 00:00:09
Приветствую, в высоконагруженной среде аллокации большого размера достаточно сильно влияют на скорость обработки той или иной части сервиса, для того чтобы более тонко контролировать память, появились арены. Как же они включаются? Тут всё просто, нужен флаг GOEXPERIMENT=arenas. Разберем на примере работу памяти с "маленькими объектами".
За "маленькие объекты" (крошечные) в Golang отвечает так называемый "tiny allocator". Как же он работает?
В golang, все объекты небольшого размера проходят путь: tiny allocator -> mcache -> span pool (с tinySpanClass) - heap.
Маленьким же объектом в свою очередь считается объект, который меньше 16 байт, если посмотреть в исходный код, то можно увидеть почему выбрали такую константу:
// Size of the memory block used for combining (maxTinySize) is tunable.
// Current setting is 16 bytes, which relates to 2x worst case memory
// wastage (when all but one subobjects are unreachable).
// 8 bytes would result in no wastage at all, but provides less
// opportunities for combining.
// 32 bytes provides more opportunities for combining,
// but can lead to 4x worst case wastage.
// The best case winning is 8x regardless of block size.
Все сделано прежде всего для оптимизации.
Все эти действия внутри одного P, поэтому аллокация маленького объекта очень незначительная. GC вмешивается только дважды за цикл: stop the world, в начале и конце маркировки (termination), а между ними работает concurrent mark. Попросту говоря - используется автоматический mark and sweep. В свою же очередь в арене - многие объекты сыпятся в один спан и отдаются рантайму одной операцией, наш GC не вмешивается и нам самим приходится следить за жизненным циклом.
Объекты арены не участвуют в tri-color до Free
, там сдвигается только указатель без глобальных структур внутри выделенного span
Выше в статье я упомянул про "Span", что же это такое в классическом понимании Go?
Span - непрерывная последовательность страниц, минимальный блок, которым операторы аллокации и GC оперируют внутри кучи.
Когда в локальном mcache не остаётся свободных ячеек нужного размера, он забирает полностью свободный span из mcentral и делит его bump-указателем под объекты
mcentral, когда ему больше не хватает памяти, запрашивает новый span у глобального менеджера страниц mheap. Тот, в свою очередь, резервирует N страниц в page heap
Более подробно можно раскрыть эту тему в следующих статьях, пока что зацикливаться не будем.
Для начала включим наши арены:
export GOEXPERIMENT=arenas
Предисловие: для больших же объектов го выделяет свой собственный mspan (набор страниц) и отдача идет из глобального mheap. Рассмотрим пример с использованием бенча + арен:
func nsPerAlloc(b *testing.B) {
b.ReportMetric(float64(b.Elapsed().Nanoseconds())/M, "ns/alloc")
}
type Big [1 << 20]byte
const M = 10_000
func BenchmarkHeapBig(b *testing.B) {
for i := 0; i < b.N; i++ {
buffers := make([]*Big, M)
for j := 0; j < M; j++ {
buf := new(Big)
buf[0] = byte(j)
buffers[j] = buf
}
}
nsPerAlloc(b)
}
func BenchmarkArenaBig(b *testing.B) {
for i := 0; i < b.N; i++ {
a := arena.NewArena()
buffers := make([]*Big, M)
for j := 0; j < M; j++ {
buf := arena.New[Big](a)
buf[0] = byte(j)
buffers[j] = buf
}
a.Free()
}
nsPerAlloc(b)
}
goos: darwin
goarch: arm64
pkg: a
cpu: Apple M3 Pro
BenchmarkHeapBig-11 28 1843796832 ns/op 5162629 ns/alloc 10485842133 B/op 10001 allocs/op
BenchmarkArenaBig-11 1 1314971541 ns/op 131497 ns/alloc 11800062248 B/op 1445 allocs/op
PASS
ok a 53.578s
Почему так?
1) каждый большой объект в рантайме идет по пути большой (large) аллокации с выделением mspan, 10k аллокаций - 10к sys calls
2) арена же в свою очередь резервирует span большой и небольшие аллокации для slice headers
Вот пример кода, который охватывает основное API для работы с ними:
type Point struct {
x, y int
}
const N = 1_000
func main() {
a := arena.NewArena() // создание
defer a.Free() // освобождение
p := arena.New[Point](a) // выделение под структуру
p.x, p.y = 10, 20
points := arena.MakeSlice[Point](a, 0, 100) // слайс внутри той же арены
for i := 0; i < 10; i++ {
pt := arena.New[Point](a)
pt.x, pt.y = i, i*i
points = append(points, *pt)
}
heapPoints := arena.Clone(points) // теперь весь слайс в heap
fmt.Println("first =", heapPoints, "len =", len(heapPoints))
}
go run tiny.go
first = [{0 0} {1 1} {2 4} {3 9} {4 16} {5 25} {6 36} {7 49} {8 64} {9 81}] len = 10
Сама арена хранит внутри себя указатель
type Arena struct {
a unsafe.Pointer
}
В свою очередь, NewArena
вызывает функцию runtime_arena_newArena() unsafe.Pointer
. Который выделяет один общий mspan через общую кучу.
Как же GC видит арену?
особенность же арены, как я подмечал выше, что GC не сканирует арену и не допускает объекты к нему, потому что помечает ее как noscan
после Free
, GC начинает просматривать арену как объект и сама арена помечана как `zombie`
sweep-worker после mark-term переводит span в idle и память может быть отдана в обычный heap, повторное использование как раз-таки происходит после ближайшего цикла GC
Существует еще особенность, связанная с тем, что у нее есть finalizer, что нужен, если разработчик не вызовет Free
, то рантайм уловит его.
вызов runtime_arena_arena_New
получает тип через reflect.Type
учитывает выравнивание, копирует zero-word и просто сдвигает bump указатель
резервирует backing массив тем же bump указателем, но заголовок среза размещает в обычной куче
для оптимизации GC внутри чанка: Pointer-ful объекты растут снизу вверх, Pointer-free — сверху вниз. Это даёт рантайму право раньше закончить сканирование и пропустить очистку bitmap для чистых участков памяти
Копирует тип на обычную кучу через runtime_arena_heapify, обнуляя все связи с ареной.
В результате:
данные живут сколь угодно долго
GC начинает их сканировать уже как обычный объект
Также стоит учесть момент, что если забыт Clone
или идет пользование объектом после Free
, программа падает с SIGSEGV, что дает нам легко отслеживать такие махинации с прошлым адресным пространством.
когда нужно обработать большие данные за один запрос, например - большой краткоживущий буффер и нужно освобождение разом
большие объекты разных размеров, чтобы не плодить sync.Pool
, а как мы знаем, GC может освободить их в не подходящий момент ;)
если мы и используем арены, то лучше всего использовать build тег, потому что их могут убрать в будущем, как написано в самих комментариях к исходному коду
несколько фиксированных буфферов, sync.Pool
сделает это быстрее, бенч это очень хорошо покажет
объекты живут дольше запроса
и самое главное - арена не потокобезопасна, поэтому лучше ограничиться выполнением ее в одной горутине
Надеюсь вам понравилась данная статья, если будут какие-то предложения или улучшения по статье - пишите! Буду благодарен!