Вы неправильно используете IDisposable: почему using не спасает, когда объект утекает в другой поток

Страницы:  1

Ответить
 

Professor Seleznov


Большинство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();
// Контейнер вызовет Dispose при завершении
// Вариант B: вы передаёте готовый экземпляр
var cache = new RedisCache(connection);
services.AddSingleton(cache);
// Контейнер НЕ вызовет Dispose
Разница в том, кто создал объект.
Если контейнер создал, он считает себя владельцем и вызовет Dispose. Если вы создали и передали, контейнер считает владельцем вас. Соединение утекает, в логах ничего, а через неделю пул соединений Redis исчерпается.
Коллекции одноразовых объектов
var handlers = new List();
for (int i = 0; i < 5; i++)
handlers.Add(new HttpClientHandler());
List не реализует IDisposable. Когда список уходит из scope, GC соберёт его, но Dispose на элементах не вызовет. HttpClientHandler держит сокеты, и финализатор когда-нибудь их освободит, но «когда-нибудь» не работает на сервере с тысячами запросов.
Решение простое:
var handlers = new List();
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(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 решает только самый простой случай, для всех остальных нужно думать.
pic
Ошибки с IDisposable, using, временем жизни объектов и DI‑контейнером — это как раз тот слой C#‑разработки, где знание синтаксиса уже не спасает. Нужно понимать, как код ведёт себя в реальном приложении: под нагрузкой, в многопоточности, при работе с сетью, файловой системой, контейнерами зависимостей и внешними сервисами.
На курсе «C#-разработчик. Продвинутый уровень» разбираем C# не на уровне «как написать класс», а на уровне инженерной практики: проектирование приложений, качество кода, работа с асинхронностью, производительность, тестирование и поддерживаемость решений в продакшене.
Перед стартом можно пройти бесплатное вступительное тестирование: оно поможет оценить текущий уровень и понять, насколько программа курса подходит под ваши задачи.

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