Большинство C#-разработчиков знают правило: если объект реализует IDisposable, оберни его в using. В 80% случаев это работает. Оставшиеся 20% начинаются, когда объект передаётся в другой метод, уходит в фоновый поток, живёт в DI-контейнере или попадает в коллекцию.

В этих случаях using создаёт баги, которые неделями ловят в продакшене.

using + return = закрытый объект

Код, который выглядит правильно:

using var connection = new SqlConnection(connectionString);
await connection.OpenAsync();
return connection;

Метод открывает соединение и возвращает его. Using гарантирует Dispose при выходе из scope. Проблема в том, что выход из scope происходит сразу после return, и вызывающий код получает уже закрытое соединение. На локальной машине с быстрой БД это может работать из-за connection pooling. В проде под нагрузкой вы получите ObjectDisposedException.

Исправление: убрать using и переложить ответственность за Dispose на вызывающий код. Это неприятно, потому что вызывающий должен знать, что ему вернули IDisposable, а если он передаст дальше, ответственность размывается. Но альтернатива хуже.

Фоновый поток: using закрывает раньше, чем нужно

public void StartProcessing()
{
    using var stream = File.OpenRead("data.bin");
    Task.Run(() => ProcessStream(stream));
}

Using закрывает stream при выходе из StartProcessing. Task.Run запускает обработку в фоне.

Получается гонка: если ProcessStream начнёт читать после закрытия stream, будет ObjectDisposedException. Если успеет до, всё работает. Нестабильный баг, который воспроизводится только под нагрузкой.

Правильный вариант: передать владение потоку.

public void StartProcessing()
{
    var stream = File.OpenRead("data.bin");
    Task.Run(async () =>
    {
        try
        {
            await ProcessStream(stream);
        }
        finally
        {
            await stream.DisposeAsync();
        }
    });
}

Тот, кто реально использует ресурс, тот и освобождает.

DI-контейнер: неочевидное различие

// Вариант A: контейнер создаёт объект
services.AddSingleton<ICache, RedisCache>();
// Контейнер вызовет Dispose при завершении

// Вариант B: вы передаёте готовый экземпляр
var cache = new RedisCache(connection);
services.AddSingleton<ICache>(cache);
// Контейнер НЕ вызовет Dispose

Разница в том, кто создал объект.

Если контейнер создал, он считает себя владельцем и вызовет Dispose. Если вы создали и передали, контейнер считает владельцем вас. Соединение утекает, в логах ничего, а через неделю пул соединений Redis исчерпается.

Коллекции одноразовых объектов

var handlers = new List<HttpMessageHandler>();
for (int i = 0; i < 5; i++)
    handlers.Add(new HttpClientHandler());

List<T> не реализует IDisposable. Когда список уходит из scope, GC соберёт его, но Dispose на элементах не вызовет. HttpClientHandler держит сокеты, и финализатор когда-нибудь их освободит, но «когда-нибудь» не работает на сервере с тысячами запросов.

Решение простое:

var handlers = new List<HttpMessageHandler>();
try
{
    // работа с handlers
}
finally
{
    foreach (var h in handlers) h.Dispose();
}

HttpClient: самый известный IDisposable, который не надо диспозить

HttpClient реализует IDisposable, и интуиция подсказывает обернуть его в using:

using var client = new HttpClient();
var response = await client.GetAsync("https://api.example.com/data");

Код корректный, но на сервере это антипаттерн.

При каждом Dispose HttpClient закрывает пул TCP-соединений. Новый HttpClient открывает новые соединения. Под нагрузкой вы исчерпаете порты: сокеты после закрытия висят в состоянии TIME_WAIT примерно две минуты (зависит от ОС). При тысяче запросов в секунду за минуту накопится 60 000 сокетов в TIME_WAIT, и новые соединения перестанут открываться.

Правильный подход: один HttpClient на всё время жизни приложения (или IHttpClientFactory, которая управляет пулом за вас):

// В Startup/Program.cs:
services.AddHttpClient<MyService>(client =>
{
    client.BaseAddress = new Uri("https://api.example.com");
});

// В MyService:
public class MyService
{
    private readonly HttpClient _client;
    
    public MyService(HttpClient client)
    {
        _client = client;  // фабрика управляет временем жизни
    }
}

IHttpClientFactory решает обе проблемы: не создаёт лишних соединений и при этом периодически обновляет DNS (чего не делает вечно живущий HttpClient).

Это пример, когда правило «IDisposable = using» не просто не помогает, а вредит. IDisposable на HttpClient существует для корректного завершения при shutdown приложения, а не для того, чтобы закрывать его после каждого запроса.

IAsyncDisposable: два контракта

С C# 8.0 появился IAsyncDisposable и await using. Некоторые ресурсы требуют асинхронного освобождения (flush буферов, закрытие сетевых соединений). Но теперь у вас два контракта, и реализовывать приходится оба, потому что объект может оказаться в контексте, который не поддерживает await: синхронный Dispose из финализатора, из старого кода, из библиотеки, знающей только про IDisposable.

public class FileProcessor : IAsyncDisposable, IDisposable
{
    private readonly FileStream _stream;
    
    public async ValueTask DisposeAsync()
    {
        await _stream.FlushAsync();
        await _stream.DisposeAsync();
    }
    
    public void Dispose()
    {
        _stream.Flush();
        _stream.Dispose();
    }
}

Если ваш тип оборачивает только managed-ресурсы (другие IDisposable), финализатор не нужен. SafeHandle уже реализует правильный паттерн финализации для нативных ресурсов.

Классический паттерн с protected virtual void Dispose(bool disposing) и деструктором ~ClassName нужен только если вы напрямую работаете с IntPtr.

Правило одного владельца

У каждого IDisposable-объекта должен быть ровно один владелец, и этот владелец должен быть очевиден из кода. Создаётся в методе и не покидает его: using. Передаётся наружу: вызывающий код становится владельцем. Живёт в DI-контейнере: контейнер владелец, но только если он его создал. Уходит в фоновый поток: поток владелец.

Если вы не можете за три секунды сказать, кто вызовет Dispose на конкретном объекте, у вас проблема. Using решает только самый простой случай, для всех остальных нужно думать.

Ошибки с IDisposable, using, временем жизни объектов и DI‑контейнером — это как раз тот слой C#‑разработки, где знание синтаксиса уже не спасает. Нужно понимать, как код ведёт себя в реальном приложении: под нагрузкой, в многопоточности, при работе с сетью, файловой системой, контейнерами зависимостей и внешними сервисами.

На курсе «C#-разработчик. Продвинутый уровень» разбираем C# не на уровне «как написать класс», а на уровне инженерной практики: проектирование приложений, качество кода, работа с асинхронностью, производительность, тестирование и поддерживаемость решений в продакшене.

Перед стартом можно пройти бесплатное вступительное тестирование: оно поможет оценить текущий уровень и понять, насколько программа курса подходит под ваши задачи.

Также можно присоединиться к открытым урокам:

Открытые уроки бесплатные, проходят в рамках онлайн‑курса и дают возможность познакомиться с преподавателями‑практиками, форматом обучения и задать вопросы по программе.

Комментарии (1)


  1. NarkkoZ
    06.05.2026 06:45

    C Redis конечно интересный пример. Там ведь Singleton он все равно не вызовет Dispose до закрытия приложения. В целом псыл понятен, но вот пример совершенно не корректный