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
  

  1. Что такое error в Go?
  2. Почему "errors are values"?
  3. Как создавать ошибки (errors.New vs fmt.Errorf)?
  4. Что такое оборачивание ошибок (error wrapping) с помощью %w?
  5. Как работает errors.Is?
  6. Как работает errors.As?
  7. Что такое сторожевые ошибки (sentinel errors)?
  8. Что такое кастомные типы ошибок (custom error types)?
  9. Что такое defer?
  10. Когда выполняется defer?
  11. В каком порядке выполняются несколько defer?
  12. Что происходит с аргументами defer?
  13. Что такое panic?
  14. Когда использовать panic?
  15. Что такое recover?
  16. Где работает recover?
  17. Почему recover работает только внутри defer?
  18. Когда panic допустим в production?
  19. Что такое принцип "fail-fast"?
  20. Как правильно обрабатывать цепочки ошибок?
  21. Как логировать ошибки?
  22. Как добавлять контекст к ошибкам?
  23. Что такое errors.Join (Go 1.20+)?
  24. Как использовать defer для изменения возвращаемого значения?
  25. Что произойдет, если panic возникнет в goroutine без recover?
  26. Как recover может вернуть nil?
  27. Как обрабатывать панику в горутинах, чтобы не уронить все приложение?
  28. Как работает errcheck линтер и почему он важен?
  29. Что такое «идиоматическая обработка ошибок» в Go?
  30. Чем отличается обработка ошибок в Go от исключений в Java/C++?
  31. Как defer влияет на производительность в горячих циклах?
  32. Как тестировать код с panic и recover?
  33. Что такое runtime.Goexit() и как он взаимодействует с defer?
  34. Как порядок выполнения defer связан с потоком выполнения функции?
  35. Можно ли использовать recover для обработки ошибок в обычной логике?
  36. Что такое "shameless panicking" и когда это уместно?
  37. Как реализовать паттерн "Circuit Breaker" с использованием ошибок?
  38. Как работает defer с замыканиями?
  39. Как defer влияет на читаемость и поддерживаемость кода?
  40. Что происходит с defer при вызове os.Exit?
  41. Как использовать defer для профилирования и трассировки?
  42. Какие линтеры помогают следить за корректной обработкой ошибок?
  43. Как реализовать механизм повторных попыток (retry) с использованием ошибок?
  44. Как работает цепочка defer во вложенных функциях?
  45. Можно ли изменить значение переменной, которую возвращает функция, с помощью defer?
  46. Почему не стоит использовать panic для обычного управления потоком выполнения?
  47. Как defer взаимодействует с recover от паники в другой горутине?
  48. Что такое "panic wrapping" в Go 1.21+?
  49. Как defer, panic и recover обрабатываются на уровне рантайма?
  50. Какие лучшие практики обработки ошибок существуют в микросервисах на Go?

Что такое error в Go?

Краткий ответ (30–60 секунд)

error — это встроенный интерфейс с единственным методом Error() string. Любой тип, реализующий этот метод, может использоваться как ошибка. Простота интерфейса отражает философию Go: ошибки — это обычные значения, которые передаются явно, без скрытых исключений.

Развёрнутый ответ

Интерфейс error определён в пакете builtin:

Go
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. Первый используется для статических ошибок-меток, второй — для динамических сообщений и добавления контекста.

Развёрнутый ответ

Go
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:

Go
type errorString struct {
    s string
}

Интерфейс реализуется на *errorString, поэтому два вызова errors.New("a") не равны друг другу через ==, т.к. представляют разные указатели. Это осознанное решение: sentinel‑ошибки определены на уровне пакета как глобальные переменные, их можно сравнивать напрямую через ==.

С %w создаётся структура wrapError:

Go
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, и для каждой ошибки в цепочке выполняет:

Go
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. Это аналог приведения типа, но с обходом обёрток.

Развёрнутый ответ

Go
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). Они позволяют передать богатый контекст и классифицировать ошибки по поведению, а не по строковому сообщению.

Развёрнутый ответ

Пример:

Go
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 помещается на вершину, а при возврате извлекается в обратном порядке.

Развёрнутый ответ

Пример:

Go
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 попадёт захваченное значение на момент объявления.

Развёрнутый ответ

Go
func demo() {
    x := 1
    defer fmt.Println(x)  // захватывается 1
    x = 2
    // Выведет: 1
}

Если defer — замыкание, то замыкание захватывает ссылку на переменную, и её значение будет прочитано на момент выполнения:

Go
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 модифицировать значение переменной, переданной как аргумент?
    Только если это указатель. Для *x defer запоминает указатель, и изменение по указателю в отложенной функции отразится на оригинале.


Что такое panic?

Краткий ответ (30–60 секунд)

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

Развёрнутый ответ

panic принимает один аргумент любого типа, который становится "значением паники". Это значение можно получить через recover. Паника предназначена для неисправимых ошибок программирования (нарушение инвариантов, разыменование nil‑указателя), но не для обычной обработки ошибок, таких как неверный пользовательский ввод.

Под капотом (internals)

Вызов panic переходит в runtime.gopanic (src/runtime/panic.go). Происходит:

  1. Создание записи паники.
  2. Цикл по _defer списку горутины, выполнение всех defer.
  3. Если recover вызывает runtime.gorecover и возвращает значение паники, gopanic переходит к восстановлению.
  4. Иначе выводится сообщение о панике и полный дамп стека, программа завершается через 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 паника приводит к краху программы.

Пример идиомы:

Go
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 бесполезным.

Развёрнутый ответ

Go
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. Не следует вынимать строковое сообщение внутренней ошибки; нужно пропускать ошибку сквозь обёртки, сохраняя возможность программного анализа цепочки.

Развёрнутый ответ

Паттерн "цепочечной обработки":

Go
func service() error {
    if err := repo.Save(data); err != nil {
        return fmt.Errorf("service: save failed: %w", err)
    }
    return nil
}

На уровне HTTP‑обработчика:

Go
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:

Go
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), либо через кастомный тип ошибки с дополнительными полями. Контекст должен содержать семантическую информацию: что пытались сделать, с какими параметрами, идентификатор транзакции. Это помогает при отладке, не разрывая цепочку.

Развёрнутый ответ

Примеры:

Go
if err != nil {
    return fmt.Errorf("reading config file %s: %w", filename, err)
}

Для обогащения типа можно создать обёртку:

Go
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 искать по всем элементам. Это замена ручному сбору слайса ошибок.

Развёрнутый ответ

Go
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 секунд)

Используя именованные результаты, можно в отложенной функции присвоить новое значение возвращаемой переменной после всех вычислений. Это позволяет перехватывать панику в ошибку или модифицировать результат.

Развёрнутый ответ

Go
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()
}

Также можно изменить результат в обычном сценарии:

Go
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‑панику от отсутствия паники.

Развёрнутый ответ

Пример диагностики:

Go
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.

Развёрнутый ответ

Паттерн безопасного запуска:

Go
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 для ожидаемых ошибок.
  • Добавлять контекст на каждом уровне.
  • Логировать один раз на верхнем уровне.
  • Не преобразовывать ошибки в строки и обратно.
  • Использовать кастомные типы для богатого контекста.

Пример идиоматичного кода:

Go
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 сработает в конце итерации), либо закрывайте ресурс вручную.

Пример оптимизации:

Go
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. Проверяют факт возникновения паники, её значение, а также корректность восстановления.

Развёрнутый ответ

Go
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 и обрабатывает.

Развёрнутый ответ

Go
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
}

Вызывающая сторона:

Go
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 (если передаются) вычисляются сразу.

Развёрнутый ответ

Go
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().

Развёрнутый ответ

Go
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 профилирования:

Go
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 и не превышено число попыток — повторяет. Иначе возвращает ошибку.

Развёрнутый ответ

Go
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 выполнятся позже.

Развёрнутый ответ

Пример:

Go
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 может присвоить ему новое значение до фактического возврата из функции. Без именованного параметра изменить возвращаемое значение невозможно.

Развёрнутый ответ

Go
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.

Развёрнутый ответ

В микросервисах ошибки должны содержать информацию, достаточную для диагностики распределённой трассировки. Пример:

Go
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‑уровне преобразуем ошибку в код:

Go
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), обязательно сохраняя причину.

  • Как тестировать обработку ошибок в микросервисах?
    Интеграционные тесты с моками, проверка конкретных типов ошибок и кодов ответа.