Stephen Toub's AsyncLazy<T>
implementation, based on a Lazy<Task<T>>
, is pretty nice and concise, but there are a few things that are not entirely to my liking:
In case the asynchronous operation fails, the error is cached, and will be propagated to all future awaiters of the AsyncLazy<T>
instance. There is no way to un-cache the cached Task
, so that the asynchronous operation can be retried. This makes the AsyncLazy<T>
practically unusable for the purpose of implementing a caching system, for example.
The asynchronous delegate is invoked on the ThreadPool
. There is no way to invoke it on the calling thread.
If we try to solve the previous problem by invoking the taskFactory
delegate directly instead of wrapping it in Task.Factory.StartNew
, then in the unfortunate case that the delegate blocks the calling thread for a significant amount of time, all threads that will await
the AsyncLazy<T>
instance will get blocked until the completion of the delegate. This is a direct consequence of how the Lazy<T>
type works. This type was never designed for supporting asynchronous operations in any way.
The Lazy<Task<T>>
combination generates warnings in the latest version of the Visual Studio 2019 (16.8.2). It seems that this combination can produce deadlocks in some scenarios.
The first issue has been addressed by Stephen Cleary's AsyncLazy<T>
implementation (part of the AsyncEx library), that accepts a RetryOnFailure
flag in its constructor. The second issue has also been addressed by the same implementation (ExecuteOnCallingThread
flag). AFAIK the third and the fourth issues have not been addressed.
Below is an attempt to address all of these issues. This implementation instead of being based on a Lazy<Task<T>>
, it is based on a transient nested task (Task<Task<T>>
).
/// <summary>
/// Represents the result of an asynchronous operation that is invoked lazily
/// on demand, with the option to retry it as many times as needed until it
/// succeeds, while enforcing a non-overlapping execution policy.
/// </summary>
public class AsyncLazy<TResult>
{
private Func<Task<TResult>> _taskFactory;
private readonly bool _retryOnFailure;
private Task<TResult> _task;
public AsyncLazy(Func<Task<TResult>> taskFactory, bool retryOnFailure = false)
{
ArgumentNullException.ThrowIfNull(taskFactory);
_taskFactory = taskFactory;
_retryOnFailure = retryOnFailure;
}
public Task<TResult> Task
{
get
{
var capturedTask = Volatile.Read(ref _task);
if (capturedTask is not null) return capturedTask;
var newTaskTask = new Task<Task<TResult>>(_taskFactory);
Task<TResult> newTask = null;
newTask = newTaskTask.Unwrap().ContinueWith(task =>
{
if (task.IsCompletedSuccessfully || !_retryOnFailure)
{
_taskFactory = null; // No longer needed (let it get recycled)
return task;
}
// Discard the stored _task, to trigger a retry later.
var original = Interlocked.Exchange(ref _task, null);
Debug.Assert(ReferenceEquals(original, newTask));
return task;
}, default, TaskContinuationOptions.DenyChildAttach |
TaskContinuationOptions.ExecuteSynchronously,
TaskScheduler.Default).Unwrap();
capturedTask = Interlocked
.CompareExchange(ref _task, newTask, null) ?? newTask;
if (ReferenceEquals(capturedTask, newTask))
newTaskTask.RunSynchronously(TaskScheduler.Default);
return capturedTask;
}
}
public TaskAwaiter<TResult> GetAwaiter() => Task.GetAwaiter();
public ConfiguredTaskAwaitable<TResult> ConfigureAwait(
bool continueOnCapturedContext)
=> Task.ConfigureAwait(continueOnCapturedContext);
}
Usage example:
var lazyOperation = new AsyncLazy<string>(async () =>
{
return await _httpClient.GetStringAsync("https://stackoverflow.com");
}, retryOnFailure: true);
//... (the operation has not started yet)
string html = await lazyOperation;
The taskFactory
delegate is invoked on the calling thread (the thread that calls the await lazyOperation
in the example above). If you prefer to invoke it on the ThreadPool
, you can either change the implementation and replace the RunSynchronously
with the Start
method, or wrap the taskFactory
in Task.Run
(new AsyncLazy<string>(() => Task.Run(async () =>
in the example above). Normally an asynchronous delegate is expected to return quickly, so invoking it on the calling thread shouldn't be a problem. As a bonus it opens the possibility of interacting with thread-affine components, like UI controls, from inside the delegate.
This implementation propagates all the exceptions that might be thrown by the taskFactory
delegate, not just the first one. This might be important in a few cases, like when the delegate returns directly a Task.WhenAll
task. To do this first store the AsyncLazy<T>.Task
in a variable, then await
the variable, and finally in the catch
block inspect the Exception.InnerExceptions
property of the variable.
An online demonstration of the AsyncLazy<T>
class can be found here. It demonstrates the behavior of the class when used by multiple concurrent workers, and the taskFactory
fails.