Here is a different approach to throttling. I've already posted an answer that is based on a Timer
and works well, but another intriguing idea is to throttle by reporting the progress updates to the UI thread one-by-one. The progress values are stored in a buffer, and when the UI thread has finished processing a value, the next value in the buffer is pushed to the UI thread. This scheme prevents the flooding of the UI message loop, allowing a large number of updates without freezing the UI.
The implementation below has a buffer with a default size of 1
. In case the buffer is full, the UI thread is working, and a new value is reported, the oldest value stored in the buffer is discarded in order to make room for the new value.
/// <summary>
/// A <see cref="IProgress{T}"/> implementation that reports progress updates
/// to the captured <see cref="SynchronizationContext"/> one by one.
/// </summary>
private class BufferedProgress<T> : IProgress<T>
{
private readonly List<Entry> _buffer = new();
private readonly int _boundedCapacity = 1;
private readonly Action<T> _handler;
private readonly TaskScheduler _scheduler;
private record struct Entry(T Value, TaskCompletionSource TCS);
public BufferedProgress(Action<T> handler)
{
ArgumentNullException.ThrowIfNull(handler);
_handler = handler;
if (SynchronizationContext.Current is not null)
_scheduler = TaskScheduler.FromCurrentSynchronizationContext();
else
_scheduler = TaskScheduler.Default;
}
public int BoundedCapacity
{
get => _boundedCapacity;
init
{
ArgumentOutOfRangeException.ThrowIfNegative(value,
nameof(BoundedCapacity));
_boundedCapacity = value;
}
}
public void Report(T value)
{
TaskCompletionSource discardedTcs = null;
bool startNewTask = false;
lock (_buffer)
{
if (_buffer.Count > _boundedCapacity)
{
// The maximum size of the buffer has been reached.
if (_boundedCapacity == 0) return;
Debug.Assert(_buffer.Count >= 2);
// Discard the oldest inert entry in the buffer, located in index 1.
// The _buffer[0] is the currently running entry.
// The currently running entry removes itself when it completes.
discardedTcs = _buffer[1].TCS;
_buffer.RemoveAt(1);
}
_buffer.Add(new(value, null));
if (_buffer.Count == 1) startNewTask = true;
}
discardedTcs?.SetCanceled(); // Notify any waiter of the discarded value.
if (startNewTask) StartNewTask(value);
}
private void StartNewTask(T value)
{
// The starting of the Task is offloaded to the ThreadPool. This allows the
// UI thread to take a break, so that the UI remains responsive.
// The Post method is async void, so it never throws synchronously.
// The async/await below could be omitted, because the Task will always
// complete successfully.
ThreadPool.QueueUserWorkItem(async state => await Task.Factory.StartNew(
Post, state, CancellationToken.None, TaskCreationOptions.DenyChildAttach,
_scheduler), value);
}
#pragma warning disable CS1998
// Since this method is async void, and is executed by a scheduler connected
// to the captured SynchronizationContext, any error thrown by the _handler
// is rethrown on the captured SynchronizationContext.
private async void Post(object state)
#pragma warning restore CS1998
{
try
{
T value = (T)state;
_handler(value);
}
finally
{
TaskCompletionSource finishedTcs;
(T Value, bool HasValue) next = default;
lock (_buffer)
{
// Remove the finished value from the buffer, and start the next value.
Debug.Assert(_buffer.Count > 0);
Debug.Assert(Equals(_buffer[0].Value, state));
finishedTcs = _buffer[0].TCS;
_buffer.RemoveAt(0);
if (_buffer.Count > 0) next = (_buffer[0].Value, true);
}
finishedTcs?.SetResult(); // Notify any waiter of the finished value.
if (next.HasValue) StartNewTask(next.Value);
}
}
/// <summary>
/// Returns a Task that will complete successfully when the last value
/// added in the buffer is processed by the captured SynchronizationContext.
/// In case more values are reported, causing that value to be discarded because
/// of lack of empty space in the buffer, the Task will complete as canceled.
/// </summary>
public Task WaitToFinish()
{
lock (_buffer)
{
if (_buffer.Count == 0) return Task.CompletedTask;
Span<Entry> span = CollectionsMarshal.AsSpan(_buffer);
// ^1 is the last index in the buffer.
return (span[^1].TCS ??= new TaskCompletionSource(
TaskCreationOptions.RunContinuationsAsynchronously)).Task;
}
}
}
Usage example:
BufferedProgress<string> progress = new(value =>
{
lblProgress.Text = $"Progress: {value}";
}) { BoundedCapacity = 10 };
//...
await progress.WaitToFinish();
The WaitToFinish
method can be called at the end of a long running operation by the UI thread, to wait until all reporting activity has completed.
I've tested the BufferedProgress<T>
in a Windows Forms .NET 8 application, and in my PC the Label
is updated about 4,000 times per second, without causing any perceivable non-responsiveness or freezing.