Замыкания и переменная цикла (Go 1.22)#
Модуль: Core Go · Уровень: Senior
TL;DR#
Замыкание (closure) — функциональный литерал, захватывающий переменные окружающей области по ссылке, а не по значению: все замыкания, созданные в одной области, видят одну и ту же переменную и все её изменения. Захваченная переменная переживает функцию, в которой объявлена, поэтому компилятор перемещает её в кучу (escape). Отсюда классическая ловушка: до Go 1.21 включительно переменная цикла for создавалась один раз на весь цикл, и горутины/defer/замыкания, запущенные внутри, видели её финальное значение. В Go 1.22 семантику изменили на уровне языка: переменные, объявленные в for ... :=, создаются заново на каждой итерации. Изменение включается версией go в go.mod, а не версией компилятора.
Простыми словами#
Замыкание — это функция, которая «помнит» переменные того места, где была создана.
Ключевое слово здесь — именно помнит переменную, а не её значение на момент создания. Если после создания замыкания переменную изменить, замыкание увидит новое значение. Это и удобно (счётчики, конфигурируемые обработчики), и опасно.
Опасно ровно в одном месте — цикл. До Go 1.22 переменная i в for i := 0; ... была одна на весь цикл, а не своя на каждом шаге. Запустил в цикле десять горутин, каждая печатает i — все десять печатали 10, потому что смотрели в одну и ту же ячейку памяти, а к моменту их запуска цикл уже закончился.
В Go 1.22 это починили в самом языке: теперь на каждой итерации создаётся новая переменная. Но старый код никуда не делся, и на собеседовании про эту ловушку спрашивают до сих пор — заодно проверяя, знаешь ли ты, что исправление привязано к версии go в go.mod, а не к версии установленного компилятора.
Если ты с другого языка. В JavaScript была ровно та же история: var в цикле давал одну переменную на всех, а let — свою на итерацию. Go прошёл этот путь на десять лет позже и решил его так же. В Java проблему обошли иначе — там захватить можно только final-переменную, поэтому компилятор просто не даёт написать такой код.
Словарь этой статьи:
- замыкание (closure) — функция вместе с захваченным окружением;
- захват по ссылке — замыкание работает с той же переменной, а не с копией;
- escape в кучу — перенос захваченной переменной из стека в кучу, потому что она переживает свою функцию;
- loopvar — название изменения семантики переменной цикла в Go 1.22.
Теория#
Что такое замыкание#
Функциональный литерал в Go может ссылаться на переменные снаружи себя. Такая функция вместе с этими переменными и называется замыканием.
func counter() func() int {
count := 0 // локальная переменная...
return func() int { // ...но замыкание её переживает
count++
return count
}
}
c := counter()
fmt.Println(c(), c(), c()) // 1 2 3count объявлена внутри counter, но живёт дольше вызова: возвращённая функция продолжает её использовать. Компилятор это видит и размещает count в куче.
Два разных вызова counter() дают два независимых замыкания со своими переменными. А вот два замыкания, созданные в одном вызове, разделяют одну переменную:
func pair() (func(), func() int) {
n := 0
return func() { n++ }, func() int { return n }
}
inc, get := pair()
inc(); inc()
fmt.Println(get()) // 2 — обе функции смотрят в одну ячейкуЗахват по ссылке — не по значению#
Это главное свойство, из которого растут все ловушки.
x := 1
f := func() { fmt.Println(x) }
x = 42
f() // 42, а не 1Замыкание сохранило не «единицу», а доступ к переменной x.
Если нужен снимок значения на момент создания, его надо сделать явно — параметром функции или новой переменной:
x := 1
f := func(v int) func() { return func() { fmt.Println(v) } }(x)
x = 42
f() // 1 — v это копия, сделанная в момент вызоваКлассическая ловушка: переменная цикла до Go 1.22#
// Go <= 1.21
for i := 0; i < 3; i++ {
go func() {
fmt.Println(i) // 3 3 3 (или что угодно — здесь ещё и гонка)
}()
}Спецификация до 1.22 говорила: переменные, объявленные в заголовке for, создаются один раз перед первой итерацией и переиспользуются. Все три горутины захватили одну переменную; пока планировщик до них добрался, цикл успел завершиться и оставить в ней 3.
Ровно то же было с range:
// Go <= 1.21
for _, v := range items {
go process(&v) // все горутины получают адрес ОДНОЙ переменной v
}Идиоматическое лечение — «затенение» переменной внутри тела цикла:
for i := 0; i < 3; i++ {
i := i // новая переменная на каждой итерации
go func() { fmt.Println(i) }()
}Либо передача параметром:
for i := 0; i < 3; i++ {
go func(i int) { fmt.Println(i) }(i)
}Что изменилось в Go 1.22#
С Go 1.22 переменные, объявленные в for ... := (и в трёхчастной форме, и в range), создаются заново на каждой итерации. Код выше теперь печатает 0 1 2 в произвольном порядке — то есть ровно то, что все и имели в виду.
Три важные детали, которые проверяют на собеседовании:
Изменение управляется версией языка, а не компилятором. Оно применяется, только если в
go.modмодуля указаноgo 1.22или выше. Собрал старый модуль (go 1.19) новым компилятором — получишь старую семантику. Это часть общей политики обратной совместимости Go.Затрагивает только переменные, объявленные в самом
for. Переменная из внешней области как захватывалась одна на всех, так и захватывается:
sum := 0
for _, v := range items {
go func() { sum += v }() // sum по-прежнему общая (и это гонка)
}- Идиома
i := iне сломалась, просто стала избыточной. Линтеры вродеcopyloopvarподсказывают её убрать.
Инструмент миграции: GOEXPERIMENT=loopvar в Go 1.21 позволял попробовать новое поведение заранее, а go vet в старых версиях ловил самые очевидные случаи через анализатор loopclosure.
Замыкания и defer#
defer вычисляет аргументы сразу, но тело замыкания — потом. Разница видна на примере:
func f() {
x := 1
defer fmt.Println("аргумент:", x) // напечатает 1 — аргумент вычислен здесь
defer func() { fmt.Println("замыкание:", x) }() // напечатает 2 — читает переменную позже
x = 2
}На этом же свойстве построен приём с изменением именованного результата:
func do() (err error) {
defer func() {
if err != nil {
err = fmt.Errorf("do: %w", err) // замыкание правит уже установленный результат
}
}()
return doSomething()
}Замыкания и аллокации#
Захваченная переменная почти всегда уезжает в кучу — иначе она не пережила бы свою функцию. Это видно в escape-анализе:
go build -gcflags=-m ./...
./main.go:7:2: moved to heap: countПрактические следствия для горячих путей:
- замыкание, захватывающее переменные, само по себе является аллокацией (структура с указателями на захваченное);
- функция без захвата (
func(a, b int) int { return a + b }) аллокации не требует; - в цикле на миллион итераций создание замыкания на каждом шаге заметно в профиле.
Замыкания в горутинах: что осталось опасным после 1.22#
Изменение loopvar починило захват переменной цикла — и только его. Всё остальное осталось как было:
for _, item := range items {
go func() {
results = append(results, process(item)) // гонка: общий слайс
}()
}Здесь item уже свой на каждой итерации, а вот results — общий, и это по-прежнему гонка данных. Go 1.22 не делает конкурентный код безопасным, он лишь убрал один конкретный источник ошибок.
Подводные камни / gotchas#
go.modрешает. Модуль сgo 1.19соберётся новым компилятором со старой семантикой цикла. При разборе чужого кода первым делом смотри директивуgo.- Внешние переменные не затронуты. Захват переменной, объявленной до цикла, работает по-старому — одна на всех.
deferв цикле по-прежнему копится до конца функции, независимо от версии. Отдельная проблема, не связанная с loopvar.- Указатель на переменную цикла. До 1.22
&vвrangeдавал один и тот же адрес на всех итерациях — классический способ получить слайс из одинаковых элементов. - Аллокации. Замыкание в горячем цикле — это объект в куче на каждой итерации. Если захват не нужен, пиши обычную функцию.
- Гонка при чтении счётчика. Даже после 1.22 обращение к общей переменной из нескольких горутин требует мьютекса или atomic.
t.Parallel()в table-driven тестах — исторически самое частое место, где ловушка стреляла. На старых версиях безtc := tcвсе подтесты работали с последним кейсом.
Вопросы на собеседовании#
В: Что напечатает цикл с горутинами и fmt.Println(i) на Go 1.21 и на 1.22?
О: На 1.21 — скорее всего три раза 3 (и это ещё гонка данных, поведение формально не определено), потому что переменная одна на весь цикл. На 1.22 — 0, 1, 2 в произвольном порядке, так как переменная создаётся заново на каждой итерации.
В: От чего зависит, какая семантика применится?
О: От версии языка, указанной директивой go в go.mod модуля, а не от версии установленного компилятора. Это следствие политики обратной совместимости: старый код не должен менять поведение при обновлении тулчейна.
В: Как захватывает переменные замыкание в Go — по значению или по ссылке? О: По ссылке. Замыкание работает с той же переменной, и все изменения видны обеим сторонам. Чтобы зафиксировать значение, нужно явно создать копию — новой переменной или параметром функции.
В: Почему захваченная переменная попадает в кучу? О: Потому что она переживает функцию, в которой объявлена. Escape-анализ видит, что на неё остаётся ссылка после возврата, и размещает её в куче, иначе указатель стал бы висячим.
В: Нужна ли ещё идиома v := v в цикле?
О: На модулях с go 1.22+ — нет, она избыточна и линтеры предлагают её убрать. На более старых модулях — обязательна.
В: Исправление 1.22 сделало конкурентный код в цикле безопасным? О: Нет. Оно убрало ровно одну ошибку — общую переменную цикла. Общие структуры данных, счётчики и слайсы, к которым обращаются несколько горутин, по-прежнему требуют синхронизации.
В: В чём разница между defer f(x) и defer func(){ f(x) }()?
О: В первом случае x вычисляется в момент выполнения defer, во втором — в момент фактического вызова, при выходе из функции. Второй вариант увидит все изменения x, сделанные после регистрации.
На что копают на senior+#
- Понимание, что захват идёт по ссылке, и умение объяснить, почему переменная уходит в кучу.
- Знание, что loopvar — изменение языка, привязанное к директиве
goвgo.mod, а не свойство компилятора; понимание, зачем сделано именно так. - Осознание границ исправления: что оно чинит, а что нет.
- Стоимость замыканий в горячих путях, умение увидеть их в
-benchmemи-gcflags=-m. - Follow-up: «Как выглядит замыкание в памяти?» (структура с указателем на код и на захваченные переменные), «Почему до 1.22
go vetловил не все случаи?» (анализаторloopclosureработал только с очевидными паттернами вродеgoиdeferпоследним оператором тела).