Комментарии 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
Одна строка — тысячи горутин: как мы поймали утечку памяти в сервисе на Go