1.6Функции, defer, panic/recover
Проследим момент вычисления замыканий и defer, а затем отделим управляемую ошибку от аварийного раскручивания стека.
Функции как объекты первого класса
Функция в Go ведёт себя как обычное значение: её можно присвоить
переменной, передать аргументом, вернуть из другой функции,
положить в структуру, слайс или мапу. Тип функции задаёт её
сигнатура: func(int, string) error. Нулевое значение
nil, и вызов nil-функции паникует.
// 1. Значение и тип
var fn func(int) int
fmt.Println(fn == nil) // true
// fn(1) // panic: runtime error: invalid memory address or nil pointer dereference
// // [signal SIGSEGV: segmentation violation]
fn = func(x int) int { return x * 2 }
// 2. Именованный тип функции: идиома стандартной библиотеки
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }
// теперь обычная функция реализует http.Handler:
var _ http.Handler = HandlerFunc(myFunc)
// 3. Функция высшего порядка: middleware
func WithLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
next.ServeHTTP(w, r)
slog.Info("req", "path", r.URL.Path, "dur", time.Since(start))
})
}
// 4. Функциональные опции: так делают «конструктор с параметрами»
type Option func(*Server)
func WithTimeout(d time.Duration) Option { return func(s *Server){ s.timeout = d } }
func NewServer(addr string, opts ...Option) *Server {
s := &Server{addr: addr, timeout: 30 * time.Second} // дефолты
for _, o := range opts { o(s) }
return s
}
srv := NewServer(":8080", WithTimeout(5*time.Second))
// 5. Функция в поле структуры: поведение подменяется в тестах без интерфейса
type Client struct {
doRequest func(*http.Request) (*http.Response, error)
}
v.Method, или method value: связанное с
получателем замыкание типа func(args).
Получатель захватывается в момент создания, и это
отдельная ловушка. T.Method, или method
expression: обычная функция типа func(T, args),
где получатель стал первым параметром.
type C struct {
n int
}
func (c C) Get() int { return c.n }
c := C{1}
f := c.Get // method value: копия c захвачена сейчас
c.n = 99
fmt.Println(f()) // 1, а не 99
g := C.Get // method expression: func(C) int
fmt.Println(g(c)) // 99
// С pointer receiver захватывается указатель, а не копия:
type D struct {
n int
}
func (d *D) Get() int { return d.n }
d := D{1}
h := d.Get // захвачен &d
d.n = 99
fmt.Println(h()) // 99
Замыкания: что именно захватывается
Замыкание: функция, объявленная внутри другой функции и использующая её локальные переменные. Название отсюда: она замыкает на себе кусочек чужой области видимости и уносит его с собой. Внешняя функция давно вернулась, а переменная жива, пока жива функция-замыкание.
Из одного факта выводятся все задачи на эту тему: замыкание захватывает переменную, а не её значение. То есть ссылку на ячейку памяти, а не снимок того, что в ней лежало. Переменная переживает функцию, где объявлена? Тогда компилятор при escape-анализе переносит её в кучу, а замыкание держит на неё указатель. Все замыкания, созданные в одной области видимости, делят одну и ту же ячейку.
func counter() func() int {
n := 0 // уедет в кучу: живёт дольше counter()
return func() int {
n++ // изменяем ту самую ячейку
return n
}
}
c1, c2 := counter(), counter()
fmt.Println(c1(), c1(), c1()) // 1 2 3 - одна ячейка
fmt.Println(c2()) // 1 - у второго замыкания своя ячейка
// Два замыкания из одной области делят одну ячейку
func pair() (func(), func() int) {
n := 0
return func(){ n++ }, func() int { return n }
}
inc, get := pair()
inc(); inc()
fmt.Println(get()) // 2
i объявлялась
один раз на весь цикл; с 1.22 компилятор создаёт новую
переменную на каждой итерации, и классическая ловушка исчезла.
// Классика собеседований
for i := 0; i < 3; i++ {
go func() { fmt.Print(i, " ") }()
}
time.Sleep(time.Second)
// Go < 1.22: "3 3 3" (обычно) - одна переменная на весь цикл
// Go >= 1.22: "0 1 2" в произвольном порядке - своя переменная на итерацию
// То же с range:
for _, v := range []int{1, 2, 3} {
go func() { fmt.Print(v, " ") }()
}
// Go < 1.22: "3 3 3"; Go >= 1.22: 1 2 3 в любом порядке
// Как писали до 1.22 (и как всё ещё пишут для совместимости):
for i := 0; i < 3; i++ {
i := i // затеняем: новая переменная на итерацию
go func() { fmt.Print(i) }()
}
for i := 0; i < 3; i++ {
go func(i int) { fmt.Print(i) }(i) // передаём копию параметром
}
// оба варианта печатают 0, 1 и 2 ровно по разу, но в произвольном
// порядке: четыре прогона подряд дали "201", "210", "201", "210"
Переменные, объявленные в for ... := ... и в
for ... := range ..., теперь живут одну
итерацию, а не весь цикл. Включает это строка go 1.22 в go.mod, то есть решает модуль,
а не версия компилятора: старый модуль соберётся по старым
правилам даже свежим тулчейном. Найти такие места в старом коде
помогает go vet -loopclosure (в 1.22+ анализатор
учитывает языковую версию модуля).
Отдельно: go тут был не единственным случаем. Ещё
defer внутри цикла и сохранение &v
в слайс. Все три закрыло одно изменение.
// Переменная объявлена ВНЕ цикла - семантика прежняя, ловушка осталась
x := 0
for i := 0; i < 3; i++ {
x = i
go func(){ fmt.Print(x) }() // все смотрят в один x
}
// четыре прогона подряд на go1.27 дали "222": горутины стартуют после цикла,
// когда x уже равен 2. Гарантии нет - но именно так это обычно и выглядит.
// Захват элемента слайса по указателю - тоже осталось
var ptrs []*int
for _, v := range []int{1,2,3} { ptrs = append(ptrs, &v) }
// в Go 1.22+ здесь как раз всё хорошо: три разных v.
// А вот так - по-прежнему одна ячейка:
v2 := 0
for _, x := range []int{1,2,3} { v2 = x; ptrs = append(ptrs, &v2) }
Вариативные функции
...T в последнем параметре означает: внутри функции
это обычный слайс []T. Компилятор на месте
вызова собирает переданные аргументы в новый слайс.
func Sum(nums ...int) int { // внутри nums имеет тип []int
total := 0
for _, n := range nums { total += n }
return total
}
Sum() // 0 - nums == nil (не пустой слайс, а именно nil)
Sum(1, 2, 3) // 6 - компилятор создал []int{1,2,3}
s := []int{1, 2, 3}
// Sum(s) // ошибка компиляции:
// // cannot use s (variable of type []int) as int value in argument to Sum
Sum(s...) // 6 - передали существующий слайс, новый не создаётся
// При s... слайс не копируется: функция получает тот же массив
func Zero(nums ...int) { for i := range nums { nums[i] = 0 } }
Zero(s...)
fmt.Println(s) // [0 0 0] - исходный слайс изменён
Zero(1, 2, 3) // а тут менялся бы временный слайс
// Смешивать нельзя:
// Sum(1, s...) // ошибка компиляции: too many arguments in call to Sum
// // have (number, []int...)
// Идиома: вариативный интерфейс
func Printf(format string, args ...any) // каждый аргумент упаковывается в any
Каждый вызов f(a, b, c) создаёт слайс. Если
аргументы не утекают, компилятор положит его на стек, и
аллокации не будет. Но для ...any (как у
fmt.Printf) каждый аргумент ещё и упаковывается в
интерфейс, а это почти всегда escape в кучу. Отсюда практика: в
горячем пути и в логгерах берут типизированные варианты,
slog с slog.Int("n", 1) вместо
...any, strconv.Itoa вместо
fmt.Sprintf("%d").
defer: механика
defer регистрирует вызов, а выполнится тот при
выходе из функции, любом: return, паника,
конец тела. Не при выходе из блока и не при выходе из цикла.
Порядок LIFO: последний
зарегистрированный defer сработает первым.
func demo() {
for i := 0; i < 3; i++ {
defer fmt.Print(i, " ") // аргумент вычислен сразу: 0, 1, 2
}
}
// вывод: 2 1 0 - LIFO
func demo2() {
i := 0
defer fmt.Println("аргумент:", i) // скопировано 0
defer func() { fmt.Println("замыкание:", i) }()// прочтёт при выходе
i = 42
}
// замыкание: 42
// аргумент: 0
// Метод тоже вычисляется сразу, вместе с получателем:
var b bytes.Buffer
defer fmt.Println(b.String()) // "" - String() вызовется сейчас
b.WriteString("текст")
// напечатает пустую строку
// А так правильно:
defer func(){ fmt.Println(b.String()) }()
defer срабатывает при выходе из функции, а
не итерации. Цикл на 100 000 файлов накопит 100 000 отложенных
Close(), и выполнятся они только в конце.
Дескрипторы кончатся раньше. Лечения два: вынести тело итерации
в отдельную функцию (тогда defer сработает на каждой) или
закрывать вручную, без defer.
// Плохо: 100k открытых файлов одновременно
func processAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // выполнится только в конце processAll
process(f)
}
return nil
}
// Хорошо, вариант 1: у замыкания своя область видимости
func processAll1(paths []string) error {
for _, p := range paths {
err := func() error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close() // выполнится на выходе из этой функции
return process(f)
}()
if err != nil { return err }
}
return nil
}
// Хорошо, вариант 2: именованная функция нагляднее
func processOne(p string) error {
f, err := os.Open(p)
if err != nil { return err }
defer f.Close()
return process(f)
}
defer и именованные возвращаемые значения
return x в Go разворачивается в две операции:
сначала присвоить x в возвращаемое значение, потом
выполнить defer-ы и только потом вернуть управление.
Если возвращаемое значение именованное, defer его
перепишет: он видит переменную.
// Без имени: defer меняет копию, эффекта нет
func a() int {
x := 1
defer func() { x = 99 }()
return x // 1
}
// С именем: defer меняет саму возвращаемую переменную
func b() (x int) {
x = 1
defer func() { x = 99 }()
return x // 99
}
// То же с ошибкой, так чаще всего и делают
func doTx(db *sql.DB) (err error) {
tx, err := db.Begin()
if err != nil { return err }
defer func() {
if p := recover(); p != nil {
tx.Rollback()
panic(p) // пробрасываем дальше
} else if err != nil {
tx.Rollback() // видим ошибку, потому что err именованная
} else {
err = tx.Commit() // и можем вернуть ошибку коммита
}
}()
// ... работа ...
return nil
}
// Добавляем к ошибке контекст
func readConfig(path string) (err error) {
defer func() {
if err != nil { err = fmt.Errorf("readConfig(%s): %w", path, err) }
}()
// ... любые return err без обёртки ...
}
// На границе пакета паника становится ошибкой
func Parse(s string) (res AST, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("parse: %v", r)
}
}()
return mustParse(s), nil // внутри может паниковать
}
«return x не атомарен. Компилятор разворачивает его
в ret = x; выполнить defer'ы; вернуть ret.
Именованное возвращаемое значение и есть тот самый
ret, обычная переменная, поэтому defer её читает и
переписывает. Три главных применения: rollback транзакции,
обогащение ошибки контекстом и конверсия паники в error на
границе пакета.»
Сколько стоит defer
Вопрос «дорогой ли defer» проверяет, читал ли ты
release notes. Правильный ответ: раньше был дорогим, с Go 1.14 практически бесплатен в типичном
случае. Стоимость менялась три раза, и это хорошая история для
собеса.
_defer в таблице ниже - служебная запись, которую рантайм
заводит на каждый отложенный вызов: что вызвать, с какими аргументами
и кто следующий в списке.
| Версия | Как реализовано | Порядок стоимости | Что происходит |
|---|---|---|---|
| до Go 1.12 | Запись _defer в куче |
~50 нс |
Каждый defer - аллокация структуры и вставка
в связный список горутины
|
| Go 1.13 | _defer на стеке |
~35 нс | Запись кладётся в стековый фрейм - аллокации в куче нет, список остался |
| Go 1.14+ | Open-coded defers | ~1 нс, почти ноль |
Компилятор инлайнит вызов прямо перед каждым
return. Списка нет вообще
|
defer перестал быть заметной статьёй расхода.
Компилятор откатывается к записям на стеке, если: defer стоит
в цикле (число вызовов неизвестно на этапе компиляции), в
функции больше 8 defer-ов, функция компилируется с отключёнными оптимизациями
(-gcflags=-N) или с -race, либо число
return, умноженное на число defer-ов, больше 15. Отсюда
практический вывод: defer в цикле плох дважды. Семантикой, то
есть накоплением до выхода из функции, и выключенной
оптимизацией.
// Замер: go test -bench=. -benchmem
func BenchmarkDirect(b *testing.B) {
for b.Loop() { // Go 1.24+: так теперь пишут бенчмарки
mu.Lock()
counter++
mu.Unlock()
}
}
func BenchmarkDefer(b *testing.B) {
for b.Loop() {
func() {
mu.Lock()
defer mu.Unlock() // open-coded: разница в единицы наносекунд
counter++
}()
}
}
Не убирай defer ради «производительности» без
профиля. Читаемость и гарантия, что ресурс освободится при любом
выходе, включая панику, стоят дороже наносекунды. Единственный
реальный повод отказаться: горячий цикл с миллионами итераций,
где профиль прямо показывает runtime.deferreturn.
panic и recover: механика
panic(v) это не «исключение» в смысле Java/C++.
Никакого catch-блока нет, вмешаться можно ровно одним способом:
recover() внутри отложенной функции. Механика по
шагам:
-
Рантайм создаёт запись
_panicи кладёт её в цепочку паник текущей горутины. - Текущая функция обрывается прямо в точке паники.
- Начинается раскрутка стека: фреймы снимаются со стопки один за другим, от того, где случилась паника, к вызывающим, и в каждом выполняются его отложенные функции в порядке LIFO.
-
Если какая-то из них вызывает
recover()и та возвращает неnil, паника погашена. Функция, которой принадлежал этот defer, завершается нормально и отдаёт свои возвращаемые значения, те, что лежат в них на этот момент. - Никто не сделал recover: раскрутка доходит до вершины стека горутины, рантайм печатает сообщение и стектрейс в stderr и убивает весь процесс с кодом выхода 2.
recover() в отложенной функции
гасит её, и владелец этого defer возвращается штатно.
recover(), вызванный не из defer, всегда возвращает
nil. Вызванный из функции, которую позвала
отложенная функция, тоже nil. Рантайм сверяет, что
кадр вызывающего именно тот, который сейчас выполняется как
defer текущей паники. Поэтому обёртка defer myRecover(), где внутри myRecover стоит
recover(), работает (она сама отложенная), а
defer func(){ helper() }() с recover внутри
helper нет.
// 1) Не работает: recover не в отложенной функции
func bad1() {
if r := recover(); r != nil { /* всегда nil */ }
panic("boom")
}
// 2) Не работает: recover на уровень глубже, чем defer
func helper() { recover() } // nil
func bad2() {
defer func() { helper() }() // helper - не отложенная функция
panic("boom")
}
// 3) Работает: recover прямо в теле отложенной функции
func good1() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
panic("boom")
}
// 4) Работает: сама именованная функция отложена
func catch(err *error) {
if r := recover(); r != nil { *err = fmt.Errorf("recovered: %v", r) }
}
func good2() (err error) {
defer catch(&err) // catch и есть отложенная функция
panic("boom")
return nil
}
Что recover НЕ ловит
Часть аварий в Go это вообще не panic, а fatal error рантайма. Их не перехватить ничем, процесс умирает сразу и без раскрутки стека:
-
fatal error: concurrent map writes/concurrent map read and map write: гонка на мапе, пойманная встроенной проверкой. -
fatal error: all goroutines are asleep - deadlock!: рантайм видит, что работать некому. -
fatal error: stack overflow: стек упёрся в лимит (по умолчанию 1 ГБ на 64-битных). -
fatal error: out of memory, ошибки в самом рантайме, сбой в CGO-коде. -
os.Exit()вообще не паника, defer-ы не выполняются. -
runtime.Goexit()завершает горутину и выполняет defer-ы, ноrecover()вернётnil: паники нет.
«panic это управляемое рантаймом сворачивание горутины: defer-ы выполняются, recover возможен. fatal error это отказ рантайма: ни defer-ов, ни recover. Гонка на мапе, дедлок и переполнение стека относятся ко второму, поэтому "у нас всё обёрнуто в recover, сервис не упадёт" неверно.»
Когда panic уместен, а когда нужен error
panic оправдан
-
Ошибка программиста, а не среды: nil там, где по
контракту не может быть nil; индекс за пределом; невозможная
ветка
switch, где уместенpanic("unreachable"). -
Инициализация:
regexp.MustCompile,template.Must,sql.Registerс дублем драйвера. Программа с битой конфигурацией не должна стартовать. - Нарушение инварианта, после которого продолжать опаснее, чем упасть (повреждённая структура данных, рассогласованный кэш).
-
Внутренний механизм внутри одного пакета: быстрый
выход из глубокой рекурсии, если паника гарантированно
ловится на границе пакета (так делает
encoding/json,text/template).
panic недопустим
-
Ожидаемые ошибки: файл не найден, сеть недоступна,
невалидный ввод пользователя, строка не парсится в число.
Это
error. - Как замена возврата ошибки в публичном API библиотеки: вызывающий не обязан оборачивать твои функции в recover.
- Как поток управления, чтобы «выйти из трёх циклов сразу». Для этого есть метки.
-
Через границу пакета: если внутри используешь панику
как механизм, обязан перехватить её на экспортируемой
функции и вернуть
error.
// Паттерн encoding/json: паника внутри, error снаружи
type parseError struct {
err error
}
func (p *parser) fail(format string, a ...any) {
panic(parseError{fmt.Errorf(format, a...)}) // свой тип, чтобы отличать чужие паники
}
func Parse(data []byte) (res Doc, err error) {
defer func() {
if r := recover(); r != nil {
pe, ok := r.(parseError)
if !ok { panic(r) } // чужую панику пробрасываем: она про баг, а не про ввод
err = pe.err
}
}()
p := &parser{data: data}
return p.parseDoc(), nil // внутри вызывается fail() на любой глубине
}
Пойманную панику всегда проверяй по типу и чужие
пробрасывай дальше. Глухой recover(), который молча съедает
любую панику, прячет настоящие баги: nil pointer
dereference превратится в невнятное «что-то пошло не так», и
причину искать будет нечем. И всегда логируй
debug.Stack(), иначе трейс потерян навсегда.
Паника в горутине
Самый частый вопрос по теме и самая частая ошибка в проде. Ответ короткий: recover ловит панику только в своей горутине. Стек у каждой горутины свой, раскрутка идёт по нему и до фреймов «родителя» не доходит. Родителя в стековом смысле вообще нет, горутины не вложены.
// Не работает: процесс упадёт
func main() {
defer func() {
if r := recover(); r != nil { fmt.Println("поймали:", r) } // не выполнится
}()
go func() { panic("из горутины") }()
time.Sleep(time.Second)
}
// panic: из горутины ... exit status 2
// Работает: recover внутри той же горутины
func safeGo(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
// Если панику нужно обработать наверху, её передают в канал
func run(fn func() error) (err error) {
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic: %v", r)
}
}()
done <- fn()
}()
return <-done
}
Три подвоха. Первый: net/http действительно
перехватывает панику хендлера, но только в той горутине, где
выполняется хендлер; запустил хендлер go doWork(), и паника оттуда убьёт сервер целиком. Второй:
errgroup.Group ошибки собирает, а паники не
перехватывает, они убивают процесс (в свежих версиях
golang.org/x/sync паника пробрасывается в
Wait(), но полагаться на это без проверки версии
нельзя). Третий: паника в горутине, порождённой
defer-ом или коллбеком библиотеки, тоже требует
своего recover, так что обёртка вроде safeGo нужна
на каждой точке запуска.
os.Exit vs panic vs return из main
| Способ | defer-ы текущей функции | Стектрейс | Код выхода | Другие горутины |
|---|---|---|---|---|
return из main |
Да, выполнятся | Нет | 0 | Убиваются мгновенно, их defer-ы не выполняются |
panic(v) |
Да, по всей раскрутке | Да, в stderr | 2 | Убиваются, defer-ы не выполняются |
os.Exit(n) |
Нет | Нет | n |
Убиваются немедленно |
log.Fatal(...) |
Нет (это os.Exit(1)) |
Нет | 1 | Убиваются немедленно |
runtime.Goexit() |
Да, только своей горутины | Нет | - | Работают дальше; если это была последняя - fatal error |
Классическая ошибка: log.Fatalf("не смог подключиться: %v", err) в середине функции, где висят defer db.Close() и defer tracer.Shutdown(). Ничего
из этого не выполнится, буферы не сбросятся, спаны не улетят.
Лечится «тонким main»: вся логика в run() error, а
main занимает три строки.
// Канонический паттерн: os.Exit только в самом конце
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1) // defer-ов здесь уже нет, можно
}
}
func run() error {
db, err := sql.Open("pgx", os.Getenv("DSN"))
if err != nil { return err }
defer db.Close() // выполнится при любом return из run
shutdown, err := initTracing()
if err != nil { return err }
defer shutdown(context.Background())
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
return serve(ctx, db)
}
main это обычная функция, запущенная в главной
горутине; когда она возвращается, рантайм вызывает
runtime.exit(0), не дожидаясь никого. В тестах
t.Fatal сделан через runtime.Goexit():
defer-ы теста выполнятся, t.Cleanup отработает
штатно, а код после t.Fatal нет. Паника,
случившаяся во время раскрутки другой паники, печатает
обе: panic: первая [recovered]. Частая картина,
когда неаккуратный defer сам падает.
Вопросы
10func(...)(...): её можно присвоить переменной,
передать аргументом, вернуть, положить в мапу или поле
структуры.
Что из этого следует
-
Есть тип функции:
type Handler func(w http.ResponseWriter, r *http.Request). Типы совместимы по сигнатуре, а не по имени. -
Функции тут значения, поэтому работают таблицы
функций:
map[string]func(int) intвместо длинногоswitch. -
Работают функции высшего порядка:
sort.Slice(s, less),http.HandlerFunc, middleware видаfunc(next Handler) Handler. - Есть анонимные функции и замыкания, то есть анонимные функции, захватившие переменные окружения.
// Функция как значение
var op func(int, int) int = func(a, b int) int { return a + b }
// Таблица вместо switch
var ops = map[string]func(int, int) int{
"+": func(a, b int) int { return a + b },
"*": func(a, b int) int { return a * b },
}
// Функция, возвращающая функцию (генератор middleware)
func WithTimeout(d time.Duration) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), d)
defer cancel()
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// Приём "тип-функция реализует интерфейс"
type HandlerFunc func(http.ResponseWriter, *http.Request)
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) { f(w, r) }
Чего в Go нет
Нет перегрузки функций, нет параметров по умолчанию, нет
именованных аргументов при вызове. Обходят это тремя
способами: структура опций, functional options
(func(*Server)) или отдельные функции
NewX / NewXWithY.
Значение функционального типа это указатель на
funcval: структура, где лежит адрес кода, а следом (у
замыканий) захваченные переменные. Отсюда и запрет на
сравнение: f == g не компилируется, разрешено
только f == nil. Отсюда же аллокация в куче,
если замыкание захватило много всего.
i, а с
1.22 у каждой итерации своя.
Механика
Анонимная функция сослалась на локальную переменную внешней
функции: escape-анализ решит, что переменная убегает в
кучу, и замыкание получит на неё указатель. Все замыкания,
захватившие одну переменную, смотрят в одну ячейку памяти и
видят правки друг друга. Захват «по значению» получается
только явно: передать параметром или снять копию через
x := x.
func counter() func() int {
n := 0 // n убегает в кучу
return func() int { n++; return n }
}
c := counter()
c(); c(); fmt.Println(c()) // 3 - состояние живёт в замыкании
// Два замыкания делят одну переменную
func pair() (inc, get func() int) {
n := 0
return func() int { n++; return n }, func() int { return n }
}
Ловушка цикла и Go 1.22
До Go 1.22 переменная, объявленная в for i := 0; ... или for i, v := range ...,
создавалась один раз на весь цикл. Классика: for i := 0; i < 3; i++ { go func(){ print(i) }()
} печатала 3 3 3. С Go 1.22 такие переменные
живут одну итерацию, и код печатает 0 1 2 в произвольном порядке.
Новая семантика включается строкой go 1.22
(или выше) в go.mod, а не версией компилятора. Модуль
с go 1.21 собирается по старым правилам даже
тулчейном 1.25. Ровно этого уточнения и ждут: оно
показывает, что механизм языковых версий ты понимаешь, а
не выучил новость наизусть.
На что ещё ловят
-
Ловушка живёт не в одних горутинах: тот же эффект
при сохранении замыканий в слайс или при
defer func(){ use(v) }()в цикле. -
Изменение не касается
переменных, объявленных до цикла:
i := 0; for ; i < 3; i++ { ... }ведёт себя по-старому. - Цена небольшая, но есть: новая переменная на итерацию даёт аллокацию, если её захватило замыкание. В обычном цикле без замыканий компилятор её не делает.
-
Старый приём
v := vв 1.22+ безвреден, но избыточен;go vetв новых версиях больше не ругается на loopclosure для новых модулей.
...T внутри функции - это обычный
[]T; компилятор на месте вызова собирает слайс
из аргументов, а f(s...) передаёт
твой слайс без копирования.
Механика
Вариативный параметр обязан быть последним, и он только
один. При вызове sum(1,2,3) компилятор
аллоцирует временный массив из трёх элементов и передаёт
слайс на него. При sum(s...) копии нет: функция
получает заголовок исходного слайса и, изменив элемент,
изменит его у вызывающего.
func sum(nums ...int) int { // внутри nums имеет тип []int
t := 0
for _, n := range nums { t += n }
return t
}
sum() // nums не []int{} длины 0, а nil
sum(1, 2, 3) // компилятор собрал слайс
s := []int{1,2,3}
sum(s...) // тот же слайс, без копирования
// Подвох: функция может испортить твой слайс
func spoil(xs ...int) { if len(xs) > 0 { xs[0] = -1 } }
spoil(s...) // s[0] == -1
// Подвох: append в вариативный параметр пишет в твой массив, если у него есть запас cap
func addOne(xs ...int) []int { return append(xs, 1) }
buf := make([]int, 2, 10)
_ = addOne(buf...) // единица легла в массив buf: buf[:3][2] == 1
Частые ошибки
-
sum(s...)иsum(s)это разные вещи; второе не скомпилируется (кроме случая...[]int). -
Смешивать нельзя:
sum(1, s...)даёт ошибку компиляции. -
При
len(args) == 0параметр равенnil, а не пустому слайсу.lenиrangeс ним работают нормально, ноargs != nilбудет ложью. -
...any(как вfmt.Println) заставляет боксировать каждый аргумент в интерфейс, а это аллокации. Отсюда и совет не зватьfmt.Sprintfв горячем цикле.
append(dst, src...) тоже вариативный вызов,
только со встроенной функцией. А fmt.Errorf(format string, a ...any) в связке с %w умеет
обернуть несколько ошибок сразу (с Go 1.20): несколько
%w в одном формате дают ошибку, у которой
Unwrap() []error.
Три правила
- LIFO. Отложенные вызовы кладутся в стек: последний объявленный выполнится первым. Ровно это и нужно для парного захвата ресурсов: открыл A, открыл B, закроется B, потом A.
-
Аргументы вычисляются сразу.
defer fmt.Println(i)приi == 0напечатает 0, даже если дальшеi = 42. Откладывается только вызов, не вычисление операндов. Получатель метода тоже аргумент:defer b.String()вычислитb.String()немедленно. -
Работает на уровне функции. Ни
if, ниfor, ни блок{}defer не запускают. Толькоreturn, паника или конец тела.
func demo() {
for i := 0; i < 3; i++ { defer fmt.Print(i, " ") }
}
// печатает: 2 1 0
func trap() {
i := 0
defer fmt.Println("A:", i) // A: 0 - i скопирован сейчас
defer func(){ fmt.Println("B:", i) }() // B: 42 - замыкание видит переменную
i = 42
}
// вывод: B: 42, затем A: 0
Чем плох defer в цикле
Он копится: цикл на 100 000 файлов зарегистрирует 100 000
отложенных Close(), и все они выполнятся только
в конце функции. Ресурсы (дескрипторы, соединения,
блокировки) держатся всё это время, и приложение упирается в
too many open files. Плюс defer в цикле
отключает open-coded оптимизацию: количество вызовов
неизвестно на этапе компиляции, и компилятор откатывается на
записи _defer в стеке.
Лечение: вынести тело итерации в отдельную функцию, именованную или анонимную, вызванную тут же. Тогда defer сработает на каждой итерации. Второй путь: закрывать явно, без defer, аккуратно обработав все ветки выхода.
defer и
nil
var f func(); defer f() скомпилируется, но
упадёт паникой в момент выхода из функции, а не в
момент объявления. И ещё: defer resp.Body.Close() сразу после
http.Get, до проверки err, даст
nil pointer dereference, потому что при ошибке
resp равен nil. Сначала проверка ошибки,
потом defer.
return x компилируется в «положить
x в возвращаемую переменную → выполнить defer-ы → вернуть
управление»; если возвращаемое значение именованное, defer
видит эту переменную и может её переписать.
func a() int { x := 1; defer func(){ x = 99 }(); return x } // 1
func b() (x int) { x = 1; defer func(){ x = 99 }(); return x } // 99
func c() (x int) { defer func(){ x++ }(); return 5 } // 6
В a() defer меняет локальную x, но
значение уже скопировано в анонимный слот результата, и
эффекта нет. В b() и c()
именованная x и есть слот результата,
поэтому правка видна.
Три практических применения
-
Транзакции. Один defer решает, коммитить или
откатывать, потому что видит итоговую
err. И может вернуть ошибку самогоCommit(). -
Обогащение ошибки контекстом. Один defer
оборачивает любую ошибку функции, не трогая десяток
return err. - Конверсия паники в error на границе пакета: без именованного результата ошибку из recover не вернуть.
func transfer(ctx context.Context, db *sql.DB, from, to int64, amount int64) (err error) {
tx, err := db.BeginTx(ctx, nil)
if err != nil { return fmt.Errorf("begin: %w", err) }
defer func() {
if p := recover(); p != nil {
_ = tx.Rollback()
panic(p) // не глотаем чужую панику
}
if err != nil {
if rbErr := tx.Rollback(); rbErr != nil && !errors.Is(rbErr, sql.ErrTxDone) {
err = errors.Join(err, fmt.Errorf("rollback: %w", rbErr))
}
return
}
err = tx.Commit() // ошибка коммита попадёт наружу
}()
if _, err = tx.ExecContext(ctx, `UPDATE accounts SET balance=balance-$1 WHERE id=$2`, amount, from); err != nil {
return fmt.Errorf("debit: %w", err)
}
if _, err = tx.ExecContext(ctx, `UPDATE accounts SET balance=balance+$1 WHERE id=$2`, amount, to); err != nil {
return fmt.Errorf("credit: %w", err)
}
return nil
}
Напиши внутри tx, err := db.Begin() в новой
области видимости (например, внутри if), и
появится вторая переменная err,
затеняющая именованную. Defer будет смотреть на старую.
Отсюда правило: в функциях с именованным err
пиши для него =, а не :=.
go vet такого не ловит, ловит
shadow в golangci-lint.
return.
Три поколения
-
До 1.13. На каждый
deferаллоцируется структура_deferв куче и вставляется в связный список горутины (g._defer). При выходеdeferreturnобходит список. Порядок 50 нс плюс мусор для GC. -
1.13. Записи
_deferпереехали в стековый фрейм. Аллокации в куче нет, но список и его обход остались: ~35 нс. -
1.14, open-coded defers. Если число defer-ов
известно статически, компилятор инлайнит нужные
вызовы перед каждым
return, а какие из них успели «зарегистрироваться», отслеживает битовой маскойdeferBitsв регистре или на стеке. Никакого списка и обхода: overhead около одной наносекунды.
Когда оптимизация не включается
Компилятор откатывается к стековым записям, если
defer стоит в цикле, если их в функции
больше восьми или если оптимизации отключены (-gcflags="all=-N -l", то есть под отладчиком). Проверяется это через go build -gcflags=-m: в
выводе видно «open-coded defer».
Open-coded defers создают проблему: списка нет, а при
панике рантайму нужно знать, какие вызовы выполнить в
раскручиваемом фрейме. Выручают метаданные
funcdata, которые компилятор кладёт рядом с
функцией: рантайм при раскрутке читает их и
восстанавливает список отложенных вызовов. Так что паника
по-прежнему корректно выполняет все defer-ы, просто путь
этот медленный. И это нормально, паника не должна быть
горячей.
«Сегодня defer в обычной функции стоит
порядка наносекунды, экономить на нём незачем.
Отказываться стоит только по данным профиля, и почти
всегда настоящая проблема даже не defer, а defer в
цикле: он и ресурсы держит, и оптимизацию отключает.»
recover() действует,
только если вызван непосредственно из отложенной
функции, и тогда её владелец возвращается штатно.
Пошагово
-
panic(v)создаёт запись_panicи обрывает текущую функцию прямо в этой точке. - Рантайм начинает раскрутку: в каждом фрейме, снизу вверх, выполняются его отложенные функции. Все до одной, паника этому не мешает.
-
Если очередная отложенная функция вызвала
recover()и получила не nil, паника гаснет. Функция, которой принадлежал defer, завершается нормально и отдаёт то, что лежит в её (именованных) возвращаемых значениях. - Не перехватил никто: раскрутка доходит до корня горутины, рантайм печатает сообщение и стектрейс в stderr и завершает весь процесс с кодом 2.
Где именно должен стоять recover
Только внутри тела отложенной функции, той самой, что стоит
после defer. Вызов recover() из
обычного кода вернёт nil. Вызов из функции,
которую позвала отложенная функция, тоже nil.
Причина: рантайм сверяет, что кадр вызывающего
recover совпадает с кадром, который сейчас
выполняется как defer текущей паники.
func safe() (err error) {
defer func() { // ОК: recover прямо здесь
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
return work()
}
// Работает и так: сама именованная функция отложена
func catch(err *error) {
if r := recover(); r != nil { *err = fmt.Errorf("panic: %v", r) }
}
func safe2() (err error) { defer catch(&err); return work() }
Чего recover не ловит
Fatal error рантайма: concurrent map writes, дедлок («all goroutines are asleep»),
stack overflow, out of memory. Это не паника:
раскрутки нет, defer-ы не выполняются, процесс умирает
сразу. Мимо проходит и os.Exit(), а
runtime.Goexit() паникой не считается (defer-ы
выполнятся, но recover() вернёт nil).
1) recover() возвращает значение, переданное
в panic, типа any, поэтому его
приводят к типу и проверяют. 2) Панику можно
перебросить: panic(r) после проверки
типа, и с чужими паниками так и надо. 3) Паника во
время раскрутки другой паники не теряется: рантайм
печатает обе, помечая первую как [recovered].
error - для ожидаемых сбоев среды
и ввода; panic - для ошибок программиста и
нарушенных инвариантов, когда продолжать работу опаснее, чем
упасть.
panic оправдан
-
Баг в коде: невозможная ветка, нарушенный контракт,
nil там, где его быть не может.
panic("unreachable")честнее, чем молча вернуть ноль. -
Инициализация:
regexp.MustCompile,template.Must, парсинг зашитой конфигурации. Программа с битым конфигом не должна стартовать: лучше упасть на первой секунде, чем отдавать мусор. -
Внутренний механизм пакета: быстрый выход из
глубокой рекурсии парсера, но при условии, что паника
гарантированно ловится на экспортируемой функции. Так
устроены
encoding/jsonиtext/template.
panic недопустим
- Файл не найден, сеть недоступна, БД вернула ошибку, пользователь прислал мусор. Это обычные ошибки, их возвращают.
- В публичном API библиотеки: вызывающий не обязан оборачивать твои функции в recover.
-
Как способ выйти из вложенных циклов: для этого есть метки
(
outer: for { ... break outer }).
Правило границы
Раз внутри пакета паника работает как механизм, перехвати её
на границе и верни error, обязательно
отличая свою панику от чужой по типу и пробрасывая
чужие дальше. Иначе глухой recover проглотит настоящий nil
pointer dereference и спрячет баг.
«Вопрос не в тяжести ошибки, а в том, чья это ошибка. Ошибка окружения даёт error, и обработать её обязан вызывающий. Ошибка программиста даёт panic: чинить её в рантайме бессмысленно, чинят в коде. И отдельно: даже в сервисе с глобальным recover-middleware паника остаётся инцидентом, а не рабочим путём; ей место в алертах, а не в тишине.»
Почему так
Раскрутка идёт по стеку конкретной горутины. Горутины не
вложены друг в друга: у запущенной go f()
собственный стек, «родительского» фрейма в нём нет. Дойдя до
корня своего стека, паника упирается в тупик: рантайм
вызывает fatalpanic, печатает трейс и завершает
процесс. Ни один defer в других горутинах при этом не
выполнится.
// Обязательная обёртка для любой фоновой горутины
func Go(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
metrics.PanicsTotal.Inc()
log.Printf("panic: %v\n%s", r, debug.Stack())
}
}()
fn()
}()
}
// Если результат нужен наверху, отдаём панику как ошибку
func RunSafe(fn func() error) (err error) {
done := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
done <- fmt.Errorf("panic: %v\n%s", r, debug.Stack())
}
}()
done <- fn()
}()
return <-done
}
Три вещи, которые тут проверяют
-
net/httpставит recover вокруг вызова хендлера, но только в горутине соединения. Если хендлер сам запустилgoи там паника, сервер умрёт. Плюсhttp.ErrAbortHandler, специальная паника, которую сервер гасит молча. -
errgroupсобираетerror, но исторически не перехватывал паники; в свежих версияхx/syncпаника пробрасывается вWait(), но закладываться на это без проверки версии нельзя. - Даже с глобальным recover-middleware паника оставляет систему в неопределённом состоянии: незакрытые транзакции, недоснятые локи. Recover помогает не уронить соседние запросы, но баг за тебя не починит.
go func(){ ... }() без recover в фоновом
воркере, кроне или обработчике сообщений из брокера. Один
невалидный payload, и под падает, Kubernetes его
перезапускает, сообщение приходит снова, под падает снова:
crash loop. Правило простое: ни одного «голого»
go в проде, только через обёртку с
recover и метрикой.
os.Exit - не выполняет defer-ы
вообще; panic - выполняет по всей раскрутке и
печатает трейс, код 2; return из main -
выполняет defer-ы main и завершает процесс с кодом 0.
| Способ | defer | Трейс | Код | Типичное применение |
|---|---|---|---|---|
return из main |
Да (только main) | Нет | 0 | Штатное завершение |
panic(v) |
Да, все по пути | Да | 2 | Невосстановимый баг |
os.Exit(n) |
Нет | Нет | n | Свой код выхода для CLI |
log.Fatal |
Нет | Нет | 1 | Только в самом main |
runtime.Goexit() |
Да (своей горутины) | Нет | - | Внутри t.Fatal |
Главный практический вывод
log.Fatalf это os.Exit(1), поэтому
все defer db.Close(), defer tracer.Shutdown(), defer f.Sync() тихо
не выполнятся: буферы не сброшены, спаны не отправлены,
соединения не закрыты. Отсюда паттерн «тонкий main»: вся
работа в run() error с нормальными
return, а main занимает три строки
с единственным os.Exit(1) в самом конце, когда
defer-ов уже нет.
func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
Когда main возвращается, рантайм не ждёт
остальные горутины: они обрываются на месте, их defer-ы не
выполняются. Для graceful shutdown нужен явный
signal.NotifyContext +
server.Shutdown(ctx) +
wg.Wait(). Полноту стектрейса при панике
задаёт переменная окружения GOTRACEBACK:
none, single (по умолчанию,
только упавшая горутина), all,
system, crash (последний ещё и
роняет core dump).
Что стоит запомнить
- Аргументы defer вычисляются при регистрации, вызовы идут LIFO.
- Замыкание читает захваченную переменную.
- recover работает в той же горутине.
Частые ошибки
- Не теряй ошибку Close без решения.
- recover не заменяет ожидаемые ошибки.
Самопроверка
Сначала реши задачу самостоятельно, затем открой ответ и сравни ход рассуждений.
Чтение кода
1. Проследи значения
Что напечатает фрагмент? Объясни, почему получился такой результат.
x := 1
defer fmt.Println(x)
x = 2
Ответ и разбор
Печатается 1: аргумент вызова defer вычислен при регистрации defer.
Код-ревью
2. Найди ошибку и исправь
Автор ожидает вернуть 4. Проследи порядок и назови фактический результат.
func f() (n int) {
defer func() { n++ }()
return 4
}
Ответ и разбор
Возвращается 5. return 4 сначала присваивает 4 именованной переменной n, затем выполняется deferred closure и увеличивает n, после чего значение возвращается. Если нужно вернуть строго 4, убери изменение n в defer или не используй именованный результат для побочного действия.
Применение
3. Реши задачу
Открыты ресурс A, затем ресурс B; оба надо закрыть при выходе, причём B должен закрыться раньше A. В каком порядке регистрировать defer?
Ответ и разбор
Регистрируй defer A.Close() сразу после успешного открытия A, затем defer B.Close() после открытия B. Defer выполняются LIFO, значит сначала закроется B, затем A. Ошибки Close стоит обработать согласно контракту функции, а не молча терять.
Применение
4. Реши задачу
HTTP handler вызывает библиотеку, которая может запаниковать в той же горутине. Где поставить recover и что он не перехватит?
Ответ и разбор
Поставь deferred recovery middleware вокруг вызова handler в той же горутине; преобразуй panic в 500, залогируй stack trace без чувствительных данных и продолжи обслуживать сервер. Это не поймает panic в горутине, которую библиотека отдельно запустила. Для такой работы нужен собственный канал ошибок/завершения у владельца горутины.