Перейти до змісту

Panic і recover

07-errors.md розглядає основний механізм обробки помилок у Go — повернення error. Ця стаття охоплює інший: panic (паніка) і recover (відновлення) — вони існують для невеликого набору випадків, де звичайне повернення помилки не підходить.

Головне правило від початку: не використовуйте panic для звичайних помилок. Використовуйте його для помилок програміста (неможливі стани, порушення контракту) та для невідновлюваних умов часу виконання. Для всього іншого — повертайте error.

Що робить panic

Виклик panic(v) зупиняє нормальне виконання функції і починає розмотування стеку:

  1. Відкладені виклики поточної функції виконуються у порядку LIFO.
  2. Функція повертається до свого викликача.
  3. Виконуються відкладені виклики викликача.
  4. Викликач повертається до свого викликача.
  5. Це продовжується доти, доки:
  6. відкладена функція не викличе recover() і не зупинить розмотування, або
  7. panic не досягне main і не завершить програму з виведенням стека викликів.

Помилки часу виконання — розіменування nil-вказівника, вихід за межі зрізу, ділення цілого числа на нуль, відправлення в закритий канал — усі ініціюють неявний panic.

package main

import "fmt"

func main() {
    defer fmt.Println("deferred in main")
    panic("boom")
}
// output:
// deferred in main
// panic: boom
//
// goroutine 1 [running]:
// main.main()
//     .../main.go:7 +0x...
// exit status 2

Відкладений fmt.Println виконався (розмотування panic все одно виконує відкладені виклики), а потім програма аварійно завершилась.

Що робить recover

recover() — це вбудована функція, яка щось робить лише всередині відкладеної функції. В будь-якому іншому місці вона повертає nil і нешкідлива.

  • Під час нормального виконання (без активного panic): recover повертає nil.
  • Під час активного panic: recover повертає значення, передане до panic, і зупиняє розмотування в поточній функції. Ця функція нормально повертається до свого викликача.
package main

import "fmt"

func safe() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered:", r)
        }
    }()
    panic("oops")
}

func main() {
    safe()
    fmt.Println("main keeps going")
}
// output:
// recovered: oops
// main keeps going

Конструкція defer func() { ... }() — це універсальний патерн:

  • Відкладений виклик повинен бути функцією (не можна зробити defer recover() напряму — технічно можна, але це майже завжди помилка).
  • recover() повинен викликатись з функції, яка сама безпосередньо є відкладеною. Якщо відкладена функція викликає іншу допоміжну, а та викликає recover(), panic перехоплено не буде — на тій глибині panic вже не активний.
func wrong() {
    defer func() {            // це відкладена функція
        helper()              // helper викликається ВІД відкладеної функції
    }()
    panic("boom")
}

func helper() {
    if r := recover(); r != nil {       // ніколи не спрацює — неправильний фрейм
        fmt.Println("won't print")
    }
}

Виправлення просте: викликайте recover безпосередньо всередині літерала відкладеної функції, а не з допоміжної функції.

Повний приклад розмотування

package main

import "fmt"

func main() {
    f()
    fmt.Println("returned normally from f")
}

func f() {
    defer func() {
        if r := recover(); r != nil {
            fmt.Println("recovered in f:", r)
        }
    }()
    fmt.Println("calling g")
    g(0)
    fmt.Println("returned normally from g")
}

func g(i int) {
    if i > 3 {
        fmt.Println("panicking!")
        panic(fmt.Sprintf("%v", i))
    }
    defer fmt.Println("defer in g", i)
    fmt.Println("printing in g", i)
    g(i + 1)
}

Вивід:

calling g
printing in g 0
printing in g 1
printing in g 2
printing in g 3
panicking!
defer in g 3
defer in g 2
defer in g 1
defer in g 0
recovered in f: 4
returned normally from f

Зверніть увагу:

  • Кожен defer на шляху розмотування виконався.
  • Рядок «returned normally from g» у g не виведено — panic пропустив його.
  • recover у f зупинив розмотування; main побачила, що f повернулась нормально, і вивела свій останній рядок.

Коли використовувати panic — три законні випадки

  1. Помилка програміста, яку система типів не може зловити. Виклик divide(0, 0), індексування зрізу, в якому щойно підтверджено 3 елементи, на позиції 5, звернення до поля структури, яке мало бути задане. Це баги; правильна поведінка — аварійно завершитись і виявити їх.

Стандартна бібліотека надає один допоміжний засіб для цього:

if user == nil {
    panic("logic error: caller must initialise user")
}
  1. Невідновлювана умова часу виконання. Програма буквально не може продовжувати роботу. Наприклад, втрата необхідної конфігурації при запуску.

  2. Як внутрішній механізм розмотування, з відновленням на межі пакету. Стандартна бібліотека так і робить — encoding/json панікує всередині під час рекурсивного обходу і відновлюється на верхівці Marshal/Unmarshal, потім повертає звичайний error викликачу.

Зовнішній API все одно повертає error. Викликач ніколи не бачить panic.

Коли не використовувати panic

  • Очікуваний випадок помилки («файл не існує», «введення невалідне», «мережевий виклик не вдався»). Поверніть error.
  • Щоб заощадити на перевірках помилок. Повторювані блоки if err != nil — це ідіома, а не запах коду.
  • Щоб імітувати виключення. Дизайн Go свідомо відкидає виключення. Не варто їх винаходити заново.

З досвіду Python: Python використовує виключення як для звичайних помилок, так і для помилок програміста (KeyError, IndexError, TypeError). Go розділяє відповідальність — error для звичайних збоїв, panic лише для «такого не повинно траплятись». Перехоплення panic через recover — приблизний аналог except: на межі процесу, а не звичайний інструмент управління потоком.

recover на межі горутини

(Пропустіть цей розділ, якщо ви ще не стикалися з горутинами — це легковагові конкурентні задачі Go, розглядаються в окремій темі. Ключове слово go запускає функцію як горутину: go work() виконує work паралельно з викликачем, не блокуючи його.)

Panic розмотує лише поточну горутину (goroutine). Якщо горутина панікує і ніщо в її стеку не виконує recover, вся програма завершується аварійно — навіть якщо всі інші горутини були справні.

Тому: якщо ви запускаєте горутину, яка може панікувати, сама горутина повинна мати recover на верхівці (або ви маєте бути впевнені, що горутина не може панікувати).

go func() {
    defer func() {
        if r := recover(); r != nil {
            log.Println("worker crashed:", r)
        }
    }()
    runJob()
}()

Це часте джерело виробничих збоїв: необроблений panic у фоновій горутині завалює весь процес.

Значення panic може бути будь-якого типу

panic приймає any. Можна панікувати з рядком, помилкою, власною структурою — будь-чим.

panic("oops")
panic(errors.New("disk full"))
panic(struct{ Code int }{42})

Угода: якщо панікуєте з помилкою, можна виконати recover і повернути її як помилку. Частина r.(error) — це перевірка типу (type assertion) — читайте як «якщо r насправді містить error, дай мені його як e, і встанови ok в true; інакше ok дорівнює false». Перевірки типу отримають власний розгляд в темі інтерфейсів; поки що сприймайте цю конструкцію як «розпакувати значення any у відомий тип, безпечно».

func safeRun(f func()) (err error) {
    defer func() {
        if r := recover(); r != nil {
            if e, ok := r.(error); ok {
                err = e
            } else {
                err = fmt.Errorf("panic: %v", r)
            }
        }
    }()
    f()
    return nil
}

Короткий довідник

Потреба Запис
Аварійне завершення при неможливому стані panic("invariant violated: ...")
Перехопити panic на межі defer func() { if r := recover(); r != nil { ... } }()
Перетворити panic на помилку recover у іменоване повернення: defer func() { if r := recover(); r != nil { err = ... } }()
Захистити фонову горутину загорніть її тіло в defer recover() і залогуйте значення
Просто повернути помилку замість цього завжди ваш перший вибір — дивіться 07-errors.md

Джерела