Проблемы

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

  1. Снижение производительности из-за расходов на выделение памяти

  2. Снижение производительности из-за расходов на сборку мусора

  3. Появление ошибкиOut of Memory , если скорость появления мусора превышает скорость его уборки

Указанные проблемы могут решаться несколькими способами:

  1. Увеличением объема вычислительных ресурсов (память, процессор)

  2. Тонкой настройкой механизма сборщика мусора

  3. Минимизацией числа побегов в кучу

В данной статье я рассмотрю только третий путь.

С чистого листа

Если все еще впереди, но уже поставлена цель добиться производительности, близкой к максимально возможной, то нужно знать в лицо главных замедлителей в плане работы с памятью. Встречаем основные конструкции, число которых следует минимизировать: make , new , map ,go . Есть и более скрытые способы учинить побег, их я рассмотрю уже в процессе "охоты", а пока - основные способы профилактики.

Вместо постоянного выделения памяти через make и new следует максимально переиспользовать уже ранее выделенное. Одним из способов добиться такого переиспользования является sync.Pool(), на habr этот способ был рассмотрен здесь. Чтобы поменьше быть КО замечу, что использовать элементы типа []byte ,как это делается в статье по ссылке, не стоит - при каждом возврате будет дополнительно выделяться 32 байта памяти (для go1.14.6 windows/amd64). Мелочь, но неприятно; если стремиться к совершенству, лучше переиспользовать интерфейсы или указатели, а еще лучше использовать butebufferpool от @valyala.

С map история такая. Интенсивное использование map ведет к интенсивному выделению памяти, но это не единственная проблема. Если приложению нужен огромный кэш, и этот кэш реализован через map, то можем получить то, из-за чего Discord перешел на Rust. Т.е. на постоянное, в рамках уборки мусора, сканирование гигантского скопления указателей будут тратиться ресурсы, и по каким-то метрикам система выйдет за рамки требований. Для решения этой проблемы великий @valyala сделал fastcache, там же можно найти и ссылки на альтернативные решения, и, опять же у него, наряду с другими советами по повышению производительности, можно найти достаточно детальный разбор, как использовать slices вместо maps.

С оператором go просто - все обработку нужно развести по фиксированному набору горутин и каналов. Запускать горутину на каждый запрос можно, но относительно дорого и плохо предсказуемо по расходу памяти.

Имеет смысл сделать такое замечание, и я его сделаю - предотвращение массовых "побегов" имеет свою цену, в частности, упомянутый fastcache далеко не "идиоматичен". Нам, например, идеально подходит кэш []byte->[]byteно, не факт, что это так для всех. Возможно, дешевле будет усилить аппаратную часть, а то и вообще ничего не делать - все зависит от требований к системе, те самые "rps", "95th percentile latency" и т.д. Возможно, и даже скорее всего, все будет работать и в "коробочном" варианте, да еще и с запасом. Так что будет вполне разумным сделать прототип "горячих путей" обработки и погонять на скорость. Т.е. заняться той самой "охотой".

Охота

Пойдем опять "на поклон" к Александру Валялкину и выполним:

git clone https://github.com/valyala/fasthttp

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

go test -bench=PServerGet10Req -benchmem -memprofile netmem.out

и

go test -bench=kServerGet10Req -benchmem  -memprofile fastmem.out

Первая команда запустит тесты для стандартного http.Server, вторая - для fasthttp.Server. По выводу мы заметим, что fasthttp примерно в десять раз быстрее и все операции проходят в zero-allocation режиме. Но это не все, теперь у нас есть профили netmem.out и fastmem.out. Смотреть их можно по-разному, для быстрой оценки ситуации я предпочитаю такой способ:

echo top | go tool pprof netmem.out

Что дает разбивку потребления памяти по 10 самым "прожорливым" функциям:

Showing top 10 nodes out of 53
      flat  flat%   sum%        cum   cum%
  698.15MB 21.85% 21.85%   710.15MB 22.22%  net/textproto.(*Reader).ReadMIMEHeader
  466.13MB 14.59% 36.43%   466.13MB 14.59%  net/http.Header.Clone
  423.07MB 13.24% 49.67%  1738.32MB 54.39%  net/http.(*conn).readRequest
  384.12MB 12.02% 61.69%   384.12MB 12.02%  net/textproto.MIMEHeader.Set
  299.07MB  9.36% 71.05%  1186.24MB 37.12%  net/http.readRequest
  137.02MB  4.29% 75.33%   137.02MB  4.29%  bufio.NewReaderSize
  134.02MB  4.19% 79.53%   134.02MB  4.19%  net/url.parse
  122.45MB  3.83% 83.36%   122.45MB  3.83%  bufio.NewWriterSize (inline)
   99.51MB  3.11% 86.47%   133.01MB  4.16%  context.WithCancel
   87.11MB  2.73% 89.20%    87.11MB  2.73%  github.com/andybalholm/brotli.(*h5).Initialize

Можно получить подробную схему убеганий в графическом виде через:

go tool pprof -svg netmem.out > netmem.svg 

После выполнения команды в netmem.svg будет картинка типа такой (фрагмент):

Есть и более крутой способ:

go tool pprof -http=:8088 netmem.out

Здесь, по идее, должен запуститься браузер, и этот браузер с какой-то вероятностью покажет текст: Could not execute dot; may need to install graphviz. Те, кто работает на Unix-подобных системах и так знают, что делать, пользователям же Windows могу посоветовать поставить chocolatey а затем, с правами администратора, вызвать cinst graphviz. После этого можно начать по-всякому крутить профиль. Моя любимая крутилка вызывается через VIEW/Source:

Здесь, кроме очевидных убеганий через make, мы также видим большие потери на преобразование []byteв string. Операции со строками весьма затратны и, если "идем на рекорд", их следует избегать и работать исключительно с []byte. Еще одним способом "убежать", с которым встречался, является возврат адреса локальной переменной, т.е. return &localVar . Есть и другие варианты, но углубляться не буду - ваш личный профиль их покажет.

Несмотря на сокрушительное превосходство fasthttp в этом тесте, именно эту библиотеку я не рекомендовал бы использовать. Или рекомендовал бы с осторожностью - с fasthttp у вас не будет поддержки HTTP/2.0, поддержка websockets отполирована не с такой тщательностью, как сам fasthttp (на момент, когда я эту тему изучал), ну и, главное, на реальной нагрузке может и не получиться десятикратного выигрыша. У нас в одном тесте на железе типа c5.4xlarge получалось 250.000 RPS для fasthttp.Server против 190.000 RPS для http.Server . Выигрыш есть, но вам точно надо больше, чем 190.000 RPS? Тут очень многое зависит от профиля нагрузки, от того, что с этой нагрузкой делается дальше, ну и от требований к системе, само собой.

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

Выполнив ряд экспериментов, мы остановились на flatbuffers, но сделали вокруг него обертку dynobuffers, назначение которой - читать и писать без генерации кода, используя имена полей. Вещица пока сыровата, но, надеюсь, скоро доделаем.

Результаты чтения "все поля большого объекта":

Avro         23394 ns/op    11257 B/op
Dyno_Untyped  6437 ns/op      808 B/op
Dyno_Typed    3776 ns/op        0 B/op
Flat          1132 ns/op        0 B/op
Json         87331 ns/op    14145 B/op

Результаты чтения "несколько полей большого объекта":

Avro         19311 ns/op    11257 B/op
Dyno_Typed    62.2 ns/op        0 B/op
Flat          19.8 ns/op        0 B/op
Json         83824 ns/op    11073 B/op 

Последний сценарий является для нас основным, ради которого все и затевалось, и здесь ускорение, по сравнению с тем же linkedin/goavro - весьма и весьма существенное.

Опять скажу - все зависит от конкретных данных и способов их обработки. Например, весь выигрыш на (де)сериализации можно потерять при сохранении, ибо avro часто дает "пакует" данные более компактно, чем flatbuffer.

Заключение

С проблемами, которые ведут к снижению производительности в Go, вполне можно бороться, но нужно иметь в виду, что эта борьба имеет свою цену. Прежде, чем начинать ее, лучше экспериментально сопоставить возможности выбранного способа обработки данных с требованиями к системе - может быть, все будет работать прямо "из коробки" с минимальными ухищрениями.

Ссылки