Отчеты о ходе выполнения и условия гонки продолжения задачи

У меня есть класс LogWriter, который принимает путь к файлу и System.Windows.Forms.TextBox, а затем записывает в оба метода public void Write(string progress). Он использует StreamWriter с AutoFlush для добавления к файлу и, следовательно, сам реализует IDisposable для удаления потока. Он просто каждый раз устанавливает свойство Text в текстовом поле.

У меня есть метод в другом классе, который порождает процессы и использует обработчики событий ErrorDataReceived и OutputDataReceived для вызова Report() в экземпляре Progess.

В обработчике событий нажатия асинхронной кнопки в форме я делаю следующее:

using(LogWriter logger = new LogWriter(filePath, txtProgress))
{
    IProgress<string> progress = new Progress<string>(s => logger.Write(s));
    await Task.Run(() => this._C.RunProcess(progress));
} //disposal point

До этого я реализовал это с помощью ContinueWith в обработчике синхронизации:

LogWriter logger = new LogWriter(filePath, txtProgress);
Task t = Task.Run(() => this._C.RunProcess(progress));
t.ContinueWith((_) => { coreCurrentLogger.Dispose(); }, TaskScheduler.FromCurrentSynchronizationContext());

Я понимаю, что вызов Progress.Report() использует SynchonizationContext.Post() для постановки лямбды в очередь на выполнение через некоторое время. В этом случае я считаю, что SynchonizationContext будет соответствовать потоку пользовательского интерфейса.

В обоих случаях этот код ведет себя так, как я ожидаю. И TextBox, и файл содержат финальную строку, записанную в выходной поток порожденным процессом.

Основываясь на вышеизложенном, существует ли в любой из реализаций состояние гонки, при котором экземпляр LogWriter может быть удален до того, как все Process.Report() лямбды будут выполнены?

Есть ли разница между двумя реализациями? Нужен ли мне ConfigureAwait(true) в первом примере?

Я бы создал объект LogWriter внутри метода, переданного как делегат Action<T>, вызванный Progress.Report(), вместо использования Lambda, и удалил бы его там, что делает его ~похожим на второй фрагмент кода. Вы пропустили эту часть? который устанавливает TextBox, для краткости, или... -- Если у этого объекта LogWriter есть другие задачи в этом контексте, добавьте подробности

Jimi 21.08.2024 15:30

LogWriter добавляется к текстовому файлу, поэтому я не хочу открывать и закрывать файл при каждом вызове Report(). Для краткости я опустил содержимое этого класса: конструктор открывает StreamWriter и сохраняет ссылку на TextBox. Метод Write() выполняет ``tb.Text = value` и sw.WriteLine(value).

Dan Def 21.08.2024 15:40

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

Jimi 21.08.2024 15:42

Ага. Это просто «быстрый и грязный» способ инкапсуляции логики записи в текстовое поле, чтобы пользователь мог его видеть, и записи той же строки в файл. Мне нужно сделать это полдюжины раз (одновременно), и я не хотел, чтобы C&P везде было одно и то же или ссылался на пакет журналирования. Спасибо за ваш отзыв.

Dan Def 21.08.2024 15:48

«Нужен ли мне ConfigureAwait(true) в первом примере?» ConfigureAwait(true) — нет операции. Добавление его в любой код никогда не изменит его поведение.

Servy 21.08.2024 16:30

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

Dan Def 21.08.2024 16:41
Стоит ли изучать 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 называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
2
6
78
2
Перейти к ответу Данный вопрос помечен как решенный

Ответы 2

Я понимаю, что вызов Progress.Report() использует SynchonizationContext.Post() для постановки лямбды в очередь на выполнение через некоторое время. В этом случае я считаю, что SynchonizationContext будет соответствовать потоку пользовательского интерфейса.

Правильный.

Существует ли состояние гонки в любой из реализаций, при котором экземпляр LogWriter может быть удален до того, как все Process.Report() лямбды будут выполнены?

Да, обе реализации страдают от этого состояния гонки. Класс Progress<T> не предоставляет никакого механизма уведомления о завершении асинхронных Report операций, поэтому, пока вы используете этот класс, условия гонки неизбежны. Вы можете облегчить это состояние гонки, добавив await Task.Yield(); после ожидания задачи Task.Run, надеясь, что базовый SynchonizationContext обрабатывает запланированные операции в порядке FIFO. Хотя в этом нет никакой гарантии. Теоретически возможно, что ожидаемая задача Task.Yield будет выполнена с более высоким приоритетом, минуя существующие запланированные Report операции.

Если вы хотите быть абсолютно уверены, что в вашем коде нет состояния гонки, у вас нет другого выбора, кроме как отказаться от класса Progress<T> и использовать собственную реализацию IProgress<T> . Самый простой — сделать отчетность синхронной, а не асинхронной. Вы можете найти SynchronousProgress<T> здесь. Подразумевается, что фоновая операция будет приостанавливаться каждый раз, когда выдается Report, поэтому она станет немного медленнее. Пока Report выдается нечасто, влияние на производительность не будет достаточно значительным, чтобы быть заметным.

Синхронизация прогресса достигается за счет замедления базовой операции, потенциально значительного. А если базовая операция является асинхронной и вызывает Report в текущем контексте синхронизации (ожидая, что он не заблокируется), это может даже привести к взаимоблокировкам. Если обработчики просто отслеживают, сколько из них запущено, а не блокируют их все Report, вы можете включить способ ожидания всех запущенных в данный момент обработчиков (которых, скорее всего, будет немного) только в самом конце.

Servy 21.08.2024 16:25

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

Theodor Zoulias 21.08.2024 16:31

Риск взаимоблокировок вызывает особое беспокойство, даже если вы можете игнорировать производительность, особенно потому, что это может стать проблемой в будущем из-за изменений в базовой операции, которая вряд ли будет знать или учитывать детали реализации в отчетах о ходе выполнения. Это также довольно распространенное явление, когда отчеты о ходе работы происходят достаточно часто, и я ожидаю, что это вызовет беспокойство.

Servy 21.08.2024 16:40

Другая проблема, связанная с блокировкой, заключается в том, что если поток пользовательского интерфейса блокируется/интенсивно используется несвязанными процессами, это замедляет фактическую работу, а не только отчеты пользовательского интерфейса. Это еще один случай, когда влияние на производительность может быть незначительным при первоначальном тестировании, но становится проблемой из-за изменений в [потенциально] несвязанных частях системы. Производительность этой операции внезапно стала зависеть от всего пользовательского интерфейса.

Servy 21.08.2024 16:48

Синхронная отчетность @Servy определенно открывает сценарии тупиковой ситуации, спасибо за указание на это. Я бы не назвал это особенно тревожным, потому что если произойдет взаимоблокировка, это будет замечено сразу, во время разработки приложения. Это справедливо и для типичных взаимоблокировок синхронизации с асинхронными. Они делают свое присутствие чрезвычайно громким. Маловероятно, что они попадут в производственный код. Разработчик буквально вынужден немедленно устранять проблему, используя любой из доступных обходных путей.

Theodor Zoulias 21.08.2024 16:51

Если только поведение не условно. Например, если первая ожидаемая задача использует ConfigureAwait, но лишь изредка возвращает завершенную задачу, или отчет о ходе выполнения является условным и редко появляется до первой ожидаемой задачи с ConfigureAwait(false). Но да, в обычных ситуациях это будет замечено сразу. Но устранить риск достаточно легко, и я считаю, что это стоит сделать. Особенно, если этот метод отчетности о прогрессе используется ОП во многих местах.

Servy 21.08.2024 16:57

@Servy Лично я сторонник синхронных отчетов по трем причинам: 1. они раскрывают распространенную ошибку слишком частых отчетов. 2. Он предоставляет неправильно написанный код, который без причины блокирует поток пользовательского интерфейса. 3. Это очень просто. Особенно распространен 1-й. Я видел, как слишком много людей заполняли планировщик пользовательского интерфейса, вызывая Report миллион раз подряд, а затем задавались вопросом, почему пользовательский интерфейс не отвечает. Синхронное создание отчетов вынуждает разработчика Report действовать консервативно, иначе операция займет вечность (но пользовательский интерфейс останется отзывчивым).

Theodor Zoulias 21.08.2024 17:09

Я предпочитаю решать проблему напрямую, а не косвенно. Имейте что-то, что специально отслеживает скорость реагирования пользовательского интерфейса и регистрирует длительные сообщения пользовательского интерфейса или чрезмерную длину очереди. Это, а также блокировка потока пользовательского интерфейса или переполнение его сообщениями обычно видно при тестировании даже без синхронного отчета о ходе выполнения. Люди просто игнорируют это.

Servy 21.08.2024 17:32

@Servy, альтернатива синхронным отчетам, не страдающая от проблем Progress<T>, может представлять собой класс, планирующий не более одной асинхронной операции. В случае, если Report вызывается снова до того, как предыдущая ожидающая операция будет запущена в потоке пользовательского интерфейса, старая T ожидающей операции будет заменена новой T без запуска дополнительной асинхронной операции. Результатом станут более точные отчеты о прогрессе при большой нагрузке на пользовательский интерфейс и более снисходительное поведение в случае злоупотреблений Report.

Theodor Zoulias 21.08.2024 18:18

Это не поможет OP, поскольку они специально регистрируют всю информацию, и поэтому пропуск какой-либо ее части неприемлем (в лучшем случае они могут быть пакетными, и одно обновление пользовательского интерфейса может включать информацию из многочисленных отчетов о ходе работы), но это конечно, уместно во многих контекстах, не связанных с этим вопросом, да.

Servy 21.08.2024 18:33

@Servy, извини, я забыл добавить, что эта гипотетическая IProgress<T> реализация будет включать в себя механизм уведомлений вашего TrackableProgress<T> класса. По сути, это будет BoundedTrackableProgress<T> с ограниченной емкостью 1.

Theodor Zoulias 21.08.2024 18:42
Ответ принят как подходящий

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

Сам класс Progress не позволяет дождаться завершения всех существующих обработчиков.

Решение состоит в том, чтобы обернуть объект Progress в объект, который отслеживает количество текущих обработчиков и предоставляет Task, чтобы уведомить вас, когда все текущие обработчики завершены.

public class TrackableProgress<T> : IProgress<T>
{
    private IProgress<T> nestedProgress;
    private int handlersRunning = 0;
    private TaskCompletionSource? taskCompletionSource;
    public object key = new object();

    public TrackableProgress(Action<T> action)
    {
        nestedProgress = new Progress<T>(Handler);

        void Handler(T t)
        {
            try
            {
                lock (key)
                    handlersRunning++;

                action(t);
            }
            finally
            {
                lock (key)
                {
                    handlersRunning--;
                    if (handlersRunning == 0)
                    {
                        taskCompletionSource?.TrySetResult();
                        taskCompletionSource = null;
                    }
                }
            }
        }
    }
    void IProgress<T>.Report(T value)
    {
        nestedProgress.Report(value);
    }
    public Task HandlersFinished()
    {
        Task? result = null;
        lock (key)
        {
            if (handlersRunning > 0)
            {
                taskCompletionSource ??= new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
                result = taskCompletionSource.Task;
            }
        }
        return result ?? Task.CompletedTask;
    }
}

Это позволит вам написать:

using(LogWriter logger = new LogWriter(filePath, txtProgress))
{
    TrackableProgress<string> progress = new TrackableProgress<string>(s => logger.Write(s));
    await Task.Run(() => this._C.RunProcess(progress));
    await progress.HandlersFinished();
}

Это хорошее решение. +1 Вы могли бы рассмотреть возможность создания TrackableProgress<T>IAsyncDisposable и перемещения функциональности HandlersFinished в метод DisposeAsync. Это сделало бы использование класса более простым, хотя и немного более загадочным.

Theodor Zoulias 21.08.2024 16:40

Вы также можете рассмотреть возможность инициализации TaskCompletionSource с помощью параметра TaskCreationOptions.RunContinuationsAsynchronous, иначе продолжение после await progress.HandlersFinished(); может выполняться внутри раздела lock (key). Это может произойти, если await настроен с помощью ConfigureAwait(false).

Theodor Zoulias 21.08.2024 16:59

@TheodorZoulias Спасибо, я забываю об этом варианте больше, чем следовало бы.

Servy 21.08.2024 17:05

Незначительно связано: гарантирует ли SynchonizationContext, являющийся экземпляром WindowsFormsSynchronizationContext, что лямбды Report() будут выполняться в том порядке, в котором они поставлены в очередь? Т.е. Возникает ли у Winforms та же проблема, что описана здесь: stackoverflow.com/questions/33699053

Dan Def 21.08.2024 17:51

@TheodorZoulias, не могли бы вы объяснить немного больше, почему продолжение «может работать внутри раздела замка (ключа)»? Связано ли это с повторным заказом при оптимизации?

Dan Def 22.08.2024 09:18

О, потому что TrySetResult() вызывается внутри блокировки, которая может запустить продолжение прямо здесь?

Dan Def 22.08.2024 09:34

@DanDef да, причина в TrySetResult, который вызывается внутри раздела lock. Нечто подобное произойдет, если вы Cancel найдёте CancellationTokenSource внутри раздела lock. Обратные вызовы, прикрепленные к связанному CancellationToken, вызываются прямо здесь.

Theodor Zoulias 22.08.2024 11:52

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