Убедитесь, что задачи dotnet останавливаются, когда они выходят за пределы области сборщика мусора

У меня есть фоновая задача, которую я моделирую с помощью 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), но вся документация, которую я нашел, указывает на то, что эти методы предназначены специально для удаления неуправляемых ресурсов.

  • Вопрос 1: Как мне заставить GC вызвать _cts.Cancel()
  • Вопрос 2: Если ответ включает использование Dispose(bool disposing), существует ли какая-либо документация о том, как это будет вызываться с помощью disposing: false?

Примечание о задачах и GC Могут ли экземпляры .NET Task выйти за пределы области действия во время выполнения?

Быстрый вопрос: пробовали ли вы сделать DisposeAsync() асинхронным и ожидать выполнения задачи? это обеспечит завершение задачи, а затем вы сможете завершить метод Dispose.

Pedro Costa 17.07.2024 18:40

Мой вопрос касается сценария, в котором DisposeAsync не вызывается. Итак, метод DisposeAsync предназначен только для полноты картины. Добавил редактирование, чтобы сделать это более понятным

Shane 17.07.2024 18:42

Где создается фактическая задача? Только works здесь является активной задачей, все остальное — синтаксический сахар, необходимый механизму async/await. Речь идет не об удалении work, а о выходе из цикла, что может произойти только в том случае, если ваш код детерминированно удаляет Worker. Утечек нет, вот как написан этот код для работы.

Panagiotis Kanavos 17.07.2024 18:48

@Panagiotis Kanavos, проверьте метод IncorrectUsage() (это новое редактирование). В этом случае задача никогда не завершится, что является утечкой памяти.

Shane 17.07.2024 18:51

Это не утечка памяти, это ошибка в коде. Вы явно разработали Worker для завершения цикла, а не задачи, при вызове Dispose. Это значит, что вам необходимо вызвать Dispose. Вам придется переосмыслить этот дизайн и перестать называть его Задачей — это не так. Он даже не будет работать в фоновом режиме, если work сам по себе не является реальной асинхронной операцией или результатом Task.Run.

Panagiotis Kanavos 17.07.2024 18:52

Я хотел бы сосредоточить обсуждение на том, как предотвратить эту «ошибку утечки памяти» с помощью финализатора GC. Это возможно? Я не хочу вдаваться в причины, почему контекст слишком сложен для вопроса SO.

Shane 17.07.2024 18:55

Если вместо получения IAsyncDisposable вы получили BackgroundService или IHostedService, то промежуточное программное обеспечение DI само позаботится о вызове StopAsync, чтобы остановить цикл в методе ExecuteAsync. Однако теперь это ответственность звонящего.

Panagiotis Kanavos 17.07.2024 18:55

@Шейн, ты задаешь неправильный вопрос, используя неверные термины, о проблемах, вызванных дизайном кода. Это очень просто, и любые споры обернутся Worker в Task или забывчивостью вызова Cancel в утечку памяти.

Panagiotis Kanavos 17.07.2024 18:57

ДИ здесь не участвует. Думайте о работнике как о чем-то гораздо более простом и общем, например, о List<>. У меня может быть 100 или 1000 таких проектов одновременно. Насколько ресурсоемкими являются фоновые задачи?

Shane 17.07.2024 18:59

@Shane и даже вопрос о финализаторе показывают непонимание GC - когда GC запускается и вызывает финализатор, нет НИКАКОЙ гарантии, что какой-либо из этих управляемых объектов все еще жив. Если финализатор попытается вызвать _cts, он может обнаружить, что он мертв.

Panagiotis Kanavos 17.07.2024 18:59

@Shane think of это BackgroundWorker .NET Framework 1 без прогресса. How resource intensive are background tasks? нет переднего и заднего плана Задача. Существуют задачи, привязанные к ЦП, например, созданные Task.Run, которые используют ЦП, и асинхронные задачи, которые запускаются, когда что-то сигнализирует им о завершении, например о завершении ввода-вывода. Дело не в том, что вы помогаете мне понять, как работают сборщик мусора или задачи. Что касается рабочих - Parallel.ForEachAsync поверх IAsyncEnumerable, возможно, тот, который создается Каналом, является отличным способом одновременной обработки бесконечных потоков входящих сообщений.

Panagiotis Kanavos 17.07.2024 19:02

@Panagiotis Kanavos «финализатор пытается вызвать _cts и может обнаружить, что он мертв»: на самом деле нет, не будет. Выполняемый поток/задача будет сохранять ссылку на него и поддерживать его работоспособность; «забыть вызвать Cancel»: это философский аргумент об ожиданиях в неуправляемой и управляемой средах. Нам не следует обсуждать здесь; «Насколько ресурсоемкими являются фоновые задачи?»: извините, я хотел сказать «Насколько ресурсоемкими являются BackgroundServices». Ни одна из этих работ не связана с процессором. Темы не подходят, задачи

Shane 17.07.2024 19:06

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

Panagiotis Kanavos 17.07.2024 19:07

Вы пытаетесь утверждать, что StopAsync (это то, что делает DisposeAsync) должен вызываться автоматически, а тот факт, что это не работает, является утечкой. IDisposable или IAsyncDisposable не подключаются к инфраструктуре GC, они предназначены для того, чтобы код приложения вызывал детерминированное удаление. Не сбор мусора.

Panagiotis Kanavos 17.07.2024 19:13

@Panagiotis Kanavos «следует вызывать автоматически», да, в этом вся суть; «то, что не работает, — это утечка»: когда память не может быть освобождена — это утечка, независимо от причины; Я изучил BackgroundServices, и они просто перефразировали проблему: «как мне убедиться, что вызывается остановка async, если разработчик неправильно использует компонент». Я чувствую, что теперь мне нужно добавить контекст: этот компонент Worker представляет собой тип списка. Это не сервис, это не DI, это зависит от того, какой разработчик будет использовать его для улучшения. Существует возможность внедрить фабрику в DI, которая решит эту проблему косвенно.

Shane 17.07.2024 19:34
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
1
15
80
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

Как мне заставить 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 г.), но содержит базовые принципы, которые, вероятно, применяются до сих пор. Из цитируемого текста я делаю следующие выводы:

  1. Невозможно перезапустить _cts (освободить его память) при запуске финализатора ~Worker, поскольку _cts является частью достижимого графа финализированного Worker.
  2. Вполне возможно, что _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() идеально.

Выглядит неплохо. Я проведу несколько экспериментов и постараюсь добиться успеха. В противном случае это кажется простым решением (будет отмечено как принятое, если я не смогу найти случай сбоя)

Shane 18.07.2024 13:09

@Шейн, единственное, что меня беспокоит, потому что мой опыт работы с финализаторами незначителен, это возможность того, что _cts будет переработан GC до экземпляра Worker, которому он принадлежит. Я бы предположил, что сборщик мусора перерабатывает заброшенные объекты в правильном порядке с учетом ссылок между ними, но на 100% не уверен.

Theodor Zoulias 18.07.2024 14:15

Я думаю, что смогу обойти это, сделав код немного более сложным. Я могу передать CTS в метод DoWork, чтобы он не перерабатывался до завершения DoWork. Мне просто нужно выяснить, как получить статус Task _worker (ссылочный тип), используя тип значения, и использовать статус в качестве прокси для «_cts может быть очищен». Но это слишком хакерский вопрос для SO.

Shane 18.07.2024 16:00

@Шейн, интересный вопрос: Порядок выполнения деструктора? Цитата: «Если объект A имеет ссылку на объект B и оба имеют финализаторы, возможно, объект B уже был финализирован, когда запускается финализатор объекта A». Так что, похоже, мое предположение в предыдущем комментарии было неверным.

Theodor Zoulias 18.07.2024 16:32

@Шейн, но ты прав, задача DoWork сохраняет корни _cts. Так что проблема возникает только в том случае, если задача завершится до того, как Worker будет завершен GC. В этом случае ~Worker может работать после того, как _cts будет переработан. И я не знаю, как GC поведет себя в этом случае.

Theodor Zoulias 18.07.2024 16:38
_cts вполне может быть переработано к моменту запуска финализатора. Вот почему финализаторы предназначены только для очистки неуправляемых ресурсов. И даже если CTS еще не переработан, задача или объекты, которые он использует, могут быть переработаны. ОП хотел, чтобы кто-то с самого начала сказал, что финализатор в порядке, но это не так.
Panagiotis Kanavos 19.07.2024 16:27

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

Panagiotis Kanavos 19.07.2024 16:29

@PanagiotisKanavos спасибо за ваши комментарии. Я отредактировал ответ и добавил некоторую информацию из статьи Криса Брамма, которую я нашел в статье Эрика Липперта. На данный момент мое мнение таково, что вызов _cts.Cancel() в финализаторе несколько опасен, но уровень опасности можно контролировать (уменьшить) путем изучения и экспериментирования.

Theodor Zoulias 19.07.2024 19:12

Другие вопросы по теме