JavaRush /Курсы /Go SELF /Гарантированное освобождение ресурсов

Гарантированное освобождение ресурсов

Go SELF
18 уровень , 1 лекция
Открыта

1. Ресурс в Go: любой сценарий «взял верни»

Когда говорят «ресурс», мозг новичка обычно рисует файл на диске. Но на практике ресурс — это любая штука, для которой есть договор владения: ты что-то «взял», и пока не «вернул», оно считается занятым, заблокированным или расходует ограниченный лимит. И если забыть вернуть — система может начать вести себя странно, медленно или просто упасть в самый неподходящий момент (обычно прямо перед дедлайном).

В Go к ресурсам часто относятся файловые дескрипторы (нужно Close()), сетевые соединения (тоже Close()), блокировки (Unlock()), иногда даже «временная аренда» чего-то в памяти. Общая идея одна: получить ресурс легко, но закрыть/освободить нужно гарантированно, даже если в середине функции вы сделали return из-за ошибки.

Здесь defer выступает как «стикер на монитор»: «Не забудь выключить свет, когда выйдешь».

2. Базовый протокол: получил проверил err поставил defer

Главная ловушка новичка в том, что defer выглядит как «что-то, что я напишу потом». А правильная привычка наоборот: как только вы успешно получили ресурс, сразу ставьте defer на его освобождение — пока вы ещё помните, что ресурс вообще появился.

Есть почти ритуальная последовательность:

  1. получить ресурс и ошибку
  2. если ошибка — выйти
  3. если успех — поставить defer на освобождение
  4. продолжать основную работу

Мини-скелет (абстрактный, просто чтобы в голове «встало»):

package main

import "fmt"

func doSomething() (err error) {
    res, err := acquire()
    if err != nil {
        return err
    }
    defer release(res)

    fmt.Println("work with resource")
    return nil
}

func acquire() (int, error) { return 1, nil }
func release(_ int)         {}

func main() {
    _ = doSomething()
}

Заметьте важный психологический момент: defer ставится не в конце, а «рядом с местом, где ресурс появился».

3. Close: почему забыть закрыть — реально проблема

Файлы, соединения и прочие «Close-able» штуки в Go обычно дают вам метод Close() error. Даже если вы пока ещё не проходили файловую систему глубоко, сам принцип нам уже нужен: открыл — закрой.

Покажу два варианта: «хрупкий» и «нормальный».

Хрупкий (пример учебный — не копируйте так в жизнь):

package main

import (
    "fmt"
    "os"
)

func readFirstByte(path string) (byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return 0, err
    }

    var buf [1]byte
    _, err = f.Read(buf[:])
    f.Close() // если мы раньше сделаем return, файл не закроется
    if err != nil {
        return 0, err
    }
    return buf[0], nil
}

func main() {
    b, err := readFirstByte("data.txt")
    fmt.Println(b, err)
}

Нормальный (закрытие гарантировано при любом выходе из функции):

package main

import (
    "fmt"
    "os"
)

func readFirstByte(path string) (byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return 0, err
    }
    defer f.Close()

    var buf [1]byte
    _, err = f.Read(buf[:])
    if err != nil {
        return 0, err
    }
    return buf[0], nil
}

func main() {
    b, err := readFirstByte("data.txt")
    fmt.Println(b, err)
}

Модель мышления здесь очень простая: defer f.Close() превращает «мне надо помнить закрыть» в «закроется само при выходе».

4. Unlock: тот же ресурс, только про «доступ»

Когда вы видите Lock(), думайте: «я взял монопольный доступ». Пока вы не сделали Unlock(), остальные части программы (или другие горутины, но это мы пока не углубляем) могут ждать. Даже если вы пока не пишете конкурентный код, сам паттерн Lock/Unlock полезно понимать как пример ресурса, который нельзя забывать освобождать.

Классический паттерн выглядит так:

mu.Lock()
defer mu.Unlock()

Сделаем минимальный пример. Здесь конкуренции нет, но паттерн читается:

package main

import (
    "fmt"
    "sync"
)

var mu sync.Mutex
var counter int

func incSafely() {
    mu.Lock()
    defer mu.Unlock()

    counter++
    fmt.Println("counter =", counter) // counter = 1 (потом 2, 3...)
}

func main() {
    incSafely()
    incSafely()
}

Почему defer хорош именно тут? Потому что добавьте в середину функции 3 проверки, 2 ранних return, и шанс забыть Unlock() резко вырастет. defer убирает этот риск.

5. Ошибки при cleanup: что делать, если Close вернул ошибку

Теперь самое интересное (и самое жизненное): иногда освобождение ресурса само может завершиться ошибкой.

mu.Unlock() ошибок не возвращает — там нечего обсуждать. А вот Close() очень часто возвращает error. И это не «формальность»: например, при записи данных ошибка может всплыть именно в момент закрытия (буфер дописывается, система наконец-то говорит «ой, места нет», и привет).

Проблема: defer f.Close() выглядит красиво, но… куда деть ошибку из Close()?

Есть несколько стратегий, и важно понимать их смысл, а не заучивать «магическую строчку».

Простая стратегия: игнорировать ошибку Close

Иногда это допустимо. Например, вы читали файл (не писали), и закрытие почти никогда не важно для «корректности» результата. Тогда можно честно сделать так:

defer func() { _ = f.Close() }()

Но важно: это осознанное решение. Не «я не знал что делать, поэтому _ =».

Практичная стратегия: если основной ошибки нет — вернуть ошибку Close

То есть Close() становится «ошибкой по умолчанию», но не перетирает основную.

Для этого удобно использовать именованный err, потому что defer может менять именованный результат прямо перед возвратом.

Пример:

package main

import (
    "fmt"
    "os"
)

func readSomething(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer func() {
        if cerr := f.Close(); cerr != nil && err == nil {
            err = cerr
        }
    }()

    // представим, что тут работа, которая может вернуть ошибку
    return nil
}

func main() {
    fmt.Println(readSomething("data.txt"))
}

Идея: если всё было хорошо, но закрытие не удалось — мы хотим об этом сообщить.

«Честная» стратегия: вернуть обе ошибки через errors.Join

Если у вас уже есть ошибка из основной работы, а Close() тоже упал, то выбор «какую ошибку оставить» — всегда немного грустный. errors.Join позволяет не выбирать: возвращаем обе. Это очень в духе Go: не прячем проблемы под ковёр.

package main

import (
    "errors"
    "fmt"
    "os"
)

func doWork(path string) (err error) {
    f, err := os.Open(path)
    if err != nil {
        return err
    }
    defer func() {
        if cerr := f.Close(); cerr != nil {
            err = errors.Join(err, cerr)
        }
    }()

    // Пусть основная работа “падает”
    return errors.New("work failed")
}

func main() {
    fmt.Println(doWork("data.txt"))
}

Здесь errors.Join(err, cerr) аккуратно обработает даже случай, когда err == nil (тогда вернётся просто cerr). Это удобно: можно не писать лишние if.

Как выбрать стратегию: короткий ориентир

Когда вы в реальном проекте решаете «что делать с ошибкой Close», полезно иметь в голове не «рецепт», а критерий. Держите компактную таблицу-ориентир.

Ситуация Что обычно делаем Почему
Читали данные, результат уже получен, Close() почти не влияет Иногда игнорируем Close() Ошибка закрытия редко важна для логики чтения
Писали данные, и успешность операции важна Ошибку Close() нельзя терять Ошибка может означать, что данные не сохранились
Есть ошибка основной работы и есть ошибка Close() Часто errors.Join Чтобы не «выбирать, кого потерять»

Почему deferred-cleanup должен быть «коротким и скучным»

Иногда хочется в defer сделать половину программы: логирование, ретраи, «а давай ещё вот тут проверим…». Не надо.

defer хорош как маленькая гарантия: закрыть, разблокировать, отпустить. Чем сложнее cleanup, тем выше шанс, что он сам начнёт падать непредсказуемо, и вы получите «ошибку при обработке ошибки при закрытии обработки ошибки».

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

6. Defer в цикле: «не баг, но ловушка»

Очень типичный сюрприз: человек пишет defer внутри цикла и думает, что закрытие произойдёт «в конце итерации». Нет: закрытие произойдёт в конце функции.

Смотрите:

package main

import "fmt"

func demo() {
    for i := 0; i < 3; i++ {
        defer fmt.Println("close resource", i)
        fmt.Println("work", i)
    }
}

func main() {
    demo()
}
// work 0
// work 1
// work 2
// close resource 2
// close resource 1
// close resource 0

Это иногда нормально (например, вы правда хотите закрыть всё в конце). Но если вы в цикле открываете много ресурсов, то defer внутри цикла может привести к тому, что вы держите ресурсы открытыми слишком долго.

Если вам нужно «закрывать в конце каждой итерации», обычно делают так: выносите тело итерации в отдельную функцию и ставите defer внутри неё — тогда «конец итерации» совпадает с «выходом из функции».

package main

import "fmt"

func oneIteration(i int) {
    defer fmt.Println("cleanup", i)
    fmt.Println("work", i)
}

func main() {
    for i := 0; i < 3; i++ {
        oneIteration(i)
    }
}
// work 0
// cleanup 0
// work 1
// cleanup 1
// work 2
// cleanup 2

7. Пример: экспорт отчёта с корректным defer и Close

Представим, что наше учебное консольное приложение уже умеет держать список задач в памяти (например, []string) и печатать их. Добавим «экспорт отчёта в файл» как учебную иллюстрацию владения ресурсом и корректного закрытия. Мы не углубляемся в файловую систему — нас интересует только паттерн: открыл defer обработал ошибку Close().

Функция экспорта (обратите внимание на именованный err и errors.Join):

package main

import (
    "errors"
    "fmt"
    "os"
)

func exportTasks(path string, tasks []string) (err error) {
    f, err := os.Create(path)
    if err != nil {
        return fmt.Errorf("create report %q: %w", path, err)
    }
    defer func() {
        if cerr := f.Close(); cerr != nil {
            err = errors.Join(err, fmt.Errorf("close report %q: %w", path, cerr))
        }
    }()

    for i, t := range tasks {
        _, werr := fmt.Fprintf(f, "%d) %s\n", i+1, t)
        if werr != nil {
            return fmt.Errorf("write report %q: %w", path, werr)
        }
    }
    return nil
}

А вот минимальный main, который вызывает экспорт:

package main

import "fmt"

func main() {
    tasks := []string{"купить молоко", "прочитать про defer", "не паниковать"}

    err := exportTasks("tasks.txt", tasks)
    fmt.Println("export err:", err) // export err: <nil>  (если всё хорошо)
}

Почему этот пример хороший именно для сегодняшней темы:

Он показывает, что «освобождение ресурса» — это часть контракта функции. Мы не надеемся, что ОС сама «как-нибудь закроет файл». Мы закрываем гарантированно и не теряем ошибку закрытия, если она случилась.

8. Типичные ошибки

Ошибка №1: ставить defer до проверки err.
Очень распространённый баг выглядит так: вы открыли ресурс, не проверили ошибку, поставили defer, а потом выяснилось, что ресурса на самом деле нет. В лучшем случае это приведёт к панике/ошибке «nil pointer», в худшем — к странному поведению. Правильный порядок почти всегда один: сначала if err != nil { return ... }, и только потом defer Close/Unlock.

Ошибка №2: думать, что defer в цикле срабатывает «в конце итерации».
defer привязан к выходу из функции, а не к блоку цикла. Поэтому внутри больших циклов легко случайно «накопить» сотни отложенных закрытий и держать ресурсы открытыми намного дольше, чем вы ожидали. Если нужно «ресурс на итерацию», выносите итерацию в отдельную функцию.

Ошибка №3: терять ошибку Close() «по привычке».
Когда вы пишете данные, ошибка Close() может быть реальным сигналом, что запись не завершилась корректно. Игнорировать её — всё равно что сказать пользователю «файл сохранён», не глядя. Если операция важная, используйте именованный err и аккуратно добавляйте ошибку закрытия через errors.Join или хотя бы возвращайте cerr, когда основной ошибки нет.

Ошибка №4: в defer делать «полбизнес-логики» и пытаться продолжать работу после критичного сбоя.
defer — не место для сложных сценариев и уж точно не место, где вы «чините всё на ходу». Cleanup должен быть предсказуемым и коротким: закрыть, разблокировать, освободить. Если cleanup падает — вы обычно сообщаете об этом наружу через err, а не пытаетесь «продолжить как будто ничего не было».

Ошибка №5: забывать, что Unlock() — это тоже освобождение ресурса.
Иногда люди относятся к Unlock() как к «необязательной скобке», особенно если пока не используют горутины. Но блокировка — это ресурс доступа. Не освободили — значит, кто-то потом будет ждать. Паттерн Lock(); defer Unlock() — один из самых читаемых и безопасных способов писать защищённый участок кода.

1
Задача
Go SELF, 18 уровень, 1 лекция
Недоступна
Уборка смены
Уборка смены
1
Задача
Go SELF, 18 уровень, 1 лекция
Недоступна
Безопасный сейф
Безопасный сейф
1
Задача
Go SELF, 18 уровень, 1 лекция
Недоступна
Закрыть файл
Закрыть файл
1
Задача
Go SELF, 18 уровень, 1 лекция
Недоступна
Двойной отчёт
Двойной отчёт
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ