Обновить

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

Уровень сложностиПростой
Время на прочтение6 мин
Охват и читатели8.3K
Всего голосов 21: ↑20 и ↓1+23
Комментарии11

Комментарии 11

Отличный разбор, спасибо !

Рада стараться

Еще одно напоминание, что нужно пользоваться линтерами. Например линтер fatcontext из golangci-lint именно для поиска таких проблем и сделан.

Тут проблема в переопределении входного параметра ctx одноимённой локальной переменной.

ctx = logger.AddLogLabelsToContext(ctx, labels1) // ctx1 -> исходный
ctx = logger.AddLogLabelsToContext(ctx, labels2) // ctx2 -> ctx1 -> исходный
ctx = logger.AddLogLabelsToContext(ctx, labels3) // ctx3 -> ctx2 -> ctx1 -> исходный

Однако переписав код так:

taskCtx := AddLogLabelsToContext(ctx, labels1) // taskCtx1 -> исходный
taskCtx := AddLogLabelsToContext(ctx, labels2) // taskCtx2 -> исходный
taskCtx := AddLogLabelsToContext(ctx, labels3) // taskCtx3 -> исходный

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

в первом случае цепочка будет расти с каждой новой задачей. во втором случае taskCtx будет подчищен после завершения задачи (исходный ctx не будет подчищен)
PS забавно - мы полные тезки.

Вот и встретились два одиночества )

Не понимаю вашей логики. И вообще не вижу логики, поэтому и задал свой вопрос. В первом случае мы получим один вот такой список длинной N + 1:

  • ctx3 -> ctx2 -> ctx1 -> исходный

Во втором случае получим N вот таких списков:

  • taskCtx1 -> исходный

  • taskCtx2 -> исходный

  • taskCtx3 -> исходный

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

проблема первого кейса в том, что вся цепочка будет использована в следующей задаче и поэтому не может быть удалена сборщиком.
Во втором же случае между задачами будет использован только общий (исходный) ctx, а taskCtxN после выполнения его зоны видимости становится ненужным и будет почищен сборщиком. То есть в цепочках taskCtxN -> исходный ctx будет почищен taskCtxN, но не ctx.

А, кажется, я понял, спасибо. А может и не понял. Вот ещё раз код:

func (p *poller) PollEvents(ctx context.Context) {
    for acquiredTask := range events {
        // ✅ Локальная переменная — короткоживущая, будет собрана сборщиком мусора (GC)
        taskCtx := AddLogLabelsToContext(ctx, map[string]string{
            "task_id": acquiredTask.TaskID,
        })
        p.process(taskCtx)
    }
}

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

Скорее всего taskCtx будет создана в куче, но утверждать точно не возьмусь. Если так, то сборщик мусора когда-нибудь подчистит все эти контексты, но не быстро, хоть короткоживучесть и упоминается в комментарии.

А теперь снова про связные списки контекстов. Как я уже говорил, их либо один длинной N+1, либо N длинной 2. Вы говорите, что N списков длинной 2 будут собираться, в отличии от одного длинного списка. Потому что вся цепочка будет использоваться в следующей задаче. Но и куча маленьких двухэлементных списков тоже будет использоваться в следующей задаче. Так что они примерно в одинаковых условиях. Здесь что-то не чисто. Сдаётся мне проблема не в этом.

Скорее всего отсутствие утечки во втором случае связано с тем, что переменные taskCtx создаются на стэке и удаляются по завершению работы функции. Но и длинные списки должны были бы собираться сборщиком, хотя и не быстро, как я уже говорил. Если они не собираются, значит на элементы списка есть ещё указатели из программы и тогда... что тогда? Тогда проблема не в том, что список получается длинным, а в том, что его не собрать. Почему-то.

Спросил у гошников в профильном чате, там сказали следующее:

Вообще дело оказалось не просто в "одной строке" а всего в одном символе. Если бы вместо ctx= было ctx:= - всё работало бы правильно.

ctx:= - новая переменная, область видимости которой ограничена телом цикла, она будет создаваться на каждой итерации и уничтожаться по её завершеннии.
ctx= - старая переменная. Она объявлена в методе и будет "жить" до завершения всего метода (а метод долгоживущий, потому что в нем цикл забирающий данные из канала).

Хоть новое имя переменной с := хоть переиспользование старого имени - это дало бы тот же эффект: новая переменная с укороченной областью видимости.

Вот тут подробности, если кому интересно: https://t.me/golangl/396448

ну так ровно это я и писал - область видимости

Спасибо, я разобрался.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Информация

Сайт
otus.ru
Дата регистрации
Дата основания
Численность
101–200 человек
Местоположение
Россия
Представитель
OTUS