05. Ошибки, defer, panic recover
«Ошибки — это значения. Программы манипулируют значениями, и обработка ошибок — это просто программирование, а не магия исключений».
— Роб Пайк
Архитектура отказоустойчивости: явная обработка, стек defer и раскрутка паники
В Go отсутствует механизм неявных исключений (try-catch-finally). Вместо этого рантайм предлагает прозрачную и предсказуемую модель: ошибки возвращаются как обычные значения интерфейса error, очистка ресурсов гарантируется стеком отложенных вызовов _defer, а нештатные аварийные ситуации обрабатываются через механизм panic/recover с раскруткой стековых фреймов.
flowchart TD
classDef reg fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
classDef lifo fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef exec fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
subgraph Registration["Регистрация в теле функции (сверху вниз)"]
D1["1. defer f1()"]:::reg
D2["2. defer f2()"]:::reg
D3["3. defer f3()"]:::reg
end
subgraph Stack["Связный список _defer в runtime.g (LIFO)"]
S3["Head -> defer f3()"]:::lifo
S2["Next -> defer f2()"]:::lifo
S1["Tail -> defer f1()"]:::lifo
end
subgraph Execution["Порядок выполнения при выходе (LIFO)"]
E3["1-й выполнен: f3()"]:::exec
E2["2-й выполнен: f2()"]:::exec
E1["3-й выполнен: f1()"]:::exec
end
D1 --> S1
D2 --> S2
D3 --> S3
S3 --> E3
S2 --> E2
S1 --> E1
flowchart TD
classDef panic fill:#7f1d1d,stroke:#f87171,stroke-width:2px,color:#f8fafc
classDef unwind fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
classDef recover fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
classDef fatal fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
Trigger["Вызов panic(v) или nil-pointer dereference"]:::panic --> Mark["Создание runtime._panic в текущей горутине"]:::panic
Mark --> Unwind["Раскрутка фреймов стека (Stack Unwinding)"]:::unwind
Unwind --> CheckDefer{"Есть ли отложенный defer?"}:::unwind
CheckDefer -->|"Да"| ExecDefer["Выполнение функции defer"]:::unwind
ExecDefer --> CheckRecover{"Вызван ли recover()?"}:::unwind
CheckRecover -->|"Да"| Recovered["Паника поглощена! Возврат значения v, нормализация"]:::recover
CheckRecover -->|"Нет"| NextFrame["Переход к предыдущему фрейму вызова"]:::unwind
NextFrame --> CheckDefer
CheckDefer -->|"Нет (стек исчерпан)"| Crash["Аварийное падение процесса: crash dump + exit code 2"]:::fatal
flowchart LR
classDef sentinel fill:#1e3a5f,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
classDef typed fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
classDef wrapped fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
classDef check fill:#064e3b,stroke:#34d399,stroke-width:2px,color:#f8fafc
subgraph ErrorTypes["Паттерны ошибок"]
S["Sentinel: io.EOF, sql.ErrNoRows (статические переменные)"]:::sentinel
T["Custom Typed: &PathError{...}, &QueryError{...} (структуры)"]:::typed
W["Wrapped: fmt.Errorf('context: %w', err)"]:::wrapped
end
subgraph Inspection["Проверка (Go 1.13+)"]
Is["errors.Is(err, target) -> обход Unwrap() по дереву"]:::check
As["errors.As(err, &target) -> приведение типа по дереву"]:::check
end
S --> Is
W --> Is
T --> As
W --> As
Навигатор по вопросам главы
- Что такое
errorв Go? - Почему "errors are values"?
- Как создавать ошибки (
errors.Newvsfmt.Errorf)? - Что такое оборачивание ошибок (error wrapping) с помощью
%w? - Как работает
errors.Is? - Как работает
errors.As? - Что такое сторожевые ошибки (sentinel errors)?
- Что такое кастомные типы ошибок (custom error types)?
- Что такое
defer? - Когда выполняется
defer? - В каком порядке выполняются несколько
defer? - Что происходит с аргументами
defer? - Что такое
panic? - Когда использовать
panic? - Что такое
recover? - Где работает
recover? - Почему
recoverработает только внутриdefer? - Когда
panicдопустим в production? - Что такое принцип "fail-fast"?
- Как правильно обрабатывать цепочки ошибок?
- Как логировать ошибки?
- Как добавлять контекст к ошибкам?
- Что такое
errors.Join(Go 1.20+)? - Как использовать
deferдля изменения возвращаемого значения? - Что произойдет, если
panicвозникнет вgoroutineбезrecover? - Как
recoverможет вернутьnil? - Как обрабатывать панику в горутинах, чтобы не уронить все приложение?
- Как работает
errcheckлинтер и почему он важен? - Что такое «идиоматическая обработка ошибок» в Go?
- Чем отличается обработка ошибок в Go от исключений в Java/C++?
- Как
deferвлияет на производительность в горячих циклах? - Как тестировать код с
panicиrecover? - Что такое
runtime.Goexit()и как он взаимодействует сdefer? - Как порядок выполнения
deferсвязан с потоком выполнения функции? - Можно ли использовать
recoverдля обработки ошибок в обычной логике? - Что такое "shameless panicking" и когда это уместно?
- Как реализовать паттерн "Circuit Breaker" с использованием ошибок?
- Как работает
deferс замыканиями? - Как
deferвлияет на читаемость и поддерживаемость кода? - Что происходит с
deferпри вызовеos.Exit? - Как использовать
deferдля профилирования и трассировки? - Какие линтеры помогают следить за корректной обработкой ошибок?
- Как реализовать механизм повторных попыток (retry) с использованием ошибок?
- Как работает цепочка
deferво вложенных функциях? - Можно ли изменить значение переменной, которую возвращает функция, с помощью
defer? - Почему не стоит использовать
panicдля обычного управления потоком выполнения? - Как
deferвзаимодействует сrecoverот паники в другой горутине? - Что такое "panic wrapping" в Go 1.21+?
- Как
defer,panicиrecoverобрабатываются на уровне рантайма? - Какие лучшие практики обработки ошибок существуют в микросервисах на Go?
Что такое error в Go?
Краткий ответ (30–60 секунд)
error — это встроенный интерфейс с единственным методом Error() string. Любой тип, реализующий этот метод, может использоваться как ошибка. Простота интерфейса отражает философию Go: ошибки — это обычные значения, которые передаются явно, без скрытых исключений.
Развёрнутый ответ
Интерфейс error определён в пакете builtin:
type error interface {
Error() string
}
Это минималистичный контракт, позволяющий создавать ошибки любой сложности — от простых строк до типов с дополнительными полями (например, кодами ошибок или вложенными причинами). Ошибки возвращаются из функций и проверяются явно, обычно с помощью if err != nil. Такой подход даёт полный контроль над потоком исполнения и делает обработку ошибок предсказуемой, что критически важно для надёжного production-кода.
Ключевое свойство Go‑ошибок: они являются значениями (values), а не исключениями. Это означает, что ошибку можно сохранить, передать, сравнить, обернуть, добавить контекст — как любой другой объект.
Под капотом (internals)
Интерфейс error — это двухсловная структура: указатель на таблицу методов типа и указатель на данные. При возврате nil ошибки оба слова равны нулю. var err error = (*MyError)(nil) не равно nil на уровне интерфейса: это так называемая «nil‑interface с ненулевым динамическим типом», классическая ловушка. Проверка if err != nil смотрит на само значение интерфейса, а не только на _type.
В исходниках runtime интерфейсы представлены структурой iface (или eface для пустого интерфейса, но error — непустой интерфейс, поэтому используется iface с interfacetype). Детали: src/runtime/runtime2.go.
Типичные ошибки кандидатов
-
Возврат nil‑указателя на конкретный тип вместо чистого nil интерфейса
Если объявить переменную типа*MyErrorравнойnil, а затем вернуть её какerror, интерфейс не будетnil(потому что тип определён). Исправление: явно возвращайтеnil, а не nil‑указатель.Go func f() error { var e *MyError = nil return e // НЕПРАВИЛЬНО: интерфейс != nil } // Правильно: func f() error { return nil } -
Игнорирование ошибок без комментариев
Пустое_ = doSomething()скрывает намерение. Если осознанно игнорируете ошибку, добавьте комментарий// ignore error because .... -
Множественное сравнение с sentinel‑ошибками без использования
errors.Is
После введения%wсравнивать напрямуюif err == io.EOFрискованно, если ошибка может быть обёрнута. Используйтеerrors.Is.
Возможные дополнительные вопросы интервьюера
-
Почему
error— это интерфейс, а не базовый тип?
Интерфейс позволяет создавать богатые типы ошибок с дополнительным поведением (например,Temporary() bool) без жёсткой иерархии, сохраняя обратную совместимость. -
Как
errorинтерфейс связан с дженериками?
С введением дженериков мы можем объединять ошибки с типами, напримерResult[T], реализующийerror, или использоватьerrors.Joinдля слияния нескольких ошибок без потери информации.
Почему "errors are values"?
Краткий ответ (30–60 секунд)
Это философия Go: ошибки — это не управляющие конструкции, а обычные значения. Их можно присваивать, передавать в функции, оборачивать, логировать, обрабатывать как данные. Это даёт явный поток управления и позволяет строить сложную логику обработки без магии исключений.
Развёрнутый ответ
Фраза "errors are values" подчёркивает, что в Go нет специальной системы исключений; error — такой же тип, как int или string. Это означает:
- Ошибки можно вычислять и накапливать.
- Можно создавать вспомогательные типы и функции, управляющие потоком обработки (например, пакет
errgroup). - Явная передача ошибок заставляет разработчика думать о каждом возможном сбое.
Пример ценности: конвейер обработки, в котором каждый этап может прерваться при ошибке, но при этом выполняется очистка ресурсов. Значения позволяют строить цепочки, где ошибка передаётся сквозь этапы, а defer обеспечивает освобождение.
Под капотом (internals)
Нет скрытых макроопераций: вызов err.Error() — это обычный виртуальный вызов метода интерфейса. Компилятор не делает никаких специальных предположений об error, за исключением того, что он присутствует в сигнатурах многих функций. Отсутствие try/catch и unwinding значительно упрощает анализ кода и снижает накладные расходы на вызовы, потому что не нужно сохранять контекст для потенциального прыжка.
Типичные ошибки кандидатов
-
Попытка использовать
errorкак поток управления (например, подобие исключений с повторным выбрасыванием)
Go не предоставляет механизма "поймать и перевыбросить". Вместо этого ошибку оборачивают и возвращают выше. Не нужно имитировать try/catch — применяйте явный возврат. -
Создание ошибок без контекста
"file not found" без указания имени файла — плохая практика. Контекст добавляют черезfmt.Errorfс%wили через кастомный тип.
Возможные дополнительные вопросы интервьюера
-
Не приводит ли явная проверка ошибок к дублированию кода?
Определённого дублирования не избежать, но его уменьшают общие обёртки (errgroup,errcheck), функции-хелперы и паттерн «цепочка обработки» с замыканиями. -
Как "errors are values" помогает при тестировании?
Ошибки можно легко создать, сравнить черезerrors.Is, внедрить mock-реализации, проверяющие поведение, без специальных фреймворков.
Как создавать ошибки (errors.New vs fmt.Errorf)?
Краткий ответ (30–60 секунд)
errors.New("текст") создаёт ошибку с фиксированным сообщением и указателем на уникальную переменную (sentinel). fmt.Errorf создаёт ошибку с форматированным сообщением и может оборачивать другую ошибку с помощью %w. Первый используется для статических ошибок-меток, второй — для динамических сообщений и добавления контекста.
Развёрнутый ответ
var ErrNotFound = errors.New("not found") // sentinel ошибка, можно сравнивать напрямую
func get(id int) error {
return fmt.Errorf("get user %d: %w", id, ErrNotFound) // wrapped
}
errors.New возвращает указатель на структуру errorString (с полем s string), поэтому каждое новое значение уникально по адресу. fmt.Errorf без %w создаёт также errorString, но с форматированием. С %w возвращается wrapError, содержащий внутреннюю ошибку. Не используйте %w для формирования сообщения, если не хотите разрешать errors.Is и errors.As проходить сквозь эту ошибку.
Под капотом (internals)
Исходники errors.New и fmt.Errorf в src/errors/errors.go и src/fmt/print.go. Структура errorString:
type errorString struct {
s string
}
Интерфейс реализуется на *errorString, поэтому два вызова errors.New("a") не равны друг другу через ==, т.к. представляют разные указатели. Это осознанное решение: sentinel‑ошибки определены на уровне пакета как глобальные переменные, их можно сравнивать напрямую через ==.
С %w создаётся структура wrapError:
type wrapError struct {
msg string
err error
}
методы Error(), Unwrap() и Is/As обеспечивают поддержку errors.Is и errors.As.
Типичные ошибки кандидатов
-
fmt.Errorf("..: %s", err.Error())вместо%w
Теряется возможность последующей проверкиerrors.Is. Исправление:fmt.Errorf("..: %w", err). -
Использование
%wв сообщении для конечного пользователя
%wсоздаёт обёрнутую ошибку, но сообщение всё равно попадает в лог. Если нужно отделить отладочную информацию от пользовательской, передавайте её в структуру отдельно. -
Создание глобальных ошибок через
fmt.Errorf
var ErrInvalid = fmt.Errorf("invalid")не даёт стабильного адреса для сравнения. Используйтеerrors.Newдля sentinel‑ошибок.
Возможные дополнительные вопросы интервьюера
-
Чем
errors.Newотличается отerrors.New("")в плане сравнения?
Каждый вызов даёт новый указатель, поэтому сравнение через==работает только с теми же экземплярами. Для пустой строки лучше использоватьerrors.New("")однократно в пакете. -
Как
%wвзаимодействует сUnwrap?
Unwrap()возвращает внутреннюю ошибку. Можно реализоватьUnwrap() []errorдля множественного оборачивания (Go 1.20+).
Что такое оборачивание ошибок (error wrapping) с помощью %w?
Краткий ответ (30–60 секунд)
Оборачивание позволяет сохранить исходную ошибку внутри новой, добавляя контекст. Спецификатор %w в fmt.Errorf создаёт обёртку, реализующую Unwrap() error, что даёт возможность errors.Is и errors.As проходить по цепочке. Это сохраняет observability и не теряет первопричину.
Развёрнутый ответ
До Go 1.13 приходилось либо терять контекст, либо писать громоздкие кастомные типы. Теперь fmt.Errorf("...: %w", err) создаёт прозрачную обёртку. Метод errors.Is рекурсивно вызывает Unwrap, пока не найдёт совпадение, а errors.As ищет первый подходящий тип в цепочке.
Лучшие практики:
- Оборачивайте ошибки на каждом уровне абстракции, добавляя семантический контекст (имя модуля, параметры).
- Не оборачивайте sentinel‑ошибки, если не уверены, что вызывающий должен видеть сквозь обёртку: для передачи факта ошибки иногда достаточно новой семантической ошибки.
- Если нужно объединить несколько ошибок, используйте
errors.Join(Go 1.20+), который реализуетUnwrap() []error.
Под капотом (internals)
%w обрабатывается в fmt: если аргумент реализует error, и формат — %w, создаётся wrapError. errors.Is и errors.As в src/errors/wrap.go используют рефлексию и методы Is(error) bool или As(interface{}) bool для взаимодействия с обёртками, которые могут иметь собственную логику сравнения (например, os.PathError).
Типичные ошибки кандидатов
-
fmt.Errorf("error: %v", err)вместо%w
Теряется связь с исходной ошибкой. Исправление: использовать%w, если планируется проверка ошибок выше. -
Переоборачивание без добавления контекста
return fmt.Errorf("%w", err)не даёт новой информации. Либо добавьте контекст, либо возвращайте исходную ошибку. -
Оборачивание ошибки, которая уже является обёрткой, без необходимости
Это создаёт глубокие цепочки, усложняяerrors.As. Оборачивайте там, где меняется абстракция.
Возможные дополнительные вопросы интервьюера
-
Как
errors.Isнаходит ошибку, если обёртка реализуетIs(error) bool?
Вызывается сначала методIs(err error) boolобёртки; если он возвращает true, ошибка считается найденной. Так можно сравнивать не только по равенству адресов. -
Можно ли оборачивать ошибку несколько раз с разным контекстом?
Да, например, на каждом уровне стека вызовов. Но избегайте циклических обёрток (Go это не предотвращает, ноerrors.Isможет зациклиться).
Как работает errors.Is?
Краткий ответ (30–60 секунд)
errors.Is(err, target) проверяет, есть ли в цепочке обёрнутых ошибок та, которая равна target (через ==) или удовлетворяет методу Is(error) bool целевой ошибки. Это позволяет прозрачно проверять наличие конкретной ошибки‑причины, не разворачивая цепочку вручную.
Развёрнутый ответ
Функция рекурсивно вызывает Unwrap(), пока не достигнет nil, и для каждой ошибки в цепочке выполняет:
if err == target { return true }
if x, ok := err.(interface{ Is(error) bool }); ok && x.Is(target) { return true }
Таким образом, можно как проверить совпадение с известной sentinel‑переменной, так и задать логику сравнения в кастомном типе (например, os.IsExist). Поддержка метода Is делает возможным сравнение ошибок с одинаковой семантикой, но разным сообщением.
Под капотом (internals)
src/errors/wrap.go: func Is(err, target error) bool. Используется type assertion и вызов Unwrap(). Если ошибка реализует Unwrap() []error (слайс), Is идёт по всем элементам (глубина в ширину). Важно: Is не вызывает As, он предназначен только для точного совпадения либо через Is.
Типичные ошибки кандидатов
-
Сравнение напрямую
if err == ErrNotFound, когда ошибка может быть обёрнута
Такое сравнение не пройдёт сквозь%w. Используйтеerrors.Is(err, ErrNotFound). -
Реализация
Isбез обработки собственной логики сравнения, просто возвращающаяerr == target
Это избыточно —errors.Isуже делает это. РеализовывайтеIsтолько если нужно сравнение, не сводящееся к==. -
Зацикливание из-за неправильной
Unwrapна себя
Приводит к бесконечной рекурсии. Проверяйте, чтоUnwrapрано или поздно вернётnil.
Возможные дополнительные вопросы интервьюера
-
Как
errors.Isработает сerrors.Join?
errors.Joinвозвращает ошибку, реализующуюUnwrap() []error,Isперебирает все срезанные ошибки. -
Можно ли использовать
errors.Isдля проверки типа ошибки?
Нет,Isсравнивает на равенство, а не на тип. Для типа используйтеerrors.As.
Как работает errors.As?
Краткий ответ (30–60 секунд)
errors.As(err, target) ищет в цепочке обёрнутых ошибок ту, которая может быть присвоена в переменную, на которую указывает target (не-nil указатель на тип ошибки). При успехе target обновляется и возвращается true. Это аналог приведения типа, но с обходом обёрток.
Развёрнутый ответ
var pathErr *os.PathError
if errors.As(err, &pathErr) {
// pathErr теперь содержит конкретную ошибку
}
target должен быть указателем на интерфейс или на тип, реализующий error. As рекурсивно вызывает Unwrap() и для каждой ошибки проверяет reflectlite.TypeOf(err).AssignableTo(targetType). Если ошибка реализует As(interface{}) bool, вызывается этот метод, что позволяет кастомным обёрткам напрямую записать результат в target.
Под капотом (internals)
src/errors/wrap.go: func As(err error, target interface{}) bool. Используется reflectlite для безопасных операций над типами без полного пакета reflect. Если target не является ненулевым указателем на тип, реализующий error, As паникует.
Типичные ошибки кандидатов
-
Передача
targetкак*MyErrorбез взятия адреса
errors.As(err, pathErr)не скомпилируется — необходимо&pathErr. Ошибка времени компиляции. -
Путаница с
errors.Is: попытка найти тип черезIs
Isпроверяет только равенство, а не тип. Для извлечения значения используйтеAs. -
Игнорирование возвращаемого значения
errors.Asи использование неинициализированногоtarget
ЕслиAsвернулаfalse,targetне изменяется (остаётся nil). Всегда проверяйтеok.
Возможные дополнительные вопросы интервьюера
-
Можно ли извлечь ошибку‑строку с помощью
errors.As?
Нет, базовый тип*errorStringявляется неэкспортируемым. Извлечь можно только интерфейс с методомError(), но это не даст доступа к строке. Приходится проверять поведение черезIs. -
Что делает кастомный метод
Asв обёртке?
Позволяет напрямую назначить целевую переменную, возможно, извлекая ошибку из сложной структуры без последовательногоUnwrap.
Что такое сторожевые ошибки (sentinel errors)?
Краткий ответ (30–60 секунд)
Sentinel errors — это предопределённые переменные уровня пакета (например, io.EOF), используемые для сигнализации конкретных состояний сравнением == или errors.Is. Они являются частью публичного API пакета и служат меткой для вызывающего кода.
Развёрнутый ответ
Sentinel error обычно создаётся как var ErrSomething = errors.New("something"). Вызывающий может проверить if errors.Is(err, pkg.ErrSomething). Они должны быть частью контракта функции. Не создавайте sentinel‑ошибки динамически, иначе Is не сработает. Рекомендуется документировать, какие sentinel‑ошибки могут быть возвращены.
Sentinel errors хороши для постоянных состояний (нет ресурса, конец потока), но злоупотребление ими приводит к жёсткой связанности пакетов. Альтернатива — поведенческие интерфейсы (например, net.Error с Timeout()).
Под капотом (internals)
Sentinel‑ошибка — это просто глобальная переменная типа *errorString. Поскольку errorString не экспортируется, из внешнего пакета можно только сравнить с ней, но не создать. Компилятор не делает различий; это чисто идиоматический паттерн.
Типичные ошибки кандидатов
-
Динамическое создание sentinel‑ошибок
func Err(msg string) error { return errors.New(msg) }– каждый вызов возвращает новый указатель,errors.Isне найдёт. Используйте глобальные переменные. -
Использование sentinel‑ошибки для передачи динамических данных
Sentinel предназначен только для сигнала. Данные передаются кастомным типом. -
Изменяемые sentinel‑ошибки
Не изменяйте глобальную переменную после инициализации — это сломает сравнение и thread‑safety.
Возможные дополнительные вопросы интервьюера
-
Чем sentinel ошибка лучше кастомного типа?
Простотой проверкиerrors.Isбез извлечения данных. Но она не несёт богатого контекста. -
Как совместить sentinel и оборачивание?
Обернуть sentinel через%w, тогдаerrors.Isпройдёт сквозь обёртку и найдёт оригинал.
Что такое кастомные типы ошибок (custom error types)?
Краткий ответ (30–60 секунд)
Кастомный тип ошибки — это любая структура, реализующая error и, возможно, дополнительные методы (например, Temporary() bool или StatusCode() int). Они позволяют передать богатый контекст и классифицировать ошибки по поведению, а не по строковому сообщению.
Развёрнутый ответ
Пример:
type ValidationError struct {
Field string
Value interface{}
Err error
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation of %s: %v", e.Field, e.Err)
}
func (e *ValidationError) Unwrap() error { return e.Err }
Вызывающий может применить errors.As для извлечения *ValidationError и анализа полей. Кастомные типы позволяют реализовывать интерфейсы поведения, например, interface{ GRPCStatus() *status.Status } для gRPC.
Под капотом (internals)
Кастомный тип — это обычный тип, хранящийся в iface. errors.As использует рефлексию чтобы проверить, совместим ли тип с целевым. Трюк: если кастомная ошибка обёрнута через %w, As всё равно пройдёт к ней через цепочку Unwrap.
Типичные ошибки кандидатов
-
Отсутствие
Unwrap, что ломает цепочку
Если вы оборачиваете ошибку в кастомный тип, реализуйтеUnwrap() error, чтобыerrors.Isиerrors.Asмогли дойти до причины. -
Использование кастомного типа как sentinel (сравнение через
==)
Структуры с полями не сравнимы через==, если содержат несравнимые поля. Используйтеerrors.Asдля проверки типа, а не равенства. -
Перегрузка
Error()сложной логикой
МетодErrorможет вызываться часто; он должен быть быстрым и не аллоцировать тяжело.
Возможные дополнительные вопросы интервьюера
-
Как скрыть внутренние детали ошибки от пользователя, но сохранить для отладки?
Можно реализовать интерфейсLoggableс методомLogMessage() stringдля безопасного логирования, аError()возвращать общее сообщение. -
Почему лучше использовать поведенческие интерфейсы вместо кастомных типов?
Интерфейсы уменьшают связанность пакетов. Вызывающий проверяетif te, ok := err.(Temporary); ok { ... }, не импортируя конкретный тип.
Что такое defer?
Краткий ответ (30–60 секунд)
defer — ключевое слово Go, откладывающее выполнение вызова функции до момента возврата из окружающей функции. Используется для освобождения ресурсов (закрытие файлов, анлок мьютексов), а также для перехвата паники через recover.
Развёрнутый ответ
defer гарантирует, что отложенный вызов выполнится независимо от того, как функция завершилась (нормально или через панику). Аргументы вычисляются немедленно, но сам вызов откладывается. Несколько defer складываются в стек (LIFO). defer работает на уровне функции, а не блока кода.
Паттерны: закрытие ресурса сразу после открытия, захват состояния в начале функции (например, профилирование), изменение возвращаемого значения через именованный результат.
Под капотом (internals)
Каждый defer компилируется в вызов runtime.deferproc (или deferprocStack для стека горутины), который помещает запись в структуру _defer в g._defer (связный список, начало у горутины). При возврате из функции runtime.deferreturn вызывает их в обратном порядке. В Go 1.13+ для простых случаев используется динамическое выделение на стеке через deferprocStack, что ускоряет выполнение.
Типичные ошибки кандидатов
-
Использование
deferв цикле для закрытия ресурсов внутри итерации без замыкания
defer f.Close()в цикле накапливает отложенные вызовы до конца функции, что может привести к исчерпанию файловых дескрипторов. Исправление: обернуть итерацию в функцию. -
Ожидание, что
deferвыполнится приos.Exit
os.Exitнемедленно завершает программу,deferне вызывается. -
Захват переменной из цикла в отложенном замыкании (баг ранних версий Go)
Начиная с Go 1.22 переменная цикла заново создаётся на каждой итерации, так что проблема менее остра, но с версиями младше надо быть осторожным: передавать через параметр.
Возможные дополнительные вопросы интервьюера
-
Как
deferвлияет на производительность?
Накладные расходы минимальны: от 10 до 35 нс для простого случая за счёт inline‑оптимизаций. В горячих циклах может быть заметно. -
Можно ли отменить выполнение
defer?
Нет. Единственный способ — не доходить до возврата функции (например,os.Exit).
Когда выполняется defer?
Краткий ответ (30–60 секунд)
Отложенный вызов исполняется непосредственно перед тем, как функция передаёт управление вызывающему коду: при выполнении return, достижении конца функции или при начале раскрутки паники. Аргументы вычисляются в момент объявления defer.
Развёрнутый ответ
Порядок: сначала вычисляются значения возвращаемых результатов (если они именованные, они могут быть изменены defer), затем выполняются все отложенные вызовы в порядке LIFO, и только после этого управление возвращается. Если случилась паника, recover в defer может остановить раскрутку.
Под капотом (internals)
Компилятор вставляет вызов deferreturn перед каждым RET инструкцией. Когда функция имеет defer, её пролог регистрирует необходимость очистки через deferproc. При возврате deferreturn выполняет записи, удаляя их из списка горутины.
Типичные ошибки кандидатов
-
Утверждение, что
deferвыполняется сразу после выхода из блока (как в некоторых языках)
deferпривязан к функции, а не к блокуif/for. Это фундаментальное отличие. -
Полагаться на порядок между возвратом значений и
deferбез именованных результатов
Если возвращаемые значения не именованы,deferработает с копией, не влияя на реальный результат.
Возможные дополнительные вопросы интервьюера
-
Что будет, если внутри
deferизменить возвращаемую переменную?
При именованном возврате (например,func foo() (result int))deferможет изменитьresult, и это отразится на возвращаемом значении. -
Может ли
deferизменить возвращаемую ошибку послеreturn err?
Да, если именованный параметрerr,deferможет присвоить ему новое значение, и функция вернёт изменённое.
В каком порядке выполняются несколько defer?
Краткий ответ (30–60 секунд)
В порядке LIFO (Last In, First Out) — последний добавленный defer исполняется первым. Это похоже на стек: каждый новый defer помещается на вершину, а при возврате извлекается в обратном порядке.
Развёрнутый ответ
Пример:
defer fmt.Println("1") // помещается первым
defer fmt.Println("2") // помещается вторым
defer fmt.Println("3") // помещается третьим
// Вывод: 3 2 1
LIFO логически оправдана: ресурсы должны освобождаться в порядке, обратном захвату. Это напоминает схему вложенных операций.
Под капотом (internals)
Записи _defer формируют односвязный список, голова которого хранится в g._defer. Новый defer добавляется в начало списка. При выполнении deferreturn идёт по списку с головы, обрабатывая и удаляя записи, пока не дойдёт до той, которая соответствует текущей функции (защита от вложенных функций).
Типичные ошибки кандидатов
-
Недооценка влияния LIFO на логику освобождения мьютексов
Если мьютексы захватываются в порядке A, B, то освобождаться должны B, A. LIFO автоматически так и делает, если defer объявлены сразу после Lock. -
Смешивание нескольких
deferс возвратом ошибки между ними
Порядок всё равно LIFO, всеdeferвыполнятся при любомreturn.
Возможные дополнительные вопросы интервьюера
-
Как узнать, сколько
deferактивно в функции?
Черезruntime.Stackили отладчик, но семантически полагаться на это не стоит. -
Могут ли несколько
deferвнутри цикла нарушить порядок?
Нет, они просто добавляются в список в порядке итераций, но выполнятся все после выхода из функции в обратном порядке.
Что происходит с аргументами defer?
Краткий ответ (30–60 секунд)
Аргументы отложенного вызова вычисляются в момент выполнения оператора defer, а не в момент вызова. Это значит, что если аргумент — переменная, изменённая позже, в defer попадёт захваченное значение на момент объявления.
Развёрнутый ответ
func demo() {
x := 1
defer fmt.Println(x) // захватывается 1
x = 2
// Выведет: 1
}
Если defer — замыкание, то замыкание захватывает ссылку на переменную, и её значение будет прочитано на момент выполнения:
defer func() { fmt.Println(x) }() // выведет 2
Понимание этого различия критично для корректной работы с ресурсами и итераторами.
Под капотом (internals)
При компиляции defer f(a) аргумент a сохраняется в структуру _defer рядом с указателем на функцию. Если это замыкание без аргументов, то в _defer сохраняется указатель на замыкание, а переменная захватывается через замыкание по ссылке (указатель на копию или оригинал в зависимости от того, была ли переменная перемещена в кучу).
Типичные ошибки кандидатов
-
Попытка передать в defer результат вызова, который нужно выполнить позже
defer f(time.Now())вычислит время сразу, а не в момент завершения. Для отложенного вычисления используйте замыкание:defer func() { f(time.Now()) }(). -
Захват итератора цикла в defer (в версиях до 1.22)
for i := 0; i < 3; i++ { defer func() { fmt.Println(i) }() }напечатает 3,3,3. Решение: передать i как параметр функции:defer func(i int) { fmt.Println(i) }(i).
Возможные дополнительные вопросы интервьюера
-
Как defer влияет на escape analysis?
Если переменная захвачена замыканием в defer, она обычно сбегает в кучу. -
Можно ли в defer модифицировать значение переменной, переданной как аргумент?
Только если это указатель. Для*xdefer запоминает указатель, и изменение по указателю в отложенной функции отразится на оригинале.
Что такое panic?
Краткий ответ (30–60 секунд)
panic — это встроенная функция, останавливающая нормальное выполнение текущей горутины и запускающая раскрутку стека с выполнением всех отложенных defer. Если паника не обработана recover, горутина завершается аварийно и программа падает.
Развёрнутый ответ
panic принимает один аргумент любого типа, который становится "значением паники". Это значение можно получить через recover. Паника предназначена для неисправимых ошибок программирования (нарушение инвариантов, разыменование nil‑указателя), но не для обычной обработки ошибок, таких как неверный пользовательский ввод.
Под капотом (internals)
Вызов panic переходит в runtime.gopanic (src/runtime/panic.go). Происходит:
- Создание записи паники.
- Цикл по
_deferсписку горутины, выполнение всехdefer. - Если
recoverвызываетruntime.gorecoverи возвращает значение паники,gopanicпереходит к восстановлению. - Иначе выводится сообщение о панике и полный дамп стека, программа завершается через
runtime.fatalpanic.
Типичные ошибки кандидатов
-
Использование
panicвместо возврата ошибки для обработки некорректных входных данных
Это делает код хрупким и не соответствует идиомам Go. Только для критических ошибок. -
Запуск горутины без защиты от паники
Паника в горутине безrecoverвалит всю программу. Все горутины верхнего уровня должны иметьrecover.
Возможные дополнительные вопросы интервьюера
-
Можно ли перехватить панику в другой горутине?
Нет, паника раскручивает стек только своей горутины. Другая горутина не может её поймать. Нужно предусмотретьrecoverвнутри той же горутины. -
Чем
panic(nil)отличается от обычной ошибки?
recoverвозвращаетnil, но это не паника сnil— на самом делеpanic(nil)не идентичен отсутствию паники. Лучше не использоватьnil.
Когда использовать panic?
Краткий ответ (30–60 секунд)
panic следует использовать только для сигнализации невосстановимых ошибок программирования (багов): нарушение инвариантов, неверное использование библиотеки (например, sync.Mutex.Unlock() на разлоченном мьютексе), отказ при инициализации программы. Для ожидаемых ошибок времени выполнения всегда возвращайте error.
Развёрнутый ответ
Примеры оправданного panic:
- Инициализация глобальных переменных:
regexp.MustCompileпаникует при невалидном паттерне, потому что без этого программа не может работать. - Ситуации, когда дальше выполнение невозможно и безопаснее упасть:
nilуказатель у обязательного параметра. - Тесты и бенчмарки:
t.Fatalвызываетpanicвнутри, но это допустимо.
Правило: если функция не может выполнить свою работу из-за ошибки программиста, — паникуйте. Если из-за внешних условий — возвращайте error.
Под капотом (internals)
Никаких специальных оптимизаций для panic нет — это тяжёлая операция раскрутки стека и выполнения всех defer. Поэтому его использование в горячих путях неприемлемо.
Типичные ошибки кандидатов
-
Паника при валидации HTTP запроса
Валидация ожидаема; возвращайтеerrorи отвечайте 400 кодом. -
Паника при закрытии уже закрытого канала или nil‑канала
Это допустимо в некоторых библиотеках, но в production лучше контролировать состояние канала и возвращать ошибку. -
Отсутствие
recoverв горутинах, запускаемых из библиотеки
Библиотеки не должны ронять приложение. Используйте внутреннийrecoverс логированием.
Возможные дополнительные вопросы интервьюера
-
Как разграничить "ожидаемые" и "неожиданные" ошибки?
Используйте контракт: если ошибка может возникнуть при корректном использовании, этоerror; если только при баге в коде —panic. -
Можно ли использовать
panicдля выхода из глубокой рекурсии?
Можно, но это антипаттерн. Лучше передаватьerrorили использовать контекст отмены.
Что такое recover?
Краткий ответ (30–60 секунд)
Встроенная функция recover возвращает значение, переданное в panic, и останавливает раскрутку стека, восстанавливая нормальное выполнение. Вызывается только внутри отложенной функции (defer). Если паники не было, recover возвращает nil.
Развёрнутый ответ
recover позволяет горутине пережить панику и продолжить выполнение после defer-функции. После успешного recover выполнение продолжается с точки вызова defer, а оставшиеся defer всё равно выполняются. Без recover паника приводит к краху программы.
Пример идиомы:
func safeCall() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
mayPanic()
return nil
}
Под капотом (internals)
recover — это вызов runtime.gorecover (src/runtime/panic.go). Он проверяет, выполняется ли раскрутка паники для текущей горутины, и возвращает аргумент паники. После этого gopanic переводит горутину в состояние recovered и продолжает выполнение. Важно: recover нужно вызывать непосредственно в defer-функции, а не из вложенного вызова, потому что он полагается на фрейм стека того defer, который обрабатывает панику.
Типичные ошибки кандидатов
-
Вызов
recoverвнеdeferили напрямую в функции безdefer
recoverвне контекста паники вернётnilи не остановит раскрутку. Всегдаdefer func() { if r := recover()... }(). -
Заворачивание
recoverв отдельную функцию, вызываемую изdefer
Это не сработает:recoverдолжен быть внутри той же функции, которая была отложена. Если обернуть, контекст паники будет уже не тот. -
Использование
recoverкак инструмента управления потоком
Это злоупотребление, затрудняет отладку и понимание кода. Только для критических защит.
Возможные дополнительные вопросы интервьюера
-
Можно ли восстановиться после
runtime.Goexit?
runtime.Goexit()не генерирует панику, а просто завершает горутину, выполняяdefer.recoverтам не сработает. -
Что вернёт
recover, еслиpanic(nil)?
recoverвернётnil. Такое поведение может скрыть факт паники, поэтому не используйтеpanic(nil).
Где работает recover?
Краткий ответ (30–60 секунд)
recover должен вызываться строго внутри отложенной функции (defer) и в той же горутине, где произошла паника. Любой другой контекст (вне defer, в другой горутине, в обёртке, вызванной из defer) делает recover бесполезным.
Развёрнутый ответ
defer func() {
// recover работает здесь
if r := recover(); r != nil { ... }
}()
Если recover вызван вложенно: defer myRecover() где myRecover делает recover() — не сработает, потому что recover не видит активной паники (она обрабатывается контекстом defer, который вызвал myRecover). Непосредственно внутри defer func() — обязательно.
Под капотом (internals)
runtime.gorecover ищет запись паники, связанную с текущей горутиной. Она активна только во время выполнения defer — после выхода из defer запись паники помечается как восстановленная, и последующие recover вернут nil.
Типичные ошибки кандидатов
-
Попытка глобального перехвата паник с помощью обёртки над
http.Handler
В обработчике HTTP паника ловится встроенным middleware, но если вы пишете свою обёртку,recoverдолжен быть непосредственно в defer обработчика, а не в вызываемой им функции. -
Использование
recoverв финальном блоке другой функции
Выносdeferв отдельную функцию разрушает связь.
Возможные дополнительные вопросы интервьюера
-
Что случится, если вызвать
recoverпосле завершения паники в той же горутине?
Вернётnil, никакой ошибки не будет, паника уже остановлена. -
Можно ли
recoverв функции, вызванной изdefer(не напрямую)?
Нет, как объяснено выше.
Почему recover работает только внутри defer?
Краткий ответ (30–60 секунд)
Потому что recover ищет активную панику в текущем вызове defer, который обрабатывается во время раскрутки стека. Без defer паника либо ещё не началась, либо уже отработана, и recover не может её перехватить. Дизайн гарантирует, что восстановление возможно только в фазе очистки, сохраняя структурную целостность.
Развёрнутый ответ
Механизм паники в Go поэтапный: сначала выполняется последовательность defer-функций, каждая из которых может попытаться восстановиться. recover доступен только внутри этих отложенных вызовов, потому что только они обладают привилегированным знанием о том, как восстановить инварианты. Если бы recover был доступен произвольно, можно было бы обойти критическую очистку.
Под капотом (internals)
Когда происходит panic, runtime устанавливает флаг _panic в структуре горутины и начинает обход _defer. При вызове каждой отложенной функции контекст паники "передаётся" в этот стековый фрейм. gorecover проверяет, что текущий фрейм соответствует активной панике; если нет — возвращает nil. После выполнения defer паника либо восстанавливается, либо продолжает раскрутку.
Типичные ошибки кандидатов
-
Утверждение, что
recoverможно вызвать в любой момент междуpanicи выходом из функции
Безdeferэто не так; раскрутка стека не даёт "приостановить" выполнение произвольно. -
Неправильное ожидание, что
recoverвinit()безdeferсработает
Паника вinitроняет программу, так какinitне вызывается в отложенном контексте, способном перехватить.
Возможные дополнительные вопросы интервьюера
-
Может ли
recoverвdeferвернуть ошибку, которая позже обработана?
Да, можно превратить панику в ошибку, как в примере выше. -
Почему нельзя просто использовать try/catch как в Java?
Go выбрал явную обработку ошибок, чтобы повысить читаемость и избежать скрытых потоков управления.defer/recoverдостаточно для краевых случаев.
Когда panic допустим в production?
Краткий ответ (30–60 секунд)
Допустим в строго ограниченных ситуациях: нарушение инвариантов, указывающих на баг (например, недостижимая ветка, некорректное состояние внутренней структуры), критический сбой при старте приложения, проблемы с обязательными зависимостями, а также для немедленного прерывания тестов. В остальных случаях возвращайте error.
Развёрнутый ответ
Примеры допустимого использования в продакшене:
regexp.MustCompileдля статических регулярных выражений — если регулярка невалидна, сервис не должен стартовать.- Паника внутри
sync.Mutex.Unlock()при повторном разблокировании — это баг в коде. log.Fatalв крайнем случае, если логирование не срабатывает, но лучше аварийное завершение с информативным сообщением.
Ключевой критерий: без падения программа могла бы продолжить работу в некорректном состоянии, что хуже, чем сам крах. Паника предпочтительна для fail‑fast философии.
Под капотом (internals)
Паника в production вызывает дамп стека и перезапуск процесса оркестратором (например, Kubernetes). Это приемлемо, потому что современные системы рассчитаны на отказ экземпляров. Но это дорогой способ сигнализации: лучше использовать error и graceful shutdown.
Типичные ошибки кандидатов
-
Паника при таймауте БД или недоступности внешнего сервиса
Это ожидаемые transient ошибки, их надо возвращать какerrorи пытаться retry. -
Использование
panicдля выхода из вложенных циклов
Это аналогgoto, разрушает структуру. Используйтеbreakс метками или возврат ошибки. -
Запуск горутин без
recoverв production
Каждая горутина, запущенная сервером, должна иметь верхнеуровневыйdefer/recover, чтобы единичная паника не убила весь процесс.
Возможные дополнительные вопросы интервьюера
-
Как мониторить паники в production?
Использовать middleware восстановления, метрики (счётчик паник), Sentry/DataDog, логирование с трассировкой. -
Что использовать вместо
panicдля контролируемого аварийного завершения?
log.Fatalилиos.Exitдля немедленного остановки, или graceful shutdown через сигналы ОС.
Что такое принцип "fail-fast"?
Краткий ответ (30–60 секунд)
Принцип fail‑fast требует, чтобы программа немедленно сигнализировала об ошибке или некорректном состоянии, как только оно обнаружено, вместо того чтобы продолжать работу с возможно повреждёнными данными. В Go это выражается в ранних проверках и явном возврате ошибок или panic при нарушении инвариантов.
Развёрнутый ответ
Пример: при загрузке конфигурации, если файл отсутствует или невалиден, лучше упасть при старте, чем через час получить загадочную ошибку. В функциях валидации входных параметров сразу возвращаем ошибку. panic применяется, если нарушение свидетельствует о баге, который должен быть исправлен немедленно.
Конкретные техники:
- Проверка предусловий в начале публичной функции.
- Быстрый возврат
if err != nil { return err }. - Использование
Must-функций для статической инициализации.
Под капотом (internals)
Fail‑fast не имеет специальной поддержки в рантайме, это архитектурный принцип. Ранние возвраты избегают лишней работы и сложного состояния. Быстрое падение упрощает отладку: дамп стека показывает точное место нарушения.
Типичные ошибки кандидатов
-
Игнорирование ошибок и продолжение выполнения с нулевыми значениями
Это маскирует проблему. Необходимо проверить и вернуть ошибку. -
Излишняя толерантность: попытка угадать корректное поведение вместо отказа
Приводит к непредсказуемым багам. Лучше явно сообщить об ошибке.
Возможные дополнительные вопросы интервьюера
-
Как balance между fail‑fast и отказоустойчивостью?
Разделяйте ожидаемые ошибки (retry) и ошибки программирования (fail‑fast). Используйте паттерны вроде Circuit Breaker для внешних сбоев. -
Чем fail‑fast отличается от defensive programming?
Defensive programming предполагает обработку любых входных данных; fail‑fast — немедленное прекращение при явных нарушениях контракта.
Как правильно обрабатывать цепочки ошибок?
Краткий ответ (30–60 секунд)
Каждый уровень добавляет контекст через fmt.Errorf с %w, а на верхнем уровне выполняет проверку с помощью errors.Is или errors.As. Не следует вынимать строковое сообщение внутренней ошибки; нужно пропускать ошибку сквозь обёртки, сохраняя возможность программного анализа цепочки.
Развёрнутый ответ
Паттерн "цепочечной обработки":
func service() error {
if err := repo.Save(data); err != nil {
return fmt.Errorf("service: save failed: %w", err)
}
return nil
}
На уровне HTTP‑обработчика:
if errors.Is(err, ErrNotFound) {
http.Error(w, "not found", 404)
return
}
При множественных ошибках используйте errors.Join для объединения без потери отдельных причин.
Под капотом (internals)
errors.Is и errors.As рекурсивно следуют по Unwrap(). errors.Join создаёт joinError, реализующий Unwrap() []error. Это позволяет анализировать несколько независимых ошибок. В Go 1.20+ Unwrap() []error поддерживается и errors.Is, errors.As.
Типичные ошибки кандидатов
-
Разрыв цепочки через
err.Error()и создание новой ошибки
Теряется возможность идентификации исходной причины. -
Оборачивание ошибок на каждом уровне без добавления новой информации
return fmt.Errorf("%w", err)бесполезен; либо возвращайте исходную ошибку, либо добавляйте контекст. -
Использование
errors.Isдля проверки типа вместоerrors.As
Приводит к упущению возможности извлечь данные.
Возможные дополнительные вопросы интервьюера
-
Что делать, если нужно отправить пользователю понятное сообщение, но сохранить внутреннюю ошибку?
Используйте кастомный тип с полемUserMessageи методомError, возвращающим безопасное сообщение, аUnwrapвозвращает внутреннюю ошибку для логирования. -
Как организовать цепочку ошибок в микросервисах?
Оборачивайте, добавляя идентификатор сервиса, trace id и код ошибки, сохраняя первопричину дляerrors.Is.
Как логировать ошибки?
Краткий ответ (30–60 секунд)
Логировать ошибку нужно один раз — на верхнем уровне стека вызовов, например в HTTP‑обработчике, — с добавлением контекста (параметры запроса, trace id). Логируйте с уровнем Error или Warn. Используйте структурированные логгеры, поля error или err для метаданных. Избегайте логирования и повторного выбрасывания одной и той же ошибки.
Развёрнутый ответ
Пример с zerolog:
if err != nil {
log.Error().Err(err).Str("user_id", id).Msg("could not get user")
return err
}
Ошибка должна передаваться выше для окончательной обработки. Логирование на промежуточных слоях допустимо только при добавлении существенного контекста или если ошибка обрабатывается (fallback). Для отладки используйте %+v в логах, чтобы вывести цепочку, если кастомный тип реализует fmt.Formatter.
Под капотом (internals)
Структурированные логгеры (zap, zerolog) обходят цепочку ошибок через errors API и собирают сообщения. При использовании %+v с wrapError вывод включает стек (если добавлен через github.com/pkg/errors, но в стандартном пакете стек не сохраняется).
Типичные ошибки кандидатов
-
Логирование и возврат ошибки на каждом уровне
Результат: дублирование записей, шум. Логируйте один раз на границе. -
Логирование
err.Error()вместо передачи структурированной ошибки
Теряется контекст и возможность фильтрации по типу ошибки. -
Логирование с уровнем Info для ошибок
Ошибки должны быть как минимум Warn или Error, чтобы их можно было мониторить.
Возможные дополнительные вопросы интервьюера
-
Как логировать ошибки в асинхронных обработчиках?
В горутине верхнего уровня обязательноrecoverи логирование с идентификатором задачи, без возврата. Используйте контекст для передачи trace. -
Как бороться с тем, что ошибка может содержать чувствительные данные?
РеализуйтеSafeLog()метод или используйте кастомный тип, скрывающий данные вError(), но раскрывающий вLogError().
Как добавлять контекст к ошибкам?
Краткий ответ (30–60 секунд)
Контекст добавляется оборачиванием с помощью fmt.Errorf("описание: %w", err), либо через кастомный тип ошибки с дополнительными полями. Контекст должен содержать семантическую информацию: что пытались сделать, с какими параметрами, идентификатор транзакции. Это помогает при отладке, не разрывая цепочку.
Развёрнутый ответ
Примеры:
if err != nil {
return fmt.Errorf("reading config file %s: %w", filename, err)
}
Для обогащения типа можно создать обёртку:
type withContext struct {
ctx string
err error
}
func (w *withContext) Error() string { return w.ctx + ": " + w.err.Error() }
func (w *withContext) Unwrap() error { return w.err }
Важно: добавляйте контекст, отражающий намерение операции, а не механизм (не "ошибка чтения файла", а "не удалось загрузить конфигурацию").
Под капотом (internals)
fmt.Errorf("%w") порождает wrapError с полем msg. Unwrap возвращает внутреннюю ошибку. Стек вызовов не сохраняется, но можно использовать pkg/errors для добавления стека при необходимости (хотя идиоматический Go рекомендует полагаться на логирование и контекст).
Типичные ошибки кандидатов
-
Добавление контекста через
"%s: " + err.Error()
ТеряетсяUnwrap, проверкиerrors.Isне работают. -
Контекст на каждом уровне без новой информации
Создаёт шумные сообщения типа "service: save: db: insert: connection refused". Лучше объединить в осмысленную цепочку. -
Использование
%wс несколькими ошибками (до 1.20)
%wпринимает ровно одну ошибку; для нескольких используйтеerrors.Join.
Возможные дополнительные вопросы интервьюера
-
Как добавить контекст, не изменяя публичный API ошибки?
Можно обернуть, сохранив поведение методов через встраивание или делегированиеUnwrap. -
Чем контекст отличается от "error wrapping"?
Wrapping — это механизм сохранения связи; контекст — это информация, добавляемая при оборачивании.
Что такое errors.Join (Go 1.20+)?
Краткий ответ (30–60 секунд)
errors.Join объединяет несколько ошибок в одну, которая представляет все переданные ошибки. Результирующая ошибка реализует Unwrap() []error, позволяя errors.Is и errors.As искать по всем элементам. Это замена ручному сбору слайса ошибок.
Развёрнутый ответ
err := errors.Join(err1, err2, nil) // nil игнорируются
if errors.Is(err, io.EOF) {
// true, если любая из err1/err2 равна io.EOF
}
Полезна при валидации нескольких полей или сборе результатов параллельных операций. Join никогда не возвращает nil, если хотя бы одна ошибка не-nil; если все nil, вернёт nil. Это безопасно для дальнейших проверок.
Под капотом (internals)
errors.Join (реализация в src/errors/join.go) создаёт неэкспортируемый тип joinError, содержащий слайс ошибок. Метод Error() конкатенирует сообщения через \n. Is и As перебирают слайс. Если все ошибки nil, возвращается nil напрямую.
Типичные ошибки кандидатов
-
Проверка на
nilчерез==для результатаJoin
Joinможет вернутьnil, но если есть хоть одна ошибка, результат не‑nil. Используйтеif err != nil. -
Использование
Joinдля оборачивания одной ошибки
errors.Join(err)просто вернётerr; не добавляет контекста. Для оборачивания одной ошибки с контекстом —fmt.Errorf. -
Ожидание, что
Joinвыполняет сортировку или дедупликацию ошибок
Это не так; он просто сохраняет переданный список.
Возможные дополнительные вопросы интервьюера
-
Как извлечь все ошибки из
Join?
ЧерезUnwrap() []error:if e, ok := err.(interface{ Unwrap() []error }); ok { ... }. -
Как
errors.Isвзаимодействует сJoin, если одна из ошибок — тожеJoin?
Isрекурсивно раскрывает всеUnwrap() []errorв ширину, что позволяет находить ошибки глубоко.
Как использовать defer для изменения возвращаемого значения?
Краткий ответ (30–60 секунд)
Используя именованные результаты, можно в отложенной функции присвоить новое значение возвращаемой переменной после всех вычислений. Это позволяет перехватывать панику в ошибку или модифицировать результат.
Развёрнутый ответ
func compute() (result int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
result = defaultVal // если хотим
}
}()
// основная логика, может вернуть result, err
return doWork()
}
Также можно изменить результат в обычном сценарии:
func timing() (duration time.Duration) {
start := time.Now()
defer func() { duration = time.Since(start) }()
// работа
return // автоматически присвоит duration = time.Since(start)
}
Под капотом (internals)
Именованные результаты находятся в стеке вызываемой функции и доступны defer-функциям через замыкание. При return значения сначала вычисляются в регистры/стек, затем выполняются defer, которые могут изменить именованные переменные прямо в стеке, после чего эти значения возвращаются вызывающему коду.
Типичные ошибки кандидатов
-
Попытка изменить неименованный возвращаемый параметр
func f() int { defer func() { /* не получится изменить возвращаемое значение */ }() }— нужно использовать именованный результат. -
Изменение результата в defer без учёта порядка LIFO
Если несколько defer изменяют одну переменную, побеждает последний выполненный (т.е. первый задекларированный). Понимайте порядок. -
Прямое присвоение err внутри defer без проверки, что оригинальная ошибка тоже важна
Можно случайно затереть полезную ошибку. Лучше обернуть.
Возможные дополнительные вопросы интервьюера
-
Как defer влияет на возврат интерфейса ошибки?
Если именованный результатerr errorи defer присваивает ему конкретный тип, может возникнуть nil‑интерфейсная ловушка, если исходная функция возвращала nil, а defer присвоил(*MyError)(nil). Будьте осторожны. -
Можно ли таким образом изменить несколько возвращаемых значений?
Да, все именованные одновременно доступны.
Что произойдет, если panic возникнет в goroutine без recover?
Краткий ответ (30–60 секунд)
Паника в такой горутине вызовет раскрутку стека этой горутины и, не встретив recover, приведёт к аварийному завершению всей программы с дампом стека. defer в горутине выполнятся, но глобально процесс упадёт.
Развёрнутый ответ
Go не изолирует панику в рамках горутины. Неловящаяся паника в любой горутине приводит к вызову runtime.fatalpanic, который завершает процесс с кодом ошибки 2. Это актуально и для горутин, запущенных из библиотек: необходимо всегда защищать их. Исключение — паника в тестах, где она перехватывается тестовым фреймворком.
Под капотом (internals)
В runtime.gopanic после исчерпания defer вызывается fatalpanic -> exit(2). Сигнал SIGABRT не посылается. Никакой глобальный обработчик не предусмотрен.
Типичные ошибки кандидатов
-
Запуск горутин из http-обработчиков без
recover
Встроенныйnet/httpловит панику только в основной горутине обслуживания запроса; вручную созданные горутины не защищены. -
Игнорирование
recoverдля фоновых задач
Любая горутина верхнего уровня (например, worker) должна иметьdefer recoverс логированием.
Возможные дополнительные вопросы интервьюера
-
Можно ли настроить поведение при падении процесса?
Можно зарегистрироватьdebug.SetCrashOutput(Go 1.22+) для получения дополнительного сбоя, но процесс всё равно завершится. -
Как отладить панику в горутине?
Включить дампы ядра (GOTRACEBACK=crash), использоватьpprof, Sentry.
Как recover может вернуть nil?
Краткий ответ (30–60 секунд)
recover возвращает nil в трёх случаях: если паники не было; если паника была, но recover вызван не в том defer (вложенный вызов); или если паника была вызвана с аргументом nil (panic(nil)). Последний случай опасен, так как невозможно отличить nil‑панику от отсутствия паники.
Развёрнутый ответ
Пример диагностики:
defer func() {
r := recover()
if r == nil {
// либо нет паники, либо panic(nil), либо recover вне контекста
}
}()
До Go 1.21 panic(nil) было невозможно отличить от "нет паники". Начиная с Go 1.21 ввели panic(nil) теперь оборачивается в специальный тип runtime.PanicNilError, и recover возвращает это значение вместо nil, что позволяет различить. Однако код, рассчитывающий на nil, может вести себя иначе.
Под капотом (internals)
panic(nil) в 1.21+ внутри рантайма превращается в panic(new(runtime.PanicNilError)), поэтому recover возвращает *PanicNilError, а не nil. В 1.20 и ранее возвращался nil.
Типичные ошибки кандидатов
-
Проверка
if err := recover(); err != nilи затем логирование "unknown panic"
Приpanic(nil)проверка не пройдёт, паника будет проигнорирована. Используйтеif err := recover(); err != nil || recoverWasCalled()— но надёжнее избегатьpanic(nil). -
Специальная обработка nil как "всё хорошо"
С Go 1.21 лучше явно запретитьpanic(nil)и использовать конкретные значения.
Возможные дополнительные вопросы интервьюера
-
Как в Go 1.21+ обработать
panic(nil)?
Проверьтеerrors.Is(r, runtime.PanicNilError)(если импортироватьruntime). Или используйте кастомное значение для паники. -
Зачем мог понадобиться
panic(nil)?
Раньше некоторые использовали для прерывания горутины; теперь естьruntime.Goexit.
Как обрабатывать панику в горутинах, чтобы не уронить все приложение?
Краткий ответ (30–60 секунд)
Каждая горутина верхнего уровня должна начинаться с defer recover(), который логирует панику и предотвращает крах программы. Это делается либо явно в каждой горутине, либо через обёртку, запускающую переданную функцию с recover.
Развёрнутый ответ
Паттерн безопасного запуска:
func runSafe(fn func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("panic in goroutine: %v\nstack: %s", r, debug.Stack())
}
}()
fn()
}()
}
В production используют библиотеки типа github.com/oklog/run или собственные врапперы. В HTTP‑серверах для фоновых задач обязательно применяйте этот паттерн. Метрики паник помогут оперативно находить баги.
Под капотом (internals)
debug.Stack() возвращает трассировку стека на момент вызова. Внутри recover стек содержит информацию о панике. Запись в лог должна быть асинхронной и надёжной.
Типичные ошибки кандидатов
-
Использование глобального
deferвmainв надежде поймать панику из других горутин
Не перехватывает, так как паника изолирована горутиной. -
Замалчивание паники без логирования
Если простоrecoverи ничего не делать, ошибка останется незамеченной, что может привести к некорректному поведению. -
Отсутствие логирования стека
log.Printf("%v", r)даёт только сообщение паники, стек важен для отладки.
Возможные дополнительные вопросы интервьюера
-
Нужно ли перезапускать горутину после паники?
Зависит от задачи: worker pool может перезапускать воркеры; однократная задача — возможно, нет. Главное — мониторинг. -
Можно ли использовать sync.WaitGroup в такой обёртке?
Да, обёртка должна корректно обрабатывать WaitGroup, вызывая Done в defer после recover.
Как работает errcheck линтер и почему он важен?
Краткий ответ (30–60 секунд)
errcheck — это статический анализатор, проверяющий, что возвращаемые ошибки не игнорируются без явного указания. Он находит вызовы функций, возвращающих error, у которых результат не присвоен переменной или не проверен. Это критично для надёжности кода.
Развёрнутый ответ
Игнорирование ошибок — одна из главных причин багов в production. errcheck позволяет на уровне CI гарантировать, что каждая ошибка либо обработана, либо осознанно проигнорирована с _ =. Линтер конфигурируется: можно исключить определённые функции или позволить игнорирование через комментарий.
Пример использования: errcheck ./... в CI пайплайне. Важность: раннее обнаружение пропущенных проверок ошибок предотвращает скрытые сбои.
Под капотом (internals)
Линтер использует пакет golang.org/x/tools/go/analysis. Он анализирует AST, ищет вызовы, возвращающие ошибку, и проверяет, использован ли результат (присваивание, проверка if, передача в функцию). Может учитывать аннотации типов исключений.
Типичные ошибки кандидатов
-
Заявление, что
errcheckнужен только новичкам
Даже опытные разработчики иногда пропускают проверку ошибок. Линтер — стандарт качества. -
Игнорирование всех ошибок через
_без понимания последствий
_ = f()следует использовать только с комментарием, почему ошибка не важна. -
Отсутствие
errcheckв CI
Включайте его, иначе ревьюеры пропустят.
Возможные дополнительные вопросы интервьюера
-
Как отличить осознанное игнорирование от забытой проверки?
Добавляйте комментарий// nolint:errcheckили_ = ... // ok to ignore. -
Чем
errcheckотличается отgo vet?
go vetпроверяет другие аспекты;errcheckспециализирован на ошибках. Они дополняют друг друга.
Что такое «идиоматическая обработка ошибок» в Go?
Краткий ответ (30–60 секунд)
Идиоматичная обработка — это явная проверка if err != nil { return fmt.Errorf(...) } с оборачиванием контекста, использование errors.Is/As для проверки причины, а также применение sentinel ошибок и поведенческих интерфейсов. Ошибки возвращаются как последний параметр; не используются исключения или скрытая передача.
Развёрнутый ответ
Ключевые принципы:
- Не игнорировать ошибки.
- Не использовать
panicдля ожидаемых ошибок. - Добавлять контекст на каждом уровне.
- Логировать один раз на верхнем уровне.
- Не преобразовывать ошибки в строки и обратно.
- Использовать кастомные типы для богатого контекста.
Пример идиоматичного кода:
func FetchUser(id string) (*User, error) {
user, err := db.FindUser(id)
if err != nil {
return nil, fmt.Errorf("fetch user %s: %w", id, err)
}
return user, nil
}
Под капотом (internals)
Идиома опирается на интерфейс error и пакет errors. Компилятор не навязывает паттерны, но сообщество Go выработало соглашения, закреплённые в Effective Go и best practices.
Типичные ошибки кандидатов
-
Использование
panicпри возврате ошибки
Противоречит основному принципу Go. -
Проверка
if err != nil { log.Fatal(err) }внутри бизнес‑логики
Код не должен завершать программу; ошибка должна возвращаться. -
Игнорирование ошибок
_ = json.Unmarshal(data, &v)
Это пропуск потенциальной некорректной структуры данных.
Возможные дополнительные вопросы интервьюера
-
Почему в Go ошибка — последнее возвращаемое значение?
Соглашение для читаемости и производительности: возврат ошибки в регистре. -
Чем плох подход "exception-like" с
errors.Wrapи стеком?
Он добавляет накладные расходы и поощряет ленивое оборачивание, Go предпочитает явный контекст.
Чем отличается обработка ошибок в Go от исключений в Java/C++?
Краткий ответ (30–60 секунд)
В Go ошибки — это значения, возвращаемые явно и проверяемые через if err != nil; нет try/catch, нет неявной раскрутки стека. Исключения в Java/C++ неявно передаются вверх, прерывая поток выполнения до ближайшего catch, и могут быть пропущены. Go даёт полный контроль, но требует больше дисциплины.
Развёрнутый ответ
Сравнение:
- Видимость: в Go вызов возвращает ошибку, и вы сразу видите, что она может произойти. В Java checked exceptions видны в сигнатуре, но unchecked (runtime) — скрыты.
- Производительность: исключения в C++/Java дороги в момент выброса из-за сбора стектрейса; в Go возврат ошибки — дешёвый.
- Обработка: Go обязывает проверять каждую ошибку (можно игнорировать, но линтеры напомнят). Исключения можно не ловить, что ведёт к падению.
- Обёртывание: в Go встроены
errors.Is/As, в Java —getCause().
Под капотом (internals)
В Java исключения создают объект, заполняют stack trace, затем рантайм ищет обработчик. В Go возврат ошибки — обычный возврат значения, error — интерфейс. Паника в Go аналогична исключению, но используется крайне редко.
Типичные ошибки кандидатов
-
Сравнение
errorс Java RuntimeException
panicближе к unchecked исключению, но его не используют для обычного потока. Ошибки Go — это скорее checked exceptions, но без обязательности объявления. -
Попытка реализовать try/catch через
defer/recover
Это неидиоматично и усложняет отладку.
Возможные дополнительные вопросы интервьюера
-
Почему в Go не ввели исключения?
Создатели языка сочли, что явная обработка ошибок улучшает читаемость и уменьшает вероятность пропущенных ошибок, особенно в долгоживущих серверных процессах. -
Как в Go обработать ошибку в деструкторе?
В Go нет деструкторов; используйтеdeferдля очистки и, если надо, передавайте ошибку через замыкание в именованный результат.
Как defer влияет на производительность в горячих циклах?
Краткий ответ (30–60 секунд)
defer имеет небольшую накладную стоимость (~10–35ns на вызов), но в горячих циклах эта стоимость может суммироваться и стать значительной. Современный Go оптимизирует простые случаи (inline, стековые выделения), но в критичных участках кода лучше избегать defer или вынести его за цикл.
Развёрнутый ответ
Профилирование покажет, стоит ли убирать defer. Если в теле цикла открывается файл и закрывается — defer накапливает отложенные вызовы до конца функции, что приводит к росту потребления памяти и замедлению. В таких случаях либо оборачивайте итерацию в отдельную функцию (defer сработает в конце итерации), либо закрывайте ресурс вручную.
Пример оптимизации:
for _, name := range names {
func() {
f, _ := os.Open(name)
defer f.Close() // выполнится в конце итерации
// обработка f
}()
}
Под капотом (internals)
Начиная с Go 1.13, для простых defer (без замыканий, с малым числом аргументов) runtime использует deferprocStack, размещая структуру на стеке горутины, а не в куче. Это ускоряет. В цикле, если каждый defer не выходит из области видимости до завершения функции, память на стеке не освобождается, и список растёт.
Типичные ошибки кандидатов
-
Повсеместное использование defer в высоконагруженных циклах без профилирования
Может привести к заметному оверхеду. Профилируйте. -
deferв цикле с большим числом итераций (>1e6) без вложенной функции
Риск переполнения стека и замедления. -
Слепая оптимизация "defer не нужен" без бенчмарков
Часто накладные расходы пренебрежимо малы, а читаемость важнее.
Возможные дополнительные вопросы интервьюера
-
Когда именно Go использует стековый defer?
Когда компилятор может доказать, что defer не сбегает из функции и число аргументов невелико, он вызываетdeferprocStack. -
Помогает ли
deferв случае паники?
Да, но если паника в цикле без defer, ресурсы утект. Поэтому безопасность иногда важнее производительности.
Как тестировать код с panic и recover?
Краткий ответ (30–60 секунд)
Тестируют через запуск функции в отдельной горутине или через рефлексию, перехватывая панику с помощью recover внутри теста, либо используя утилиты типа testify/assert.Panics. Проверяют факт возникновения паники, её значение, а также корректность восстановления.
Развёрнутый ответ
func TestMustCompile(t *testing.T) {
assert.Panics(t, func() { MustCompile("bad[regexp") })
}
Для проверки отсутствия паники — assert.NotPanics. При тестировании границ, где паника ожидаема из-за бага, также можно поймать через defer recover в тесте и сверить значение.
Тестирование recover: можно протестировать, что после перехвата функция возвращает ожидаемую ошибку.
Под капотом (internals)
testify внутри использует defer recover(). Важно: паника в тестовой горутине будет перехвачена фреймворком, но не в вложенных горутинах без защиты.
Типичные ошибки кандидатов
-
Тестирование паники через вызов функции и ожидание, что тест упадёт автоматически
Тест упадёт с паникой без деталей. Нужно обернуть. -
Проверка
recoverбезdefer
recoverвне defer вернёт nil, тест будет ложноположительным. -
Ожидание, что
assert.Panicsперехватит панику из дочерней горутины
Он ловит только панику текущей горутины.
Возможные дополнительные вопросы интервьюера
-
Как протестировать, что функция восстанавливается после паники и возвращает ошибку?
Вызвать функцию, которая внутри делаетpanic, затемrecoverи преобразует вerror, и проверить возвращённую ошибку черезassert.Error. -
Можно ли тестировать
recoverв unit‑тестах?
Да, это важно для покрытия критических секций.
Что такое runtime.Goexit() и как он взаимодействует с defer?
Краткий ответ (30–60 секунд)
runtime.Goexit() немедленно завершает текущую горутину, но перед этим выполняет все отложенные defer. Это не паника, recover не срабатывает. Используется редко, например, для выхода из горутины в тестах или обработчиках без возврата ошибки.
Развёрнутый ответ
Goexit аналогичен return на верхнем уровне функции горутины, но может быть вызван из любой глубины стека. Все defer выполняются в обычном порядке, после чего горутина завершается. Никакая информация не возвращается. Если Goexit вызвана в main‑горутине, программа не завершается, пока другие горутины работают.
Под капотом (internals)
runtime.Goexit вызывает runtime.goexit1 -> runtime.mcall(goexit0), который сначала выполняет все defer текущей горутины, затем вызывает планировщик для переключения на другие горутины. Внутри используется флаг g.runningDefer.
Типичные ошибки кандидатов
-
Использование
Goexitвместо возврата ошибки
Это ломает стандартный контракт функций; вызывающий не узнает о причине завершения. -
Ожидание, что
recoverпоймаетGoexit
Не поймает, так как это не паника.
Возможные дополнительные вопросы интервьюера
-
Как принудительно завершить горутину снаружи?
Только через канал или контекст, попросив её вернуться.Goexitможно вызвать только изнутри. -
Можно ли отменить
Goexit?
Нет, после вызова горутина завершается.
Как порядок выполнения defer связан с потоком выполнения функции?
Краткий ответ (30–60 секунд)
defer регистрирует отложенные вызовы в порядке их появления в потоке управления (ветвления исполнения). Они выполняются строго в обратном порядке (LIFO) при любом выходе из функции. Ветви кода, не выполненные при обычном исполнении, не добавляют defer.
Развёрнутый ответ
Если в функции есть if, и defer объявлен внутри блока, он регистрируется только когда исполнение доходит до этого блока. При панике или return все зарегистрированные (т.е. пройденные) defer выполнятся LIFO. Defer, объявленные в пропущенных ветках, не выполняются.
Под капотом (internals)
Компилятор размещает вызовы deferproc в линейный код; они исполняются при проходе через соответствующие инструкции. Список _defer строится динамически.
Типичные ошибки кандидатов
-
Заблуждение, что все
deferв функции регистрируются до начала выполнения
Нет, только те, что встретились по пути исполнения. -
Использование
deferвifбез понимания, что приelseего не будет
Важно для очистки ресурсов: если ресурс открывается только в if, defer должен быть там же.
Возможные дополнительные вопросы интервьюера
-
Что будет с defer в цикле, который прерывается паникой?
Все defer, добавленные на пройденных итерациях, выполнятся при раскрутке. -
Может ли defer из вложенной функции повлиять на список вызывающей?
Нет, у каждой функции свой список defer.
Можно ли использовать recover для обработки ошибок в обычной логике?
Краткий ответ (30–60 секунд)
Нет, recover предназначен только для предотвращения краха от неожиданных паник. Использование panic/recover для передачи ожидаемых ошибок является антипаттерном, разрушающим идиому Go, усложняющим чтение и отладку.
Развёрнутый ответ
Обычная логика должна опираться на возврат error. panic — это механизм для багов; recover — страховочная сетка. Подмена их под поток управления (например, для выхода из глубокой рекурсии) делает код магическим, скрывает контракты и создаёт риск пропуска ошибок, которые должны быть обработаны явно.
Под капотом (internals)
Паника и восстановление требуют раскрутки стека и взаимодействия с рантаймом, что дороже обычного возврата. К тому же стек при панике может потерять часть информации, если не используется defer с захватом стека.
Типичные ошибки кандидатов
-
Имитация исключений:
panic(err)иrecoverв вызывающем
Создаёт скрытые пути, путает инструменты отладки. -
Использование
recoverдля игнорирования всех ошибок внутри функции
Это превращает возможные баги в невидимые.
Возможные дополнительные вопросы интервьюера
-
Есть ли случаи, когда
panic/recoverдля потока управления приемлемы?
Практически нет. Разве что внутри DSL или очень узких библиотек, но и там лучше вернуть error. -
Как тогда прервать несколько уровней вложенных вызовов?
Использовать контекст с отменой и проверкуctx.Err()на каждом уровне.
Что такое "shameless panicking" и когда это уместно?
Краткий ответ (30–60 секунд)
Термин, введённый Дейвом Чейни, описывает практику использования panic в функциях с префиксом Must (например, template.Must или regexp.MustCompile), когда ошибка при инициализации глобального состояния должна немедленно прервать программу, потому что дальнейшая работа невозможна. Уместно только на этапе старта приложения.
Развёрнутый ответ
MustCompile паникует, если регулярное выражение синтаксически некорректно. Это осознанное решение: конфигурация или статический ресурс невалиден — сервис не должен стартовать. Таким образом, отказ происходит немедленно и очевидно. Не применяйте к runtime ошибкам, зависящим от данных.
Под капотом (internals)
Must функции — обычные обёртки над возвращающими (T, error), с проверкой и panic. Никакой специальной поддержки. Паника в init или глобальных переменных также вызывает завершение перед входом в main.
Типичные ошибки кандидатов
-
Использование
Mustдля проверки результатов сетевого вызова
Сеть может временно отказать; это не повод ронять всё приложение. -
Отсутствие
Mustдля обязательных параметров конфигурации
Лучше упасть сразу, чем потом узнать оnilуказателе. -
Паника внутри
Mustбез информативного сообщения
Сообщение должно объяснять причину и как исправить (файл, строка конфига).
Возможные дополнительные вопросы интервьюера
-
Как протестировать
Mustфункцию, не роняя тестовый процесс?
Использоватьassert.Panics. -
Чем
Mustотличается отlog.Fatal?
log.Fatalвыводит сообщение и вызываетos.Exit(1), минуяdefer.Mustвызываетpanic, что позволяет перехватить в тестах.
Как реализовать паттерн "Circuit Breaker" с использованием ошибок?
Краткий ответ (30–60 секунд)
Используем кастомный тип ошибки, сигнализирующий о срабатывании предохранителя, и обёртку, которая отслеживает счётчики ошибок/успехов. При превышении порога возвращаем ошибку типа CircuitOpenError без реального вызова. Вызывающий проверяет errors.As и обрабатывает.
Развёрнутый ответ
type CircuitOpenError struct{ Name string }
func (e *CircuitOpenError) Error() string { return "circuit open: " + e.Name }
func (cb *CircuitBreaker) Call(fn func() error) error {
if cb.open {
return &CircuitOpenError{Name: cb.name}
}
err := fn()
cb.record(err)
return err
}
Вызывающая сторона:
if errors.As(err, &CircuitOpenError{}) {
// fallback
}
Такой подход сохраняет идиоматическую обработку ошибок и позволяет использовать errors.As для принятия решений.
Под капотом (internals)
Рантайм не имеет отношения; это pure Go pattern. Можно использовать библиотеки вроде gobreaker. Важно, чтобы ошибка предохранителя не оборачивалась внутрь, иначе As может не найти.
Типичные ошибки кандидатов
-
Использование sentinel ошибки для Circuit Open
Sentinels не несут контекст (какой именно предохранитель). Кастомный тип предпочтительней. -
Возврат простой строковой ошибки без возможности идентификации
Вызывающий не сможет отличить открытый предохранитель от таймаута. -
Смешивание логики Circuit Breaker с повторными попытками без чёткого контракта
Retry должен проверять тип ошибки и не пытаться, если цепь разомкнута.
Возможные дополнительные вопросы интервьюера
-
Как реализовать Half‑Open состояние без нарушения обработки ошибок?
Можно возвращать ошибкуCircuitHalfOpenErrorили использоватьerrors.Asдля извлечения специфического поведения. -
Как метрики ошибок влияют на архитектуру?
Circuit Breaker должен экспортировать метрики, но ошибки остаются значениями.
Как работает defer с замыканиями?
Краткий ответ (30–60 секунд)
Когда defer-вызов содержит замыкание (функциональный литерал), переменные из окружающего контекста захватываются по ссылке. Замыкание выполняется при завершении функции с захваченными на тот момент значениями. Аргументы самого defer (если передаются) вычисляются сразу.
Развёрнутый ответ
x := 1
defer func() { fmt.Println(x) }() // захватывает x по ссылке, выведет 2
x = 2
В отличие от defer fmt.Println(x), где x вычисляется сразу. Замыкания позволяют отложить чтение переменной до момента выполнения, что полезно для записи изменяемых метрик, продолжительности.
Под капотом (internals)
Замыкание создаётся как структура, содержащая указатели на захваченные переменные. Если переменная сбегает, она аллоцируется в куче. Defer хранит указатель на замыкание и вызывает его.
Типичные ошибки кандидатов
-
Неожиданное поведение из-за захвата итератора цикла (до Go 1.22)
Замыкание увидит финальное значение. Исправление: передача через параметр илиi := i. -
Тяжёлые аллокации из-за большого числа замыканий в defer
Каждое замыкание аллоцирует кучу. Профилируйте и минимизируйте захват. -
Изменение захваченной переменной в нескольких defer
Порядок выполнения LIFO определяет итоговое значение.
Возможные дополнительные вопросы интервьюера
-
Как defer с замыканием влияет на escape analysis?
Захваченные переменные обычно уходят в кучу, если переживают фрейм функции. -
Можно ли использовать defer с замыканием для отложенной инициализации?
Да, например, для вычисления времениdefer func(start time.Time) { ... }(time.Now()).
Как defer влияет на читаемость и поддерживаемость кода?
Краткий ответ (30–60 секунд)
Правильно использованный defer улучшает читаемость: размещая очистку ресурсов рядом с их захватом, код становится логически связанным. Однако злоупотребление defer для побочных эффектов или сложной логики запутывает поток выполнения и ухудшает понимание.
Развёрнутый ответ
Идиома: открыл — сразу defer Close(). Читатель видит пару «получить-освободить» в одном месте. Неправильное применение: несколько defer, изменяющих одну переменную, или defer с длинными замыканиями, которые прячут основную логику. Принцип: используйте defer для очистки, а не для бизнес-логики.
Под капотом (internals)
Ничего специфичного; это вопрос стиля. Чем проще defer, тем легче его оптимизирует компилятор.
Типичные ошибки кандидатов
-
Размещение defer далеко от захвата ресурса
Усложняет понимание, когда ресурс освобождается. -
Сокрытие сложной логики в defer
defer должен быть коротким и предсказуемым. -
Использование defer для операций, которые должны гарантированно выполниться до return
Например,defer metrics.Record(), но запись метрики может быть нужна только при успехе. Лучше явный вызов перед return.
Возможные дополнительные вопросы интервьюера
-
Как задокументировать поведение defer?
Комментировать нестандартное использование. Обычно идиома говорит сама за себя. -
Когда лучше избегать defer?
В критичных по производительности циклах или когда порядок очистки важен и не укладывается в LIFO.
Что происходит с defer при вызове os.Exit?
Краткий ответ (30–60 секунд)
os.Exit немедленно завершает программу без выполнения каких-либо отложенных defer. Ни в вызывающей функции, ни в main, ни в других горутинах defer не выполняются. Это аварийный мгновенный останов.
Развёрнутый ответ
os.Exit(code) вызывает системный вызов exit_group, завершающий процесс. Обычные механизмы очистки Go не запускаются. Поэтому defer для закрытия файлов, завершения транзакций не сработают. Если требуется graceful shutdown, необходимо использовать сигналы ОС и каналы для координации.
Под капотом (internals)
os.Exit → syscall.Exit → exit_group. Рантайм не получает возможности выполнить deferreturn. atexit-подобных механизмов нет, можно только зарегистрировать defer в main и ждать завершения всех горутин перед выходом.
Типичные ошибки кандидатов
-
Расчёт на
deferдля сброса буферов логов приlog.Fatal
log.Fatalвызываетos.Exit(1), defer не работают. Используйтеlog.Panicили ручное управление. -
Использование
os.Exitв библиотечном коде
Библиотеки не должны завершать процесс. Возвращайте ошибку. -
Завершение программы через
os.Exitв горутине без предупреждения main
Остальные горутины не успевают штатно завершиться.
Возможные дополнительные вопросы интервьюера
-
Как обеспечить выполнение defer перед выходом из приложения?
Организовать graceful shutdown: перехватить SIGTERM, закрыть канал, и в main дождаться завершения всех горутин, после чего естественно выйти. -
Можно ли перехватить
os.Exit?
Нет, это немедленный системный вызов.
Как использовать defer для профилирования и трассировки?
Краткий ответ (30–60 секунд)
С помощью defer можно измерить время выполнения функции, записать метрику или запустить/остановить профилирование CPU/трассировку, не загромождая основной код. Обычно используется замыкание с захватом time.Now().
Развёрнутый ответ
func trace(name string) func() {
start := time.Now()
log.Printf("enter %s", name)
return func() { log.Printf("exit %s (%s)", name, time.Since(start)) }
}
func process() {
defer trace("process")()
// ...
}
Этот паттерн позволяет легко добавлять и убирать трассировку. Для CPU профилирования:
f, _ := os.Create("cpu.prof")
pprof.StartCPUProfile(f)
defer pprof.StopCPUProfile()
Под капотом (internals)
Замыкание в defer захватывает start и name. Вызов trace сразу фиксирует время и возвращает функцию, которая откладывается. Эта функция срабатывает при выходе.
Типичные ошибки кандидатов
-
Забыть вызвать возвращённую функцию в defer:
defer trace("x")без скобок
Это просто вернёт функцию и не вызовет её. Нужноdefer trace("x")(). -
Измерение времени внутри defer без учёта, что defer выполняется после всех возвратов
Время включает выполнение всех defer, что может исказить метрику; для чистого времени используйте замер до defer.
Возможные дополнительные вопросы интервьюера
-
Как измерить время выполнения горутины?
Аналогично, но трассировка горутины накапливает множество записей, нужно агрегировать. -
Как избежать overhead defer при продакшене?
Использовать сборку с билд-тегами, чтобы трассировочные defer исчезали.
Какие линтеры помогают следить за корректной обработкой ошибок?
Краткий ответ (30–60 секунд)
Основные линтеры: errcheck (игнорирование возвращаемых ошибок), staticcheck (SA1019 и другие проверки обработки ошибок), govet (проверка на неиспользуемые ошибки, nilerr), golangci-lint с набором правил errcheck, errorlint (утверждение новых errors.Is/As). Они обеспечивают соблюдение идиом.
Развёрнутый ответ
errcheck: гарантирует, что ошибки проверяются.errorlint: проверяет, что не используются прямые сравнения==с ошибками, а также что%wиспользуется вfmt.Errorfпри оборачивании.wrapcheck: следит, чтобы ошибки из внешних вызовов оборачивались.nilerr: детектит возврат nil ошибки при не-nil значении.
Интеграция этих линтеров в CI обязательна для высокого качества кода.
Под капотом (internals)
Линтеры работают на уровне AST и типов. errorlint проверяет, что аргумент %w является ошибкой, и что сравнение ошибок заменено на errors.Is.
Типичные ошибки кандидатов
-
Игнорирование линтеров на проекте
Приводит к постепенной деградации кодовой базы. -
Неправильная настройка
errcheck(исключение всех функций)
Снижает пользу. -
Использование устаревшего
golintвместоstaticcheck/golangci-lint
golintне проверяет ошибки глубоко.
Возможные дополнительные вопросы интервьюера
-
Как внедрить линтеры в старый проект?
Постепенно, сnolintкомментариями и track issue. Включить обязательную проверку для новых файлов. -
Чем
staticcheckотличается отgo vet?
staticcheckпроводит более глубокий анализ, включая поиск багов,vetфокусируется на подозрительных конструкциях.
Как реализовать механизм повторных попыток (retry) с использованием ошибок?
Краткий ответ (30–60 секунд)
Создаём кастомный тип ошибки для временных/retryable ошибок или используем поведенческий интерфейс interface{ Temporary() bool }. Retry‑логика проверяет ошибку через errors.As на наличие этого поведения, и если true и не превышено число попыток — повторяет. Иначе возвращает ошибку.
Развёрнутый ответ
type temporary interface{ Temporary() bool }
func retry(count int, fn func() error) error {
for i := 0; i < count; i++ {
err := fn()
if err == nil { return nil }
var t temporary
if !errors.As(err, &t) || !t.Temporary() {
return err // постоянная ошибка
}
time.Sleep(backoff(i))
}
return fmt.Errorf("after %d attempts: %w", count, err)
}
Важно различать постоянные и временные ошибки, чтобы не бесконечно повторять. Используйте exponential backoff.
Под капотом (internals)
errors.As с интерфейсом временной ошибки даёт гибкость. Сетевые пакеты Go (net) часто реализуют Temporary() bool. Можно расширить свой тип.
Типичные ошибки кандидатов
-
Повтор для всех ошибок без разбора
Бесполезные retry увеличивают нагрузку и задержки. Различайте ошибки. -
Возврат последней ошибки без оборачивания контекста
Теряется информация о попытках. Оборачивайте сfmt.Errorfи%w. -
Отсутствие ограничения по времени/количеству
Может привести к бесконечному циклу.
Возможные дополнительные вопросы интервьюера
-
Как отменить retry по таймауту контекста?
Проверятьctx.Err()внутри retry цикла, возвращать ошибку отмены. -
Какие библиотеки реализуют retry?
cenkalti/backoff,avast/retry-go, но понимание своей реализации ценится больше.
Как работает цепочка defer во вложенных функциях?
Краткий ответ (30–60 секунд)
Каждая функция имеет собственный список отложенных вызовов. defer во вложенной функции выполняется при возврате из этой функции, до того как управление вернётся во внешнюю. Внешняя функция продолжит выполнение или панику, а её собственные defer выполнятся позже.
Развёрнутый ответ
Пример:
func outer() {
defer fmt.Println("outer")
inner()
}
func inner() {
defer fmt.Println("inner")
// ...
}
// Вывод: inner, outer
Паника во внутренней функции раскручивает сначала её defer, затем, если не восстановлено, раскручивает внешние.
Под капотом (internals)
Список _defer привязан к горутине, но хранится с учётом фрейма функции. При вызове функции создаётся новый фрейм, и defer добавляется в начало списка, но при возврате проверяется принадлежность defer к текущему фрейму.
Типичные ошибки кандидатов
-
Попытка
recoverво внешней функции перехватить панику внутренней
recoverдолжен быть именно в той функции, где произошла паника (через defer этой функции). Внешняя функция не перехватит, если только не вызывала панику напрямую. -
Заблуждение, что defer в замыкании влияет на внешний список
Замыкание — это отдельная функция, defer управляется её фреймом.
Возможные дополнительные вопросы интервьюера
-
Что произойдёт, если во внутреннем defer выполнить
return?
Нельзя; defer не может изменить поток управления, кромеrecover.returnв defer вызовет панику. -
Можно ли из defer внутренней функции изменить возвращаемое значение внешней?
Только если внешняя возвращает именованный результат и defer внутренней функции как‑то модифицирует её через замыкание, но обычно это плохая практика.
Можно ли изменить значение переменной, которую возвращает функция, с помощью defer?
Краткий ответ (30–60 секунд)
Да, если возвращаемый параметр именован. defer может присвоить ему новое значение до фактического возврата из функции. Без именованного параметра изменить возвращаемое значение невозможно.
Развёрнутый ответ
func getResult() (res int) {
defer func() { res = 42 }()
return 10 // вернётся 42
}
Идиома используется для перехвата паники в ошибку или для подстановки значений по умолчанию. Порядок: сначала вычисляется res = 10, затем выполняется defer, который перезаписывает res.
Под капотом (internals)
Именованные результаты живут в стековом фрейме функции и доступны defer‑замыканию. Компилятор генерирует код, который после вычисления возвращаемого значения, но до RET, выполняет defer.
Типичные ошибки кандидатов
-
Попытка изменить неименованный результат
func f() int { defer func() { ... }(); return 0 }— нет эффекта. -
Изменение результата в defer без явной необходимости
Запутывает логику; используйте только когда это лучший вариант. -
Влияние нескольких defer на один результат непредсказуемо
Требует чёткого порядка.
Возможные дополнительные вопросы интервьюера
-
Можно ли изменить возвращаемую ошибку, если она не именована?
Нет. Но можно вернуть другую ошибку из defer, если именованнаяerr error. -
Как это взаимодействует с
recover?
Отлично: можно в defer поймать панику и присвоить именованной ошибке значение, функция вернёт ошибку вместо падения.
Почему не стоит использовать panic для обычного управления потоком выполнения?
Краткий ответ (30–60 секунд)
Потому что panic предназначен для невосстановимых ошибок программирования, а не для ожидаемой логики. Использование его для потока выполнения маскирует настоящие баги, делает код непрозрачным, затрудняет отладку и противоречит идиоме Go, где ошибки — значения.
Развёрнутый ответ
panic прерывает нормальный поток, пропускает явные проверки ошибок и обходит контракты. Если использовать panic/recover как "goto", это приводит к спагетти-коду, где сложно отследить состояние. К тому же panic дороже простого возврата ошибки. Статические анализаторы не ожидают такого паттерна и не помогут найти ошибки.
Под капотом (internals)
Любой panic включает раскрутку стека и выполнение всех defer, что может вызвать непреднамеренные побочные эффекты. Даже перехват через recover оставляет возможность потери контекста.
Типичные ошибки кандидатов
-
Использование
panic/recoverдля выхода из глубокой рекурсии
Лучше передаватьerrorили использовать контекст. -
Эмуляция исключений для обработки ошибок HTTP handler
Встроенныйhttp.Recoverработает, но возвращать ошибку через панику неидиоматично. Используйте middleware, который возвращает ошибку.
Возможные дополнительные вопросы интервьюера
-
Есть ли исключения из правила?
Внутренние библиотеки, реализующие DSL, могут так делать, но это крайне редко и должно быть скрыто от пользователя. -
Как
recoverв defer помогает избегать утечек ресурсов?
Он не предотвращает утечку, но даёт шанс закрыть ресурсы перед восстановлением.
Как defer взаимодействует с recover от паники в другой горутине?
Краткий ответ (30–60 секунд)
Никак. Паника распространяется только в пределах своей горутины. recover в defer одной горутины не может перехватить панику, произошедшую в другой горутине. Каждая горутина должна иметь собственный recover.
Развёрнутый ответ
Если горутина A запускает горутину B, и B паникует, defer в A не сработает для этой паники. Единственный способ — в B должен быть свой defer/recover. Для передачи информации о панике из B в A используют каналы: B ловит панику и отправляет ошибку в канал, который читает A.
Под капотом (internals)
Структура _panic привязана к g (горутине). При переключении горутин контекст паники не передаётся. Планировщик не занимается паниками других горутин.
Типичные ошибки кандидатов
-
Запуск горутины и попытка поймать её панику через внешний
recover
Это не сработает. Нужно либоrecoverвнутри горутины, либо канал. -
Использование
WaitGroupбез обработки паник
Одна упавшая горутина безrecoverможет уронить весь процесс, даже если main ждёт.
Возможные дополнительные вопросы интервьюера
-
Как организовать безопасный сбор результатов из нескольких горутин?
Использоватьerrgroupс контекстом, который внутри ловит панику и превращает в ошибку. -
Можно ли перезапустить горутину после паники без
recover?
Только если есть внешний мониторинг, который запускает новый экземпляр процесса.
Что такое "panic wrapping" в Go 1.21+?
Краткий ответ (30–60 секунд)
Начиная с Go 1.21, panic(nil) больше не приводит к recover() == nil. Вместо этого runtime оборачивает nil в специальное значение типа *runtime.PanicNilError. Это позволяет отличить отсутствие паники от panic(nil). Также ввели возможность обёртывать паники с помощью defer и recover, сохраняя цепочку.
Развёрнутый ответ
Раньше код мог случайно panic(nil) и recover возвращал nil, маскируя панику. Теперь recover возвращает *runtime.PanicNilError, и errors.Is(r, runtime.PanicNilError) истинно. Это улучшает отладку. Кроме того, в 1.21 добавили экспериментальную поддержку "wrapping panic": если внутри defer вызывается panic с новым значением, оно оборачивает исходную панику (через структуру _panic) и recover может извлечь обе. Однако пока это нестабильно.
Под капотом (internals)
panicCheck2 в runtime/panic.go проверяет аргумент и при nil заменяет на new(PanicNilError). При recover возвращается этот объект. Для обёртывания используется цепочка _panic.link.
Типичные ошибки кандидатов
-
Проверка
if r := recover(); r != nilи затем свитч по типу без учётаPanicNilError
Теперь нужно обрабатывать этот случай, чтобы ложно не интерпретировать как отсутствие паники. -
Игнорирование того, что
panic(nil)теперь детектируем
Можно случайно сломать код, который полагался на nil.
Возможные дополнительные вопросы интервьюера
-
Как в Go 1.21 эмулировать старое поведение
recover?
Проверитьif r == nil || errors.Is(r, runtime.PanicNilError) { // не было настоящей паники }, но лучше адаптироваться. -
Что такое wrapped panic?
Механизм, позволяющий добавить контекст к панике, как обёртка ошибок, но пока не для production.
Как defer, panic и recover обрабатываются на уровне рантайма?
Краткий ответ (30–60 секунд)
Рантайм Go управляет отложенными вызовами через связный список структур _defer в каждой горутине. Паника инициирует gopanic, которая проходит этот список и выполняет defer. recover вызывает gorecover, который изменяет состояние паники, останавливая раскрутку. Все действия происходят в контексте одной горутины.
Развёрнутый ответ
deferproc/deferprocStack: добавляют_deferв началоg._defer.deferreturn: при нормальном возврате вызывает записи.gopanic: устанавливает_panicна горутине, затем в цикле выполняет defer, пока не встретитrecoverили не исчерпает список.gorecover: используется вrecover, чтобы вернуть значение паники и пометить её как восстановленную.fatalpanic: если не восстановлено, печатает стек и завершает процесс.
Все структуры определены в src/runtime/runtime2.go (_defer, _panic).
Под капотом (internals)
В Go 1.14+ defer оптимизирован: для открытых файлов и т.п. может быть сгенерирован быстрый путь openDefer с хранением информации в _defer в буфере. Escape analysis определяет, можно ли разместить структуру на стеке.
Типичные ошибки кандидатов
-
Неправильное представление, что
defer— макрос, вставляемый компилятором
Это рантайм-структуры, влияющие на производительность и поведение. -
Игнорирование того, что
recoverдолжен быть вызван непосредственно в отложенной функции
Рантайм проверяет, что вызовrecoverпроисходит в правильном фрейме.
Возможные дополнительные вопросы интервьюера
-
Как рантайм определяет, что
recoverвызван в нужном месте?
Проверяет, что указатель возврата PC соответствует defer-фрейму. -
Почему
panic/recoverнельзя использовать в CGO?
Паника не должна пересекать границу C; поведение не определено, может привести к краху.
Какие лучшие практики обработки ошибок существуют в микросервисах на Go?
Краткий ответ (30–60 секунд)
- Оборачивайте ошибки на границах сервисов, добавляя trace ID, код ошибки и сообщение.
- Различайте transient (retryable) и permanent ошибки.
- Используйте кастомные типы для статусов gRPC/HTTP.
- Логируйте один раз на внешнем слое.
- Всегда возвращайте ошибку как последний параметр.
- Применяйте Circuit Breaker и Retry с
errors.As. - В фоновых горутинах обязательно
recover.
Развёрнутый ответ
В микросервисах ошибки должны содержать информацию, достаточную для диагностики распределённой трассировки. Пример:
func (s *Service) GetOrder(ctx context.Context, id string) (*Order, error) {
order, err := s.repo.Find(ctx, id)
if err != nil {
return nil, fmt.Errorf("GetOrder %s: %w", id, err)
}
return order, nil
}
На HTTP‑уровне преобразуем ошибку в код:
if errors.Is(err, ErrNotFound) { return 404 }
var cberr *CircuitOpenError
if errors.As(err, &cberr) { return 503 }
Для gRPC используем status.Errorf(codes.Internal, "...").
Под капотом (internals)
Не требуется специальных знаний; это архитектурные принципы. Важно, что линтеры и статический анализ помогают соблюдать эти практики в CI.
Типичные ошибки кандидатов
-
Смешивание пользовательских сообщений с внутренними ошибками
Пользователь не должен видеть внутреннее устройство. Создавайте отдельные поля или типы. -
Игнорирование ошибок логирования из‑за того, что "всё равно упадёт"
Логирование ошибок обязательно для post‑mortem. -
Отсутствие унифицированного контракта на ошибки между сервисами
Используйте общий пакет с sentinel‑ошибками и кодами.
Возможные дополнительные вопросы интервьюера
-
Как организовать обработку ошибок в event‑driven системе?
Возвращать ошибки в канал обратного вызова или через DLQ (dead letter queue), обязательно сохраняя причину. -
Как тестировать обработку ошибок в микросервисах?
Интеграционные тесты с моками, проверка конкретных типов ошибок и кодов ответа.