НАПИСАНИЕ HIGHLOAD СЕРВИСОВ ОСТАВЛЯЕТ СЛЕД... 2 года я проектировал… — Всё о разработке | Леонид Ченский — TG.ME

НАПИСАНИЕ HIGHLOAD СЕРВИСОВ ОСТАВЛЯЕТ СЛЕД...

2 года я проектировал сервисы на Go, которые должны были держать 200k+ RPS и иметь время ответа менее 50ms в 99p.

При таких условиях недостаточно написать просто рабочий код, он должен быть потреблять как можно меньше ресурсов. В общем, вырабатывается привычка экономить «каждый байт и аллокацию».

На другом проекте (не highload) как-то раз мне попадает на код ревью MR. В глаза сразу бросается стандартное преобразование слайса байт в строку:
str := string(bytes)


Я сразу пишу в комментариях: «тут можно сделать дешевое преобразование через unsafe»
Разработчик отвечает: «а как это сделать и зачем?»

И тут я понял сразу 2 вещи:

Во-первых далеко не все Go-разработчики работали с unsafe (оно и понятно, не все разрабатывают highload-решения).
Во-вторых, разработчик задал верный вопрос: «Зачем?». Действительно, преждевременные оптимизации скорее мешают, чем помогают. Тем более в сервисе с 1-5 rps…

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

Поделюсь одним из неочевидных здесь
👉

В Go на каждую аллокацию в куче вызывается функция mallocgc, которая имеет определенную скрытую от наших глаз логику. Когда в одном цикле мы выделяем 1000+ объектов, overhead от mallocgc становится заметным...

type MyStruct struct {
someFields [64]byte
Value bool
}

func simpleAllocate(n int) []*MyStruct {
result := make([]*MyStruct, 0, n)
for range n {
result = append(result, &MyStruct{Value: true})
}
return result
}

BenchmarkAllocate/100/Simple-10           655094        1872 ns/op      8896 B/op       101 allocs/op
BenchmarkAllocate/1000/Simple-10 67028 18604 ns/op 88192 B/op 1001 allocs/op
BenchmarkAllocate/10000/Simple-10 5565 201100 ns/op 881925 B/op 10001 allocs/op
BenchmarkAllocate/100000/Simple-10 526 2377439 ns/op 8802821 B/op 100001 allocs/op

Мы имеем издержки при аллокации на каждой итерации! Как это можно оптимизировать? Идея стара как мира — вынести из цикла:
func hackAllocate(n int) []*MyStruct {
var (
result = make([]*MyStruct, n)
resultV = make([]MyStruct, n)
)

for i := range n {
resultV[i].Value = true
result[i] = &resultV[i]
}
return result
}

Да, это кажется на первый взгляд контр-интуитивно, однако мы аллоцируем сразу нужное нам количество структур с помощью слайса, а далее просто сохраняем ссылки на них.
BenchmarkAllocate/100/Hack-10          1657069         754.9 ns/op      7424 B/op         2 allocs/op
BenchmarkAllocate/1000/Hack-10 162456 7304 ns/op 73728 B/op 2 allocs/op
BenchmarkAllocate/10000/Hack-10 17347 70141 ns/op 737284 B/op 2 allocs/op
BenchmarkAllocate/100000/Hack-10 1492 763791 ns/op 7307279 B/op 2 allocs/op


Итого: мы можем получить выигрыш в скорости до 3 раз! Тут нет никакой магии, просто сделать одну большую аллокацию проще чем много маленьких.

Хорошо, а есть более прозрачный способ решить проблему с множеством аллокаций? Да, начиная с версии Go 1.20 появились арены (но все еще как экспериментальная фича):
func arenaAllocate(mem *arena.Arena, n int) []*MyStruct {
result := arena.MakeSlice[*MyStruct](mem, n, n)
for i := range n {
s := arena.New[MyStruct](mem)
s.Value = true
result[i] = s
}
return result
}

Арены правда убирают аллокации и облегчают жизнь GC, однако по скорости работы они уступают...
BenchmarkAllocate/10000/Simple-10           6948            161145 ns/op          881920 B/op      10001 allocs/op
BenchmarkAllocate/10000/Hack-10 27702 43448 ns/op 737280 B/op 2 allocs/op
BenchmarkAllocate/10000/Arena-10 4921 219361 ns/op 729991 B/op 2 allocs/op

В итоге такой Hack-аллокатор даже без unsafe дает буст...

P.S. Про unsafe рассказываю на курсе в своем бесплатном уроке
GitHub
go/src/runtime/malloc.go at 07f0c2074c257e7f89a52152f451c66e7f762481 · golang/go
The Go programming language. Contribute to golang/go development by creating an account on GitHub.
👍13🔥7❤3🆒1
March 23, 2026 820 6 23