Большинство 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# не на уровне «как написать класс», а на уровне инженерной практики: проектирование приложений, качество кода, работа с асинхронностью, производительность, тестирование и поддерживаемость решений в продакшене.
Перед стартом можно пройти бесплатное вступительное тестирование: оно поможет оценить текущий уровень и понять, насколько программа курса подходит под ваши задачи. |
Также можно присоединиться к открытым урокам:
7 мая в 20:00 — «Качество C#‑кода: от модульных тестов к системному подходу»
Разберём, почему зелёные тесты ещё не гарантируют надёжность системы, и что на самом деле влияет на качество кода в долгоживущих проектах.19 мая в 20:00 — «Введение в OpenTelemetry и основы наблюдаемости»
Поговорим о том, как видеть поведение приложения в продакшене: не просто ловить ошибки в логах, а понимать, где именно возникает задержка, сбой или деградация.
Открытые уроки бесплатные, проходят в рамках онлайн‑курса и дают возможность познакомиться с преподавателями‑практиками, форматом обучения и задать вопросы по программе.
NarkkoZ
C
Redisконечно интересный пример. Там ведьSingletonон все равно не вызовет Dispose до закрытия приложения. В целом псыл понятен, но вот пример совершенно не корректный