Приветствую, в высоконагруженной среде аллокации большого размера достаточно сильно влияют на скорость обработки той или иной части сервиса, для того чтобы более тонко контролировать память, появились арены. Как же они включаются? Тут всё просто, нужен флаг GOEXPERIMENT=arenas. Разберем на примере работу памяти с "маленькими объектами".

Маршрут "маленького объекта"

За "маленькие объекты" (крошечные) в Golang отвечает так называемый "tiny allocator". Как же он работает?

пусть small size объекта
пусть small size объекта

В 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

Выше в статье я упомянул про "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, то рантайм уловит его.

arena.New[T](a)

  • вызов runtime_arena_arena_New получает тип через reflect.Type

  • учитывает выравнивание, копирует zero-word и просто сдвигает bump указатель

arena.MakeSlice[T](a,len,cap)

  • резервирует backing массив тем же bump указателем, но заголовок среза размещает в обычной куче

  • для оптимизации GC внутри чанка: Pointer-ful объекты растут снизу вверх, Pointer-free — сверху вниз. Это даёт рантайму право раньше закончить сканирование и пропустить очистку bitmap для чистых участков памяти

Clone

Копирует тип на обычную кучу через runtime_arena_heapify, обнуляя все связи с ареной.

В результате:

  • данные живут сколь угодно долго

  • GC начинает их сканировать уже как обычный объект

Также стоит учесть момент, что если забыт Clone или идет пользование объектом после Free, программа падает с SIGSEGV, что дает нам легко отслеживать такие махинации с прошлым адресным пространством.

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

  • когда нужно обработать большие данные за один запрос, например - большой краткоживущий буффер и нужно освобождение разом

  • большие объекты разных размеров, чтобы не плодить sync.Pool, а как мы знаем, GC может освободить их в не подходящий момент ;)

  • если мы и используем арены, то лучше всего использовать build тег, потому что их могут убрать в будущем, как написано в самих комментариях к исходному коду

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

  • несколько фиксированных буфферов, sync.Poolсделает это быстрее, бенч это очень хорошо покажет

  • объекты живут дольше запроса

  • и самое главное - арена не потокобезопасна, поэтому лучше ограничиться выполнением ее в одной горутине

Вот и все :(

Надеюсь вам понравилась данная статья, если будут какие-то предложения или улучшения по статье - пишите! Буду благодарен!

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


  1. tbl
    29.06.2025 00:31

    большое аллоцирование достаточно сильно влияет на скорость обработки той или иной части сервиса

    Что такое "большое аллоцирование"?

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

    Что такое "выполнение арены"?

    если будут какие-то предложения или улучшения по статье - пишите!

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


    1. flew1x Автор
      29.06.2025 00:31

      1) аллокация большого размера, либо множество таких аллокаций

      2) жизненный цикл арены, то есть память выделена, пространство помечено как noscan

      3) теперь я пишу как llm… удалять ничего не буду, текст полностью мой.

      Комментарий ради комментария, придирка только к игре со словами, советую внимательнее прочитать суть, а не разбрасываться словами лишь бы да кабы


      1. starwalkn
        29.06.2025 00:31

        Комментарий ради комментария

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