IDisposable и using

В каком порядке напечатаются строки — и напечатаются ли вообще, если посреди блока вылетит исключение?

IDisposable — контракт «я держу что-то, что GC освободить не может; скажи мне, когда я больше не нужен, и я освобожу это прямо сейчас».

Классический вопрос собеседования звучит так: «Зачем в C# нужен IDisposable, если есть сборщик мусора?» Начнём с кода — он уже содержит половину ответа.

using System;

class Resource : IDisposable
{
    private readonly string _name;

    public Resource(string name)
    {
        _name = name;
        Console.WriteLine($"Открыли {_name}");
    }

    public void Dispose() => Console.WriteLine($"Закрыли {_name}");
}

class Program
{
    static void Main()
    {
        try
        {
            using (Resource a = new Resource("A"))
            using (Resource b = new Resource("B"))
            {
                Console.WriteLine("Работаем с ресурсами");
                throw new InvalidOperationException("Что-то пошло не так");
            }
        }
        catch (InvalidOperationException ex)
        {
            Console.WriteLine($"Поймали: {ex.Message}");
        }
    }
}

Результат:

Открыли A
Открыли B
Работаем с ресурсами
Закрыли B
Закрыли A
Поймали: Что-то пошло не так

Разбор: два неочевидных момента

Первое: ресурсы освобождаются, несмотря на исключение. Никакого «утекло, потому что упало» не происходит. Второе: порядок обратный порядку захвата — сначала B, потом A. Это логично: внутренний ресурс мог быть построен поверх внешнего (например, StreamReader поверх FileStream), и закрывать надо начиная с самого «верхнего».

Зачем это нужно, если есть GC

Сборщик мусора умеет ровно одно: освобождать управляемую память. Всё остальное — вне его компетенции.

РесурсКто освобождает
Обычный объект в куче (List<int>, строка, DTO)GC, автоматически
Файловый дескриптор, сокет, соединение с БДТолько вы — через Dispose()
Хендл окна, GDI-объект, блок native-памятиТолько вы — через Dispose()
Подписка на событие, элемент кеша, таймерТолько вы — через Dispose()

И даже там, где GC теоретически справится, важно когда. Объект с открытым файлом может пролежать в куче минуты, пока не случится сборка нужного поколения. Всё это время файл заблокирован, соединение занимает слот пула, а пул на 100 соединений исчерпывается за пару секунд нагрузки. IDisposable — это про детерминированность: освобождение происходит в известной точке кода, а не «когда-нибудь».

Как это работает под капотом

using — синтаксический сахар. Компилятор разворачивает его в try/finally:

// то, что вы написали
using (Resource r = new Resource("A"))
{
    Work(r);
}

// то, что увидит IL
Resource r = new Resource("A");
try
{
    Work(r);
}
finally
{
    if (r != null) ((IDisposable)r).Dispose();
}

Отсюда все свойства сразу становятся очевидны: finally выполняется и при исключении, и при return; вложенные using дают вложенные try/finally, а значит освобождение идёт изнутри наружу; проверка на null означает, что using (null) не упадёт.

using-declaration (C# 8)

Чтобы не плодить лестницу отступов, с C# 8 можно писать using как объявление переменной. Тогда Dispose() вызовется в конце области видимости:

using System;

class Resource : IDisposable
{
    private readonly string _name;
    public Resource(string name) => _name = name;
    public void Dispose() => Console.WriteLine($"Dispose {_name}");
}

class Program
{
    static void Main()
    {
        Console.WriteLine("Начало Main");

        using Resource a = new Resource("A");
        using Resource b = new Resource("B");

        Console.WriteLine("Конец Main");
    }
}

Результат:

Начало Main
Конец Main
Dispose B
Dispose A

Порядок снова обратный — стек, как и полагается. Есть и асинхронный близнец: await using для типов, реализующих IAsyncDisposable (нужен, когда закрытие ресурса само по себе — операция ввода-вывода, например сброс буфера в сеть).

Полный паттерн Dispose

Если ваш класс держит неуправляемый ресурс напрямую, одного публичного Dispose() мало — нужен канонический паттерн:

public class FileBuffer : IDisposable
{
    private IntPtr _handle;      // неуправляемый ресурс
    private Stream _stream;      // управляемый ресурс, но тоже IDisposable
    private bool _disposed;

    public void Dispose()
    {
        Dispose(disposing: true);
        GC.SuppressFinalize(this);   // финализатор больше не нужен
    }

    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;       // идемпотентность: повторный вызов безопасен

        if (disposing)
        {
            // сюда попадаем ТОЛЬКО из явного Dispose()
            _stream?.Dispose();
        }

        // неуправляемое чистим всегда — и из Dispose(), и из финализатора
        if (_handle != IntPtr.Zero)
        {
            NativeMethods.CloseHandle(_handle);
            _handle = IntPtr.Zero;
        }

        _disposed = true;
    }

    ~FileBuffer() => Dispose(disposing: false);
}

Почему disposing? Потому что финализаторы вызываются на отдельном потоке и в непредсказуемом порядке: к моменту вашего финализатора _stream может быть уже финализирован. Трогать чужие управляемые объекты оттуда нельзя — можно наткнуться на полудохлый объект. Отсюда правило: управляемое освобождаем только при disposing == true, неуправляемое — всегда.

А GC.SuppressFinalize(this) говорит: «ресурсы уже отпущены, снимай меня с очереди финализации». Без этого объект переживёт лишний цикл сборки просто так.

Почему финализатор — не замена Dispose

using System;

class LegacyResource
{
    ~LegacyResource() => Console.WriteLine("   [финализатор] ресурс наконец освобождён");
}

class Program
{
    static void CreateAndForget()
    {
        LegacyResource r = new LegacyResource();
        Console.WriteLine("Создали объект, дальше ссылка на него теряется");
        GC.KeepAlive(r);
    }

    static void Main()
    {
        CreateAndForget();
        Console.WriteLine("Объект уже недостижим, но ресурс всё ещё занят");

        GC.Collect();
        GC.WaitForPendingFinalizers();

        Console.WriteLine("Освобождение случилось только после сборки мусора");
    }
}

Результат:

Создали объект, дальше ссылка на него теряется
Объект уже недостижим, но ресурс всё ещё занят
   [финализатор] ресурс наконец освобождён
Освобождение случилось только после сборки мусора

Мы вручную заставили GC поработать — и только тогда финализатор сработал. В боевом коде такой сборки может не случиться ещё долго. Более того, объект с финализатором проходит два круга: сначала сборщик кладёт его в очередь финализации (и объект автоматически переживает эту сборку, повышаясь в поколении), потом отдельный поток вызывает ~Finalize, и лишь на следующей сборке память освобождается. Плюс: порядок финализаторов не определён, при аварийном завершении процесса они могут не выполниться вовсе, а исключение внутри финализатора роняет процесс.

Итого: финализатор — это страховка на случай, если пользователь класса забыл вызвать Dispose(). Основной механизм — using.

Частые ошибки на собеседовании

  • «Dispose вызывает сборщик мусора». Нет. GC вызывает только финализатор (если он есть). Dispose() зовёте вы или компилятор через using.
  • Добавлять финализатор «на всякий случай». Если класс не держит неуправляемый ресурс напрямую (а только другие IDisposable), финализатор не нужен — он лишь замедлит сборку.
  • Забывать про GC.SuppressFinalize в классе с финализатором.
  • Не делать Dispose() идемпотентным. Повторный вызов обязан быть безопасным — отсюда флаг _disposed.
  • Оборачивать в using то, что не надо. Классика — HttpClient: его создают один раз на приложение, а using в цикле приводит к исчерпанию сокетов в состоянии TIME_WAIT.

Итоги-шпаргалка

  • IDisposable нужен потому, что GC освобождает только память, а файлы, сокеты, соединения и подписки — не его забота. Плюс важна детерминированность: «сейчас», а не «когда-нибудь».
  • using = try/finally с Dispose() в finally. Работает и при исключении, и при return. Вложенные — освобождаются в обратном порядке.
  • using var x = ...; (C# 8) освобождает в конце области видимости; await using — для IAsyncDisposable.
  • Полный паттерн: публичный Dispose()Dispose(bool disposing) + GC.SuppressFinalize(this); финализатор — только если держите неуправляемый хендл напрямую.
  • Финализатор недетерминирован, продлевает жизнь объекта на лишнюю сборку и может не выполниться. Он — страховка, а не альтернатива.
Проверьте себя
1. Во что компилятор C# разворачивает конструкцию using (var r = new Resource()) { ... }?
AВ вызов финализатора ~Resource() сразу после блока
BВ try/finally, где в finally вызывается r.Dispose()
CВ GC.Collect() сразу после блока
DВ try/catch, который молча глотает исключения внутри блока
2. Зачем в паттерне Dispose вызывать GC.SuppressFinalize(this)?
AЧтобы запретить сборщику мусора когда-либо собирать этот объект
BЧтобы снять объект с очереди финализации: ресурсы уже освобождены, а финализация продлила бы жизнь объекта на лишнюю сборку
CЧтобы Dispose() гарантированно вызвался дважды
DЧтобы принудительно запустить полную сборку мусора