Эффективное выделение памяти для высокопроизводительных сервисов go

Go

Ручное управление памятью действительно сложно (как 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Являетсяintvalue, которое является необработанным значением фиксированного размера, также может быть исключено. Посмотрите ниже,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!

Суммировать

Резюмируем ключевые моменты:

  1. Не оптимизируйте преждевременно! Используйте данные для оптимизации усилий
  2. Распределение стека дешево, выделение кучи дорого
  3. Знание правил escape-анализа позволяет нам писать более эффективный код.
  4. Использование указателей почти никогда не выделяет память в стеке
  5. Ищите API-интерфейсы, обеспечивающие управление распределением в фрагментах кода, критически важных для производительности.
  6. Используйте типы интерфейсов экономно в путях горячего кода
Оригинальная ссылка:segment.com/blog/Alibaba…