В этой главе будут рассмотрены различные типы и методы для типичных сценариев, встречающихся при написании параллельных программ. О таких сценариях можно было бы написать отдельную книгу, поэтому я выбрал лишь несколько примеров, которые показались мне наиболее полезными.
Имеется ресурс, совместно используемый несколькими частями кода. Требуется инициализировать этот ресурс при первом обращении к нему.
В фреймворк .NET включен тип, специально предназначенный для этой цели, — Lazy<T>. Экземпляр типа Lazy<T> конструируется фабричным делегатом, который используется для инициализации экземпляра. Затем экземпляр становится доступным через свойство Value. Следующий пример показывает использование типа Laz<T>:
static int _simpleValue;
static readonly Lazy<int> MySharedInteger = new Lazy<int>(() =>
_simpleValue++);
void UseSharedInteger()
{
int sharedValue = MySharedInteger.Value;
}
Сколько бы потоков ни вызывало UseSharedInteger одновременно, фабричный делегат выполняется только один раз, и все потоки ожидают одного экземпляра. После того как экземпляр будет создан, он кэшируется, и все будущие обращения к свойству Value возвращают тот же экземпляр (в приведенном примере MySharedInteger.Value всегда будет содержать 0).
Очень похожее решение может использоваться в том случае, если инициализация требует асинхронной работы; используйте Lazy<Task<T>>:
static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
new Lazy<Task<int>>(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2)).ConfigureAwait(false);
return _simpleValue++;
});
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Value;
}
В этом примере делегат возвращает Task<int>, т.е. целое значение, определяемое асинхронно. Сколько бы частей кода ни вызывало Value одновременно, Task<int> создается только один раз и возвращается всем вызывающим сторонам. Каждая вызывающая сторона получает возможность (асинхронно) ожидать завершения задачи, для чего задача передается await.
Этот паттерн может использоваться на практике, но необходимо учесть ряд дополнительных аспектов. Во-первых, асинхронный делегат может быть выполнен в любом потоке, который вызывает Value, и делегат будет выполняться в этом контексте. Если существуют разные типы потоков, которые могут вызывать Value (например, UI-поток и поток из пула потоков или потоки двух разных запросов ASP.NET), возможно, будет лучше, если асинхронный делегат будет всегда выполняться в потоке из пула. Это легко сделать, заключив фабричного делегата в вызов Task.Run:
static int _simpleValue;
static readonly Lazy<Task<int>> MySharedAsyncInteger =
new Lazy<Task<int>>(() => Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
}));
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Value;
}
Другой аспект заключается в том, что экземпляр Task<T> создается только один раз. Если асинхронный делегат выдаст исключение, то Lazy<Task<T>> будет кэшировать эту задачу с ошибкой. Такая ситуация нежелательна; в большинстве случаев лучше снова выполнить делегата при следующем запросе отложенного значения вместо того, чтобы кэшировать исключение. Механизма «сброса» Lazy<T> не существует, но можно создать новый класс, который обеспечивает повторное создание экземпляра Lazy<T>:
public sealed class AsyncLazy<T>
{
private readonly object _mutex;
private readonly Func<Task<T>> _factory;
private Lazy<Task<T>> _instance;
public AsyncLazy(Func<Task<T>> factory)
{
_mutex = new object();
_factory = RetryOnFailure(factory);
_instance = new Lazy<Task<T>>(_factory);
}
private Func<Task<T>> RetryOnFailure(Func<Task<T>> factory)
{
return async () =>
{
try
{
return await factory().ConfigureAwait(false);
}
catch
{
lock (_mutex)
{
_instance = new Lazy<Task<T>>(_factory);
}
throw;
}
};
}
public Task<T> Task
{
get
{
lock (_mutex)
return _instance.Value;
}
}
}
static int _simpleValue;
static readonly AsyncLazy<int> MySharedAsyncInteger =
new AsyncLazy<int>(() => Task.Run(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
}));
async Task GetSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger.Task;
}
Последний пример кода в этом рецепте представляет общий паттерн асинхронной отложенной инициализации. Выглядит он несколько неуклюже. Библиотека AsyncEx включает тип AsyncLazy<T>, который работает, как тип Lazy<Task<T>>, выполняющий своего фабричного делегата в пуле потоков с возможностью повторения попытки при неудаче. Возможно и прямое ожидание await, так что код объявления и использования выглядит примерно так:
static int _simpleValue;
private static readonly AsyncLazy<int> MySharedAsyncInteger =
new AsyncLazy<int>(async () =>
{
await Task.Delay(TimeSpan.FromSeconds(2));
return _simpleValue++;
},
AsyncLazyFlags.RetryOnFailure);
public async Task UseSharedIntegerAsync()
{
int sharedValue = await MySharedAsyncInteger;
}
Тип AsyncLazy<T> находится в пакете Nito.AsyncEx.
В главе 1 рассматриваются основы программирования async/await.
В рецепте 13.1 рассматривается планирование работы в пуле потоков.
Требуется создать новый наблюдаемый объект каждый раз, когда кто-то на него подписывается. Например, каждая подписка может представлять отдельный запрос к веб-службе.
В библиотеке System.Reactive существует оператор Observable.Defer, который выполняет делегата при каждой подписке на наблюдаемый объект. Делегат работает как фабрика, создающая наблюдаемый объект. В следующем примере Defer используется для вызова асинхронного метода каждый раз, когда кто-то подписывается на наблюдаемый объект:
void SubscribeWithDefer()
{
var invokeServerObservable = Observable.Defer(
() => GetValueAsync().ToObservable());
invokeServerObservable.Subscribe(_ => { });
invokeServerObservable.Subscribe(_ => { });
Console.ReadKey();
}
async Task<int> GetValueAsync()
{
Console.WriteLine("Calling server...");
await Task.Delay(TimeSpan.FromSeconds(2));
Console.WriteLine("Returning result...");
return 13;
}
При выполнении этого кода будет получен следующий результат:
Calling server...
Calling server...
Returning result...
Returning result...
Ваш собственный код обычно не подписывается на наблюдаемый объект более одного раза, но некоторые операторы System.Reactive поступают так в своей реализации. Например, оператор Observable.While заново подписывается на исходную последовательность, пока его условие остается истинным. Defer позволяет определить наблюдаемый объект, который заново вычисляется каждый раз, когда поступает новая подписка. Это может быть полезно, если потребуется обновить данные для этого наблюдаемого объекта.
В рецепте 8.6 рассматривается инкапсуляция асинхронных методов в наблюдаемых объектах.
Данные загружаются асинхронно. Требуется осуществить связывание данных с результатами (например, в компоненте модели представления (ViewModel) в архитектуре «модель—представление—модель представления»).
Если свойство используется в связывании данных, оно должно немедленно и синхронно вернуть некое подобие результата. Если фактическое значение должно определяться асинхронно, вы можете вернуть результат по умолчанию и позднее обновить свойство правильным значением.
Помните, что асинхронные операции могут завершаться не только успехом, но и неудачей. Так как вы пишете модель представления, связывание данных может использоваться для обновления пользовательского интерфейса и для ситуации ошибки.
В библиотеке Nito.Mvvm.Async имеется тип NotifyTask, который может использоваться для этой цели:
class MyViewModel
{
public MyViewModel()
{
MyValue = NotifyTask.Create(CalculateMyValueAsync());
}
public NotifyTask<int> MyValue { get; private set; }
private async Task<int> CalculateMyValueAsync()
{
await Task.Delay(TimeSpan.FromSeconds(10));
return 13;
}
}
Как показывает следующий пример, связывание данных может применяться к различным свойствам по свойству NotifyTask<T>:
<Grid>
<Label Content="Loading..."
Visibility="{Binding MyValue.IsNotCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<Label Content="{Binding MyValue.Result}"
Visibility="{Binding MyValue.IsSuccessfullyCompleted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
<Label Content="An error occurred" Foreground="Red"
Visibility="{Binding MyValue.IsFaulted,
Converter={StaticResource BooleanToVisibilityConverter}}"/>
</Grid>
В библиотеку MvvmCross входит тип MvxNotifyTask, очень похожий на NotifyTask<T>.
Можно написать собственную обертку связывания данных (вместо использования обертки из библиотек). Следующий код дает примерное представление о том, как это делается:
class BindableTask<T> : INotifyPropertyChanged
{
private readonly Task<T> _task;
public BindableTask(Task<T> task)
{
_task = task;
var _ = WatchTaskAsync();
}
private async Task WatchTaskAsync()
{
try
{
await _task;
}
catch
{
}
OnPropertyChanged("IsNotCompleted");
OnPropertyChanged("IsSuccessfullyCompleted");
OnPropertyChanged("IsFaulted");
OnPropertyChanged("Result");
}
public bool IsNotCompleted { get { return !_task.IsCompleted; } }
public bool IsSuccessfullyCompleted
{
get { return _task.Status == TaskStatus.RanToCompletion; }
}
public bool IsFaulted { get { return _task.IsFaulted; } }
public T Result
{
get { return IsSuccessfullyCompleted ? _task.Result : default; }
}
public event PropertyChangedEventHandler PropertyChanged;
protected virtual void OnPropertyChanged(string propertyName)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs
(propertyName));
}
}
Обратите внимание: пустое условие catch использовано намеренно — мы хотим перехватывать все исключения и обрабатывать их через механизм связывания данных. Также в коде не должен использоваться вызов ConfigureAwait(false), потому что событие PropertyChanged должно выдаваться в UI-потоке.
Тип NotifyTask находится в NuGet-пакете Nito.Mvvm.Async. Тип MvxNotifyTask находится в NuGet-пакете MvvmCross.
В главе 1 рассматриваются основы программирования async/await.
В рецепте 2.7 рассматривается использование ConfigureAwait.
Имеются переменные состояния, которые должны быть доступны в разных точках стека вызовов. Например, идентификатор текущей операции должен использоваться для вывода информации в журнал, но вы не хотите добавлять его в виде параметра в каждый метод.
Лучшее решение — добавить параметры к методам, сохранить данные в полях класса или воспользоваться внедрением зависимостей для предоставления данных разным частям вашего кода. Но в некоторых ситуациях это приведет к чрезмерному усложнению кода.
Тип AsyncLocal<T> позволяет связать с состоянием объект, в котором оно сможет существовать в логическом «контексте». Следующий код демонстрирует использование AsyncLocal<T> для назначения идентификатора операции, который позднее читается методом журнального вывода:
private static AsyncLocal<Guid> _operationId = new AsyncLocal<Guid>();
async Task DoLongOperationAsync()
{
_operationId.Value = Guid.NewGuid();
await DoSomeStepOfOperationAsync();
}
async Task DoSomeStepOfOperationAsync()
{
await Task.Delay(100); // Некоторая асинхронная работа
// Вывод в журнал.
Trace.WriteLine("In operation: " + _operationId.Value);
}
Во многих случаях бывает полезно создать более сложную структуру данных (например, стек) в одном экземпляре AsyncLocal<T>. Это возможно с одной оговоркой: в AsyncLocal<T> следует хранить только неизменяемые данные. Каждый раз, когда возникнет необходимость в обновлении данных, вы должны перезаписать существующее значение. Часто бывает полезно скрыть AsyncLocal<T> внутри вспомогательного типа, который гарантирует неизменяемость хранимых данных и их корректное обновление:
internal sealed class AsyncLocalGuidStack
{
private readonly AsyncLocal<ImmutableStack<Guid>> _operationIds =
new AsyncLocal<ImmutableStack<Guid>>();
private ImmutableStack<Guid> Current =>
_operationIds.Value ?? ImmutableStack<Guid>.Empty;
public IDisposable Push(Guid value)
{
_operationIds.Value = Current.Push(value);
return new PopWhenDisposed(this);
}
private void Pop()
{
ImmutableStack<Guid> newValue = Current.Pop();
if (newValue.IsEmpty)
newValue = null;
_operationIds.Value = newValue;
}
public IEnumerable<Guid> Values => Current;
private sealed class PopWhenDisposed : IDisposable
{
private AsyncLocalGuidStack _stack;
public PopWhenDisposed(AsyncLocalGuidStack stack) =>
_stack = stack;
public void Dispose()
{
_stack?.Pop();
_stack = null;
}
}
}
private static AsyncLocalGuidStack _operationIds = new
AsyncLocalGuidStack();
async Task DoLongOperationAsync()
{
using (_operationIds.Push(Guid.NewGuid()))
await DoSomeStepOfOperationAsync();
}
async Task DoSomeStepOfOperationAsync()
{
await Task.Delay(100); // Некоторая асинхронная работа
// Вывод в журнал.
Trace.WriteLine("In operation: " +
string.Join(":", _operationIds.Values));
}
Тип-обертка гарантирует, что используемые данные неизменяемы и что новые значения будут заноситься в стек. Он также предоставляет удобный способ извлечения значений из стека через IDisposable.
В старом коде можно использовать атрибут ThreadStatic для контекстного состояния, используемого синхронным кодом. При преобразовании старого кода в асинхронный AsyncLocal<T> является основным кандидатом для замены ThreadStaticAttribute. AsyncLocal<T> работает как для синхронного, так и для асинхронного кода, и этот способ должен использоваться по умолчанию для неявного состояния в современных приложениях.
В главе 1 рассматриваются основы программирования async/await.
В главе 9 рассматривается применение неизменяемых коллекций для сохранения сложных данных в форме неявного состояния.
Имеется код, к которому нужно предоставить доступ через синхронный и асинхронный API без дублирования логики. Такая ситуация часто встречается при преобразовании существующего кода в асинхронный, если существующие синхронные потребители изменять (пока) нельзя.
По возможности постарайтесь структурировать свой код в соответствии с современными паттернами проектирования (например, гексагональной архитектуры или архитектуры портов и адаптеров), отделяющими бизнес-логику от побочных эффектов (таких, как ввод/вывод). Если вы сможете добиться этого, то необходимости предоставлять как синхронный, так и асинхронный API для каких-либо целей не будет; бизнес-логика всегда будет синхронной, а ввод/вывод всегда будет асинхронным.
Это весьма амбициозная цель. В Реальном Мире старый код бывает весьма неряшливым, и у нас редко находится время для того, чтобы привести его в идеальное состояние, прежде чем преобразовывать к асинхронному виду. Существующие API часто приходится поддерживать для обеспечения обратной совместимости, даже если они были плохо спроектированы.
Здесь нет идеального решения. Многие разработчики пытаются сделать так, чтобы их синхронный код обращался с вызовами к асинхронному коду или асинхронный код обращался с вызовами к синхронному, но оба подхода представляют собой антипаттерны. В такой ситуации я предпочитаю использовать «трюк с логическим аргументом», который позволяет хранить всю логику в одном методе, предоставляя как синхронный, так и асинхронный API.
Основная идея «трюка с логическим аргументом» заключается в создании приватного базового метода, содержащего логику. Базовый метод имеет асинхронную сигнатуру и получает логический аргумент, который определяет, должен ли базовый метод быть асинхронным или нет. Если логический аргумент указывает, что базовый метод должен быть синхронным, то он должен вернуть уже завершенную задачу. Тогда вы можете написать методы как асинхронного, так и синхронного API, передающие управление базовому методу:
private async Task<int> DelayAndReturnCore(bool sync)
{
int value = 100;
// Выполнение некоторой работы.
if (sync)
Thread.Sleep(value); // Вызвать синхронный API.
else
await Task.Delay(value); // Вызвать асинхронный API.
return value;
}
// Асинхронный API
public Task<int> DelayAndReturnAsync() =>
DelayAndReturnCore(sync: false);
// Синхронный API
public int DelayAndReturn() =>
DelayAndReturnCore(sync: true).GetAwaiter().GetResult();
Метод асинхронного API DelayAndReturnAsync вызывает DelayAndReturnCore c логическим параметром sync, равным false; это означает, что метод DelayAndReturnCore может работать асинхронно и он использует await в используемом методе API «асинхронной задержки» Task.Delay. Задача, возвращаемая DelayAndReturnCore, возвращается напрямую на сторону вызова DelayAndReturnAsync.
Метод синхронного API DelayAndReturn вызывает DelayAndReturnCore с логическим параметром sync, равным true; это означает, что DelayAndReturnCore может работать синхронно и он использует метод API «синхронной задержки» Thread.Sleep. Задача, возвращаемая DelayAndReturnCore, уже должна быть завершена, что позволяет безопасно получить результат. DelayAndReturn использует GetAwaiter().GetResult() для получения результата от задачи; это позволяет обойтись без обертки AggregateException, которая могла бы потребоваться при использовании свойства Task<T>.Result.
Такое решение не идеально, но оно может помочь в построении реальных приложений.
Впрочем, необходимо учитывать ряд нюансов. Катастрофические проблемы возникнут в том случае, если метод Core неправильно обрабатывает свой параметр sync. Если метод Core когда-либо вернет незавершенную задачу при условии, что sync содержит true, то синхронный API может легко создать взаимную блокировку; единственная причина, по которой синхронный API может блокироваться по этой задаче, — если он знает, что задача уже завершена. Аналогично, если метод Core блокирует поток при переменной sync, равной false, то приложение работает не настолько эффективно, насколько могло бы.
Одним из возможных усовершенствований этого решения могло бы стать добавление в синхронном API проверки, которая убеждалась бы в том, что возвращенная задача действительно завершена. Если задача окажется незавершенной, то это указывает на серьезную ошибку программирования.
В главе 1 рассматриваются основы программирования async/await, включая обсуждение взаимных блокировок, которые могут возникнуть при блокировании по асинхронному коду вообще.
Имеется настроенная сеть потоков данных, но при обработке некоторых элементов данных происходит ошибка. Требуется реагировать на эти ошибки так, чтобы сеть потоков данных оставалась работоспособной.
Если при обработке элемента данных возникает исключение, по умолчанию в этом блоке происходит отказ, что не позволяет ему обрабатывать другие элементы данных. Основная идея решения заключается в том, чтобы рассматривать исключения как другую разновидность данных. Если сеть потока данных работает с типами, которые могут представлять как исключения, так и данные, то она останется работоспособной даже при возникновении исключений и продолжит обрабатывать другие элементы данных.
Иногда такое программирование называется рельсовым (railway), потому что элементы сети могут рассматриваться как двигающиеся по одному из двух путей. Существует нормальный «путь данных»: если все идет нормально, то элемент остается на «пути данных» и перемещается по сети, к нему применяются преобразования и различные операции, пока он не достигнет конца сети. Второй путь — «путь ошибок»; в любом блоке при возникновении исключения при обработке элемента это исключение переходит на «путь ошибок» и перемещается по сети. Элементы исключений не обрабатываются; они всего лишь передаются от блока к блоку, чтобы также достичь конца сети. Терминальные (завершающие) блоки сети в итоге получают последовательность элементов, каждый из которых может быть элементом данных или элементом исключения; элемент данных представляет данные, успешно прошедшие всю сеть, а элемент исключения представляет ошибку обработки в некоторой позиции сети.
Чтобы создать подобную структуру «рельсового» программирования, необходимо сначала определить тип, представляющий элемент данных или исключение. Если вы захотите воспользоваться готовым типом, есть несколько вариантов. Такие типы получили распространение в сообществе функционального программирования, где они обычно называются Try, Error или Exceptional, и являются особым случаем монады Either. Я определил собственный тип Try<T>, который можно использовать для примера; он находится в пакете Nito.Try, а исходный код хранится на GitHub ().
Если имеется тип Try<T> или его разновидность, создание сети становится немного монотонным, но не сложным делом. Тип каждого блока потока данных следует заменить с T на Try<T>, а любая обработка в этом блоке должна осуществляться отображением одного значения Try<T> на другое. С моим типом Try<T> это делается вызовом Try<T>.Map. На мой взгляд, удобно определить небольшие фабричные методы для «рельсовых» блоков потоков данных вместо того, чтобы включать этот дополнительный код во встроенном виде. Ниже приведен пример вспомогательного метода, который строит блок TransformBlock, работающий со значениями Try<T> вызовом Try<T>.Map:
private static TransformBlock<Try<TInput>, Try<TOutput>>
RailwayTransform<TInput, TOutput>(Func<TInput, TOutput> func)
{
return new TransformBlock<Try<TInput>, Try<TOutput>>(t =>
t.Map(func));
}
С такими вспомогательными методами код создания сети потоков данных получается более прямолинейным:
var subtractBlock = RailwayTransform<int, int>(value => value - 2);
var divideBlock = RailwayTransform<int, int>(value => 60 / value);
var multiplyBlock = RailwayTransform<int, int>(value => value * 2);
var options = new DataflowLinkOptions { PropagateCompletion = true };
subtractBlock.LinkTo(divideBlock, options);
divideBlock.LinkTo(multiplyBlock, options);
// Вставить элементы данных в первый блок.
subtractBlock.Post(Try.FromValue(5));
subtractBlock.Post(Try.FromValue(2));
subtractBlock.Post(Try.FromValue(4));
subtractBlock.Complete();
// Получить элементы данных/исключений из последнего блока.
while (await multiplyBlock.OutputAvailableAsync())
{
Try<int> item = await multiplyBlock.ReceiveAsync();
if (item.IsValue)
Console.WriteLine(item.Value);
else
Console.WriteLine(item.Exception.Message);
}
«Рельсовое программирование» — отличный способ предотвращения перехода блоков потока данных в состояние отказа. Так как «рельсовое программирование» представляет собой конструкцию функционального программирования, основанную на монадах, результат его перевода на платформу .NET получается немного громоздким, но работоспособным. Если имеется сеть потока данных, которая должна быть отказоустойчивой, то «рельсовое программирование», безусловно, заслуживает вашего внимания.
В рецепте 5.2 рассматривается обычный процесс того, как исключения переводят блоки в состояние отказа и могут распространяться по сети, если «рельсовое программирование» не используется.
Имеется продолжительная операция, которая выдает сообщения о прогрессе операции; обновления отображаются в пользовательском интерфейсе. Однако обновления поступают слишком быстро и тормозят работу пользовательского интерфейса.
В следующем примере выдаются слишком частые уведомления о прогрессе операции:
private string Solve(IProgress<int> progress)
{
// Вести максимально быстрый отсчет в течение 3 секунд.
var endTime = DateTime.UtcNow.AddSeconds(3);
int value = 0;
while (DateTime.UtcNow < endTime)
{
value++;
progress?.Report(value);
}
return value.ToString();
}
Чтобы выполнить этот код из GUI-приложения, упакуйте его в Task.Run и передайте IProgress<T>. Следующий пример предназначен для WPF, но используемые концепции действуют независимо от платформы GUI (WPF, Xamarin или Windows Forms):
// Для простоты код обновляет надпись напрямую.
// В реальном MVVM-приложении эти присваивания
// осуществлялись бы обновлением свойства ViewModel,
// связанного с пользовательским интерфейсом.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
MyLabel.Content = "Starting...";
var progress = new Progress<int>(value => MyLabel.Content = value);
var result = await Task.Run(() => Solve(progress));
MyLabel.Content = $"Done! Result: {result}";
}
Этот код на некоторое время парализует пользовательский интерфейс (около 20 секунд на моей машине). Затем интерфейс снова начинает работать, и в нем выводится только сообщение "Done! Result:". Вы не увидите промежуточные уведомления о прогрессе. Здесь фоновый код отправляет отчеты о прогрессе UI-потоку слишком быстро — настолько быстро, что после выполнения в течение всего 3 секунд UI-потоку требуется еще 17 секунд или около того для обработки всех этих уведомлений, а текст надписи обновляется снова и снова. Затем UI-поток обновляет надпись в последний раз со значением "Done! Result:" и наконец получает возможность перерисовать экран с выводом обновленного текста надписи.
Прежде всего следует понять, что сообщения о прогрессе необходимо регулировать. Только так можно гарантировать, что у пользовательского интерфейса будет достаточно времени для перерисовки между обновлениями. Затем необходимо осознать, что регулировка должна осуществляться по времени, а не по количеству отчетов. Идея регулировки, основанной на отправке одного сообщения из сотни или около того, выглядит заманчиво, но она не идеальна по причинам, изложенным в разделе «Пояснение».
Тот факт, что мы должны иметь дело со временем, наводит на мысль, что нам стоит рассмотреть возможность использования System.Reactive. Собственно, в System.Reactive имеются операторы, предназначенные специально для регулировки по времени. Похоже, System.Reactive сможет сыграть положительную роль в этом решении.
Для начала можно определить реализацию IProgress<T>, которая выдает событие для каждого отчета о прогрессе, а затем создать наблюдаемый объект, получающий эти отчеты:
public static class ObservableProgress
{
private sealed class EventProgress<T> : IProgress<T>
{
void IProgress<T>.Report(T value) => OnReport?.Invoke(value);
public event Action<T> OnReport;
}
public static (IObservable<T>, IProgress<T>) Create<T>()
{
var progress = new EventProgress<T>();
var observable = Observable.FromEvent<T>(
handler => progress.OnReport += handler,
handler => progress.OnReport -= handler);
return (observable, progress);
}
}
Метод ObservableProgress.Create<T> создает пару объектов IObservable<T> и IProgress<T>, при этом все отчеты о прогрессе, отправленные IProgress<T>, будут отправляться подписчикам IObservable<T>. Теперь мы имеем наблюдаемый поток для отчетов о прогрессе; следующим шагом должна стать его регулировка.
Пользовательский интерфейс должен обновляться медленно, чтобы реагировать на происходящее, и при этом достаточно быстро, чтобы пользователи видели обновления. Человек воспринимает информацию намного медленнее, чем происходит перерисовка экрана, поэтому существует широкий диапазон допустимых значений частоты обновления. Если вы хотите добиться истинного удобства восприятия информации, регулировки до одного обновления в секунду или около того может быть достаточно. Если вы предпочитаете обратную связь, приближенную к реальному времени, используйте одно обновление каждые 100 или 200 миллисекунд (мс). Оно будет достаточно быстрым, чтобы пользователь понимал суть происходящего и имел общее представление о прогрессе, но при этом достаточно медленным, чтобы пользовательский интерфейс успевал реагировать на происходящее.
Следует помнить и о том, что отчеты о прогрессе могут выдаваться из других потоков — в данном случае они выдаются из фонового потока. Регулировка должна происходить как можно ближе к источнику, поэтому желательно вынести ее в фоновый поток. Однако код, обновляющий пользовательский интерфейс, должен выполняться в UI-потоке. С учетом этого факта можно определить метод CreateForUi, который обеспечивает как регулировку, так и переход в UI-поток:
public static class ObservableProgress
{
// Примечание: должен вызываться из UI-потока.
public static (IObservable<T>, IProgress<T>) CreateForUi<T>(
TimeSpan? sampleInterval = null)
{
var (observable, progress) = Create<T>();
observable = observable
.Sample(sampleInterval ?? TimeSpan.FromMilliseconds(100))
.ObserveOn(SynchronizationContext.Current);
return (observable, progress);
}
}
Теперь у вас имеется вспомогательный метод, который будет регулировать обновления прогресса до того, как они доберутся до пользовательского интерфейса. В приведенном примере вспомогательный метод может использоваться в обработчике щелчка на кнопке:
// Для простоты код обновляет надпись напрямую.
// В реальном MVVM-приложении эти присваивания
// осуществлялись бы обновлением свойства ViewModel,
// связанного с пользовательским интерфейсом.
private async void StartButton_Click(object sender, RoutedEventArgs e)
{
MyLabel.Content = "Starting...";
var (observable, progress) = ObservableProgress.CreateForUi<int>();
string result;
using (observable.Subscribe(value => MyLabel.Content = value))
result = await Task.Run(() => Solve(progress));
MyLabel.Content = $"Done! Result: {result}";
}
Новый код вызывает наш вспомогательный метод ObservableProgress.CreateForUi, который создает пару IObservable<T> и IProgress<T>. Код подписывается на обновления о прогрессе и продолжает выполнение до тех пор, пока Solve не завершится. Наконец, IProgress<T> передается методу Solve с длительным выполнением. Когда Solve вызывает IProgress<T>.Report, сначала производится выборка этих отчетов в 100-миллисекундном окне; одно обновление за каждые 100 миллисекунд передается UI-потоку и используется для обновления текста надписи. Теперь пользовательский интерфейс сохраняет высокую скорость отклика!
Этот рецепт представляет собой интересную комбинацию других рецептов из книги. В нем не представлены никакие новые приемы; чтобы получить искомое решение, мы просто объединили ряд предшествующих рецептов.
У задачи есть и другое альтернативное решение, которое часто встречается на практике, — «решение с делением». Суть в том, что метод Solve сам регулирует свои обновления прогресса; например, если код хочет обрабатывать только одно обновление на каждые 100 фактических обновлений, то в коде можно использовать проверку с вычислением остатка вида
if (value % 100 == 0) progress?.Report(value);
У этого решения есть пара недостатков. Во-первых, «правильного» делителя не существует; обычно разработчик перебирает разные значения, пока не найдет то, которое хорошо работает на его машине. Однако тот же код может не лучшим образом работать на гигантском сервере клиента или на недостаточно мощной виртуальной машине. Кроме того, в разных платформах и средах по-разному организуется кэширование, в результате чего код может работать намного быстрее (или медленнее), чем ожидалось. И конечно, мощь «новейшего» компьютерного оборудования тоже изменяется со временем. Таким образом, значение делителя выбирается в какой-то степени случайно; оно не будет правильным везде и всегда.
Другой недостаток этого решения заключается в том, что оно пытается исправить проблему в неправильной части кода. Эта проблема относится исключительно к пользовательскому интерфейсу, и ее решение должен предоставлять пользовательский интерфейс. В примере этого рецепта метод Solve представляет некоторую фоновую бизнес-логику обработки; он не должен беспокоиться о проблемах, присущих пользовательскому интерфейсу. Возможно, в консольном приложении будет использоваться совсем не такой делитель, как в приложении WPF.
С другой стороны, решение с делением правильно в том, что обновления лучше регулировать перед отправкой обновлений UI-потоку. Решение в этом рецепте также действует по этому принципу: оно регулирует обновления немедленно и синхронно в фоновом потоке перед отправкой UI-потоку. Внедряя собственную реализацию IProgress<T>, пользовательский интерфейс может выполнить собственную регулировку, не требуя никаких изменений в самом методе Solve.
В рецепте 2.3 рассматривается использование IProgress<T> для уведомлений о прогрессе продолжительных операций.
В рецепте 13.1 рассматривается использование Task.Run для синхронного кода в потоке из пула потоков.
В рецепте 6.1 рассматривается использование FromEvent для упаковки событий .NET в наблюдаемых объектах.
В рецепте 6.4 рассматривается использование Sample для регулировки наблюдаемых объектов по времени.
В рецепте 6.2 рассматривается использование ObserveOn для перемещения наблюдаемых уведомлений в другой контекст.