Перейти к содержимому

Ошибки, panic, recover и defer

В Go нет исключений. Есть два принципиально разных механизма, и половина вопросов на собеседовании — про то, понимает ли кандидат границу между ними. Первый механизм — обычные значения: error это встроенный интерфейс с единственным методом Error() string, ошибка возвращается наравне с результатом и обрабатывается явным if err != nil. Это «ожидаемые» ситуации: файла нет, соединение оборвалось, пользователь прислал мусор. Второй механизм — panic: разворачивание стека текущей горутины с выполнением отложенных вызовов. Это «невозможные» ситуации: нарушен инвариант, программа находится в состоянии, из которого корректно продолжать нельзя. Правило, которое стоит произнести вслух: ошибки — для ожидаемого, паника — для программистских багов и для невозможности стартовать.

Ниже паники есть третий уровень, о котором забывают, — fatal error рантайма. Это не паника: рантайм вызывает внутреннюю throw, печатает fatal error: ..., стеки всех горутин и завершает процесс. Ни defer, ни recover тут не работают, потому что состояние рантайма уже нельзя считать согласованным. Сюда попадают одновременная запись в map, дедлок всех горутин, переполнение стека горутины, нехватка памяти, unlock of unlocked mutex. Отдельно стоят os.Exit и log.Fatal — они не разворачивают стек вообще, отложенные вызовы просто не выполняются.

defer — это связующее звено. Оператор регистрирует вызов в списке текущей горутины (аргументы и получатель вычисляются немедленно, в момент выполнения оператора defer, а тело — потом). Отложенные вызовы выполняются в порядке LIFO при любом выходе из функции: обычный return, return с ошибкой, разворачивание из-за паники, runtime.Goexit. Именно поэтому defer — единственный надёжный способ освободить ресурс и единственное место, где можно вызвать recover. Начиная с Go 1.13 defer-записи по возможности размещаются на стеке, а с Go 1.14 компилятор умеет «open-coded defers» — раскрывать их прямо в теле функции, за счёт чего типичный defer mu.Unlock() стоит единицы наносекунд. Это оптимизация применяется, только когда число defer-ов в функции статически известно и невелико; defer в цикле её выключает.

Инфраструктура работы с ошибками как со значениями появилась в Go 1.13: fmt.Errorf с глаголом %w заворачивает ошибку, errors.Unwrap разворачивает, errors.Is сравнивает по цепочке с sentinel-значением, errors.As ищет в цепочке ошибку нужного типа. В Go 1.20 добавили errors.Join и поддержку нескольких %w в одном fmt.Errorf — так появились «мультиошибки» с методом Unwrap() []error, по которым Is/As тоже умеют ходить. Современный код почти никогда не сравнивает ошибки через == и почти никогда не парсит err.Error().

Как работает defer? В каком порядке выполняются defer-вызовы?

Заголовок раздела «Как работает defer? В каком порядке выполняются defer-вызовы?»

Коротко. defer регистрирует вызов функции, который выполнится при выходе из текущей функции — при любом выходе, включая панику. Аргументы вычисляются сразу, в момент выполнения оператора defer; само тело — потом. Порядок — LIFO: последний зарегистрированный выполняется первым.

Глубже. Технически отложенные вызовы хранятся в связном списке _defer-записей у горутины (g._defer), новая запись добавляется в голову — отсюда и LIFO. Ключевая деталь для собеседования — момент вычисления аргументов:

func f() {
i := 0
defer fmt.Println("deferred:", i) // напечатает 0 — i скопирован сейчас
i = 42
fmt.Println("normal:", i) // 42
}

Если нужно «позднее» значение, откладывают замыкание: defer func() { fmt.Println(i) }() напечатает 42, потому что замыкание захватывает переменную, а не её копию. Второе: отложенная функция может изменить именованный результат — defer выполняется после того, как return присвоил значение результату, но до фактического возврата управления. Это основа идиомы «превратить панику в ошибку»:

func safeDiv(a, b int) (res int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
return a / b, nil
}

Что такое паника в Go и как ее обрабатывать?

Заголовок раздела «Что такое паника в Go и как ее обрабатывать?»

Коротко. Паника — это остановка нормального выполнения горутины: рантайм начинает разворачивать её стек, по пути выполняя все зарегистрированные defer. Если ни один из них не вызовет recover, процесс завершится с выводом сообщения и стектрейса и кодом выхода 2. Обрабатывают паники вызовом recover() внутри отложенной функции.

Глубже. Паника бывает явной (panic(v), где v — любое значение, по конвенции error или строка) и рантаймовой: разыменование nil-указателя, выход за границы слайса, деление на ноль целых, приведение типа x.(T) без второго возвращаемого значения, запись в nil-map, закрытие закрытого канала, отправка в закрытый канал. Рантаймовые паники несут значение типа runtime.Error (например, *runtime.TypeAssertionError), так что их можно отличить через errors.As/type switch. С Go 1.21 panic(nil) больше не даёт «невидимую» панику: рантайм подменяет значение на *runtime.PanicNilError, и recover() возвращает не-nil (старое поведение возвращается через GODEBUG=panicnil=1). Восстанавливать панику имеет смысл только на явной границе изоляции — HTTP-хендлер, обработчик задачи из очереди, плагин, — где падение одного запроса не должно ронять весь процесс; в такой обработке обязательно логируют debug.Stack(), иначе вы теряете единственную улику.

500 Internal Server Error — ошибка сервера, какая-то непредвиденная ошибка (например, БД недоступна)

Заголовок раздела «500 Internal Server Error — ошибка сервера, какая-то непредвиденная ошибка (например, БД недоступна)»

Коротко. Это про маппинг доменных ошибок на HTTP-коды: 4xx — вина клиента (валидация, авторизация, «не найдено»), 5xx — вина сервера (БД недоступна, паника, таймаут зависимости). Наружу при 500 отдают безликое сообщение и идентификатор запроса, а подробности пишут в лог.

Глубже. Практичный шаблон: доменный слой возвращает sentinel-ошибки или типизированные ошибки, а транспортный слой один раз конвертирует их в статусы через errors.Is/errors.As, чтобы бизнес-логика ничего не знала про HTTP.

func statusOf(err error) int {
switch {
case errors.Is(err, domain.ErrNotFound):
return http.StatusNotFound
case errors.Is(err, domain.ErrConflict):
return http.StatusConflict
case errors.As(err, new(*domain.ValidationError)):
return http.StatusBadRequest
case errors.Is(err, context.DeadlineExceeded):
return http.StatusGatewayTimeout
default:
return http.StatusInternalServerError
}
}

Отдельные тонкости, которые ждут: «БД недоступна» — это чаще 503 с Retry-After, чем 500, потому что клиенту полезно знать, что повтор осмыслен; ошибку нельзя пробрасывать текстом наружу (в сообщении драйвера бывают DSN, имена таблиц, IP); при 5xx обязательно инкрементируется метрика и пишется лог с trace/request id, который же возвращается клиенту.

Коротко. Обрывок исходника — судя по всему, речь про формат тела ответа с ошибкой в HTTP API. Минимально разумный формат: стабильный машиночитаемый код ошибки, человекочитаемое сообщение и идентификатор запроса.

Глубже. Одного поля error со свободным текстом мало: клиент вынужден парсить строку, а любая правка формулировки ломает интеграцию. Практичный вариант — {"code": "user_not_found", "message": "user not found", "request_id": "01H...", "details": {...}}, где code — часть контракта и никогда не меняется, message можно менять и локализовать, details заполняется для ошибок валидации (список полей). Стандартизированный вариант того же — RFC 9457 (ранее RFC 7807) application/problem+json с полями type, title, status, detail, instance. В gRPC роль этого играют коды codes.Code и google.rpc.ErrorDetails. Главное правило то же, что и с 500: внутренние тексты ошибок (SQL, stacktrace) в тело ответа не попадают.

Как работает ключевое слово defer в Go и для чего оно обычно используется?

Заголовок раздела «Как работает ключевое слово defer в Go и для чего оно обычно используется?»

Коротко. См. выше про механику defer. Отличие вопроса — в акценте на применении: defer нужен, чтобы освобождение ресурса стояло рядом с его захватом и выполнялось при любом пути выхода из функции, включая панику.

Глубже. Канонические применения: defer f.Close(), defer mu.Unlock(), defer wg.Done(), defer cancel() для context.WithCancel/WithTimeout, defer rows.Close() для *sql.Rows, defer tx.Rollback() (роллбек после успешного Commit — no-op, возвращающий sql.ErrTxDone), defer resp.Body.Close(), а также замер времени defer func(t time.Time){ log.Println(time.Since(t)) }(time.Now()) и восстановление после паники. Отдельная зрелая деталь: для writer-подобных ресурсов defer f.Close() теряет ошибку закрытия (а именно при Close часто вылетает ошибка сброса буфера), поэтому в функции с именованным результатом пишут defer func() { err = errors.Join(err, f.Close()) }().

Коротко. error — обычное значение, часть контракта функции; вызывающий обязан его проверить, поток управления не меняется. panic — аварийное разворачивание стека горутины, не описанное в сигнатуре; по умолчанию убивает весь процесс. Ошибки — для ожидаемых сбоев, паника — для нарушенных инвариантов и невозможности продолжать.

Глубже. Практический критерий: если ситуация может возникнуть при корректном коде и корректной эксплуатации (сеть, диск, ввод пользователя) — это error. Если ситуация означает баг в коде (nil там, где по контракту не может быть nil; невозможная ветка switch; неверный формат константы-регулярки) — уместна паника. Отсюда парная конвенция стандартной библиотеки: regexp.Compile возвращает ошибку, regexp.MustCompile паникует, и «Must»-варианты рассчитаны на вызов при инициализации пакета, а не в рантайме запроса. Ещё одно отличие, о котором забывают: паника в одной горутине не ловится recover в другой — она валит процесс целиком, поэтому «ошибку из горутины» надо передавать явно, каналом или через errgroup.

Как обработать панику и нужно ли это делать? Как правильно обрабатывать ошибки?

Заголовок раздела «Как обработать панику и нужно ли это делать? Как правильно обрабатывать ошибки?»

Коротко. Обработать — recover() в функции, вызванной через defer. Делать это стоит только на границах изоляции (HTTP-хендлер, воркер очереди, обработчик gRPC), и обязательно с логированием стека; внутри библиотечного кода паники не глотают. Ошибки же обрабатывают явно: либо разбираются с ними здесь, либо возвращают выше, обогатив контекстом через %w.

Глубже. «Правильно обрабатывать ошибки» на собеседовании разворачивают в набор правил: проверять err сразу и не игнорировать (_ = err — только с комментарием почему); не логировать и одновременно возвращать одну и ту же ошибку (иначе она попадёт в лог N раз); добавлять контекст без слова «failed to» в каждом звене (fmt.Errorf("load user %d: %w", id, err) — получается читаемая цепочка load user 42: query: connection refused); принимать решение по ошибке через errors.Is/errors.As, а не по тексту; на границе процесса — один раз залогировать полностью и сконвертировать в код ответа. Про паники: net/http уже восстанавливает панику в хендлере per-connection (кроме http.ErrAbortHandler), но делает это молча, обрывая соединение, поэтому свой middleware с recover + debug.Stack() + метрика всё равно нужен.

При запуске сервиса обнаруживается ошибка конфигурации — не указаны параметры подключения к базе данных. Вы инициируете panic. Имеет ли смысл обрабатывать эту панику?

Заголовок раздела «При запуске сервиса обнаруживается ошибка конфигурации — не указаны параметры подключения к базе данных. Вы инициируете panic. Имеет ли смысл обрабатывать эту панику?»

Коротко. Нет. Это как раз тот случай, когда сервис не может работать в принципе: восстанавливаться некуда, продолжение приведёт к процессу, который принимает трафик и на каждом запросе падает. Правильное поведение — упасть сразу, громко и с понятным сообщением.

Глубже. На старте вообще предпочтительнее не panic, а явное завершение с ненулевым кодом: в mainlog.Fatalf("config: %v", err) или fmt.Fprintln(os.Stderr, err); os.Exit(1), потому что паника печатает стектрейс, который в этом сценарии никому не нужен и только маскирует суть. Ещё лучше — чтобы функции загрузки конфига возвращали error, а решение «падать» принимал только main: так конфиг тестируем и переиспользуем. Сформулировать в ответе стоит принцип fail-fast: ошибки конфигурации и миграций ловятся при старте, до того как процесс объявит себя готовым (readiness probe), — тогда оркестратор не переключит на него трафик и откатит деплой.

Какие особенности типа error в Go вы можете назвать?

Заголовок раздела «Какие особенности типа error в Go вы можете назвать?»

Коротко. error — не тип, а встроенный интерфейс с одним методом Error() string. Ошибки — обычные значения: их можно сравнивать, хранить, передавать, заворачивать. Главная ловушка — интерфейс с nil-значением внутри не равен nil.

Глубже. Что стоит перечислить: (1) реализовать error может любой тип, чаще берут указатель на структуру, чтобы разные экземпляры не были случайно равны и чтобы errors.As находил их по типу; (2) errors.New("x") != errors.New("x")errors.New возвращает указатель на errorString, сравнение идёт по указателю; (3) типизированный nil:

type MyErr struct{}
func (e *MyErr) Error() string { return "my" }
func bad() error {
var e *MyErr // nil указатель
return e // интерфейс НЕ nil: тип *MyErr, значение nil
}
// bad() != nil → true, хотя ошибки на самом деле нет

Лечится тем, что переменная-ошибка объявляется как error, а не как конкретный тип, и возвращается nil литералом. (4) Цепочки: Unwrap() errorUnwrap() []error с Go 1.20) делают ошибку узлом дерева, по которому ходят errors.Is/errors.As. (5) error — интерфейс, значит его хранение может вызывать аллокацию, а сравнение с nil стоит одну проверку — но в горячем цикле дешёвая ошибка всё равно дешевле паники. (6) Метод Error() не должен паниковать и не должен вызывать сам себя через %v от того же значения — классический бесконечный рекурс.

Как в Go называют константы, представляющие ошибки?

Заголовок раздела «Как в Go называют константы, представляющие ошибки?»

Коротко. Sentinel errors («сигнальные», «дозорные» ошибки) — заранее объявленные переменные вида var ErrNotFound = errors.New("not found"), которые сравнивают через errors.Is. Формально это не константы: errors.New — вызов функции, поэтому только var, а не const.

Глубже. Конвенция именования — префикс Err (io.EOF — историческое исключение), экспортируются, если являются частью контракта пакета: io.EOF, io.ErrUnexpectedEOF, sql.ErrNoRows, os.ErrNotExist, context.Canceled, context.DeadlineExceeded. Настоящую константу-ошибку сделать можно — через строковый тип с методом на значении:

type Error string
func (e Error) Error() string { return string(e) }
const ErrNotFound = Error("not found")

Это встречается в библиотеках, которым важно, чтобы sentinel нельзя было переприсвоить (обычная var ErrX глобально изменяема). Минус sentinel-ошибок — они жёстко фиксируют часть публичного API: убрать ErrNoRows из пакета уже нельзя. Поэтому детализированные ошибки чаще делают типами, а sentinel оставляют для «плоских» состояний.

Какое правило стоит соблюдать при написании сообщений об ошибках? Чего следует избегать?

Заголовок раздела «Какое правило стоит соблюдать при написании сообщений об ошибках? Чего следует избегать?»

Коротко. Сообщение пишется со строчной буквы, без точки в конце и без переводов строки — потому что оно почти всегда будет вложено в другое сообщение. Избегать: заглавных букв, знаков препинания в конце, слов «error/failed» в каждом звене цепочки и утечки секретов.

Глубже. Это прямо зафиксировано в Go Code Review Comments: fmt.Errorf("something bad"), а не "Something bad." — исключение только для имён собственных и аббревиатур ("parse JSON: ..."). При заворачивании добавляют, что делали, а не «не смогли»: fmt.Errorf("read config %q: %w", path, err) — в итоге получается read config "app.yaml": open app.yaml: no such file or directory, а не failed to read config: failed to open file: error: .... Дополнительно: не дублировать информацию, которую добавит вызывающий; включать ключевые идентификаторы (id, путь, имя операции), но не пароли, токены, DSN и персональные данные; текст ошибки — не контракт, поэтому никогда не принимать решений через strings.Contains(err.Error(), "..."); для машиночитаемости добавлять sentinel или тип, а не форматировать строку.

Коротко. Всё зависит от того, вычислен ли аргумент в момент defer. defer println(i) напечатает значение i на момент выполнения оператора defer, а defer func(){ println(i) }() — значение на момент выхода из функции. Порядок вывода нескольких таких вызовов — обратный порядку их регистрации.

Глубже. Канонический вариант этой задачи:

func main() {
for i := 0; i < 3; i++ {
defer fmt.Println("A", i) // 2, 1, 0 — аргументы вычислены сразу, порядок LIFO
}
for i := 0; i < 3; i++ {
defer func() { fmt.Println("B", i) }() // Go >= 1.22: 2, 1, 0
}
}

Во втором цикле ответ зависит от версии языка: до Go 1.22 переменная цикла была одна на весь цикл, и все три замыкания печатали 3; начиная с Go 1.22 (при go >= 1.22 в go.mod) i создаётся заново на каждой итерации, поэтому печатается 2, 1, 0. Вторая типичная подколка — defer с методом на значении: получатель тоже копируется в момент регистрации. И третья — println (встроенный) пишет в stderr и не гарантирует формат; в задачах его обычно имеют в виду как fmt.Println.

Коротко. LIFO — последний зарегистрированный выполняется первым, потому что defer-записи кладутся в голову списка у горутины. Порядок выдерживается и при обычном возврате, и при разворачивании стека из-за паники.

Глубже. Порядок LIFO выбран не случайно: он естественно отражает вложенность захвата ресурсов — сначала открыли файл, потом взяли мьютекс, значит сначала отпускаем мьютекс, потом закрываем файл. При панике разворачивание идёт по кадрам: сначала выполняются все defer текущей функции (в LIFO), потом всех вызывающих вверх по стеку, пока какой-нибудь recover не остановит процесс. Если recover не случился, после выполнения всех defer-ов рантайм печатает сообщение паники и стек и завершает процесс с кодом 2. Если внутри отложенной функции возникает новая паника, она заменяет старую, а старая попадает в вывод как panic: ... [recovered] / цепочка panic: A ... panic: B.

Для чего используется defer в Go? В каком порядке выполняются несколько операторов defer?

Заголовок раздела «Для чего используется defer в Go? В каком порядке выполняются несколько операторов defer?»

Коротко. См. выше: defer — для гарантированного освобождения ресурсов и для recover; несколько операторов выполняются в порядке LIFO. Отличие этой формулировки в том, что часто хотят услышать именно связку «сколько бы точек выхода из функции ни было, cleanup написан один раз».

Глубже. Полезно добавить, что defer не привязан к блоку — только к функции. Отложенный вызов внутри if, for или любого другого блока выполнится не по выходу из блока, а по выходу из функции. Это отличает Go от RAII в C++ и от try-with-resources в Java, и это же порождает классическую ошибку с defer в цикле (см. отдельный вопрос ниже).

Коротко. Оператор, откладывающий вызов функции до момента выхода из текущей функции. Используется для парного cleanup (Close, Unlock, Done, cancel), для recover, для замера времени выполнения и для правки именованного результата перед возвратом.

Глубже. Ценный акцент для ответа — почему defer надёжнее ручного вызова в конце функции: в Go у функции обычно несколько return, и любой из них легко забыть; кроме того, паника вообще не проходит через ваш «конец функции», а через defer-ы проходит. Стоимость: с Go 1.14 обычный defer в функции без циклов и условной регистрации компилируется в open-coded вариант — по сути инлайнится в эпилог, стоит около наносекунды, и в подавляющем большинстве кода про её цену думать не нужно.

Коротко. Почти всегда, но не всегда. Отложенные вызовы выполняются при обычном возврате, при панике (во время разворачивания стека) и при runtime.Goexit. Они не выполняются при os.Exit, log.Fatal*, fatal error рантайма, при аварийном сигнале без обработчика и при kill -9.

Глубже. Полный список исключений, который стоит проговорить: (1) os.Exit завершает процесс немедленно, стек не разворачивается; log.Fatal и log.Panicln-подобные — это log.Print + os.Exit(1), значит defer-ы теряются; (2) t.Fatal в тестах вызывает runtime.Goexit, а он как раз defer-ы выполняет — но только в горутине теста, поэтому t.Fatal внутри дочерней горутины некорректен; (3) fatal error рантайма (concurrent map writes, all goroutines are asleep - deadlock!, stack overflow, OOM) идёт через throw и defer-ы не выполняет; (4) если горутина заблокирована навсегда, её defer-ы просто никогда не наступят, а при завершении main рантайм не даёт живым горутинам доиграть; (5) defer, до которого выполнение не дошло (оператор ниже места паники), естественно, не зарегистрирован и не выполнится.

Как обрабатываются ошибки Go? Расскажи про panic, recover, defer

Заголовок раздела «Как обрабатываются ошибки Go? Расскажи про panic, recover, defer»

Коротко. Ошибки — значения, возвращаются последним результатом и проверяются явно. panic разворачивает стек горутины, выполняя defer; recover, вызванный непосредственно внутри отложенной функции, останавливает разворачивание и возвращает значение паники; defer — механизм, который гарантирует выполнение кода на выходе и единственное место, где recover работает.

Глубже. Каноничная связка, которую стоит написать на доске:

func Handle(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal error", http.StatusInternalServerError)
}
}()
// ...
}

Важные оговорки: recover() вне отложенной функции всегда возвращает nil; recover() в функции, которую вызвала отложенная функция, тоже не сработает — он должен быть вызван прямо в теле deferred-функции; после успешного recover выполнение продолжается не с места паники, а с возврата из функции, в которой стоял defer; если после recover понятно, что инвариант всё равно нарушен, ошибку принято перебрасывать через panic(rec).

Коротко. Пакет errors даёт минимальный инструментарий работы с ошибками как со значениями: errors.New создаёт простую ошибку, errors.Is/errors.As инспектируют цепочку обёрток, errors.Unwrap разворачивает на один уровень, errors.Join (Go 1.20) объединяет несколько ошибок в одну.

Глубже. Смысл Is/As в том, что после появления %w ошибка перестала быть плоской: fmt.Errorf("get user: %w", sql.ErrNoRows) — это новая ошибка, которая != sql.ErrNoRows, но errors.Is(err, sql.ErrNoRows) вернёт true, потому что пройдёт по Unwrap. errors.Is умеет учитывать пользовательский метод Is(error) bool, errors.As — метод As(any) bool. errors.Join(err1, err2) возвращает ошибку с Unwrap() []error, её текст — сообщения через \n, и Is/As обходят такое дерево в глубину. С точки зрения истории: до Go 1.13 всё это жило в github.com/pkg/errorsWrap/Cause/стектрейсами), и сегодня главное отличие сторонних пакетов от стандартного — стектрейсы, которых errors не даёт.

Когда могут быть паники при использовании вейт-групп?

Заголовок раздела «Когда могут быть паники при использовании вейт-групп?»

Коротко. Три классических случая: счётчик ушёл в минус (sync: negative WaitGroup counter) — лишний Done; повторное использование WaitGroup до возврата из Wait (WaitGroup is reused before previous Wait has returned); Add вызван конкурентно с Wait, когда счётчик уже был нулевым (WaitGroup misuse: Add called concurrently with Wait).

Глубже. Первый случай почти всегда — Done без парного Add или двойной Done (например, defer wg.Done() плюс явный wg.Done() в ветке). Второй и третий возникают, когда Add(1) делают внутри запущенной горутины, а не до go: тогда Wait может увидеть нулевой счётчик, разблокироваться, и следующий Add придёт «в прошлое». Ещё одна не-паническая, но фатальная беда — копирование WaitGroup по значению (передали в функцию не указателем): счётчики разойдутся, и получите либо вечный Wait, либо панику про отрицательный счётчик; go vet ловит это правилом copylocks. И отдельно: паника внутри горутины, где Done не был поставлен через defer, оставит счётчик ненулевым — а сама паника всё равно уронит процесс, потому что recover из родителя её не поймает. В Go 1.25 у WaitGroup появился метод Go(f func()), который сам делает Add/Done и снимает большинство этих ошибок.

Коротко. Паника — штатный механизм языка: она разворачивает стек, выполняет defer и может быть остановлена recover. fatal error — это внутренний throw рантайма при ситуации, когда состояние рантайма уже нельзя считать корректным: defer-ы не выполняются, recover не работает, процесс завершается всегда.

Глубже. Типичные fatal error: concurrent map writes и concurrent map read and map write, all goroutines are asleep - deadlock!, stack overflow (горутина превысила лимит стека, по умолчанию 1 ГБ на 64-битных), out of memory, sync: unlock of unlocked mutex, unexpected signal during runtime execution. Обратите внимание на асимметрию: запись в nil-map — это обычная паника assignment to entry in nil map (ловится), а конкурентная запись в обычную map — fatal error (не ловится). Различить в выводе просто: паника начинается со строки panic: ..., fatal error — со строки fatal error: ..., и по умолчанию печатает стеки всех горутин, а не только текущей. Ещё одно частое смешение — log.Fatal: это вообще не рантаймовая штука, а просто печать в лог и os.Exit(1).

Коротко. Нет. fatal error идёт через runtime.throw, минуя механизм паник: defer не выполняется, recover никогда не вызывается, процесс гарантированно умирает. Перехватить можно только панику.

Глубже. Единственное, что можно сделать, — это подготовиться: включить GOTRACEBACK=all/crash для полных стеков и корки, писать crash-логи в файл (debug.SetCrashOutput в Go 1.23 позволяет направить вывод падения в отдельный файл), и обеспечить перезапуск процесса супервизором. debug.SetPanicOnFault(true) относится только к обращениям к невалидной памяти (mmap-регионы) и превращает их в панику, но на concurrent map writes и дедлок это никак не влияет. Правильная стратегия — не ловить, а не допускать: защищать map мьютексом или использовать sync.Map, ловить гонки в CI через -race, ограничивать глубину рекурсии, следить за лимитами памяти (GOMEMLIMIT).

Коротко. Обрывок исходника, вопрос не восстанавливается — это вариант ответа из теста, а не вопрос.

Глубже. Если это был вариант ответа к задаче на код, то полезно держать в голове чек-лист, из-за чего Go-программа завершается ненулевым кодом: неперехваченная паника (код 2), fatal error рантайма, os.Exit(n)/log.Fatal, ошибка компиляции (для go run), необработанный сигнал. Отдельно: программа завершается нормально, как только возвращается main, — живые горутины при этом убиваются без выполнения их defer.

Что такое ошибки и какие есть способы их обработки?

Заголовок раздела «Что такое ошибки и какие есть способы их обработки?»

Коротко. Ошибка в Go — обычное значение типа error, возвращаемое функцией. Способов обработки четыре: разобраться на месте (retry, fallback, дефолт), вернуть выше как есть, вернуть выше с добавленным контекстом через %w, либо превратить в фатальное завершение (log.Fatal в main).

Глубже. Дополнительные приёмы, которые отличают сильный ответ: агрегирование через errors.Join, когда нужно сообщить обо всех проблемах сразу (валидация формы, закрытие нескольких ресурсов); классификация ошибок на «повторяемые» и «окончательные», чтобы retry-политика была осмысленной; sentinel/типизированные ошибки вместо строк, чтобы вызывающий мог принять решение; явный _ = err с комментарием там, где ошибка действительно не важна (defer resp.Body.Close()); и не-обработка как решение — если функция ничего полезного с ошибкой сделать не может, она обязана вернуть её, а не логировать и глотать.

Что такое defer и какие особенности работы с ним?

Заголовок раздела «Что такое defer и какие особенности работы с ним?»

Коротко. См. выше про механику. Особенностей, за которые обычно спрашивают: аргументы вычисляются в момент регистрации; порядок LIFO; область действия — функция, а не блок; отложенная функция видит и может менять именованные результаты; defer в цикле накапливает вызовы до конца функции; ошибка из отложенного Close() теряется, если её не подхватить.

Глубже. Ещё две тонкости. Первая — defer на nil-функции: var f func(); defer f() зарегистрируется нормально, а паника случится в момент выхода из функции, что путает при отладке. Вторая — defer и вызов метода по значению: defer buf.Flush() копирует получателя, если метод определён на значении; для указателя копируется указатель, что обычно и требуется. И третья, производительная: если defer стоит внутри for или под условием, компилятор не может применить open-coded defers и переходит на более дорогой путь со списком _defer-записей.

В чем различие между функциями errors.Is и errors.As?

Заголовок раздела «В чем различие между функциями errors.Is и errors.As?»

Коротко. errors.Is(err, target) проверяет, есть ли в цепочке ошибка, равная конкретному значению (sentinel). errors.As(err, &target) ищет в цепочке ошибку нужного типа и, если нашёл, записывает её в target, чтобы можно было прочитать поля.

Глубже.

var ErrNotFound = errors.New("not found")
type ValidationError struct{ Field string }
func (e *ValidationError) Error() string { return "invalid field " + e.Field }
err := fmt.Errorf("get user: %w", &ValidationError{Field: "email"})
errors.Is(err, ErrNotFound) // false — такого значения в цепочке нет
var ve *ValidationError
if errors.As(err, &ve) { // true, ve.Field == "email"
fmt.Println(ve.Field)
}

Детали, которые ждут: второй аргумент As обязан быть ненулевым указателем на тип, реализующий error (или на интерфейс), иначе — паника; Is использует ==, поэтому sentinel должны быть сравнимыми значениями; тип может переопределить поведение, реализовав Is(error) bool (так делает, например, fs.PathError-подобная логика в os.ErrNotExist) или As(any) bool; обе функции с Go 1.20 умеют обходить деревья, порождённые errors.Join и множественным %w. Практическое правило: sentinel → Is, ошибка с данными → As.

Как работает оператор defer и можно ли использовать его внутри цикла?

Заголовок раздела «Как работает оператор defer и можно ли использовать его внутри цикла?»

Коротко. Можно, но обычно это ошибка: defer привязан к функции, а не к итерации, поэтому все вызовы накопятся и выполнятся только при выходе из функции — ресурсы будут держаться всё время работы цикла. Лечится выносом тела итерации в отдельную функцию (или замыкание), либо явным закрытием.

Глубже.

// Плохо: все файлы открыты до конца обработки, дескрипторы кончатся
for _, name := range names {
f, err := os.Open(name)
if err != nil { return err }
defer f.Close()
process(f)
}
// Хорошо: закрываем на выходе из итерации
for _, name := range names {
if err := func() error {
f, err := os.Open(name)
if err != nil { return err }
defer f.Close()
return process(f)
}(); err != nil {
return err
}
}

Дополнительно: defer в цикле отключает open-coded оптимизацию, каждая итерация выделяет defer-запись, и при большом числе итераций растёт память. go vet такого не ловит, но линтеры (gocritic, revive) — да. И отдельная деталь про Go 1.22: с этой версии переменная цикла создаётся заново на каждой итерации, что убирает старую ловушку с defer func(){ use(v) }(), но проблему «ресурсы не освобождаются до конца функции» это никак не решает.

Оборудование (Device): ID, тип (проектор, телевизор, видеоконференция, аудиосистема), модель, статус (online/offline/error)

Заголовок раздела «Оборудование (Device): ID, тип (проектор, телевизор, видеоконференция, аудиосистема), модель, статус (online/offline/error)»

Коротко. Обрывок исходника, вопрос не восстанавливается — это фрагмент условия задачи на проектирование, а не вопрос по ошибкам.

Глубже. Единственное, что здесь пересекается с темой: статус error у сущности — это состояние доменной модели, а не Go-ошибка. Их не стоит смешивать: Device.Status моделируется как отдельный тип-перечисление (type Status string c константами и методом Valid() error), а причина сбоя хранится отдельным полем (LastError string / *DeviceError). Функция GetDevice при этом возвращает error только на проблемы получения данных, а «устройство в статусе error» — валидный успешный ответ.

Как вы отлаживаете приложение при возникновении ошибки?

Заголовок раздела «Как вы отлаживаете приложение при возникновении ошибки?»

Коротко. Вопрос про опыт: интервьюер хочет услышать системный процесс — воспроизвести, локализовать по логам/трейсам/метрикам, сузить до кода, подтвердить гипотезу и закрыть тестом. Ответ строится как «сначала данные, потом гипотезы, потом изменения».

Глубже. Каркас хорошего ответа: (1) собрать факты — сообщение ошибки целиком, стектрейс, request/trace id, версия сборки, окружение; (2) посмотреть, воспроизводится ли — локально, в тесте, под нагрузкой; (3) инструменты Go, которые стоит назвать поимённо: структурные логи (log/slog), net/http/pprof (goroutine-дамп для зависаний, heap для утечек, profile для CPU), go tool trace, детектор гонок go test -race, GOTRACEBACK=all и debug.Stack() для падений, delve для пошаговой отладки, errors.As для разбора цепочки; (4) бинарный поиск по коммитам (git bisect) для регрессий; (5) зафиксировать баг тестом, а не только починить. Типичные ошибки в ответе: «смотрю логи» без конкретики, отсутствие упоминания воспроизведения, отладка «правкой наугад», отсутствие шага «добавил тест/метрику, чтобы поймать это раньше».

Как вы узнавали, что в сервисе произошла ошибка, до жалоб пользователей?

Заголовок раздела «Как вы узнавали, что в сервисе произошла ошибка, до жалоб пользователей?»

Коротко. Вопрос про опыт эксплуатации: ждут observability — метрики с алертами на SLO, структурные логи с уровнями и корреляцией, трейсинг, health/readiness пробы и дежурство. Ключевая мысль: алерты вешаются на симптомы, видимые пользователю (доля 5xx, задержка p99, отставание консьюмера), а не на каждый лог с уровнем ERROR.

Глубже. Из чего собрать ответ: RED-метрики (rate, errors, duration) по каждому эндпоинту через Prometheus + Grafana, алерты по error budget / burn rate, а не по абсолютному числу ошибок; отдельный счётчик recovered-паник — рост означает баг; sentry-подобный сборщик исключений с дедупликацией; логи в JSON (slog) с trace id, чтобы связать с трейсом в Jaeger/Tempo; синтетические проверки и readinessProbe, чтобы поломанный инстанс выводился из балансировки; алерт на «отсутствие трафика/сообщений» — это ловит целый класс тихих отказов. Хороший ответ обязательно содержит пример: «после инцидента X добавили метрику Y и алерт Z, следующий такой случай поймали за 3 минуты». Типичные ошибки: рассказ только про логи; алерты, на которые никто не смотрит; отсутствие разницы между liveness и readiness.

Можно ли выполнять несколько раз defer в функции?

Заголовок раздела «Можно ли выполнять несколько раз defer в функции?»

Коротко. Да, количество defer в функции не ограничено. Все зарегистрированные вызовы выполнятся при выходе в порядке LIFO. Ограничение только здравого смысла: defer внутри цикла копит вызовы до конца функции.

Глубже. Каждый выполненный оператор defer добавляет отдельную запись, даже если это один и тот же вызов в цикле — регистрируется столько раз, сколько раз до него дошло выполнение. Условный defer (внутри if) регистрируется только если ветка выполнилась. С точки зрения производительности: пока число defer-ов в функции статически известно и их не больше восьми, компилятор с Go 1.14 разворачивает их open-coded (без списка и без аллокаций); в противном случае используется более медленный путь. Практически это значит, что три-четыре defer подряд в функции — совершенно нормальный код.

Какие есть основные кейсы, где используется defer?

Заголовок раздела «Какие есть основные кейсы, где используется defer?»

Коротко. Освобождение ресурсов (Close, Unlock, RUnlock, Done, cancel, Rollback), восстановление после паники (recover), доработка именованного результата (обогащение ошибки контекстом), инструментирование (замер времени, трассировка входа/выхода), восстановление глобального состояния в тестах.

Глубже. Несколько идиом, которые хорошо звучат в ответе:

// 1. Не потерять ошибку Close
func write(path string, data []byte) (err error) {
f, err := os.Create(path)
if err != nil { return err }
defer func() { err = errors.Join(err, f.Close()) }()
_, err = f.Write(data)
return err
}
// 2. Обогатить любую возвращаемую ошибку контекстом в одном месте
func loadUser(id int64) (u *User, err error) {
defer func() {
if err != nil { err = fmt.Errorf("load user %d: %w", id, err) }
}()
// ... несколько return-ов
return nil, sql.ErrNoRows
}
// 3. Замер времени
defer func(start time.Time) { metrics.Observe(time.Since(start)) }(time.Now())

Плюс тестовая идиома t.Cleanup(...), которая делает то же, что defer, но переживает вложенные t.Run и t.Fatal, и defer для восстановления подменённых глобальных переменных: old := cfg.Timeout; defer func(){ cfg.Timeout = old }().

Коротко. Нет — см. выше про fatal error. Дублируется вопрос про throw рантайма; отличие формулировки в том, что здесь часто подразумевают ещё и log.Fatal, который тоже «перехватить» нельзя, потому что это os.Exit(1): ни defer, ни recover не срабатывают.

Глубже. Если в коде нужна «фатальность», но с возможностью корректно завершиться, log.Fatal из библиотек убирают: функция возвращает ошибку, а os.Exit вызывается ровно в одном месте — в конце main, после того как все defer уже отработали. Типичный шаблон:

func main() {
if err := run(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func run() error {
// здесь все defer-ы работают нормально
return nil
}

Коротко. defer нужен, чтобы код освобождения ресурса стоял рядом с захватом и срабатывал при любом выходе из функции, включая панику. Выполняется он не всегда: os.Exit, log.Fatal, fatal error рантайма и убийство процесса сигналом отложенные вызовы пропускают.

Глубже. См. подробный список исключений выше («Is defer always called?»). Стоит добавить нюанс про runtime.Goexit: он завершает горутину, но при этом честно выполняет её defer; именно на этом построен t.Fatal в тестах. И ещё: если main вернулась, рантайм завершает процесс, не дожидаясь других горутин и не выполняя их отложенные вызовы, — поэтому «graceful shutdown через defer в горутине» не работает, нужен явный sync.WaitGroup или контекст.

Коротко. recover останавливает разворачивание стека при панике и возвращает значение, переданное в panic. Нужен, чтобы одна сбойная единица работы (HTTP-запрос, задача из очереди, плагин) не роняла весь процесс, и чтобы превратить внутреннюю панику библиотеки в нормальную ошибку на её границе.

Глубже. Работает только при вызове непосредственно в отложенной функции той же горутины, где произошла паника; в остальных случаях возвращает nil. Второе применение, менее известное, — паника как внутренний механизм раннего выхода из глубокой рекурсии внутри одного пакета: так исторически делали парсеры (encoding/json, text/template, go/parser) — паникуют собственным приватным типом, ловят его на верхней границе и конвертируют в error, а чужие паники пробрасывают дальше:

type parseErr struct{ err error }
func Parse(b []byte) (v Value, err error) {
defer func() {
if r := recover(); r != nil {
pe, ok := r.(parseErr)
if !ok { panic(r) } // чужая паника — не глотаем
err = pe.err
}
}()
return parse(b), nil
}

Что нельзя делать: молча глотать recover() без логирования и метрики; восстанавливаться в библиотеке общего назначения и продолжать работу с потенциально повреждённым состоянием; ловить панику вместо того, чтобы починить nil-разыменование.

Что думаешь о языке Go в сравнении с другими языками? Что думаешь про обработку ошибок?

Заголовок раздела «Что думаешь о языке Go в сравнении с другими языками? Что думаешь про обработку ошибок?»

Коротко. Вопрос на рассуждение, фактического ответа нет. Ждут взвешенную позицию: назвать явные плюсы модели «ошибки — значения», честно признать её издержки (многословность, отсутствие стектрейсов из коробки, риск проигнорировать ошибку) и сравнить с исключениями и с Result-типами, не скатываясь в «Go лучший / Go плохой».

Глубже. Каркас ответа. Тезис: в Go обработка ошибок — часть сигнатуры и часть основного потока управления, поэтому по коду видно, где что может сломаться, и нет невидимых путей выхода, как у исключений; цена — if err != nil примерно на каждый третий вызов. Сравнение: в Java/Python исключение короче в записи, но легко пролетает через слои незамеченным и часто ловится слишком широко (catch Exception); в Rust Result<T, E> + ? даёт ту же явность, что и Go, но с более компактным синтаксисом и исчерпывающими типами ошибок благодаря enum-ам — Go этого не даёт, у него один интерфейс error. Что улучшилось в самом Go: %w, errors.Is/As (1.13), errors.Join и множественный %w (1.20). Что остаётся болью: нет стектрейсов в стандартных ошибках, лёгко потерять ошибку (_ =), предложения по сокращению синтаксиса (try, check/handle) команда отклонила, и в 2025 году официально закрыла тему изменения синтаксиса обработки ошибок. Типичные ошибки в ответе: категоричность, жалобы без предложений, незнание современных errors.*, утверждение, что «в Go нет исключений вообще» без упоминания panic/recover.

Коротко. См. выше — ошибки как значения, if err != nil, обогащение контекстом через fmt.Errorf("...: %w", err), инспекция через errors.Is/errors.As, паника только для нарушенных инвариантов. Отличие этой формулировки — она открытая, и её стоит рассказывать как связную историю: контракт → обогащение → решение → граница.

Глубже. Структура, которая хорошо ложится в устный ответ: (1) error — интерфейс с Error() string, ошибки возвращаются, а не бросаются; (2) создание — errors.New, fmt.Errorf, собственный тип со структурой; (3) обогащение — %w, ровно один раз на слой, с описанием операции; (4) инспекция — errors.Is для sentinel, errors.As для типов, errors.Join для агрегации; (5) политика — на границе процесса ошибка логируется один раз со всем контекстом и превращается в код ответа/exit code; (6) отдельно panic/recover для багов и границ изоляции; (7) инструменты дисциплины — errcheck/golangci-lint, go vet (в частности, проверка неверного %w), тесты на ветки с ошибками.

Коротко. См. выше: аварийное завершение нормального потока выполнения горутины с разворачиванием стека и выполнением отложенных вызовов; без recover завершает процесс с кодом 2 и печатью стека.

Глубже. Полезно уточнить, что panic — встроенная функция, принимающая any, и что вокруг неё есть небольшая экосистема: runtime.Error — интерфейс для паник, порождённых рантаймом; recover — единственный способ остановить разворачивание; debug.Stack() — получить стек в обработчике; GOTRACEBACK управляет полнотой печати (none, single — по умолчанию, all, system, crash). Вложенная паника (паника внутри defer во время разворачивания) не отменяет первую, а печатается вместе с ней; после recover панику можно перебросить panic(r), и в выводе она будет помечена как [recovered].

В каких случаях recover не может отловить панику?

Заголовок раздела «В каких случаях recover не может отловить панику?»

Коротко. Если recover вызван не напрямую из отложенной функции; если паника произошла в другой горутине; если это не паника, а fatal error рантайма или os.Exit; и если defer с recover был зарегистрирован уже после того, как выполнение до него не дошло.

Глубже. Разберём по пунктам. (1) Прямой вызов: defer recover() бесполезен — recover обязан быть вызван функцией, которую напрямую вызвал defer; defer func(){ helper() }(), где recover внутри helper, тоже не сработает. (2) Горутины: recover защищает только свою горутину, поэтому у каждой запущенной горутины должен быть собственный defer с recover — иначе паника в фоновом воркере убьёт весь сервис:

go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("worker panic: %v\n%s", r, debug.Stack())
}
}()
work()
}()

(3) Не-паники: concurrent map writes, дедлок всех горутин, stack overflow, OOM, sync: unlock of unlocked mutex, os.Exit, log.Fatal — здесь recover не вызывается вовсе. (4) Порядок: defer регистрируется в момент выполнения оператора, поэтому если паника случилась выше по коду, чем defer, восстанавливать некому. (5) Тонкость с http.ErrAbortHandler: net/http сам восстанавливает паники хендлера, и если вы паникуете этим значением, сервер намеренно молча обрывает соединение — «поймать» это в своём middleware, стоящем выше, не получится, поскольку паника уже обработана внутри сервера.

Каким инструментом можно обрабатывать ошибки при синхронизации?

Заголовок раздела «Каким инструментом можно обрабатывать ошибки при синхронизации?»

Коротко. golang.org/x/sync/errgroup. Он заменяет sync.WaitGroup там, где горутины могут возвращать ошибки: g.Go(func() error {...}), g.Wait() возвращает первую ненулевую ошибку, а errgroup.WithContext отменяет общий контекст, как только кто-то упал.

Глубже. sync.WaitGroup умеет только считать, поэтому классический ручной вариант — канал ошибок с буфером на N и сбор в конце, либо мьютекс вокруг слайса ошибок с последующим errors.Join. errgroup делает это правильно и добавляет SetLimit(n) для ограничения параллелизма:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, id := range ids {
g.Go(func() error {
return fetch(ctx, id) // Go >= 1.22: id корректно захватывается
})
}
if err := g.Wait(); err != nil {
return fmt.Errorf("fetch batch: %w", err)
}

Оговорки: g.Wait() отдаёт только первую ошибку — если нужны все, собирайте сами и объединяйте errors.Join; errgroup не восстанавливает паники в горутинах, для этого нужен свой defer recover внутри задачи; контекст из WithContext отменяется и при первой ошибке, и после возврата из Wait. Смежные инструменты, которые можно упомянуть: context для отмены по таймауту и sync.Once для того, чтобы сохранить ровно первую ошибку инициализации.

Коротко. defer func() { if r := recover(); r != nil { ... } }() в той же горутине, где может произойти паника, и обязательно с логированием debug.Stack().

Глубже. Практический пример middleware, который стоит уметь написать на доске:

func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
rec := recover()
if rec == nil {
return
}
if rec == http.ErrAbortHandler { // намеренный обрыв — пробрасываем
panic(rec)
}
log.Printf("panic %v: %v\n%s", r.URL.Path, rec, debug.Stack())
panicsTotal.Inc()
w.WriteHeader(http.StatusInternalServerError)
}()
next.ServeHTTP(w, r)
})
}

Ключевые детали: перехватывать надо на каждой горутине отдельно; recover() возвращает any, поэтому для разбора используют type switch (error, string, runtime.Error); заголовки нельзя записать, если хендлер уже начал писать тело — поэтому в проде часто используют обёртку над ResponseWriter, которая знает, был ли уже WriteHeader.

Коротко. По типу и по цепочке, а не по тексту: errors.Is для сравнения с sentinel, errors.As для извлечения конкретного типа с полями, при необходимости switch e := err.(type). Текст ошибки — для человека, не для кода.

Глубже. Типичный разбор ошибки из реального кода выглядит так:

switch {
case err == nil:
case errors.Is(err, context.Canceled): // клиент ушёл
case errors.Is(err, context.DeadlineExceeded):// таймаут
case errors.Is(err, sql.ErrNoRows): // пусто, не ошибка бизнес-уровня
case errors.Is(err, io.EOF):
default:
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
// нарушение уникальности
}
}

Что ещё помогает «понять, что за ошибка»: печать через %+v для типов, которые умеют расширенный вывод (github.com/pkg/errors, cockroachdb/errors); errors.Unwrap в цикле, чтобы увидеть всю цепочку; проверка на net.Error с Timeout() bool для сетевых сбоев (метод Temporary() объявлен устаревшим с Go 1.18 и не должен использоваться); отдельный тип ошибки с полем-кодом, если решения принимает вызывающий на другом сервисе. Антипаттерн, который прямо назовут ошибкой: strings.Contains(err.Error(), "duplicate key").

Коротко. См. выше. Кратко для устного ответа: оператор, который откладывает вызов до выхода из функции; аргументы вычисляются сразу, вызовы выполняются LIFO; нужен для гарантированного освобождения ресурсов и для recover.

Глубже. Если хочется добавить что-то помимо уже сказанного — расскажите про эволюцию реализации: до Go 1.13 каждая defer-запись выделялась в куче и стоила ~50 нс, в 1.13 их научились класть на стек, в 1.14 появились open-coded defers, когда компилятор при статически известном наборе defer-ов вставляет вызовы прямо в эпилог функции и хранит битовую маску «какие из них уже зарегистрированы»; стоимость упала до единиц наносекунд, и старый совет «не используйте defer в горячем коде» с тех пор в большинстве случаев неактуален. Исключение — defer в цикле и в функциях с динамическим числом defer-ов: там остаётся медленный путь.

Как поймать ошибки выполнения (runtime-ошибки), если они есть?

Заголовок раздела «Как поймать ошибки выполнения (runtime-ошибки), если они есть?»

Коротко. Runtime-ошибки в Go приходят двумя способами: как обычные error (их проверяют if err != nil) и как паники типа runtime.Error — nil-разыменование, выход за границы, деление на ноль, неудачное type assertion. Вторые ловятся recover в отложенной функции той же горутины; fatal error рантайма не ловится вообще.

Глубже. Если ошибки могут возникнуть в запущенных горутинах, ловить их нужно внутри каждой:

func safeGo(ctx context.Context, name string, fn func() error) <-chan error {
ch := make(chan error, 1)
go func() {
defer func() {
if r := recover(); r != nil {
ch <- fmt.Errorf("%s panicked: %v\n%s", name, r, debug.Stack())
}
}()
ch <- fn()
}()
return ch
}

Для группы горутин это делает errgroup (плюс свой recover внутри задачи). Разбор пойманной паники: if re, ok := r.(runtime.Error); ok { ... } отличит баг рантайма от собственного panic("..."). И правило, которое стоит проговорить: ловить runtime-панику — временная мера для устойчивости сервиса, а не способ обработки; каждая пойманная паника должна ехать в метрику и заводить баг.

Что такое ошибки в Go, как обрабатывать ошибки в Go?

Заголовок раздела «Что такое ошибки в Go, как обрабатывать ошибки в Go?»

Коротко. См. выше про error как интерфейс и про политику обработки. Отличие формулировки — от вас ждут и определение, и практику в одном ответе: интерфейс с Error() string, возврат последним значением, обязательная проверка, обогащение через %w, решение через errors.Is/As.

Глубже. Хороший короткий рассказ: «ошибка — это значение, а не поток управления; функция возвращает (T, error), вызывающий обязан проверить err; если он не может ничего сделать — возвращает выше, добавив контекст fmt.Errorf("операция: %w", err); если может — обрабатывает: retry с backoff для повторяемых, fallback, дефолт, отдельный код ответа; на границе сервиса ошибка один раз логируется со всем контекстом и конвертируется в HTTP/gRPC-код; паника — не для этого, она для нарушенных инвариантов». Хорошо добавить про инструменты дисциплины: errcheck в golangci-lint ловит проигнорированные ошибки, go vet — неверное использование %w, а тесты на ветки с ошибками пишутся так же обязательно, как на happy path.

Коротко. Три уровня: errors.New/fmt.Errorf для простых случаев; sentinel-переменные var ErrX = errors.New("...") для состояний, которые проверяет вызывающий; собственный тип со структурой и методом Error() string (плюс Unwrap), когда с ошибкой нужно передать данные.

Глубже.

// Sentinel
var ErrNotFound = errors.New("not found")
// Свой тип с данными и поддержкой errors.Is / errors.As
type NotFoundError struct {
Kind string
ID int64
err error
}
func (e *NotFoundError) Error() string {
return fmt.Sprintf("%s %d not found", e.Kind, e.ID)
}
func (e *NotFoundError) Unwrap() error { return e.err } // цепочка
func (e *NotFoundError) Is(target error) bool { // errors.Is(err, ErrNotFound) == true
return target == ErrNotFound
}

Правила: метод Error() определяют на указателе, а возвращают *NotFoundError — тогда два разных экземпляра не равны случайно и errors.As работает предсказуемо; поля, по которым принимают решения, делают экспортируемыми; в текст не кладут то, что уже добавит вызывающий; для оборачивания чужой ошибки реализуют Unwrap. Осторожно с типизированным nil: функция должна возвращать error, и в успешном случае — литерал nil, а не переменную конкретного типа.

Предположим, ваша функция должна возвращать детализированные Recoverable и Fatal ошибки. Как это реализовано в пакете net? Как это надо делать в современном Go?

Заголовок раздела «Предположим, ваша функция должна возвращать детализированные Recoverable и Fatal ошибки. Как это реализовано в пакете net? Как это надо делать в современном Go?»

Коротко. В net это сделано интерфейсом net.Error с методами Timeout() bool и Temporary() bool: вызывающий делал type assertion и решал, повторять ли операцию. Temporary() признан ошибочным дизайном и объявлен устаревшим с Go 1.18 — семантика была расплывчатой и у разных реализаций разной. В современном Go то же самое делают через типизированные/sentinel-ошибки и errors.Is/errors.As, либо через явный метод-предикат вроде Retryable() bool на собственном типе.

Глубже. Исторический слой net: net.OpError (обёртка с полями Op, Net, Addr, Err), net.DNSError с IsTimeout/IsTemporary/IsNotFound, net.AddrError, net.ErrClosed (sentinel, добавлен в Go 1.16 — до него приходилось сравнивать строку “use of closed network connection”). Реализовать «Recoverable/Fatal» сегодня стоит так:

var ErrRetryable = errors.New("retryable")
type OpError struct {
Op string
Retryable bool
Err error
}
func (e *OpError) Error() string { return e.Op + ": " + e.Err.Error() }
func (e *OpError) Unwrap() error { return e.Err }
func (e *OpError) Is(target error) bool {
return target == ErrRetryable && e.Retryable
}
// Вызывающий:
if errors.Is(err, ErrRetryable) { /* backoff и повтор */ }

Что стоит добавить в ответ: категорию ошибки должен определять тот, кто ближе всего к источнику (драйвер, транспорт), а не строковый матчинг на верхнем уровне; для сети практическая проверка «повторяемости» — это errors.Is(err, context.DeadlineExceeded), net.Error.Timeout(), errors.Is(err, syscall.ECONNRESET), errors.Is(err, io.ErrUnexpectedEOF); повторять можно только идемпотентные операции; в gRPC ту же роль играют коды (codes.Unavailable — повторяемая, codes.InvalidArgument — нет).

Коротко. См. выше. Одно предложение для собеседования: defer откладывает вызов функции до выхода из текущей функции, вычисляя аргументы немедленно и выполняя отложенные вызовы в порядке LIFO.

Глубже. Если вопрос задан коротко, ждут короткий ответ плюс один пример: mu.Lock(); defer mu.Unlock(). Дальше интервьюер, как правило, углубляется в момент вычисления аргументов, поведение при панике и defer в цикле — см. соответствующие вопросы выше.

Что будет, если использовать несколько деферов?

Заголовок раздела «Что будет, если использовать несколько деферов?»

Коротко. Все они выполнятся при выходе из функции в обратном порядке регистрации (LIFO). Никакого ограничения на количество нет; на корректность это не влияет, если не забывать, что каждая отложенная функция может изменить именованный результат и что паника в одной из них прервёт выполнение остальных не сразу — оставшиеся всё равно выполнятся, но паника будет распространяться дальше.

Глубже.

func main() {
defer fmt.Println("1")
defer fmt.Println("2")
defer fmt.Println("3")
}
// вывод: 3 2 1

Нюанс с паникой внутри отложенной функции: остальные (зарегистрированные раньше) отложенные вызовы всё равно выполнятся во время разворачивания, а в итоговом выводе будет цепочка паник. Нюанс с производительностью: до восьми defer-ов в функции без циклов компилятор раскрывает open-coded; больше — переходит на список записей.

Где в коде уместно бросать паники, на Ваш взгляд?

Заголовок раздела «Где в коде уместно бросать паники, на Ваш взгляд?»

Коротко. В инициализации, когда программа не может корректно стартовать (MustCompile, template.Must, sql.Register с дублем), и при нарушении внутреннего инварианта, который означает баг («недостижимая» ветка switch, nil там, где по контракту nil невозможен). В обычном рантайм-пути библиотек и в обработке пользовательского ввода — не уместно.

Глубже. Аргументация, которая звучит зрело: паника — это заявление «продолжать нельзя, состояние повреждено», поэтому её цена — весь процесс; всё, что клиент может исправить или повторить, обязано быть error. Отсюда конвенции стандартной библиотеки: пара Compile/MustCompile, template.New(...).Parse/template.Must, sync.Mutex паникует на неверном использовании (Unlock без Lock — вообще fatal error), container/list не паникует на пустом списке, а возвращает nil. Ещё уместные места: default: в исчерпывающем switch по внутреннему enum (panic(fmt.Sprintf("unknown state %v", s))), генерируемый код, тесты и init. Неуместные: валидация входных данных API, сетевые сбои, отсутствие файла, любое место в библиотеке, которое разработчик-пользователь не может проконтролировать. И отдельная граница: если вы паникуете внутри пакета ради раскрутки рекурсии, ловите панику собственного приватного типа на границе пакета — наружу паника утекать не должна.

Коротко. См. выше «Как писать свои ошибки в Go». Минимальный вариант — errors.New("...") или fmt.Errorf("...: %w", err); полноценный — свой тип с методом Error() string (обычно на указателе) и, при необходимости, Unwrap, Is, As.

Глубже. Как выбрать форму: если вызывающему нужно только отличить ситуацию — sentinel; если нужны данные (какое поле невалидно, какой id не найден, какой HTTP-код вернуть) — тип со структурой; если ошибок несколько и надо сообщить обо всех — errors.Join. Ещё одна форма — константная ошибка на строковом типе (type Error string с методом на значении), которая позволяет объявить её через const и защититься от переприсваивания. Что важно не сломать: возвращаемый тип функции должен быть error, а не конкретный тип (иначе типизированный nil); метод Error() не должен вызывать fmt.Sprintf("%v", e) от самого себя; текст — со строчной буквы и без точки.

  • Путать panic и fatal error: утверждать, что recover спасёт от concurrent map writes, дедлока или переполнения стека. Не спасёт — там даже defer не выполняется.
  • Считать, что defer выполняется всегда. os.Exit, log.Fatal и throw рантайма его пропускают.
  • Забывать, что аргументы отложенного вызова вычисляются в момент defer, и обещать, что defer fmt.Println(i) напечатает финальное значение i.
  • Рассчитывать, что recover в родительской горутине поймает панику дочерней. Паника из горутины без собственного recover валит весь процесс.
  • Вызывать recover не напрямую из отложенной функции (defer recover() или recover во вложенном хелпере) — он вернёт nil.
  • Сравнивать ошибки через == после появления %w и разбирать их через strings.Contains(err.Error(), ...) вместо errors.Is/errors.As.
  • Ставить defer f.Close() в цикле и не замечать, что дескрипторы держатся до конца функции; а для writer-ов — терять ошибку Close.
  • Возвращать конкретный тип вместо интерфейса error и получать «не-nil ошибку», которая на самом деле nil (типизированный nil).
  • Логировать ошибку и одновременно возвращать её выше — один и тот же сбой попадает в лог несколько раз с разным контекстом.
  • Использовать панику для валидации входных данных или для «выхода из глубокой функции» в публичном API.