Замыкания и переменная цикла (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 3

count объявлена внутри 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 в произвольном порядке — то есть ровно то, что все и имели в виду.

Три важные детали, которые проверяют на собеседовании:

  1. Изменение управляется версией языка, а не компилятором. Оно применяется, только если в go.mod модуля указано go 1.22 или выше. Собрал старый модуль (go 1.19) новым компилятором — получишь старую семантику. Это часть общей политики обратной совместимости Go.

  2. Затрагивает только переменные, объявленные в самом for. Переменная из внешней области как захватывалась одна на всех, так и захватывается:

sum := 0
for _, v := range items {
    go func() { sum += v }() // sum по-прежнему общая (и это гонка)
}
  1. Идиома 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 последним оператором тела).