У меня есть фоновая задача, которую я моделирую с помощью Task
и которая останавливается с помощью IAsyncDisposable
.
// implementation
public sealed class Worker : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
private readonly Task _worker;
public Worker()
{
_cts = new CancellationTokenSource();
_worker = DoWork(_cts.Token);
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
return new ValueTask(_worker);
}
private static async Task DoWork(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
do async work
}
}
}
static async Task CorrectUsage()
{
await using var worker = new Worker();
do more stuff
}
static async Task IncorrectUsage()
{
var worker = new Worker();
do more stuff
}
В обычных обстоятельствах это работает нормально, однако мне кажется, что здесь произошла утечка памяти, которая может произойти, если вызывающий код не избавится от worker
. Если компонент Worker
используется неправильно, я бы хотел, чтобы сборщик мусора уничтожил выполняемую задачу, когда она выходит за пределы области действия с помощью вызова _cts.Cancel()
.
Я читал о ~Worker()
и Dispose(bool disposing)
, но вся документация, которую я нашел, указывает на то, что эти методы предназначены специально для удаления неуправляемых ресурсов.
_cts.Cancel()
Dispose(bool disposing)
, существует ли какая-либо документация о том, как это будет вызываться с помощью disposing: false
?Примечание о задачах и GC Могут ли экземпляры .NET Task выйти за пределы области действия во время выполнения?
Мой вопрос касается сценария, в котором DisposeAsync не вызывается. Итак, метод DisposeAsync предназначен только для полноты картины. Добавил редактирование, чтобы сделать это более понятным
Где создается фактическая задача? Только works
здесь является активной задачей, все остальное — синтаксический сахар, необходимый механизму async/await
. Речь идет не об удалении work
, а о выходе из цикла, что может произойти только в том случае, если ваш код детерминированно удаляет Worker
. Утечек нет, вот как написан этот код для работы.
@Panagiotis Kanavos, проверьте метод IncorrectUsage() (это новое редактирование). В этом случае задача никогда не завершится, что является утечкой памяти.
Это не утечка памяти, это ошибка в коде. Вы явно разработали Worker
для завершения цикла, а не задачи, при вызове Dispose. Это значит, что вам необходимо вызвать Dispose
. Вам придется переосмыслить этот дизайн и перестать называть его Задачей — это не так. Он даже не будет работать в фоновом режиме, если work
сам по себе не является реальной асинхронной операцией или результатом Task.Run
.
Я хотел бы сосредоточить обсуждение на том, как предотвратить эту «ошибку утечки памяти» с помощью финализатора GC. Это возможно? Я не хочу вдаваться в причины, почему контекст слишком сложен для вопроса SO.
Если вместо получения IAsyncDisposable
вы получили BackgroundService
или IHostedService
, то промежуточное программное обеспечение DI само позаботится о вызове StopAsync
, чтобы остановить цикл в методе ExecuteAsync
. Однако теперь это ответственность звонящего.
@Шейн, ты задаешь неправильный вопрос, используя неверные термины, о проблемах, вызванных дизайном кода. Это очень просто, и любые споры обернутся Worker
в Task
или забывчивостью вызова Cancel
в утечку памяти.
ДИ здесь не участвует. Думайте о работнике как о чем-то гораздо более простом и общем, например, о List<>
. У меня может быть 100 или 1000 таких проектов одновременно. Насколько ресурсоемкими являются фоновые задачи?
@Shane и даже вопрос о финализаторе показывают непонимание GC - когда GC запускается и вызывает финализатор, нет НИКАКОЙ гарантии, что какой-либо из этих управляемых объектов все еще жив. Если финализатор попытается вызвать _cts, он может обнаружить, что он мертв.
@Shane think of
это BackgroundWorker .NET Framework 1 без прогресса. How resource intensive are background tasks?
нет переднего и заднего плана Задача. Существуют задачи, привязанные к ЦП, например, созданные Task.Run, которые используют ЦП, и асинхронные задачи, которые запускаются, когда что-то сигнализирует им о завершении, например о завершении ввода-вывода. Дело не в том, что вы помогаете мне понять, как работают сборщик мусора или задачи. Что касается рабочих - Parallel.ForEachAsync
поверх IAsyncEnumerable
, возможно, тот, который создается Каналом, является отличным способом одновременной обработки бесконечных потоков входящих сообщений.
@Panagiotis Kanavos «финализатор пытается вызвать _cts и может обнаружить, что он мертв»: на самом деле нет, не будет. Выполняемый поток/задача будет сохранять ссылку на него и поддерживать его работоспособность; «забыть вызвать Cancel»: это философский аргумент об ожиданиях в неуправляемой и управляемой средах. Нам не следует обсуждать здесь; «Насколько ресурсоемкими являются фоновые задачи?»: извините, я хотел сказать «Насколько ресурсоемкими являются BackgroundServices». Ни одна из этих работ не связана с процессором. Темы не подходят, задачи
Они легкие, поскольку представляют собой не что иное, как реализации интерфейса IHostedService, который имеет методы StartAsync и StopAsync, вызываемые средой выполнения.
Вы пытаетесь утверждать, что StopAsync (это то, что делает DisposeAsync) должен вызываться автоматически, а тот факт, что это не работает, является утечкой. IDisposable
или IAsyncDisposable
не подключаются к инфраструктуре GC, они предназначены для того, чтобы код приложения вызывал детерминированное удаление. Не сбор мусора.
@Panagiotis Kanavos «следует вызывать автоматически», да, в этом вся суть; «то, что не работает, — это утечка»: когда память не может быть освобождена — это утечка, независимо от причины; Я изучил BackgroundServices, и они просто перефразировали проблему: «как мне убедиться, что вызывается остановка async, если разработчик неправильно использует компонент». Я чувствую, что теперь мне нужно добавить контекст: этот компонент Worker
представляет собой тип списка. Это не сервис, это не DI, это зависит от того, какой разработчик будет использовать его для улучшения. Существует возможность внедрить фабрику в DI, которая решит эту проблему косвенно.
Как мне заставить GC вызвать
_cts.Cancel()
?
Так:
public sealed class Worker : IAsyncDisposable
{
private readonly CancellationTokenSource _cts;
~Worker()
{
_cts.Cancel();
}
public ValueTask DisposeAsync()
{
_cts.Cancel();
_cts.Dispose();
GC.SuppressFinalize(this);
return new ValueTask(_worker);
}
}
_cts.Cancel()
выдает исключение, если _cts
удаляется. Строка GC.SuppressFinalize(this) гарантирует, что финализатор ~Worker
не будет вызываться, предотвращая это исключение.
В качестве примечания: эта реализация DisposeAsync()
асинхронно предоставляет исключение задачи _worker
, если таковое имеется. Это противоречит коллективному мнению сообщества C#, согласно которому Dispose
/DisposeAsync
не должно генерировать исключения.
Я должен также ответить на вопрос, который вы не задали, а именно: есть ли что-то принципиально неправильное в вызове _cts.Cancel()
в финализаторе. Там может быть. Я не могу гарантировать вам со 100% уверенностью, что все в порядке. Цитата из статьи Криса Брамма «Финализация»:
Одним из правил финализации является то, что метод
Finalize
не должен касаться других объектов. Иногда люди ошибочно полагают, что это потому, что другие объекты уже собраны. Тем не менее, как я объяснил, повышается весь достижимый граф из финализируемого объекта.Настоящая причина этого правила — избегать прикосновения к объектам, которые, возможно, уже были завершены. Это потому, что финализация неупорядочена.
Итак, как и большинство руководящих принципов, это создано для того, чтобы его можно было нарушить при определенных обстоятельствах. Например, если ваш объект «содержит» приватный объект, который сам по себе не является финализируемым, вы, очевидно, можете без риска ссылаться на него из своего собственного метода Finalize.
Эта статья была написана в то время, когда .NET 2.0 еще находилась в разработке (20 февраля 2004 г.), но содержит базовые принципы, которые, вероятно, применяются до сих пор. Из цитируемого текста я делаю следующие выводы:
_cts
(освободить его память) при запуске финализатора ~Worker
, поскольку _cts
является частью достижимого графа финализированного Worker
._cts
уже финализирован (вызывается его финализатор ~CancellationTokenSource
), когда финализатор ~Worker
запускается. AFAICS текущая реализация 🔁 CancellationTokenSource
класса CancellationTokenSource
(.NET 8) не включает финализатор. Если вы используете более раннюю версию платформы .NET, возможно, вам захочется найти и изучить реализацию CancellationTokenSource
целевой версии .NET. Пока _cts.Cancel()
не имеет финализатора, вероятность возникновения исключения _cts
значительно снижается. Я не могу гарантировать, что вероятность равна нулю, поскольку могут существовать другие финализируемые объекты, связанные с конкретными используемыми вами API, которые все еще могут содержать ссылку на _cts.Cancel()
при вызове _cts.Cancel()
, и эти объекты могут уже быть завершено. Вы можете рассмотреть возможность включения try
в блок catch
с пустым блоком Finalize
, чтобы минимизировать влияние этого сценария. Согласно ответу Ханса Пассана, который описывает поведение .NET 2.0:Исключение в финализаторе является фатальным. Хост CLR по умолчанию завершает работу приложения при любом необработанном исключении.
Я должен добавить еще одно мудрое слово из статьи Криса Брамма:
Трудно реализовать
_cts.Cancel()
идеально.
Выглядит неплохо. Я проведу несколько экспериментов и постараюсь добиться успеха. В противном случае это кажется простым решением (будет отмечено как принятое, если я не смогу найти случай сбоя)
@Шейн, единственное, что меня беспокоит, потому что мой опыт работы с финализаторами незначителен, это возможность того, что _cts
будет переработан GC до экземпляра Worker
, которому он принадлежит. Я бы предположил, что сборщик мусора перерабатывает заброшенные объекты в правильном порядке с учетом ссылок между ними, но на 100% не уверен.
Я думаю, что смогу обойти это, сделав код немного более сложным. Я могу передать CTS в метод DoWork
, чтобы он не перерабатывался до завершения DoWork
. Мне просто нужно выяснить, как получить статус Task _worker
(ссылочный тип), используя тип значения, и использовать статус в качестве прокси для «_cts может быть очищен». Но это слишком хакерский вопрос для SO.
@Шейн, интересный вопрос: Порядок выполнения деструктора? Цитата: «Если объект A имеет ссылку на объект B и оба имеют финализаторы, возможно, объект B уже был финализирован, когда запускается финализатор объекта A». Так что, похоже, мое предположение в предыдущем комментарии было неверным.
@Шейн, но ты прав, задача DoWork
сохраняет корни _cts
. Так что проблема возникает только в том случае, если задача завершится до того, как Worker
будет завершен GC
. В этом случае ~Worker
может работать после того, как _cts
будет переработан. И я не знаю, как GC
поведет себя в этом случае.
_cts
вполне может быть переработано к моменту запуска финализатора. Вот почему финализаторы предназначены только для очистки неуправляемых ресурсов. И даже если CTS еще не переработан, задача или объекты, которые он использует, могут быть переработаны. ОП хотел, чтобы кто-то с самого начала сказал, что финализатор в порядке, но это не так.
Это означает, что финализатор может генерировать исключения, которые не могут быть обработаны приложением, что приводит к его завершению.
@PanagiotisKanavos спасибо за ваши комментарии. Я отредактировал ответ и добавил некоторую информацию из статьи Криса Брамма, которую я нашел в статье Эрика Липперта. На данный момент мое мнение таково, что вызов _cts.Cancel()
в финализаторе несколько опасен, но уровень опасности можно контролировать (уменьшить) путем изучения и экспериментирования.
Быстрый вопрос: пробовали ли вы сделать DisposeAsync() асинхронным и ожидать выполнения задачи? это обеспечит завершение задачи, а затем вы сможете завершить метод Dispose.