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(), у Go зазвичай повертають 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 доречний саме тут? Бо додайте в середину функції три перевірки, два ранні return, і шанс забути Unlock() різко зросте. defer прибирає цей ризик.

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

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ