Pull to refresh

Простые методы оптимизации программ Go

Reading time11 min
Views15K
Original author: Stephen Whitworth
Я всегда забочусь о производительности. Точно не знаю, почему. Но меня просто бесят медленные сервисы и программы. Похоже, я не одинок.

В тестах A/B мы попытались замедлять выдачу страниц с шагом 100 миллисекунд и обнаружили, что даже очень небольшие задержки приводят к существенному падению доходов. — Грег Линден, Amazon.com

По опыту, низкая производительность проявляется одним из двух способов:

  • Операции, которые хорошо выполняются в небольших масштабах, становятся нежизнеспособными с ростом числа пользователей. Обычно это операции O(N) или O(N²). Когда база пользователей мала, всё работает отлично. Продукт спешат вывести на рынок. По мере роста базы возникает всё больше неожиданных патологических ситуаций — и сервис останавливается.
  • Много отдельных источников неоптимальной работы, «смерть от тысячи порезов».

Бóльшую часть карьеры я либо занимался наукой о данных с Python, либо создавал сервисы на Go. Во втором случае у меня гораздо больше опыта в оптимизации. Go обычно не является узким местом в службах, которые я пишу — программы при работе с БД часто ограничены I/O. Однако в пакетных конвейерах машинного обучения, какие я разрабатывал, программа часто ограничена CPU. Если программа Go чрезмерно использует процессор, есть различные стратегии.

Эта статья объясняет некоторые методы, которые можно использовать для значительного повышения производительности без особых усилий. Я намеренно игнорирую методы, которые требуют значительных усилий или больших изменений в структуре программы.

Прежде чем начать


Прежде чем вносить какие-то изменения в программу, потратьте время на создание подходящей базовой линии для сравнения. Если вы этого не сделаете, то будете блуждать в темноте, задаваясь вопросом, есть ли польза от сделанных изменений. Первым делом напишите бенчмарки и возьмите профили для использования в pprof. Лучше всего писать бенчмарк тоже на Go: это упрощает использование pprof и профилирование памяти. Также используйте benchcmp: полезный инструмент для сравнения разницы в производительности между тестами.

Если код не очень совместим с бенчмарками, просто начните с чего-то, что можно измерить. Можете профилировать код вручную с помощью runtime/pprof.

Итак, начнём!

Используйте sync.Pool для повторного использования ранее выделенных объектов


sync.Pool реализует список освобождения. Это позволяет повторно использовать ранее выделенные структуры и амортизирует распределение объекта по многим видам использования, уменьшая работу сборщика мусора. API очень простой. Реализуйте функцию, которая выделяет новый экземпляр объекта. API вернёт тип указателя.

var bufpool = sync.Pool{
    New: func() interface{} {
        buf := make([]byte, 512)
        return &buf
    }}

После этого можете сделать Get() объектов из пула и Put() их обратно, когда закончите.

// sync.Pool returns a interface{}: you must cast it to the underlying type
// before you use it.
b := *bufpool.Get().(*[]byte)
defer bufpool.Put(&b)


// Now, go do interesting things with your byte buffer.
buf := bytes.NewBuffer(b)

Тут есть нюансы. До версии Go 1.13 пул очищался при каждой сборке мусора. Это может негативно сказаться на производительности программ, которые выделяют много памяти. Начиная с 1.13, кажется, больше объектов выживают после GC.

!!! Перед возвращением объекта в пул обязательно обнулить поля структуры.

Если вы этого не сделаете, то можете получить из пула «грязный» объект, который содержит данные от предыдущего использования. Это серьёзная угроза безопасности!

type AuthenticationResponse {
    Token string
    UserID string
}

rsp := authPool.Get().(*AuthenticationResponse)
defer authPool.Put(rsp)

// If we don't hit this if statement, we might return data from other users! 
if blah {
    rsp.UserID = "user-1"
    rsp.Token = "super-secret"
}

return rsp

Безопасный способ всегда гарантировать нулевую память — сделать это явно:

// reset resets all fields of the AuthenticationResponse before pooling it.
func (a* AuthenticationResponse) reset() {
    a.Token = ""
    a.UserID = ""
}

rsp := authPool.Get().(*AuthenticationResponse)
defer func() {
    rsp.reset()
    authPool.Put(rsp)
}()

Единственный случай, когда это не проблема, — когда вы используете именно ту память, в которую производили запись. Например:

var (
    r io.Reader
    w io.Writer
)

// Obtain a buffer from the pool.
buf := *bufPool.Get().(*[]byte)
defer bufPool.Put(&buf)

// We only write to w exactly what we read from r, and no more. 
nr, er := r.Read(buf)
if nr > 0 {
    nw, ew := w.Write(buf[0:nr])
}

Избегайте использования структур, содержащих указатели в качестве ключей для большой карты


Фух, я был слишком многословен. Прошу прощения. Часто говорили (в том числе мой бывший коллега Фил Перл) о производительности Go при большом размере кучи. Во время сборки мусора среда выполнения сканирует объекты с указателями и отслеживает их. Если у вас очень большая карта map[string]int, то GC должен проверить каждую строку. Это происходит при каждой сборке мусора, поскольку строки содержат указатели.

В этом примере мы записываем 10 миллионов элементов в map[string]int и замеряем продолжительность сборки мусора. Мы выделяем нашу карту в области пакета, чтобы гарантировать выделение памяти из кучи.

package main

import (
    "fmt"
    "runtime"
    "strconv"
    "time"
)

const (
    numElements = 10000000
)

var foo = map[string]int{}

func timeGC() {
    t := time.Now()
    runtime.GC()
    fmt.Printf("gc took: %s\n", time.Since(t))
}

func main() {
    for i := 0; i < numElements; i++ {
        foo[strconv.Itoa(i)] = i
    }

    for {
        timeGC()
        time.Sleep(1 * time.Second)
    }
}

Запустив программу, увидим следующее:

 inthash → go install && inthash
gc took: 98.726321ms
gc took: 105.524633ms
gc took: 102.829451ms
gc took: 102.71908ms
gc took: 103.084104ms
gc took: 104.821989ms

Это довольно долго в компьютерной стране!

Что можно сделать для оптимизации? Хорошей идеей кажется повсеместное удаление указателей, чтобы не загружать сборщик мусора. В строках есть указатели; поэтому давайте реализуем это как map[int]int.

package main

import (
    "fmt"
    "runtime"
    "time"
)

const (
    numElements = 10000000
)

var foo = map[int]int{}

func timeGC() {
    t := time.Now()
    runtime.GC()
    fmt.Printf("gc took: %s\n", time.Since(t))
}

func main() {
    for i := 0; i < numElements; i++ {
        foo[i] = i
    }

    for {
        timeGC()
        time.Sleep(1 * time.Second)
    }
}

Запустив программу ещё раз, увидим:

 inthash → go install && inthash
gc took: 3.608993ms
gc took: 3.926913ms
gc took: 3.955706ms
gc took: 4.063795ms
gc took: 3.91519ms
gc took: 3.75226ms

Гораздо лучше. Мы ускорили сборку мусора в 35 раз. При использовании в продакшне нужно будет хэшировать строки в целые числа перед вставкой в карту.

Кстати, есть ещё много способов избежать GC. Если вы выделяете гигантские массивы бессмысленных структур, int'ов или байтов, GC не будет это сканировать: то есть вы экономите на времени GC. Такие методы обычно требуют существенной переработки программы, поэтому сегодня не будем углубляться в эту тему.

Как и при любой оптимизации, эффект может отличаться. См. тред твитов от Дамиана Грыски с интересным примером, как удаление строк из большой карты в пользу более умной структуры данных фактически увеличило потребление памяти. Вообще, читайте всё, что он публикует.

Генерация кода маршалинга, чтобы избежать отражения в рантайме


Маршалинг и демаршалинг вашей структуры в различные форматы сериализации, такие как JSON — типичная операция, особенно при создании микросервисов. У многих микросервисов это вообще единственная работа. Функции вроде json.Marshal и json.Unmarshal полагаются на отражение в рантайме для сериализации полей структуры в байты и наоборот. Это может работать медленно: отражение не так эффективно, как явный код.

Однако, есть варианты оптимизации. Механика маршалинга в JSON выглядит примерно так:

package json

// Marshal take an object and returns its representation in JSON.
func Marshal(obj interface{}) ([]byte, error) {
    // Check if this object knows how to marshal itself to JSON
    // by satisfying the Marshaller interface.
    if m, is := obj.(json.Marshaller); is {
        return m.MarshalJSON()
    }

    // It doesn't know how to marshal itself. Do default reflection based marshallling.
    return marshal(obj)
}

Если мы знаем процесс маршализации в JSON, у нас есть зацепка, чтобы избежать отражения в рантайме. Но мы не хотим вручную писать весь код маршализации, что же делать? Поручить компьютеру генерацию этого кода! Генераторы кода вроде easyjson смотрят на структуру и генерируют высокооптимизированный код, который полностью совместим с существующими интерфейсами маршалинга, такими как json.Marshaller.

Загружаем пакет и записываем следующую команду в $file.go, содержащем структуры, для которых вы хотите сгенерировать код.

easyjson -all $file.go

Должен сгенерироваться файл $file_easyjson.go. Поскольку easyjson реализовал для вас интерфейс json.Marshaller, то вместо отражения по умолчанию будут вызываться эти функции. Поздравляю: вы только что ускорили свой JSON-код в три раза. Есть много трюков, чтобы ещё больше увеличить производительность.

Рекомендую этот пакет, потому что сам использовал его раньше, и успешно. Но будьте бдительны. Пожалуйста, не воспринимайте это как приглашение начать со мной агрессивные споры о самых быстрых JSON-пакетах.

Следует убедиться в повторной генерации кода маршалинга при изменении структуры. Если вы забудете это сделать, то новые добавляемые поля не будут сериализоваться, что приведёт к путанице! Можете использовать go generate для этих задач. Чтобы поддерживать синхронизацию со структурами, я предпочитаю поместить generate.go в корень пакета, что вызывает go generate для всех файлов пакета: это может помочь, когда у вас есть много файлов, которым нужна генерация такого кода. Главный совет: чтобы гарантировать обновление структур, вызовите go generate в CI и проверьте, что нет различий с зарегистрированным кодом.

Используйте strings.Builder для построения строк


В Go строки неизменяемы: представляйте их срезом байтов только для чтения. Это означает, что при каждом создании строки вы выделяете память и потенциально создаёте больше работы для сборщика мусора.

В Go 1.10 реализовали strings.Builder как эффективный способ создания строк. Внутренне он пишет в байтовый буфер. Только при вызове String() в билдере фактически создаётся строка. Он полагается на некоторые небезопасные хитрости, чтобы вернуть базовые байты в виде строки с нулевым распределением: см. этот блог для дальнейшего изучения, как это работает.

Сравним производительность двух подходов:

// main.go
package main

import "strings"

var strs = []string{
    "here's",
    "a",
    "some",
    "long",
    "list",
    "of",
    "strings",
    "for",
    "you",
}

func buildStrNaive() string {
    var s string

    for _, v := range strs {
        s += v
    }

    return s
}

func buildStrBuilder() string {
    b := strings.Builder{}

    // Grow the buffer to a decent length, so we don't have to continually
    // re-allocate.
    b.Grow(60)

    for _, v := range strs {
        b.WriteString(v)
    }

    return b.String()
}

// main_test.go
package main

import (
    "testing"
)

var str string

func BenchmarkStringBuildNaive(b *testing.B) {
    for i := 0; i < b.N; i++ {
        str = buildStrNaive()
    }
}
func BenchmarkStringBuildBuilder(b *testing.B) {
    for i := 0; i < b.N; i++ {
        str = buildStrBuilder()
    }

Вот результаты на моём Macbook Pro:

 strbuild → go test -bench=. -benchmem
goos: darwin
goarch: amd64
pkg: github.com/sjwhitworth/perfblog/strbuild
BenchmarkStringBuildNaive-8          5000000           255 ns/op         216 B/op          8 allocs/op
BenchmarkStringBuildBuilder-8       20000000            54.9 ns/op        64 B/op          1 allocs/op

Как видите, strings.Builder в 4,7 раза быстрее, вызывает в восемь раз меньше выделений и отнимает вчетверо меньше памяти.

Когда важна производительность, используйте strings.Builder. В общем, рекомендую использовать его везде, кроме самых тривиальных случаев построения строк.

Используйте strconv вместо fmt


fmt — один из самых известных пакетов в Go. Вероятно, вы использовали его в своей первой программе для вывода на экран “hello, world”. Но когда дело доходит до преобразования в строки целых чисел и флоатов, он не так эффективен, как его младший брат strconv. Этот пакет показывает приличную производительность с очень небольшими изменениями в API.

fmt в основном принимает interface{} в качестве аргументов функций. Тут два недостатка:

  • Вы теряете безопасность типов. Для меня это очень важно.
  • Это может увеличить количество необходимых выделений. Передача типа без указателя в качестве interface{} обычно приводит к выделению кучи. В этом блоге рассказывается, почему это так.
  • Следующая программа показывает разницу в производительности:

    // main.go
    package main
    
    import (
        "fmt"
        "strconv"
    )
    
    func strconvFmt(a string, b int) string {
        return a + ":" + strconv.Itoa(b)
    }
    
    func fmtFmt(a string, b int) string {
        return fmt.Sprintf("%s:%d", a, b)
    }
    
    func main() {}

    // main_test.go
    package main
    
    import (
        "testing"
    )
    
    var (
        a    = "boo"
        blah = 42
        box  = ""
    )
    
    func BenchmarkStrconv(b *testing.B) {
        for i := 0; i < b.N; i++ {
            box = strconvFmt(a, blah)
        }
        a = box
    }
    
    func BenchmarkFmt(b *testing.B) {
        for i := 0; i < b.N; i++ {
            box = fmtFmt(a, blah)
        }
        a = box
    }

    Бенчмарки на Macbook Pro:

     strfmt → go test -bench=. -benchmem
    goos: darwin
    goarch: amd64
    pkg: github.com/sjwhitworth/perfblog/strfmt
    BenchmarkStrconv-8      30000000            39.5 ns/op        32 B/op          1 allocs/op
    BenchmarkFmt-8          10000000           143 ns/op          72 B/op          3 allocs/op

    Как видим, вариант strconv в 3,5 раза быстрее, вызывает втрое меньше выделений и отнимает вдвое меньше памяти.

    Выделяйте ёмкость для среза с помощью make, чтобы избежать перераспределения


    Прежде чем перейти к улучшению производительности, давайте быстро обновим в памяти информацию по срезам. Срез — очень полезная конструкция в Go. Он предоставляет масштабируемый массив с возможностью принимать различные представления в одной и той же базовой памяти без перераспределения. Если заглянуть под капот, то срез состоит из трёх элементов:

    type slice struct {
        // pointer to underlying data in the slice.
        data uintptr
        // the number of elements in the slice.
        len int
        // the number of elements that the slice can 
        // grow to before a new underlying array
        // is allocated.
        cap int     
    }

    Что это за поля?

    • data: указатель на базовые данные в срезе
    • len: текущее количество элементов в срезе
    • cap: число элементов, до которых может дорасти срез перед перераспределением

    Под капотом срезы представляют собой массивы фиксированной длины. При достижении максимального значения (cap) выделяется новый массив с вдвое большим значением, память копируется из старого среза в новый, а старый массив отбрасывается.

    Я часто вижу примерно такой код, где выделяется срез с нулевой граничной ёмкостью, если ёмкость среза известна заранее:

    var userIDs []string
    for _, bar := range rsp.Users {
        userIDs = append(userIDs, bar.ID)
    }

    В этом случае срез начинается с нулевого размера len и нулевой граничной ёмкости cap. Получив ответ, мы добавляем элементы в срез, при этом достигаем граничной ёмкости: выделяется новый базовый массив, где вдвое повышается cap, а данные копируются в него. Если у нас в ответе приходит 8 элементов, это приводит к 5 перераспределениям.

    Гораздо более эффективен следующий способ:

    userIDs := make([]string, 0, len(rsp.Users))
    
    for _, bar := range rsp.Users {
        userIDs = append(userIDs, bar.ID)
    }

    Здесь мы явно выделили ёмкость для среза с помощью make. Теперь можем спокойно добавлять туда данные, без дополнительных перераспределений и копирований.

    Если вы не знаете, сколько выделять памяти, потому что ёмкость динамическая или позже вычисляется в программе, измерьте конечное распределение размера среза после работы программы. Я обычно беру 90-й или 99-й процентиль и жёстко кодирую значение в программе. В случаях, когда CPU для вас дороже RAM, установите это значение выше, чем вы думаете, что необходимо.

    Совет также применим к картам: make(map[string]string, len(foo)) выделит достаточно памяти, чтобы избежать перераспределения.

    См. эту статью о том, как в реальности работают срезы.

    Используйте методы, позволяющие передавать байтовые срезы


    При использовании пакетов используйте методы, которые позволяют передавать байтовый срез: эти методы обычно дают больше контроля над распределением.

    Хороший пример — сравнение time.Format и time.AppendFormat. Первый возвращает строку. Под капотом это выделяет новый байтовый срез и вызывает на нём time.AppendFormat. Второй берёт байтовый буфер, записывает форматированное представление времени и возвращает расширенный байтовый срез. Такое часто встречается в других пакетах стандартной библиотеки: см. strconv.AppendFloat или bytes.NewBuffer.

    Почему это повышает производительность? Ну, теперь вы можете передавать байтовые срезы, которые получили от sync.Pool, вместо того, чтобы каждый раз выделять новый буфер. Или можете увеличить начальный размер буфера до значения, которое больше подходит для вашей программы, чтобы уменьшить количество повторных копирований среза.

    Резюме


    Все эти методы можете применить к своей кодовой базе. Со временем вы построите ментальную модель для рассуждений о производительности в программах Go. Это значительно поможет в их проектировании.

    Но применяйте их в зависимости от ситуации. Это советы, а не Евангелие. Всё измеряйте и проверяйте бенчмарками.

    И знайте, когда остановиться. Повышение производительности — хорошее упражнение: задача интересна, а результаты сразу видны. Однако полезность повышения производительности во многом зависит от ситуации. Если ваша служба выдаёт ответ за 10 мс, а сетевая задержка составляет 90 мс, наверное, не стоит пытаться сократить эти 10 мс до 5 мс: у вас всё равно останется 95 мс. Даже если предельно оптимизировать сервис до 1 мс, всё равно общая задержка составит 91 мс. Наверное, есть рыба покрупнее.

    Оптимизируйте с умом!

    Ссылки


    Если хотите получить дополнительную информацию, вот большие источники вдохновения:

Tags:
Hubs:
+20
Comments10

Articles