У меня есть класс 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
добавляется к текстовому файлу, поэтому я не хочу открывать и закрывать файл при каждом вызове Report()
. Для краткости я опустил содержимое этого класса: конструктор открывает StreamWriter
и сохраняет ссылку на TextBox
. Метод Write()
выполняет ``tb.Text = value` и sw.WriteLine(value)
.
Итак, это средство записи журналов, которое также занимается пользовательским интерфейсом. Своеобразный. В любом случае, то, что я написал ранее, это то, что я бы сделал лично (помимо использования реального пакета ведения журнала)
Ага. Это просто «быстрый и грязный» способ инкапсуляции логики записи в текстовое поле, чтобы пользователь мог его видеть, и записи той же строки в файл. Мне нужно сделать это полдюжины раз (одновременно), и я не хотел, чтобы C&P везде было одно и то же или ссылался на пакет журналирования. Спасибо за ваш отзыв.
«Нужен ли мне ConfigureAwait(true)
в первом примере?» ConfigureAwait(true)
— нет операции. Добавление его в любой код никогда не изменит его поведение.
Я понял, что мне нужно, чтобы удаление происходило в потоке пользовательского интерфейса, и ConfigureAwait(true)
сделал бы это. Я забыл, что это будет поведение по умолчанию, и добавление его будет невозможным (фейспалм).
Я понимаю, что вызов
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, конечно, синхронная отчетность не идеальна, но это самое простое решение этой проблемы. Если бы у меня возникла эта проблема, я бы сначала сделал именно это и стал бы искать более сложную альтернативу только в том случае, если влияние на производительность окажется совсем не незначительным.
Риск взаимоблокировок вызывает особое беспокойство, даже если вы можете игнорировать производительность, особенно потому, что это может стать проблемой в будущем из-за изменений в базовой операции, которая вряд ли будет знать или учитывать детали реализации в отчетах о ходе выполнения. Это также довольно распространенное явление, когда отчеты о ходе работы происходят достаточно часто, и я ожидаю, что это вызовет беспокойство.
Другая проблема, связанная с блокировкой, заключается в том, что если поток пользовательского интерфейса блокируется/интенсивно используется несвязанными процессами, это замедляет фактическую работу, а не только отчеты пользовательского интерфейса. Это еще один случай, когда влияние на производительность может быть незначительным при первоначальном тестировании, но становится проблемой из-за изменений в [потенциально] несвязанных частях системы. Производительность этой операции внезапно стала зависеть от всего пользовательского интерфейса.
Синхронная отчетность @Servy определенно открывает сценарии тупиковой ситуации, спасибо за указание на это. Я бы не назвал это особенно тревожным, потому что если произойдет взаимоблокировка, это будет замечено сразу, во время разработки приложения. Это справедливо и для типичных взаимоблокировок синхронизации с асинхронными. Они делают свое присутствие чрезвычайно громким. Маловероятно, что они попадут в производственный код. Разработчик буквально вынужден немедленно устранять проблему, используя любой из доступных обходных путей.
Если только поведение не условно. Например, если первая ожидаемая задача использует ConfigureAwait
, но лишь изредка возвращает завершенную задачу, или отчет о ходе выполнения является условным и редко появляется до первой ожидаемой задачи с ConfigureAwait(false)
. Но да, в обычных ситуациях это будет замечено сразу. Но устранить риск достаточно легко, и я считаю, что это стоит сделать. Особенно, если этот метод отчетности о прогрессе используется ОП во многих местах.
@Servy Лично я сторонник синхронных отчетов по трем причинам: 1. они раскрывают распространенную ошибку слишком частых отчетов. 2. Он предоставляет неправильно написанный код, который без причины блокирует поток пользовательского интерфейса. 3. Это очень просто. Особенно распространен 1-й. Я видел, как слишком много людей заполняли планировщик пользовательского интерфейса, вызывая Report
миллион раз подряд, а затем задавались вопросом, почему пользовательский интерфейс не отвечает. Синхронное создание отчетов вынуждает разработчика Report
действовать консервативно, иначе операция займет вечность (но пользовательский интерфейс останется отзывчивым).
Я предпочитаю решать проблему напрямую, а не косвенно. Имейте что-то, что специально отслеживает скорость реагирования пользовательского интерфейса и регистрирует длительные сообщения пользовательского интерфейса или чрезмерную длину очереди. Это, а также блокировка потока пользовательского интерфейса или переполнение его сообщениями обычно видно при тестировании даже без синхронного отчета о ходе выполнения. Люди просто игнорируют это.
@Servy, альтернатива синхронным отчетам, не страдающая от проблем Progress<T>
, может представлять собой класс, планирующий не более одной асинхронной операции. В случае, если Report
вызывается снова до того, как предыдущая ожидающая операция будет запущена в потоке пользовательского интерфейса, старая T
ожидающей операции будет заменена новой T
без запуска дополнительной асинхронной операции. Результатом станут более точные отчеты о прогрессе при большой нагрузке на пользовательский интерфейс и более снисходительное поведение в случае злоупотреблений Report
.
Это не поможет OP, поскольку они специально регистрируют всю информацию, и поэтому пропуск какой-либо ее части неприемлем (в лучшем случае они могут быть пакетными, и одно обновление пользовательского интерфейса может включать информацию из многочисленных отчетов о ходе работы), но это конечно, уместно во многих контекстах, не связанных с этим вопросом, да.
@Servy, извини, я забыл добавить, что эта гипотетическая IProgress<T>
реализация будет включать в себя механизм уведомлений вашего TrackableProgress<T>
класса. По сути, это будет BoundedTrackableProgress<T>
с ограниченной емкостью 1.
Здесь действительно существует состояние гонки. Progress
Post
является обработчиками, а не отправляет их, поэтому работнику разрешено продолжить выполнение до того, как отчет о ходе выполнения действительно будет создан, а это означает, что теоретически вся операция может завершиться запуском одного или нескольких обработчиков отчетов о ходе выполнения.
Сам класс 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
. Это сделало бы использование класса более простым, хотя и немного более загадочным.
Вы также можете рассмотреть возможность инициализации TaskCompletionSource
с помощью параметра TaskCreationOptions.RunContinuationsAsynchronous, иначе продолжение после await progress.HandlersFinished();
может выполняться внутри раздела lock (key)
. Это может произойти, если await
настроен с помощью ConfigureAwait(false)
.
@TheodorZoulias Спасибо, я забываю об этом варианте больше, чем следовало бы.
Незначительно связано: гарантирует ли SynchonizationContext, являющийся экземпляром WindowsFormsSynchronizationContext
, что лямбды Report()
будут выполняться в том порядке, в котором они поставлены в очередь? Т.е. Возникает ли у Winforms та же проблема, что описана здесь: stackoverflow.com/questions/33699053
@TheodorZoulias, не могли бы вы объяснить немного больше, почему продолжение «может работать внутри раздела замка (ключа)»? Связано ли это с повторным заказом при оптимизации?
О, потому что TrySetResult()
вызывается внутри блокировки, которая может запустить продолжение прямо здесь?
@DanDef да, причина в TrySetResult
, который вызывается внутри раздела lock
. Нечто подобное произойдет, если вы Cancel
найдёте CancellationTokenSource
внутри раздела lock
. Обратные вызовы, прикрепленные к связанному CancellationToken
, вызываются прямо здесь.
Я бы создал объект LogWriter внутри метода, переданного как делегат Action<T>, вызванный
Progress.Report()
, вместо использования Lambda, и удалил бы его там, что делает его ~похожим на второй фрагмент кода. Вы пропустили эту часть? который устанавливает TextBox, для краткости, или... -- Если у этого объекта LogWriter есть другие задачи в этом контексте, добавьте подробности