Привет, Хабр! Я работаю старшим Go-разработчиком в «Лаборатории Касперского». Сегодня хочу поговорить о том, как искать узкие места и оптимизировать код на Go. Разберу процесс профилирования и оптимизации на примере простого веб-сервиса — покажу, с помощью каких встроенных инструментов искать функции, активнее всего использующие CPU и память. Расскажу, какие можно применять подходы, чтобы повысить производительность. Хотя речь пойдет о микрооптимизации, в моем примере шаг за шагом производительность удалось поднять в 5 раз!


Но для начала остановимся на бенчмаркинге, garbage collector-е и еще нескольких важных особенностях языка Go с точки зрения перформанса.


Особенности Golang


  • Язык Go — компилируемый, т. е. на таргет-платформе мы запускаем нативный машинный код.
  • В этом языке есть классная штука — goroutines, это «легковесные потоки», которыми управляет планировщик Go в рантайме. Благодаря им у нас нет «дорогих» системных вызовов при создании и смене контекста, а на стек горутины выделяется всего 2 КБ, тогда как для потока 1–2 МБ.
  • Дополнительный бонус — поддержка указателей. Программист волен выбирать, как передать объект — по указателю или по значению.

Производительность сборщика мусора (Garbage Collector)


В Go есть Garbage Collector (GC). С одной стороны, это удобная штука. Но посмотрим на него с точки зрения перформанса.


В 2015 году на GopherCon Рик Хадсон (Rick Hudson) представил такой слайд, заявив, что проблемы с задержками GC решены:


Рис. 1.1. Паузы GC в зависимости от размера heap


До Go 1.5 GC вносил серьезные задержки. В 2018 году тот же Рик Хадсон опубликовал историю улучшений GC в Go.


Переломный момент перехода с Go 1.4 на Go 1.5.


Рис. 1.2. GC latency Go 1.4–1.5


Рис. 1.3. GC latency Go 1.5–1.6


От версии к версии latency становится все меньше.


Рис. 1.4. GC latency Go 1.6–1.6.3


Рис. 1.5. GC latency Go 1.6–1.7


Рис. 1.6. GC latency Go 1.7–1.8


Из этого можно сделать вывод, что GC развивается. С точки зрения перформанса проблем с ним быть не должно.


Бенчмарки


Go богат на тулинг и бенчмарки встроены прямо в язык. Они помогают оценить эффективность кода.


Для примера возьмем задачу конкатенации строк. Представим, что мы не знаем библиотечную функцию и напишем свою реализацию. Добавим бенчмарк с помощью пакета testing. Он выглядит очень просто:


func join(elems []string, sep string) string {
    var res string
    for i, s := range elems {
        res += s
        if i < len(elems)-1 {
            res += sep
        }
    }
    return res
}

var strs = []string{
    "string a", 
    "string b", 
    "string c", 
    "string d", 
    "string e",
}

func BenchmarkMyJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = join(strs, ";")
    }
}

Запускаем с помощью:


go test -bench=Join -benchmem

Флаг -bench принимает регулярное выражение. А -benchmem дает возможность понаблюдать за расходом памяти.


После запуска мы видим следующее:


Рис. 2.1. Результат бенчмарка


Слева направо:


  • имя бенчмарка;
  • суффикс 8 — это значение GOMAXPROCS;
  • количество итераций запуска бенчмарка;
  • время одной операции;
  • количество памяти, которое выделяется на одну операцию;
  • количество аллокаций за операцию.

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


func BenchmarkStrngsJoin(b *testing.B) {
    for i := 0; i < b.N; i++ {
        _ = strings.Join(strs, ";")
    }
}

Рис. 2.2. Сравнение результатов бенчмарка


Библиотечная реализация более чем в 5 раз эффективнее как по выделяемой памяти, так и по скорости выполнения.


Общие рекомендации запуска бенчмарков


Обратите внимание на результаты нескольких запусков одного и того же бенчмарка. Результат может быть нестабилен, поскольку на него влияет множество факторов, например планировщик операционной системы:


Рис. 2.3. Нестабильный результат бенчмарков


Есть несколько способов стабилизировать результаты.


Можно использовать параметр -benchtime.


go test -bench=. -benchtime=5s

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


Следующая возможность — указать параметр -count и записать несколько запусков одного и того же бенчмарка в файл.


go test -bench=. -count=10 > bench.txt
go get golang.org/x/perf/cmd/benchstat
benchstat bench.txt

С помощью утилиты benchstat от Расса Кокса (Russ Cox) данные из файла можно представить в виде среднего значения и некой дельты:


Рис. 2.4. Вывод benchstat


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


benchstat old.txt new.txt

Рис. 2.5. Сравнение результатов с помощью benchstat


В качестве первого приближения benchstat можно использовать в continuous бенчмаркинге.


Профайлинг в Go


В рантайм Go встроен сэмплирующий профайлер pprof. Это значит, что, используя pprof, мы с определенной периодичностью останавливаем работу программы и собираем метрики, в частности call-стеки. В конце делаем выводы о том, какие функции используют больше всего ресурсов, по тому, как часто они встречаются.


pprof поможет нам ответить на следующие вопросы:


  • Какие функции больше всего используют процессор? (CPU profiler)
  • Кто и где выделяет память? Почему память не освобождается? (heap profiler)
  • Где и сколько мы ждём на блокировках? (block/mutex profiler)
  • При каких условиях операционная система создает потоки? (threadcreate)
  • Как выглядят стектрейсы всех запущенных потоков? (goroutines)

Как запускать pprof


Первый способ — с помощью go test -bench. Можно, например, при запуске использовать опцию -cpuprofile, записать результаты в файл, а затем через go tool pprof проанализировать данные.


go test . -bench . -cpuprofile cpu.prof
go tool pprof [binary] cpu.prof

Второй способ — встраивание http-хендлеров pprof прямо в приложение. Для этого мы просто импортируем net/http/pprof, и по роуту http://host:port/debug/pprof будет доступен профайлер.


import _ "net/http/pprof"

func main() {
    http.ListenAndServe("localhost:8080", nil)
}

go tool pprof [binary] http://localhost:8080/debug/pprof/profile&seconds=5

Третий способ — запускать (pprof.StartCPUProfile()) и останавливать (pprof.StopCPUProfile()) профайлер прямо из кода, используя пакет runtime/pprof.


Рекомендую при этом использовать пакет от Дейва Чейни (Dave Cheney). Он берет на себя много рутинных операций.


import "github.com/pkg/profile"

func someFunc() {
    defer profile.Start(profile.MemProfile, profile.ProfilePath(“.”)).Stop()
    ...
    // code
}

Визуализация данных


Стандартный способ анализа данных профайлера — командная строка.


Рис. 3.1. Работа с pprof из командной строки


Рис. 3.2. Работа с pprof из командной строки


Если кому-то больше нравится визуализация в браузере, достаточно указать флаг -http.


go tool pprof -http localhost:6061

Рис. 3.3. Визуализация в браузере


В браузере можно посмотреть граф — он удобнее, чем таблица в консоли.


Рис. 3.4. Граф вызовов


Сервис Foo 1.0


Переходим к примеру. Весь исходный код и историю его оптимизации можно посмотреть тут. Итак, у нас есть простой веб-сервис с одним методом /foo:


func main() {
    http.HandleFunc("/foo", foo)
    http.ListenAndServe("localhost:6060", nil)
}

В нём мы читаем тело запроса и unmarshal-им его в массив структур.


func foo(w http.ResponseWriter, r *http.Request) {
    b, err := ioutil.ReadAll(r.Body)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    var fooReq FooReq
    if err := json.Unmarshal(b, &fooReq); err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    ...
}

Структура выглядит просто — это два поля типа string:


type FooItem struct {
    StrA string `json:"srt_a"`
    StrB string `json:"str_b"`
}

type FooReq []FooItem

Далее мы пробегаем по каждой структуре в массиве, считаем хеш sha256 от ее полей, конвертируем в base64 и складываем в слайс:


func foo(w http.ResponseWriter, r *http.Request) {
    ...
    var hashes []string
    for _, foo := range fooReq {
        sha := sha256.New()
        sha.Write([]byte(foo.StrA))
        sha.Write([]byte(foo.StrB))
        hashes = append(hashes, base64.StdEncoding.EncodeToString(sha.Sum(nil)))
    }
    fooRes := FooRes{Hashes: hashes}
    ...
}

Полученный слайс хешей складываем в структуру ответа, marshal-им в json и пишем в тело ответа.


func foo(w http.ResponseWriter, r *http.Request) {
    ...
    fooRes := FooRes{Hashes: hashes}
    b, err = json.Marshal(fooRes)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        return
    }
    w.Write(b)
}

type FooRes struct {
    Hashes []string `json:"hashes"`
}

По сути, пример просто считает хеш от полей структуры. Интересно, как быстро этот веб-сервис будет обрабатывать запросы клиента?


Для теста я взял утилиту hey. В принципе, можно было взять любую другую, например широко известный ApacheBench. Также я заранее подготовил текстовый файл с данными запроса, в который сложил несколько сотен объектов нужного типа.


Запускаем наш сервис и натравливаем на него утилиту:


hey -n 10000 -c 1 -m GET -D foo_req.json http://localhost:6060/foo

Здесь параметр -n — это количество запросов, а -c — количество goroutine, из которых мы будем их отправлять.


Рассмотрим две ситуации:
в первой (как в строке выше) отправим последовательно десять тысяч запросов (параметр -c равен 1);
во второй будем эмулировать конкурентные запросы к сервису из 30 goroutines.


hey -n 10000 -c 1 -m GET -D foo_req.json http://localhost:6060/foo

Summary:
  Total:  22.0089 secs
  Slowest:  0.1363 secs
  Fastest:  0.0014 secs
  Average:  0.0022 secs
  Requests/sec:  454.3617

hey -n 10000 -c 30 -m GET -D foo_req.json http://localhost:6060/foo

Summary:
  Total:  9.1658 secs
  Slowest:  0.2334 secs
  Fastest:  0.0014 secs
  Average:  0.0267 secs
  Requests/sec:  1089.9231

Для первого кейса сервер выдает 450 rps, для второго — 1090 rps. Много это или мало, сказать сложно. Но представим, что нам нужно оптимизировать наш сервис.


Foo 1.0 CPU profiling


В этом примере можно весь код поместить в бенчмарк и уже на нем профилировать. Я рекомендую, когда это возможно, именно так и поступать. Однако для примера представим, что в сервисе много кода и мы не знаем, что именно профилировать. В этом случае придется профилировать на проде, предварительно предусмотрев соответствующие «ручки». Натравим на наш сервис утилиту hey и подключаем профайлер:


hey -n 10000 -c 1 -m GET -D foo_req.json http://localhost:6060/foo

go tool pprof /path/to/binary http://localhost:6060/debug/pprof/profile?seconds=10 

Параметр seconds=10 означает, что мы будем собирать профиль в течение 10 секунд.


Воспользуемся командой top, которая покажет функции, активнее всего использующие CPU.


Рис. 4.1. Вывод команды top


Для каждой функции у нас есть:


  • flat — время, которое мы проводим в данной функции, исключая вложенные;
  • cum — кумулятивное значение: сколько мы проводим времени в этой функции, включая вложенные.

На вершине списка у нас:


  • runtime.futex — 18%;
  • syscall.yscall — 18%;
  • runtime.tgkill — 7%.

А основа нашего сервиса, подсчет sha256, — это только 4%.


С первого взгляда кажется, что в медленной работе приложения виноват рантайм Go и syscall-ы. Но давайте отсортируем список по кумулятивному значению (флаг -cum):


Рис. 4.2. Вывод команды top20 -cum


Здесь видно, что большую часть времени мы проводим в функции main.foo. При этом 35% времени мы находимся в функции стандартного пакета json.Unmarshal.


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


Запускаем профайлер с флагом -http, чтобы посмотреть еще одно удобное представление — флейм-граф.


go tool pprof -http :8080 /path/to/binary /path/to/pprof.data


Сверху вниз здесь глубина стека, а ширина столбца отражает кумулятивное значение времени (сколько времени мы находимся в этой функции, включая все вложенные). В этом представлении также бросается в глаза, что большую часть времени мы проводим в функции json.Unmarshal. Дополнительно хочу обратить внимание на правую часть графа — здесь тоже достаточно широкие столбцы и они не связаны с нашей функцией foo.


Рис. 4.4


Видно, что функция runtime.gcBgMarkWorker отбирает у нас 11,5% CPU. И на вершине стека как раз runtime.tgkill, который был на третьем месте в топе.


Рис. 4.5


По названию функции понятно, что это GC в бэкграунде маркирует память. И это интересный момент. Ранее мы говорили о том, что с GC в Go все хорошо, тем не менее на него уходит значительная часть ресурсов. К слову, тот же Рик Хадсон предупреждал, что GC может использовать до 25% CPU.


Почему в этом случае GC потребляет так много ресурсов? Очевидно, он нагружен, поскольку мы аллоцируем много памяти. Пришло время воспользоваться heap-профайлером.


Foo 1.0 Heap profiling


Опция -alloc_space позволяет посмотреть все выделения памяти (включая те, что уже очищены).


go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap?seconds=10

Если же мы хотим посмотреть аллоцированную, но еще не освобожденную память, можно воспользоваться опциями -inuse_space или -inuse_object.


Посмотрим на top функций:


Рис. 4.6


На функцию io.ReadAll приходится больше половины всей выделенной памяти.


Но прежде чем смотреть, почему так происходит, напомню, что профайлер heap тоже сэмплирующий — он снимает профиль каждые 512 КБ выделенной памяти. Этот параметр можно регулировать — и это будет полезно, если необходимо отследить мелкие выделения памяти. Однако для нашего случая подойдет и стандартное значение.


Смотрим в код io.ReadAll. Для этого воспользуемся методом weblist. Если list отображает код в консоли, то weblist продемонстрирует его в браузере.


weblist io.ReadAll

Рис. 4.7


Очевидно, что в методе ReadAll идет создание и наполнение слайса. Здесь видно, насколько много мы аллоцируем. То же самое мы можем посмотреть на флейм-графе.


Рис. 4.8


Шаг 1 — используем sync.Pool (Foo 1.1)


Благодаря профилированию мы знаем, что оптимизировать. Чтобы сократить количество аллокаций, воспользуемся sync.Pool.


var bufPool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 1024))
    },
}

Наш пул будет создавать буферы с capacity 1 КБ.


func foo(w http.ResponseWriter, r *http.Request) {
    buf := bufPool.Get().(*bytes.Buffer)
    defer func() {
        buf.Reset()
        bufPool.Put(buf)
    }()
    _, err := io.Copy(buf, r.Body)
    ...
}

Получаем буфер из пула, используем его в методе io.Copy и перед возвращением обратно в пул не забываем сбросить буфер, вызвав Reset.


Проверяем результат:


hey -n 10000 -c 1

Было, rps Стало, rps
454.3617 475.6389

Мы получили небольшой прирост производительности для последовательных запросов. Кажется, должно быть значительно больше.


У нас сократилось количество выделенной памяти — было 1672 МБ, осталось всего 265 МБ.


Рис. 4.9


Но функция io.Copy продолжает выделять достаточно много памяти, хотя, кажется, не должна этого делать.


Второй кейс — запуск 10000 конкурентных запросов из 30 goroutines.


hey -n 10000 -c 30

И здесь прирост значительный.


Было, rps Стало, rps
1089.9231 1488.8684

Почему так? Разобраться в ситуации поможет документация по sync.Pool.


Pool's purpose is to cache allocated but unused items for later reuse, relieving pressure on the garbage collector. That is, it makes it easy to build efficient, thread-safe free lists. However, it is not suitable for all free lists. Any item stored in the Pool may be removed automatically at any time without notification. If the Pool holds the only reference when this happens, the item might be deallocated.”

Мы используем sync.Pool, чтобы снять нагрузку с GC. Но буферы sync.Pool могут быть удалены тем же GC. В итоге мы пытаемся облегчить жизнь GC, а он продолжает удалять из пула, так что мы заново аллоцируем память.


Шаг 2 — увеличиваем буфер (Foo 1.2)


Первое, что можно попробовать — увеличить объем изначального буфера. Один раз выделим память и будем ей пользоваться. Чтобы функция io.Copy больше не выделяла память.


var bufPool = sync.Pool{
    New: func() interface{} {
        return bytes.NewBuffer(make([]byte, 0, 1024*1024))
    },
}

Проверяем результат.
Получаем хороший прирост при конкурентных запросах и деградацию при последовательных.


Тест Было, rps Стало, rps
hey -n 10000 -c 1 475.6389 438.9253
hey -n 10000 -c 30 1488.8684 2278.3559

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


Можно ли что-то сделать, чтобы прирост производительности был в обоих случаях?


Шаг 3 — FreeList (Foo 1.3)


Напишем свой FreeList — на Go это сделать очень легко. Все что нам нужно это канал и оператор select.


type BufFreeList struct {
    ch chan *bytes.Buffer
}

func (p *BufFreeList) Get() *bytes.Buffer {
    select {
    case b := <-p.ch:
        return b
    default:
        return bytes.NewBuffer(make([]byte, 0, 1024*1024))
    }
}

func (p *BufFreeList) Put(b *bytes.Buffer) {
    select {
    case p.ch <- b: // ok
    default: // drop
    }
}

В методе Get мы читаем из канала, а если не получилось, создаём новый буфер. В методе Put мы складываем буфер в канал или дропаем его. Также у нас есть функция инициализации, где мы задаём вместимость буферизованного канала по количеству ядер процессора:


func NewBufFreeList(max int) *BufFreeList {
    c := make(chan *bytes.Buffer, max)
    for i := 0; i < max; i++ {
        c <- bytes.NewBuffer(make([]byte, 0, 1024*1024))
    }
    return &BufFreeList{ch: c}
}

var bufFreeList = NewBufFreeList(runtime.NumCPU())

Посмотрим, что из этого получилось:


Тест Было, rps Стало, rps
hey -n 10000 -c 1 438.9253 571.0069
hey -n 10000 -c 30 2278.3559 2406.8540

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


Шаг 4 — Heap profiling


Продолжаем профилировать. Теперь в топе json.Unmarshal.


Рис. 4.10


Рис. 4.11


И здесь мы применяем тот же подход — пишем FreeList (код такой же, поэтому подробно останавливаться на нем не буду).


type FooReq []FooItem

type FooReqFreeList struct {
    ch chan *FooReq
}

func (p *FooReqFreeList) Get() *FooReq {
    select {
    case b := <-p.ch:
        return b
    default:
        fooReq := FooReq(make([]FooItem, 0, 100))
        return &fooReq
    }
}

func (p *FooReqFreeList) Put(fooReq *FooReq) {
    fooReqSlace := (*fooReq)[:0]
    select {
    case p.ch <- &fooReqSlace: // ok
    default: // drop
    }
}

Проверяем:


Тест Было, rps Стало, rps
hey -n 10000 -c 1 571.0069 589.6130
hey -n 10000 -c 30 2406.8540 2483.3692

Мы получили небольшой прирост, но главное, сократили аллокации памяти — было 877 МБ, осталось всего 161. На флейм-графе столбец json.Unmarshal значительно сократился.


Рис. 4.12


Шаг 5 — Foo 1.4


Теперь в лидерах base64.EncodeToString, а также память почему-то выделяет sha256.Sum. Посмотрим код:


Рис. 4.13


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


А вот метод sha256.Sum.


Рис. 4.14


Из кода понятно, что достаточно передавать валидный слайс (нужного объема), чтобы память не аллоцировалась.


Приступим к оптимизации следующего участка кода.


var hashes []string
for _, foo := range *fooReq {
    sha := sha256.New()
    sha.Write([]byte(foo.StrA))
    sha.Write([]byte(foo.StrB))
    hashes = append(hashes, base64.StdEncoding.EncodeToString(sha.Sum(nil)))
}

Приведём его к виду:


// сразу аллоцируем необходимую память.
hashes := make([]string, 0, len(*fooReq))

// для sha256 мы определим массив на стеке с фиксированной длиной и будем его использовать - т.е. выделения памяти в Heap здесь вообще не будет.
var sha256Buf [sha256.Size]byte

// вместо того чтобы каждый раз в цикле создавать sha256, создадим его один раз, а в цикле будем вызывать метод Reset.
sha := sha256.New()

// заранее подготовим буфер для base64
encodedLen := base64.StdEncoding.EncodedLen(sha256.Size)
buf.Reset()
buf.Grow(encodedLen)
for i := 0; i < encodedLen; i++ {
    buf.WriteByte(0)
}

for _, foo := range *fooReq {
    sha.Reset()
    sha.Write([]byte(foo.StrA))
    sha.Write([]byte(foo.StrB))

    // используем преаллоцированные буферы
    base64.StdEncoding.Encode(buf.Bytes(), sha.Sum(sha256Buf[:0]))

    hashes = append(hashes, buf.String())
}

Проверяем и видим, что удалось еще немного выиграть


Тест Было, rps Стало, rps
hey -n 10000 -c 1 589.6130 596.2832
hey -n 10000 -c 30 2483.3692 2536.7017

Шаг 6 — Heap profiling и вредный совет по оптимизации (Foo 1.5)


Опять посмотрим на данные Heap профайлера:


   45.35MB    45.35MB    115:   hashes := make([]string, 0, len(*fooReq))
         .          .    116:   var sha256Buf [sha256.Size]byte
         .          .    117:   sha := sha256.New()
         .          .    118:   encodedLen := base64.StdEncoding.EncodedLen(sha256.Size)
         .          .    119:   buf.Reset()
         .          .    120:   buf.Grow(encodedLen)
         .          .    121:   for i := 0; i < encodedLen; i++ {
         .          .    122:           buf.WriteByte(0)
         .          .    123:   }
         .          .    124:   for _, foo := range *fooReq {
         .          .    125:           sha.Reset()
         .          .    126:           sha.Write([]byte(foo.StrA))
  225.51MB   225.51MB    127:           sha.Write([]byte(foo.StrB))
         .          .    128:           base64.StdEncoding.Encode(buf.Bytes(), sha.Sum(sha256Buf[:0]))
         .   149.51MB    129:           hashes = append(hashes, buf.String())
         .          .    130:   }

Мы выделяем память при записи в sha256 и при вызове метода buf.String(). Чтобы понять, почему так происходит, можно заглянуть в данные дизассемблера. Здесь необязательно все понимать, достаточно просто найти такие строки:


        .          .     65ac99: MOVQ 0x80(SP), CX
  225.51MB   225.51MB    65aca1: CALL runtime.stringtoslicebyte(SB)      ;main.foo main.go:127
        .          .     65aca6: MOVQ CX, DI 

         .          .     65ad9f: NOPL
  149.51MB   149.51MB     65ada0: CALL runtime.slicebytetostring(SB)      ;main.foo buffer.go:65
         .          .     65ada5: MOVQ 0x60(SP), CX 

Выделение памяти происходит в runtime.sringtoslicebyte и в обратной операции — при конвертации из слайса байт в строку. Есть способ оптимизировать это, но он из разряда вредных советов. Без острой необходимости так делать не стоит.


Посмотрим как выглядят структуры слайса и строки в рантайме Go:


type String struct {
    Data unsafe.Pointer
    Len  int
}

type Slice struct {
    Data unsafe.Pointer
    Len  int
    Cap  int
}

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


func bytesToString(b []byte) string {
    return *(*string)(unsafe.Pointer(&b))
}

func stringToBytes(s *string) []byte {
    return *(*[]byte)(unsafe.Pointer(s))
}

Тут есть некоторое ограничение. В случае bytesToString это валидная операция только если мы воспользуемся строкой до изменения буфера, на который она ссылается, т.е. нам не подходит этот метод (спасибо bat за замечание), а в случае stringToByte возвращаемые слайсы можно использовать только на чтение, так как параметр capacity не будет проинициализирован. Это можно было бы разрешить с помощью пакета reflect, но в нашем случае обойдемся без него:


for _, foo := range *fooReq {
        sha.Reset()
        sha.Write(stringToBytes(&foo.StrA))
        sha.Write(stringToBytes(&foo.StrB))
        base64.StdEncoding.Encode(buf.Bytes(), sha.Sum(sha256Buf[:0]))
        hashes = append(hashes, buf.String())
    }

Проверим, что получилось.


Тест Было, rps Стало, rps
hey -n 10000 -c 1 596.2832 609.2315
hey -n 10000 -c 30 2536.7017 2583.3775

Мы получили прирост по rps в обоих случаях.
Проверяем потребление памяти:


         .          .    115:
   68.53MB    68.53MB    116:   hashes := make([]string, 0, len(*fooReq))
         .          .    117:   var sha256Buf [sha256.Size]byte
         .          .    118:   sha := sha256.New()
         .          .    119:   encodedLen := base64.StdEncoding.EncodedLen(sha256.Size)
         .          .    120:   buf.Reset()
         .          .    121:   buf.Grow(encodedLen)
         .          .    122:   for i := 0; i < encodedLen; i++ {
         .          .    123:           buf.WriteByte(0)
         .          .    124:   }
         .          .    125:   for _, foo := range *fooReq {
         .          .    126:           sha.Reset()
         .          .    127:           sha.Write(stringToBytes(&foo.StrA))
         .          .    128:           sha.Write(stringToBytes(&foo.StrB))
         .          .    129:           base64.StdEncoding.Encode(buf.Bytes(), sha.Sum(sha256Buf[:0]))
         .   149.51MB    130:           hashes = append(hashes, buf.String())
         .          .    131:   }
         .          .    132:
         .          .    133:   fooRes := FooRes{Hashes: hashes}
         .          .    134:
         .   157.63MB    135:   b, err := json.Marshal(fooRes)
         .          .    136:   if err != nil {
         .          .    137:           w.WriteHeader(http.StatusInternalServerError)
         .          .    138:           return
         .          .    139:   }
         .          .    140:   w.Write(b)

Шаг 7 — Heap profiling и пакет easyjson (Foo 1.6)


Теперь json.Marshal больше всех выделяет памяти.


Рис. 4.15


На флейм-графе CPU профайлера json.Unmarshal тоже занимает лидирующую позицию.


Рис. 4.16


Все-таки это очень требовательный к ресурсам стандартный пакет (по большей части из-за того, что он использует пакет reflect). В качестве альтернативы можно взять пакет от ребят из Mail.ru. Вместо рефлексии он использует генерацию кода маршалинга.


Получение пакета:


go get -u github.com/mailru/easyjson/…

Данный тег необходимо указать для структуры


// easyjson:json

Запуск генератора


easyjson types.go

Для использования этого пакета достаточно в коде json заменить на easyjson:


if err := easyjson.Unmarshal(buf.Bytes(), fooReq); err != nil {
    w.WriteHeader(http.StatusInternalServerError)
    return
}

b, err := easyjson.Marshal(fooRes)

Проверяем результат:


Тест Было, rps Стало, rps
hey -n 10000 -c 1 616.7721 1145.6503
hey -n 10000 -c 30 2618.8195 5129.6624

Замена обеспечила нам прирост почти в два раза. Сверяем его с флейм-графом по CPU:


Рис. 4.17


Здесь видно, что easyjson отъедает гораздо меньше CPU. В лидерах осталась основа сервиса — sha256, чтение из сокета и запись ответа в сокет — базовые операции.
На этом можно остановиться и подвести итоги.


Итоги


Мы ускорили наш сервис почти в 5 раз для конкурентных запросов.


Тест Было, rps Стало, rps
hey -n 10000 -c 1 454.3617 1145.6503
hey -n 10000 -c 30 1089.9231 5129.6624

Пример в статье — это микрооптимизация, которая помогает, когда в целом у нас все хорошо, но нужно еще что-то выжать. Кстати, если вы хотите приложить руку к подобным изысканиям и знакомы со спецификой высоконагруженных систем, приходите в нашу команду. Мы разрабатываем не только на Go, но и на Rust и C++. Тут явно пригодятся знания в области архитектуры современных процессоров и памяти, опыт работы с БД и познания в ядре Linux. Но зато есть, где применить энтузиазм, — мы создаем действительно неординарные вещи (актуальные вакансии есть тут).


Если же вы хотите создавать высокопроизводительные системы на Go, рекомендую в первую очередь прорабатывать архитектуру — выбирать правильные инструменты и технические средства, а потом выжимать максимум, начиная с оптимизации использования памяти.

Комментарии (13)


  1. DmitriyTitov
    01.12.2021 16:28

    @andrey_shalamov Не могли бы вы пояснить механику работы вашей реализации Free List.

    Как происходит, что память возвращается в список в методе Put, а не отбрасывается в ветке default?

    Соответственно не понятен механизм выделения памяти из списка в методе Get.


    1. andrey_shalamov Автор
      01.12.2021 17:50
      +1

      основа - это работа с буферизированным каналом. Если в канале нет "свободных" буферов при вызове метода Get, мы создаём новый (ветка default). После того как мы воспользовались буфером, возвращаем его обратно в канал (метод Put). Если в канале "есть место", мы кладём буфер туда для переиспользования (метод Get вернёт нам этот буфер вместо создания нового). Иначе, мы переходим по ветке default и просто дропаем буфер.


      1. DmitriyTitov
        01.12.2021 19:16
        +1

        Да, с буфером всё встало на свои места. Спасибо.


  1. bat
    01.12.2021 16:51
    +3

    а не пробовали сразу парсить из потока и кодировать в поток?

    err = json.NewDecoder(r.Body).Decode(&fooReq)

    err = json.NewEncoder(w).Encode(fooRes)


    1. andrey_shalamov Автор
      01.12.2021 17:41

      не пробовал, но думаю, что стоит попробовать )


  1. bat
    01.12.2021 16:55
    +2

    на случай когда нужно освежить в памяти что и как с профилированием в go держу в закладках статью @mkevac https://habr.com/ru/company/badoo/blog/301990/


  1. cepera_ang
    01.12.2021 21:15
    +1

    А foo_req.json где-нибудь можно посмотреть, чтобы понять какое количество данных хешируется при запросе?


    1. andrey_shalamov Автор
      02.12.2021 06:07

      в foo_req.json 500 объектов. С рандомно сгенерированными строками str_a, str_b по 50 символов каждая.


  1. sushchyk
    01.12.2021 21:25
    +1

    В коде в шаге 1 вот эта строка лишння:
    b, err := ioutil.ReadAll(r.Body)


    1. andrey_shalamov Автор
      01.12.2021 21:28

      спасибо! поправил.


  1. kilgur
    02.12.2021 08:55
    +2

    Вариант реализации string2Byte с установкой Cap через Reflect можно подсмотреть в пакете fasthttp: ссылка, если вдруг кому-то стало интересно.


  1. bat
    02.12.2021 14:31
    +1

    andrey_shalamov hashes на выходе будет содержать набор одинаковых строк или мне показалось?

    for _, foo := range *fooReq {
            sha.Reset()
            sha.Write(stringToBytes(&foo.StrA))
            sha.Write(stringToBytes(&foo.StrB))
            base64.StdEncoding.Encode(buf.Bytes(), sha.Sum(sha256Buf[:0]))
            hashes = append(hashes, bytesToString(buf.Bytes()))
    }


    1. andrey_shalamov Автор
      03.12.2021 07:24

      fail ) тот случай когда вредный совет оказался действительно вредным. bytesToString в нашем случае нельзя применить. Спасибо за замечание! Поправил статью.