Создано на базе изображений gopherize.me


Довольно часто из Go кода нам приходится работать с различными HTTP API или самим выполнять роль HTTP сервиса.


Один из частых случаев: получаем данные в виде структуры из базы данных, отправляем структуру внешнему API, в ответ получаем другую структуру, как-то её преобразуем и сохраняем в базу.


Другими словами: такая обработка не требует множества отдельных операций со структурами запроса и ответа.


Для API нормальна ситуация, когда в структурах запроса и ответа есть поля, которые могут быть nil и могут принимать какие-то не-nil значения. Такие структуры выглядят обычно так


type ApiResponse struct {
  Code *string json:"code"`
}

И, так как это ссылочный тип, то Go компилятор делает escape анализ и может перенести данную переменную в хип. В случае частого создания таких переменных — мы получаем лишнюю нагрузку на GC и даже можем получить "утечку памяти", если GC не успевает освободить всю использованную память.


Что можно сделать в такой ситуации:


  • Изменить внешнее API так, чтобы не использовать nil значения. Иногда это допустимо, но изменение API — не всегда хорошая идея: во-первых, это лишняя работа, во-вторых — ошибки, которые могут появиться от такой переделки.
  • Изменить наш Go код так, чтобы мы могли принять nil значения, но не использую для этого ссылочные типы.

Для начала, давайте сравним разницу между работой со ссылочными типами и передачей переменных "по значению"


Все бенчмарки воспроизводимы и размещены здесь.


В Go коде мы обычно используем такие структуры со ссылочными типами


type pointerSmall struct {
 Field000 *string
 Field001 *string
 Field002 *string
 Field003 *string
 Field004 *string
 Field005 *string
}

Давайте сравним их со структурами с типами, которые передаются по значению


type valueSmall struct {
 Field000 string
 Field001 string
 Field002 string
 Field003 string
 Field004 string
 Field005 string
}

Структура со ссылками обработана с 0 аллокаций, аналогично структуре со значениями.
Здесь мы даже видим, что структура со ссылками быстрее по времени обработки.


Небольшое замечание: в данном бенчмарке мы видим две механики Go, которые объясняют эти (для кого-то неожиданные) результаты.


Первое — инлайнинг функций. Это механизм, когда вызов функции заменяется на её тело. В данном случае для структур сработал этот механизм и это позволило избежать переноса структур в хип. Как результат — нет аллокаций. Во всех следующих бенчмарках мы не будем отключать этот механизм, т.к. хотим сравнить работу обычного Go кода.


Второе — копирование структур с типами, которые передаются по значению. На копирование структур тратится дополнительное время, что мы и видим в результатах теста.


BenchmarkPointerSmall-8    1000000000          0.295 ns/op        0 B/op        0 allocs/op
BenchmarkValueSmall-8      184702404          6.51 ns/op        0 B/op        0 allocs/op

Давайте сделаем бенчмарк с вызовом функций и передачей туда структур со ссылками и указателями. Как мы видим, структуры со ссылками обрабатываются всё-ещё быстрее структур со значениями и по-прежнему с нулевым количеством аллокаций.


BenchmarkPointerSmallChain-8    1000000000          0.297 ns/op        0 B/op        0 allocs/op
BenchmarkValueSmallChain-8      59185880         20.3 ns/op        0 B/op        0 allocs/op

В сервисах часто используется JSON кодирование и декодирование структур. Давайте сравним результаты кодирования и декодирования структур, используя jsoniter. В данном бенчмарке ситуация меняется. Структуры со значениями быстрее и лучше по использованию памяти на одну операцию, лучше по количеству аллокаций.


BenchmarkPointerSmallJSON-8       49522      23724 ns/op    14122 B/op       28 allocs/op
BenchmarkValueSmallJSON-8         52234      22806 ns/op    14011 B/op       15 allocs/op

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


BenchmarkPointerSmallEasyJSON-8       64482      17815 ns/op    14591 B/op       21 allocs/op
BenchmarkValueSmallEasyJSON-8         63136      17537 ns/op    14444 B/op       14 allocs/op

Предварительный вывод: если в вашем коде длинная цепочка обработки полученного значения с итоговым результатом, то иногда будет лучше использовать ссылочные типы. Но если цепочка обработки вашего кода короткая (кодирование/декодирование значения) — то лучше использовать структуры со значениями.


Пойдём дальше. Иногда структуры растут в размере


type pointerBig struct {
 Field000 *string
 ...
 Field999 *string
}

type valueBig struct {
 Field000 string
 ...
 Field999 string
}

Сделаем бенчмарк для этих структур. Далее мы видим, что для структуры со значениями обработка как и раньше даёт 0 аллокаций, но увеличилось время обработки (это нормально, т.к. структура стала больше). Так же, структура со ссылками потеряла преимущество: ненулевое количество аллокаций и значительно большее время обработки и использование памяти на одну операцию


BenchmarkPointerBig-8       36787      32243 ns/op    24192 B/op     1001 allocs/op
BenchmarkValueBig-8        721375       1613 ns/op        0 B/op        0 allocs/op

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


BenchmarkPointerBigChain-8       36607      31709 ns/op    24192 B/op     1001 allocs/op
BenchmarkValueBigChain-8        351693       3216 ns/op        0 B/op        0 allocs/op

Попробуем сделать кодирование и декодирование. Структура со значениями лучше по всем параметрам


BenchmarkPointerBigJSON-8         250    4640020 ns/op  5326593 B/op     4024 allocs/op
BenchmarkValueBigJSON-8           270    4289834 ns/op  4110721 B/op     2015 allocs/op

Попробуем улучшить результат, используя easyjson. Структура со значениями лучше во всём. Структура со ссылками обрабатывается лучше, чем в jsoniter.


BenchmarkPointerBigEasyJSON-8         364    3204100 ns/op  2357440 B/op     3066 allocs/op
BenchmarkValueBigEasyJSON-8           380    3058639 ns/op  2302248 B/op     1063 allocs/op

Итоговый вывод: не делайте оптимизации на первом этапе разработки — лучше предпочесть использовать структуры со значениями, чем структуры со ссылками. И только когда производительность перестала устраивать — пройдите по цепочке обработки и попробуйте переключиться на передачу значений по ссылке в "горячих местах". Предпочтительно использовать кодогенераторы (easyjson и другие), чем обработку в коде — в большинстве случаев получим результаты лучше.


Переключение на структуры со значениями


Переключение выглядит просто — использовать Nullable типы. Пример из библиотеки sql — sql.NullBool, sql.NullString и другие.


Так же, для типа потребуется описать функции кодирования и декодирования


func (n NullString) MarshalJSON() ([]byte, error) {
    if !n.Valid {
        return []byte("null"), nil
    }

    return jsoniter.Marshal(n.String)
}

func (n *NullString) UnmarshalJSON(data []byte) error {
    if bytes.Equal(data, []byte("null")) {
        *n = NullString{}
        return nil
    }

    var res string

    err := jsoniter.Unmarshal(data, &res)
    if err != nil {
        return err
    }

    *n = NullString{String: res, Valid: true}

    return nil
}

Как результат избавления от ссылочных типов в API — я разработал библиотеку nan, с основными Nullable типами с функциями кодирования и декодирования для JSON, jsoniter, easyjson, gocql.


Удобство использования Nullable типов


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


Моё личное мнение — удобно, у типов тот же паттерн использования что и у ссылок на переменые.


При использовании ссылки мы пишем


if a != nil && *a == "sometext" {

С Nullable типом мы пишем


if a.Valid && a.String == "sometext" {