Reputation: 974
I have an intense computation I'm doing, which includes code run in parallel. Inside the parallel methods we await calls to async methods. Because Parallel.For can't do that, we have some code based on channels.
The problem is that it seems to be blocking the UI thread, even though we're setting up the handler to avoid that. If I use Task.Delay(1) in the worker it seems to work, but that's only curing the symptom and not the problem.
How can I keep the UI thread from getting blocked?
Here's the code to the view model:
using Prism.Commands;
using Prism.Mvvm;
using Extensions.ParallelAsync;
using System;
using System.Collections.Concurrent;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace MVVMAwaitUiThread
{
public class MainWindowViewModel : BindableBase
{
public MainWindowViewModel()
{
DoSomethingGoodCommand = new DelegateCommand(DoSomethingGood);
DoSomethingBadCommand = new DelegateCommand(DoSomethingBad);
}
private ProgressViewModel _progressViewModel;
public ProgressViewModel ProgressViewModel
{
get => _progressViewModel;
set => SetProperty(ref _progressViewModel, value);
}
private bool _isBusy = false;
public bool IsBusy
{
get => _isBusy;
set => SetProperty(ref _isBusy, value);
}
private string _workText = "";
public string WorkText
{
get => _workText;
set => SetProperty(ref _workText, value);
}
public DelegateCommand DoSomethingGoodCommand { get; private set; }
public async void DoSomethingGood()
{
IsBusy = true;
try
{
ProgressViewModel = new ProgressViewModel();
double sum = await ReallyDoSomething(1, ProgressViewModel.Progress, ProgressViewModel.CancellationToken);
WorkText = $"Did work {DateTime.Now} -> {sum}.";
}
catch (OperationCanceledException)
{
// do nothing
}
finally
{
IsBusy = false;
}
}
public DelegateCommand DoSomethingBadCommand { get; private set; }
public async void DoSomethingBad()
{
IsBusy = true;
try
{
ProgressViewModel = new ProgressViewModel();
double sum = await ReallyDoSomething(0, ProgressViewModel.Progress, ProgressViewModel.CancellationToken);
WorkText = $"Did work {DateTime.Now} -> {sum}.";
}
catch (OperationCanceledException)
{
// do nothing
}
finally
{
IsBusy = false;
}
}
/// <summary>
/// Calling this with 0 doesn't work, but 1 does
/// </summary>
private async Task<double> ReallyDoSomething(int delay, IProgress<double> progress, CancellationToken cancellationToken)
{
const double maxIterations = 250;
const int sampleCount = 10;
const int maxDegreeOfParallelism = -1; // this doesn't seem to have any effect
const double totalIterations = sampleCount * maxIterations;
int completedIterations = 0;
ConcurrentBag<double> bag = new ConcurrentBag<double>();
// In reality, I have calculations that make calls to async/await methods, but each iteration can be parallel
// Can't make async calls in parallel.for, so this is what we have come up with
await ParallelChannelsAsync.ForAsync(0, sampleCount, maxDegreeOfParallelism, cancellationToken, Eval).ConfigureAwait(false);
async Task Eval(int seed, CancellationToken cancellationToken)
{
double sum = seed;
for (int i = 0; i < maxIterations; ++i)
{
sum += i * (i + 1.0); // simulate computation
await Task.Delay(delay); // simulate an async call
Interlocked.Increment(ref completedIterations);
progress?.Report(completedIterations / totalIterations);
cancellationToken.ThrowIfCancellationRequested();
}
bag.Add(sum / maxIterations);
};
return bag.Sum();
}
}
}
This is a (very-)simplified VS2019 project which demonstrates the problem: https://drive.google.com/file/d/1ZB4r6QRu94hbxkz_qblkVQiQZCiNLN9i/view?usp=sharing
Upvotes: 3
Views: 959
Reputation: 974
Using info from @JonasH and @mm8 and a lot of debugging, the problem is really that the UI thread is being starved by events fired from INotifyPropertyChanged.PropertyChanged because we're using MVVM.
We used two pieces to solve the problem. Where the main algorithm is called, we did need to use a Task.Run() to get it on a separate thread.
But we also had a lot of calls to IProgress.Report(), and the UI was actually doing more work than it needed to. In general, if you get multiple Reports() before the UI can draw it, then you really only need the last one. So we wrote this code to basically throw away Report() calls that are 'queued' up.
/// <summary>
/// Assuming a busy workload during progress reports, it's valuable to only keep the next most recent
/// progress value, rather than back pressuring the application with tons of out-dated progress values,
/// which can result in a locked up application.
/// </summary>
/// <typeparam name="T">Type of value to report.</typeparam>
public class ThrottledProgress<T> : IProgress<T>, IAsyncDisposable
{
private readonly IProgress<T> _progress;
private readonly Channel<T> _channel;
private Task _completion;
public ThrottledProgress(Action<T> handleReport)
{
_progress = new Progress<T>(handleReport);
_channel = Channel.CreateBounded<T>(new BoundedChannelOptions(1)
{
AllowSynchronousContinuations = false,
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = true
});
_completion = ConsumeAsync();
}
private async Task ConsumeAsync()
{
await foreach (T value in _channel.Reader.ReadAllAsync().ConfigureAwait(false))
{
_progress.Report(value);
}
}
void IProgress<T>.Report(T value)
{
_channel.Writer.TryWrite(value);
}
public async ValueTask DisposeAsync()
{
if (_completion is object)
{
_channel.Writer.TryComplete();
await _completion.ConfigureAwait(false);
_completion = null;
}
}
}
Upvotes: 0
Reputation: 169200
It's unclear what your code actually does but just because a method has a asynchronous API doesn't necessarily means that it is implemented to not block.
Consider the following method as an example. It appears to be async from a callers perspective but it clearly isn't:
public Task<int> MyNotSoAsyncMethod()
{
//I'm actually blocking...
Thread.Sleep(3000);
return Task.FromResult(0);
}
How can I keep the UI thread from getting blocked?
If you want to be sure not to block the calling thread regardless of the implementation of the method you are calling, then call it on a background thread. For example by creating a Task
:
await Task.Run(DoSomethingThatMightBlock);
Upvotes: 3