Ручное управление памятью действительно сложно (как C C++), но, к счастью, у нас есть мощная автоматизированная система, которая управляет распределением памяти и жизненным циклом, что освобождает наши руки.
Однако, если вы хотите решить проблему, настроив параметры сборщика мусора JVM или оптимизировав режим выделения памяти кода go, этого далеко недостаточно. Автоматическое управление памятью помогло нам избежать большинства ошибок, но это только половина дела. Мы должны создавать наше программное обеспечение достаточно эффективно, чтобы система сбора мусора могла работать эффективно.
Создание высокопроизводительных сервисов GoCentrifugeЗдесь мы будем делиться теми вещами, связанными с памятью, которые мы время от времени узнавали.CentrifugeВ секунду могут обрабатываться сотни или тысячи событий. Центрифуга является ключевой частью инфраструктуры Сегмента. Последовательность, предсказуемость поведения обязательны. Чистое, эффективное и точное использование памяти является важной частью достижения согласованности.
В этом посте мы рассмотрим распространенные закономерности, которые приводят к неэффективности и неожиданностям в производственной среде, связанные с выделением памяти, а также практические способы устранения этих проблем. Мы сосредоточимся на основной механике распределителя, предоставляя возможность широкому кругу разработчиков управлять использованием памяти.
использовать инструменты
Первое, что мы рекомендуем, это избегать преждевременной оптимизации. Go предоставляет отличные инструменты профилирования, которые могут указывать непосредственно на участки кода, интенсивно использующие память. Колесо переделывать не надо, напрямую ссылаемся на официальный Goэта статьяВот и все. Он предоставляет надежную демонстрацию для профилирования ЦП и распределения с использованием pprof. Инструмент, который мы используем в Segment для поиска узких мест в производственном коде Go, — это он, и обучение использованию pprof является важным требованием.
Кроме того, используйте данные для управления оптимизацией.
анализ побега
Go может автоматически управлять выделением памяти. Это предотвращает большой класс потенциальных ошибок, но нельзя сказать, что человек вообще не понимает механику распределения.
Первое, что нужно помнить:Распределение стека дешево, а выделение кучи дорого.. Давайте посмотрим на конкретное значение.
Go выделяет память в двух местах: в глобальной куче для динамического выделения и в локальном стеке для каждой горутины. Go склонен квыделение стека----Большинство аллокаций go-программ находятся в стеке. Выделение стека является дешевым, потому что для него требуется всего две инструкции ЦП: одна для выделения и помещения в стек, а другая для освобождения в стеке.
Но, к сожалению, не все данные могут использовать память, выделенную в стеке.Требования к распределению стека позволяют определять переменное время жизни и объем памяти во время компиляции.. Тем не менее, кучадинамическое размещениепроисходит во время выполнения.malloc
Вы должны найти кусок свободной памяти, достаточно большой, чтобы вместить новое значение. Затем сборщик мусора просматривает кучу в поисках объектов, на которые больше нет ссылок. Без сомнения, это намного дороже, чем две инструкции, используемые при распределении стека.
Компилятор используетанализ побегаТехника выбора кучи или стека. Основная идея заключается в сборке мусора во время компиляции. Компилятор отслеживает область действия переменных области кода. Он использует данные трассировки, чтобы проверить, какие переменные имеют полностью известное время жизни. Если переменная проходит эти проверки, она может быть размещена в стеке. Если это не пройдет, это то, что он говоритпобег, он должен быть размещен в куче.
Правила escape-анализа явно не указаны в языке go. Для программистов Go самый простой способ понять правила — это поэкспериментировать. Добавляя при построенииgo build -gcflags '-m'
, вы можете увидеть результаты анализа побегов. Давайте посмотрим на пример.
package main
import "fmt"
func main() {
x := 42
fmt.Println(x)
}
$ go build -gcflags '-m' ./main.go
# command-line-arguments
./main.go:7: x escapes to heap
./main.go:7: main ... argument does not escape
Здесь мы видим переменныеx
«Экранировать в кучу», потому что он динамически выделяется в куче во время выполнения. Этот пример может быть немного запутанным. Невооруженным глазом видно, чтоx
переменная вmain()
Метод не ускользает. Вывод компилятора не объясняет, почему он считает, что переменная экранирована. Чтобы увидеть больше деталей, добавьте-m
параметры, вы можете увидеть больше вывода
$ go build -gcflags '-m -m' ./main.go
# command-line-arguments
./main.go:5: cannot inline main: non-leaf function
./main.go:7: x escapes to heap
./main.go:7: from ... argument (arg to ...) at ./main.go:7
./main.go:7: from *(... argument) (indirection) at ./main.go:7
./main.go:7: from ... argument (passed to call[argument content escapes]) at ./main.go:7
./main.go:7: main ... argument does not escape
это означает,x
Экранирование связано с тем, что оно передается в параметр метода, а параметр метода экранируется сам по себе. Вы можете увидеть больше этого позже.
Правила могут показаться произвольными, а некоторые закономерности возникают в результате экспериментов с инструментами. Вот несколько типичных ситуаций, которые приводят к побегам:
- Отправить указатель или значение с указателем на канал. Во время компиляции невозможно узнать, какая горутина получит данные в канале. Таким образом, компилятор не может определить, когда на эти данные больше не ссылаются.
-
Хранить указатели или значения с указателями в срезах. Примером такой ситуации является
[]*string
. Это всегда приведет к тому, что содержимое среза будет экранировано. Хотя базовый массив среза по-прежнему находится в куче, данные, на которые ссылаются, перемещаются в кучу. - Базовый массив срезов будет повторно нарезать память, потому что операция добавления превышает ее емкость.. Если начальный размер слайса известен во время компиляции, он размещается в стеке. Если базовое хранилище среза необходимо расширить, данные извлекаются только во время выполнения. затем он будет размещен в куче.
-
вызов метода для типа интерфейса. Вызовы методов для интерфейсных типов являются динамическими вызовами — конкретная реализация интерфейса может быть определена только во время выполнения. Рассмотрим тип интерфейса как
io.Reader
Переменныеr
. правильноr.Read(b)
вызов приведет кr
значение и байтовый срезb
Базовые массивы экранированы и поэтому размещены в куче.
По нашему опыту, эти четыре случая являются наиболее распространенными случаями динамического распределения в программах Go. Есть несколько решений для таких ситуаций. Далее мы рассмотрим некоторые конкретные примеры того, как решить проблему неэффективности памяти в производственном программном обеспечении.
связанный с указателем
Эмпирическое правило таково: указатели указывают на данные, выделенные в куче. Следовательно, уменьшение количества указателей в программе уменьшает количество выделений кучи. Это не аксиома, но мы обнаружили, что это обычная ситуация в реальных программах Go.
Обычное предположение, которое мы делаем интуитивно, таково: «Копирование значения дорого, поэтому я буду использовать указатель». Вы можете спросить, почему.
-
При разыменовывании указателя компилятор генерирует проверку. Его цель состоит в том, чтобы, если указатель равен нулю, запустить
panic()
чтобы избежать повреждения памяти. Этот дополнительный код должен выполняться во время выполнения. Это не будет nil, если данные передаются по значению. - Указатели часто имеют плохую локальность ссылки. Все значения, используемые в функции, конкатенируются в памяти стека.место ссылкиявляется важным аспектом эффективности кода. Это значительно увеличивает вероятность перегрева переменных в кеше ЦП и снижает риск промахов предварительной выборки.
- Копирование объекта в строке кэша примерно эквивалентно копированию одного указателя.. ЦП перемещает память между уровнями кэша и основной памятью в строках кэша постоянного размера. На x86 строка кэша составляет 64 байта. Кроме того, в Go используется метод, называемыйDuff`s devicesтехнология, которая делает обычные операции с памятью, такие как копирование, очень эффективными.
Указатели должны в первую очередь использоваться для отражения принадлежности и изменчивости членов. На практике использование указателей во избежание копирования должно быть редкостью. Не попадайтесь в ловушку преждевременной оптимизации. Рекомендуется передавать данные по значению и использовать указатели для передачи данных только при необходимости. Кроме того, передача по значению устраняетnil
Тем самым повышая безопасность.
Уменьшение количества указателей в программе может иметь еще один полезный результат, потому чтоСборщик мусора будет пропускать области памяти, не содержащие указателей.. Например, вообще не сканируйте возвращаемый тип.[]byte
нарезанная площадь кучи. То же самое верно для массивов структурных типов, которые не содержат полей типа указателя.
Уменьшение количества указателей не только снижает нагрузку на сборку мусора, но и приводит к «дружественному к кэшированию» коду. Чтение памяти перемещает данные из основной памяти в кэш ЦП. Кэши имеют приоритет, поэтому некоторые данные необходимо очистить, чтобы освободить место. Данные, которые очищает кеш, могут быть связаны с другими частями программы. Возникающая в результате перегрузка кэша может привести к неожиданному поведению и внезапным изменениям в поведении производственных служб.
указатель глубоко
Сокращение использования указателей часто означает изучение исходного кода того типа, который использовался для создания программы. Наш сервис Centrifuge хранит очередь неудачных операций в качестве циклического буфера повторных попыток. Он содержит набор структур данных, как показано ниже:
type retryQueue struct {
buckets [][]retryItem // each bucket represents a 1 second interval
currentTime time.Time
currentOffset int
}
type retryItem struct {
id ksuid.KSUID // ID of the item to retry
time time.Time // exact time at which the item has to be retried
}
множествоbuckets
Внешний размер является постоянной величиной, но[]retryItem
Включенные элементы будут меняться во время выполнения. Чем больше попыток, тем больше становятся эти фрагменты.
Посмотрите поближеretryItem
подробности мы узналиKSUID
Является[20]byte
, не содержит указателей и поэтому исключается правилами экранирования.currentOffset
Являетсяint
value, которое является необработанным значением фиксированного размера, также может быть исключено. Посмотрите ниже,time.Time
Реализация:
type Time struct {
sec int64
nsec int32
loc *Location // pointer to the time zone structure
}
time.Time
Структура содержитloc
указатель. существуетretryItem
Его внутреннее использование заставляет сборщик мусора помечать указатель на структуру каждый раз, когда переменная проходит через область кучи.
Мы обнаружили, что это типичный случай каскадных эффектов в непредсказуемых ситуациях. Операционные сбои обычно случаются редко. Существует лишь небольшой объем памяти для хранения переменной повторных попыток. Когда количество неудачных операций возрастает, очередь повторных попыток увеличивается до тысяч в секунду, что значительно увеличивает нагрузку на сборщик мусора.
Для этого конкретного случая использованияtime.Time
Информация о времени на самом деле не нужна. Эти метки времени живут в памяти и никогда не сериализуются. Эти структуры данных можно реорганизовать, чтобы полностью отказаться от типа time.
type retryItem struct {
id ksuid.KSUID
nsec uint32
sec int64
}
func (item *retryItem) time() time.Time {
return time.Unix(item.sec, int64(item.nsec))
}
func makeRetryItem(id ksuid.KSUID, time time.Time) retryItem {
return retryItem{
id: id,
nsec: uint32(time.Nanosecond()),
sec: time.Unix(),
}
В настоящее времяretryItem
Не содержит указателей. Это значительно снижает нагрузку на сборщик мусора, а компилятор знаетretryItem
весь след.
Пожалуйста, передайте мне кусочек (кусочек)
Использование слайсов может легко привести к неэффективному коду распределения. Если компилятор не знает размер среза, базовый массив срезов (и карт) размещается в куче. Давайте рассмотрим некоторые способы размещения слайсов в стеке, а не в куче.
Centrifuge централизованно использует Mysql. Эффективность всей программы сильно зависит от эффективности драйвера Mysql. в настоящее время используетpprof
Проанализировав поведение распределения, мы обнаружили, что код драйвера Go MySQL сериализуетсяtime.Time
Стоимость очень дорогая.
Profiler показывает, что большинство выделений кучи сериализуютсяtime.Time
в коде.
Соответствующий код вызываетtime.Time
изFormat
Здесь он возвращаетstring
. Подождите, мы же говорим о ломтиках? Ну, согласноПерейти к официальной документации,Одинstring
На самом деле этоФрагменты байтов, доступные только для чтения, с небольшой дополнительной поддержкой на уровне языка. Применяются большинство правил распределения!
Анализ данных говорит нам о том, что в этом цикле генерируется много распределений, то есть 12,38%.Format
метод. этоFormat
что ты сделал?
Оказывается, есть гораздо более эффективный способ сделать то же самое. Несмотря на то чтоFormat()
Метод удобен и прост, но мы используемAppendFormat()
На дозаторе будет удобнее. Глядя на репозиторий исходного кода, мы замечаем, что все внутренние варианты использованияAppendFormat()
вместоFormat()
, это важное напоминание,AppendFormat()
более высокая производительность.
По факту,Format
Метод просто обертываетAppendFormat
метод:
func (t Time) Format(layout string) string {
const bufSize = 64
var b []byte
max := len(layout) + 10
if max < bufSize {
var buf [bufSize]byte
b = buf[:0]
} else {
b = make([]byte, 0, max)
}
b = t.AppendFormat(b, layout)
return string(b)
}
важнее,AppendFormat()
Дает программистам больше контроля над распределением. передать фрагмент вместо чего-то вродеFormat()
распределять внутри. в сравнении сFormat
, Использовать напрямуюAppendFormat()
Можно использовать выделение среза фиксированного размера, поэтому выделение памяти будет в пространстве стека.
Вы можете взглянуть на то, что мы упомянули о драйвере Go MySQL.этот пиар
первое уведомлениеvar a [64]byte
представляет собой массив фиксированного размера. Во время компиляции мы знаем его размер, и его область действия есть только в этом методе, поэтому мы знаем, что он будет выделен в пространстве стека.
Но этот тип не может быть переданAppendFormat()
, который принимает только[]byte
тип. использоватьa[:0]
нотация для преобразования массива фиксированного размера в массив, поддерживаемый этим массивомb
Представленный тип среза. Это пройдёт проверки компилятора и выделит память в стеке.
Важнее,AppendFormat()
, сам метод проходит проверку выделения стека компилятора. в то время как предыдущая версияFormat()
, компилятор не может определить размер памяти, которую необходимо выделить, поэтому правила выделения стека не выполняются.
Это небольшое изменение резко сокращает выделение кучи для этой части кода! Подобно «режиму добавления», который мы используем в драйвере MySQL. существуетэтот пиарвнутри,KSUID
тип используемыйAppend()
метод. В коде горячего путиKSUID
использоватьAppend()
режим обрабатывает буферы фиксированного размера вместоString()
метод, который экономит такое же большое количество динамических выделений кучи. Также стоит отметить, что тот же режим добавления используется пакетом strconv для преобразования строк, содержащих числа, в числовые типы.
Тип интерфейса
Хорошо известно, что вызовы методов интерфейсных типов обходятся гораздо дороже, чем вызовы методов структурных типов. Метод типа интерфейса вызывается черезДинамическое планированиевоплощать в жизнь. Это серьезно ограничивает способность компилятора определять, как код будет выполняться во время выполнения. До сих пор мы в основном обсуждали код с фиксированным типом, чтобы компилятор мог лучше понять его поведение во время компиляции. Типы интерфейсов отбрасывают все эти правила!
К сожалению, интерфейсные типы очень полезны на уровне абстракции — они позволяют нам писать более гибкий код. Соответствующие примеры кода горячего пути, обычно используемого в программе, предоставляются стандартной библиотекой.hash
Сумка.hash
Пакет определяет ряд общих интерфейсов и предоставляет несколько конкретных реализаций. Давайте посмотрим на пример.
package main
import (
"fmt"
"hash/fnv"
)
func hashIt(in string) uint64 {
h := fnv.New64a()
h.Write([]byte(in))
out := h.Sum64()
return out
}
func main() {
s := "hello"
fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s))
}
Результаты анализа побега проверки сборки:
./foo1.go:9:17: inlining call to fnv.New64a
./foo1.go:10:16: ([]byte)(in) escapes to heap
./foo1.go:9:17: hash.Hash64(&fnv.s·2) escapes to heap
./foo1.go:9:17: &fnv.s·2 escapes to heap
./foo1.go:9:17: moved to heap: fnv.s·2
./foo1.go:8:24: hashIt in does not escape
./foo1.go:17:13: s escapes to heap
./foo1.go:17:59: hashIt(s) escapes to heap
./foo1.go:17:12: main ... argument does not escape
Это,hash
объект, входная строка и представление ввода[]byte
Все разбегутся в кучу. Невооруженным глазом явно не убежишь, но тип интерфейса сдерживает компилятор. Потерпеть неудачуhash
Интерфейс пакета не может безопасно использовать конкретную реализацию. Так что же делать разработчикам, ориентированным на производительность?
Мы столкнулись с этой проблемой при создании Centrifuge, который выполняет незашифрованное хеширование небольших строк в путях горячего кода. Поэтому мы установилибиблиотека быстрого хеширования. Создать его несложно, и тяжелая работа по-прежнему выполняется в стандартной библиотеке.fasthash
Просто переупаковал стандартную библиотеку без использования распределения кучи.
Просто взглянитеfasthash
код версии
package main
import (
"fmt"
"github.com/segmentio/fasthash/fnv1a"
)
func hashIt(in string) uint64 {
out := fnv1a.HashString64(in)
return out
}
func main() {
s := "hello"
fmt.Printf("The FNV64a hash of '%v' is '%v'\n", s, hashIt(s))
}
Взгляните на результаты анализа побегов
./foo2.go:9:24: hashIt in does not escape
./foo2.go:16:13: s escapes to heap
./foo2.go:16:59: hashIt(s) escapes to heap
./foo2.go:16:12: main ... argument does not escape
Единственный выход, который происходит, заключается в том, чтоfmt.Printf()
Динамический характер метода. Хотя обычно мы предпочитаем использовать стандартную библиотеку, в некоторых случаях существует компромисс между повышением эффективности распределения.
маленькая хитрость
Мы закончили с этой вещью, непрактичной, но интересной. Это помогает нам понять механизм escape-анализа компилятора. Просматривая стандартную библиотеку для рассмотренных оптимизаций, мы наткнулись на довольно странный фрагмент кода.
// 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)
}
Этот метод позволяет переданному указателю избежать проверок анализа выхода компилятором. Итак, что это значит? Давайте поставим эксперимент и посмотрим.
package main
import (
"unsafe"
)
type Foo struct {
S *string
}
func (f *Foo) String() string {
return *f.S
}
type FooTrick struct {
S unsafe.Pointer
}
func (f *FooTrick) String() string {
return *(*string)(f.S)
}
func NewFoo(s string) Foo {
return Foo{S: &s}
}
func NewFooTrick(s string) FooTrick {
return FooTrick{S: noescape(unsafe.Pointer(&s))}
}
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
func main() {
s := "hello"
f1 := NewFoo(s)
f2 := NewFooTrick(s)
s1 := f1.String()
s2 := f2.String()
}
Этот код содержит две реализации одной и той же задачи: они содержат строку и используютString()
Метод возвращает сохраненную строку. Однако анализ выхода компилятора говоритFooTrick
Версия вообще не убегает.
./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
./foo3.go:27:28: NewFooTrick s does not escape
./foo3.go:28:45: NewFooTrick &s does not escape
./foo3.go:31:33: noescape p does not escape
./foo3.go:38:14: main &s does not escape
./foo3.go:39:19: main &s does not escape
./foo3.go:40:17: main f1 does not escape
./foo3.go:41:17: main f2 does not escape
Эти две строки наиболее актуальны
./foo3.go:24:16: &s escapes to heap
./foo3.go:23:23: moved to heap: s
Это то, что думает компиляторNewFoo()``方法把拿了一个string类型的引用并把它存到了结构体里,导致了逃逸。但是
NewFooTrick()方法并没有这样的输出。如果去掉
noescape(), анализ escape будетFooTrick
Данные, на которые ссылается структура, перемещаются в кучу. Что тут происходит?
func noescape(p unsafe.Pointer) unsafe.Pointer {
x := uintptr(p)
return unsafe.Pointer(x ^ 0)
}
noescape()
Методы маскируют прямые зависимости между входными параметрами и возвращаемыми значениями. Компилятор не думаетp
пройдешьx
сбежать, потому чтоuintptr()
создает ссылку, непрозрачную для компилятора. Встроенныйuintptr
Имя типа наводит на мысль, что это реальный тип указателя, но с точки зрения компилятора это просто целое число, достаточно большое для хранения указателя. Последняя строка кода создает и возвращает произвольное целое число.unsafe.Pointer
ценность.
Важно понимать, что мы не рекомендуем использовать эту технику. Вот почему пакет, на который он ссылается, называетсяunsafe
, а в примечании написаноUSE CAREFULLY!
Суммировать
Резюмируем ключевые моменты:
- Не оптимизируйте преждевременно! Используйте данные для оптимизации усилий
- Распределение стека дешево, выделение кучи дорого
- Знание правил escape-анализа позволяет нам писать более эффективный код.
- Использование указателей почти никогда не выделяет память в стеке
- Ищите API-интерфейсы, обеспечивающие управление распределением в фрагментах кода, критически важных для производительности.
- Используйте типы интерфейсов экономно в путях горячего кода