Охота за убегающей памятью в Go на этапе разработки

    Проблемы

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

    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, вполне можно бороться, но нужно иметь в виду, что эта борьба имеет свою цену. Прежде, чем начинать ее, лучше экспериментально сопоставить возможности выбранного способа обработки данных с требованиями к системе - может быть, все будет работать прямо "из коробки" с минимальными ухищрениями.

    Ссылки

    Средняя зарплата в IT

    110 475 ₽/мес.
    Средняя зарплата по всем IT-специализациям на основании 6 942 анкет, за 2-ое пол. 2020 года Узнать свою зарплату
    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 6

      +2

      Можно еще пользоваться флагом -gcflags "-m", выводящий, что убегает в кучу. И, хотя escape-analysis постоянно улучшают, иногда можно встретить


      вот такие конструкции
      // NoEscape hides a pointer from escape analysis.  noescape is
      // the identity function but escape analysis doesn't think the
      // output depends on the input.  noescape is inlined and currently
      // compiles down to zero instructions.
      // USE CAREFULLY!
      //go:nosplit
      func NoEscape(p unsafe.Pointer) unsafe.Pointer {
          x := uintptr(p)
          return unsafe.Pointer(x ^ 0)
      }
        0

        Интересно, раз noescape is inlined, имеет ли смысл //go:nosplit? Или в нем часть магии?

          0

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

            0

            Я полагал, что при разворачивании inline проверок размера стека не осуществляется?


            package main
            
            import "fmt"
            
            func toBeInlined(i,j int) int{
                return i + j
            }
            
            var g int
            var l = 5
            var m = 6
            
            func main(){
                g = toBeInlined(l, m)
                fmt.Println("Hello, world!")
            }

            Ассемблер:


            ;*** main.go#13   >func main(){
            0x49dea0     65488b0c2528000000     mov rcx, qword ptr gs:[0x28]
            0x49dea9     488b8900000000         mov rcx, qword ptr [rcx]
            0x49deb0     483b6110               cmp rsp, qword ptr [rcx+0x10]
            0x49deb4     0f8685000000           jbe 0x49df3f
            0x49deba     4883ec58               sub rsp, 0x58
            0x49debe     48896c2450             mov qword ptr [rsp+0x50], rbp
            0x49dec3     488d6c2450             lea rbp, ptr [rsp+0x50]
            
            ;*** main.go#14   > g = toBeInlined(l, m)
            0x49dec8     488b0561530c00         mov rax, qword ptr [main.l]
            
            ;*** main.go#6    > return i + j
            0x49decf     48030562530c00         add rax, qword ptr [main.m]
            
            ;*** main.go#14   > g = toBeInlined(l, m)
            0x49ded6     488905c35d1000         mov qword ptr [main.g], rax
            
            ;*** main.go#15   > fmt.Println("Hello, world!")
            ...
        +1

        Сказать честно, такой код на Go сложно писать и отлаживать, и те же баги, вызванные некорректным переиспользованием одного и того же []byte из пула, например, похожи на ошибки use-after-free и прочие, которые есть в Си. Поэтому, как мне кажется, если очень хочется написать на Go нагруженный сервис, которому требуется постоянно следить за тем, чтобы не выделять слишком много памяти, то возможно и правда Rust Вам бы подошел лучше.

          +2

          Согласен. Rust нынче — очень хороший кандидат на рассмотрение, и надо иметь серьезные обоснования, чтобы выбрать не его.


          Сказать честно, такой код на Go сложно писать и отлаживать, и те же баги, вызванные некорректным переиспользованием одного и того же []byte из пула, например, похожи на ошибки use-after-free и прочие, которые есть в Си.

          Замечу, что большая часть этих проблем ловится при тестировании флагом -race. Если, конечно, "горячие пути" смоделированы в тестах.

        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

        Самое читаемое