Badoo corporate blog
High performance
Programming
Go
15 May 2017

Логирование, интерфейсы и аллокации в Go

Original author: Josh Bleecher Snyder
Translation


Привет Хабр. Последний свой пост я публиковал сравнительно недавно, так что вряд ли вы успели забыть, что меня зовут Марко. Сегодня публикую перевод небольшой заметки, которая касается нескольких очень вкусных оптимизаций из еще не вышедшего Go 1.9. Эти оптимизации позволяют генерировать меньше мусора в большинстве программ на Go. Меньше мусора – меньше задержки и затраты на сборку этого мусора.


Эта статья о новых оптимизациях компилятора, которые готовятся к релизу Go 1.9, но я бы хотел начать разговор с логирования.


Пару недель назад Питер Бургон начал тред на golang-dev с предложением стандартизировать логирование. Оно используется везде, так что вопрос производительности стоит довольно остро. Пакет go-kit использует структурное логирование, которое основывается на следующем интерфейсе:


type Logger interface {
    Log(keyvals ...interface{}) error
}

Пример вызова:


logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")

Заметьте: всё, что передаётся в вызов Log, преобразуется в интерфейс. А это значит, что будет много операций выделения памяти.


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


logger.Info("Failed to fetch URL.",
  zap.String("url", url),
  zap.Int("attempt", tryNum),
  zap.Duration("backoff", sleepFor),
)

Аргументы для logger.Info имеют тип logger.Field. logger.Field – это структура а-ля union, которая содержит тип и поле для каждого из string, int, и interface{}. И получается, что интерфейсы не нужны для передачи основных типов значений.


Но довольно о логировании. Давайте разберёмся, почему преобразование значения в интерфейс иногда требует выделения памяти.


Интерфейсы представлены двумя словами: указателем на тип и указателем на значение. Расс Кокс написал прекрасную статью про это, и я не буду даже пытаться её здесь повторить. Просто сходите и прочитайте ее.


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


Предположим, у нас есть такой код:


fmt.Println(1)

До Go 1.4 он не приводил к выделению памяти, так как значение 1 можно было положить напрямую во второе слово интерфейса.


То есть компилятор делал примерно следующее:


fmt.Println({int, 1}),

Где {typ, val} представляет два слова интерфейса.


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


i := new(int) // allocates!
*i = 1
fmt.Println({int, i})

Это было неприятно, и много копий было сломано в словесных баталиях после этого изменения.


Первая значительная оптимизация по избавлению от аллокаций была сделана чуть позже. Она срабатывала, когда результирующий интерфейс не убегал (примечание переводчика: термин из escape analysis). В этом случае временное значение можно выделить на стеке вместо кучи. Используя пример выше:


i := new(int) // now doesn't allocate, as long as e doesn't escape
*i = 1
var e interface{} = {int, i}
// do things with e that don't make it escape

К сожалению, многие интерфейсы убегают, включая те, что в fmt.Println и в моих примерах по логированию выше.


К счастью, в Go 1.9 появятся ещё несколько оптимизаций, на реализацию которых разработчиков вдохновил тот самый разговор о логировании (если, конечно, в последний момент их не откатят, что всегда возможно).


Первая оптимизация заключается в том, чтобы не выделять память, когда мы конвертируем константу в интерфейс. Так что fmt.Println(1) больше не будет приводить к выделению памяти. Компилятор кладёт значение 1 в глобальную область памяти, доступную только для чтения. Примерно так:


var i int = 1 // at the top level, marked as readonly

fmt.Println({int, &i})

Это возможно, поскольку константы неизменны (иммутабельны) и останутся такими в любом случае.


На эту оптимизацию разработчиков вдохновило как раз обсуждение логирования. В структурном логировании большое количество аргументов являются константами (точно все ключи и, наверное, часть значений). Вспомните пример из go-kit:


logger.Log("transport", "HTTP", "addr", addr, "msg", "listening")

Этот код после Go 1.9 приведёт только к одной операции выделения памяти вместо шести, так как пять из шести аргументов являются константными строками.


Вторая новая оптимизация заключается в том, чтобы не аллоцировать память при конвертации булевых значений и байтов в интерфейсы. Эта оптимизация реализована добавлением глобального [256]byte массива под названием staticbytes во все результирующие бинарники. Для данного массива верно, что staticbytes[b] = b для всех b. Когда компилятор хочет положить булевое значение, или uint8, или какое-либо другое однобайтовое значение в интерфейс, то вместо аллокации он кладёт туда указатель на элемент этого массива. Например, так:


var staticbytes [256]byte = {0, 1, 2, 3, 4, 5, ...}

i := uint8(1)
fmt.Println({uint8, &staticbytes[i]})

И третья оптимизация, которая пока находится на стадии ревью, заключается в том, чтобы не аллоцировать память при конвертации стандартных нулевых значений в интерфейс. Это относится к нулевым значениям для целых чисел, чисел с плавающей запятой, строкам и слайсам. Рантайм проверяет значение на равенство нулевому значению, и, если это так, то он использует указатель на существующий большой кусок нулей вместо аллокации памяти.


Если всё пойдёт по плану, Go 1.9 уберёт большое количество аллокаций во время преобразования в интерфейсы. Но он не уберёт все такие аллокации, и это значит, что вопрос производительности останется актуальным при обсуждении стандартизации логирования.


Довольно интересно взаимодействие между API и какими-то решениями в реализации.


Выбор и создание API требует обдумывания вопросов производительности. Не случайно ведь интерфейс io.Reader позволяет вызывающему коду использовать свой буфер.


Производительность является важным аспектом тех или иных решений. Как мы увидели выше, детали реализации интерфейсов влияют на то, где и когда произойдут операции выделения памяти. И в то же время эти самые решения зависят от того, какой код пишут люди. Авторы компилятора и рантайма стремятся оптимизировать реальный, часто используемый код. Так, решение сохранить в Go 1.4 два слова для интерфейсов вместо добавления третьего, что вызвало бы лишнюю операцию выделения памяти в fmt.Println(1), базировалось на рассмотрении кода, который пишут реальные люди.


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


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


И если вы не уверены, – спрашивайте. Это сработало (немножко) для логирования.


+57
12.8k 83
Comments 14
Top of the day