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); финализатор — только если держите неуправляемый хендл напрямую. - Финализатор недетерминирован, продлевает жизнь объекта на лишнюю сборку и может не выполниться. Он — страховка, а не альтернатива.