IDisposable, using, финализаторы, IAsyncDisposable

↑ 2.2 .NET runtime: CLR, память, GC · ← Предыдущая · → Следующая

Обязательно~8 мин чтенияУровень: middle

Зачем это на собесе

GC освобождает только управляемую память, а соединения с БД, файлы, сокеты и HTTP-ответы надо закрывать явно. Спросят паттерн Dispose, разницу Dispose и финализатора, using и кто освобождает сервисы в DI.

Объяснение

Управляемые и неуправляемые ресурсы

  • Управляемая память — освобождает GC.
  • Неуправляемые ресурсы — дескрипторы ОС (файлы, сокеты), соединения с БД, нативная память, блокировки. GC о них не знает и не может своевременно освободить.

IDisposable и using

using (var conn = new NpgsqlConnection(cs)) { ... }          // Dispose в finally
 
using var file = File.OpenRead(path);                          // using-объявление: Dispose в конце области видимости
await using var conn = await dataSource.OpenConnectionAsync(ct);   // IAsyncDisposable — асинхронное освобождение

using — это try { ... } finally { obj?.Dispose(); }. Ресурс освобождается даже при исключении.

IAsyncDisposable

Для ресурсов, чьё закрытие требует I/O (сброс буфера в сеть, закрытие соединения): DisposeAsync(). Пример: DbConnection, Stream, DbContext, клиенты брокеров.

Реализация Dispose (упрощённый современный вариант)

Если класс только владеет другими IDisposable (частый случай) — финализатор не нужен:

public sealed class ReportRenderer : IDisposable, IAsyncDisposable
{
    private readonly MemoryStream _buffer = new();
    private readonly Report _report = new();                   // FastReport — IDisposable
    private bool _disposed;
 
    public void Dispose()
    {
        if (_disposed) return;
        _report.Dispose();
        _buffer.Dispose();
        _disposed = true;
    }
 
    public async ValueTask DisposeAsync()
    {
        if (_disposed) return;
        _report.Dispose();
        await _buffer.DisposeAsync();
        _disposed = true;
    }
 
    public byte[] Render() { ObjectDisposedException.ThrowIf(_disposed, this); ... }
}

Полный паттерн Dispose (для неsealed-классов с неуправляемым ресурсом)

public class NativeHandleOwner : IDisposable
{
    private IntPtr _handle;          // неуправляемый ресурс (лучше обернуть в SafeHandle!)
    private bool _disposed;
 
    public void Dispose() { Dispose(true); GC.SuppressFinalize(this); }   // финализатор больше не нужен
 
    protected virtual void Dispose(bool disposing)
    {
        if (_disposed) return;
        if (disposing) { /* освободить управляемые IDisposable-поля */ }
        if (_handle != IntPtr.Zero) { NativeFree(_handle); _handle = IntPtr.Zero; }   // неуправляемое — всегда
        _disposed = true;
    }
 
    ~NativeHandleOwner() => Dispose(false);   // финализатор — страховка, если Dispose забыли
}

На практике для нативных дескрипторов используют SafeHandle — он сам содержит финализатор, и свой финализатор писать не нужно.

Финализатор (~Class)

  • Вызывается GC недетерминированно (когда-нибудь после того, как объект стал недостижим), в отдельном потоке финализации.
  • Объект с финализатором переживает лишнюю сборку (попадает в очередь финализации) → дороже для GC.
  • Нельзя обращаться к другим управляемым объектам (они могут быть уже собраны).
  • Это страховка, а не способ освобождения ресурсов.

Кто освобождает объекты в DI

  • Сервисы, созданные контейнером и реализующие IDisposable/IAsyncDisposable, контейнер освобождает сам при завершении их scope (Scoped — в конце запроса, Singleton — при остановке приложения).
  • Объекты, созданные вами через new внутри сервиса, освобождаете вы.
  • Экземпляр, переданный при регистрации (services.AddSingleton(new Foo())), контейнер не освобождает.
  • Не вызывайте Dispose у сервисов, полученных из DI, — ими владеет контейнер.

Особые случаи

  • HttpClient: не оборачивать в using на каждый запрос (исчерпание сокетов) → IHttpClientFactory.
  • DbContext: в DI Scoped, освобождается автоматически. Вручную — через IDbContextFactory + await using.
  • HttpResponseMessage, Stream из ответа — освобождать, иначе соединение не возвращается в пул.
  • CancellationTokenSource с таймером (CancelAfter) — Dispose, чтобы остановить таймер.

Нюансы и подводные камни

  • Забытый Dispose у соединения с БД → исчерпание пула соединений (The connection pool has been exhausted).
  • Dispose должен быть идемпотентным (безопасный повторный вызов) и не бросать исключений.
  • Возврат IDisposable из метода передаёт владение вызывающему — документируйте это.
  • using на объекте, который отдаётся наружу (например, возвращаемый Stream), закроет его до того, как вызывающий начнёт читать.

Тестирование

[Fact]
public void Throws_after_dispose()
{
    var r = new ReportRenderer();
    r.Dispose();
    r.Invoking(x => x.Render()).Should().Throw<ObjectDisposedException>();
}

Вопросы с ответами

Связанные темы