Многие Go программисты знакомы с bytes.Buffer. Одно из его преимуществ состоит в том, что он позволяет избегать выделений памяти в куче по той же схеме, что и "оптимизация коротких строк" (small buffer/size optimization): Есть только одна проблема. Эта оптимизация не работает. К концу этой статьи вы узнаете, почему эта оптимизация не работает и что мы можем с этим сделать. Введём немного упрощённое определение bytes.Buffer: Когда мы выполняем действия над Buffer, например, вызываем метод Buffer.Write, запись всегда производится в buf, однако перед этой записью внутри каждого подобного метода запускается Buffer.grow(n), который делает так, чтобы в этом самом слайсе было достаточно места для очередных n байт. Выглядеть grow может примерно так: Мы делаем допущение, что len(b.buf) — это фактическая длина данных в Buffer, что требовало бы от Write методов использования append для добавления новых байтов в слайс. В bytes.Buffer из стандартной библиотеки это не так, но для примера это является несущественной деталью реализации. Если b выделен на стеке, то и bootstrap внутри него выделен на стеке, а, значит, слайс b.buf будет переиспользовать память внутри b, не требуя дополнительной аллокации. Когда grow выявляет, что bootstrap массива уже недостаточно, будет выполнено создание нового, "настоящего" слайса, куда затем будут скопированы элементы из прошлого хранилища (из "малого буфера"). После этого Buffer.bootstrap утратит актуальность. Если вызывается Buffer.Reset, cap(b.buf) останется прежним, и необходимости в bootstrap массиве больше не будет. Далее ожидается, что читатель хотя бы поверхностно знаком с тем, что такое escape analysis в Go. Рассмотрим следующую ситуацию: Здесь b будет выделен на куче. Причина тому — "утекающий" указатель на b: В данной статье "утекает" (leaking) и "убегает" (escapes) используются почти как синонимы. В самом компиляторе есть некоторое различие, например, значение "убегает в кучу" (x escapes to heap), но параметры функции "утекающие" (leaking param x). Утекающий параметр означает, что переданный аргумент под этот параметр будет выделения в куче. Другими словами, leaking параметр заставляет аргументы убегать в кучу. Выше был очевидный случай, но как насчёт этого: Здесь нам нужен всего лишь 1 байт, всё влезает в bootstrap, сам буфер локален и не "убегает" из функции. Возможно, вы будете удивлены, но результат будет тот же, аллокация b на куче. Для уверенности можно проверить это с помощью бенчмарка: Когда runtime просит у аллокатора N байт, не обязательно, что будет выделено ровно N байт.  Если запустить go test -bench=. -benchmem с этим тестом: Запрошено 9 байтов, выделено 16. Теперь вернёмся к bytes.Buffer: Посмотрим на $GOROOT/src/runtime/sizeclasses.go: В 96 байт не влезает, выбирается 112. Но почему же это происходит? Некоторый анализ ситуации можно найти в упомянутом в самом начале issue.
Там же есть простой reproducer. Проблемное место как раз в присваивании b.buf = b.bootstrap[:]. Этот код заставляет escape analysis считать, что b.bootstrap "убегает", а поскольку это массив, то он хранится внутри самого объекта, а значит на куче нужно выделять весь b. Если бы bootstrap был слайсом, а не массивом, то этого бы не происходило, потому что есть adhoc оптимизация для присваиваний слайсов из объекта в сам объект: Ответ почему же эта оптимизация не работает для массивов уже был сформулирован выше, но вот выжимка из самого esc.go#L835-L866 (по ссылке выделен весь код оптимизации): Здесь стоит добавить, что для анализатора указателей есть несколько уровней "утечек", основные из них: Случай с массивом особенен тем, что если утекает массив, должен утекать и сам объект его содержащий. escape analysis выводит решение о том, можно ли расместить объект на стеке или нет, полагаясь только на информацию, которая доступна в теле анализируемой функции. Метод Buffer.grow принимает b по указателю, поэтому для него требуется вычислить пригодное место для размещения. Поскольку в случае массива мы не можем отличить "b escape" от "b contents escape", приходится быть более пессимистичными и приходить к выводу, что b размещать на стеке не безопасно. Предположим обратное, что self-assignment паттерн разрешает для массивов всё то же, что и для слайсов: Решение разместить b на стеке в этой ситуации приведёт к катастрофе: после выхода из функции, внутри которой был создан b, память, на которую будет ссылаться sink будет ничем иным, как мусором. Представим, что наш Buffer был объявлен несколько иначе: В отличие от обычного массива, указатель на массив не будет хранить все элементы внутри самого Buffer. Это означает, что если выделение bootstrap на куче не влечёт за собой выделение Buffer на куче. Поскольку escape analysis умеет выделять поля-указатели на стеке, когда это возможно, мы можем считать, что такое определение Buffer более удачно. Но это в теории. На практике, указатель на массив не имеет особой обработки и попадает в ту же категорию, что и слайс от обычного массива, что не совсем правильно. CL133375: cmd/compile/internal/gc: handle array slice self-assign in esc.go направлен на исправление этой ситуации. Предположим, что это изменение было принято в компилятор Go. К сожалению, переход от [64]byte к *[64]byte имеет проблему: мы теперь не можем использовать bootstrap без явной его инициализации, нулевое значение Buffer перестаёт быть полезным, нам нужен конструктор. Мы возвращаем Buffer, а не *Buffer, для избежания проблем с анализом указателей (он в Go очень консервативен), а с учётом того, что NewBuffer всегда встраивается в место вызова, лишнее копирование происходить не будет. После встраивания тела NewBuffer в место вызова escape analysis может попробовать доказать, что new(*[smallBufSize]byte) не превосходит по своему времени жизни время жизни фрейма функции, в котором он вызван. Если это так, то аллокация будет на стеке. Описанная выше оптимизация применена в пакете intel-go/bytebuf. Эта библиотека экспортирует тип bytebuf.Buffer, который дублирует bytes.Buffer на 99.9%. Все изменения сводятся к введению конструктора (bytebuf.New) и указателю на массив вместо обычного массива: Вот сравнение производительности с bytes.Buffer: Вся остальная информация доступна в README. Из-за невозможности использовать zero value и привязке к конструирующей функции New, применить эту оптимизацию к bytes.Buffer не представляется возможным. Единственный ли это способ сделать более быстрый bytes.Buffer? Ответ — нет. Но это точно способ, требующий минимальных изменений в реализации. В текущем виде, escape analysis в Go довольно слаб. Почти любые операции со значениями-указателями ведут к выделениям на куче, даже если это не является оправданным решением. Большую часть времени, которое я уделяю проекту golang/go, постараюсь направить на решение именно этих проблем, так что в ближайшем релизе (1.12) возможны некоторые улучшения. О результатах и деталях внутреннего устройства этой части компилятора вы сможете прочитать в одной из следующих моих статей. Постараюсь также предоставить набор рекомендаций, который поможет в некоторых случаях структурировать код так, чтобы в нём было меньше нежелательных выделений памяти. Making developer tools & video games