Async Lazy Initialization in C
Asynchronous lazy initialization is a design pattern that defers the
creation of an expensive resource until it is first requested, executing
the initialization logic asynchronously to avoid blocking the calling
thread. In modern .NET applications, standard synchronous lazy
evaluation can lead to thread pool starvation or application freezes
when dealing with I/O-bound or long-running tasks. This article explains
the mechanics of async lazy initialization, highlights why the standard
Lazy<T> type is insufficient for asynchronous
operations, and demonstrates how to implement a thread-safe, awaitable
AsyncLazy<T> construct in C#.
Understanding the Problem
with Lazy<T>
The .NET standard library provides Lazy<T> to
guarantee thread-safe, deferred initialization. However,
Lazy<T> expects a synchronous factory function
(Func<T>). When an operation requires asynchronous
code—such as database access, network calls, or file I/O—developers
often attempt one of two flawed workarounds:
- Blocking on Async Code: Calling
.Resultor.GetAwaiter().GetResult()inside a synchronous factory blocks the thread, which introduces latency and can cause deadlocks. - Using
Lazy<Task<T>>: Supplying anasyncfactory producesLazy<Task<T>>. While this compiles, calling.Valuereturns theTask<T>, requiring the caller toawait myLazy.Value. More critically, if the asynchronous factory throws an exception, the faulted task is cached indefinitely by default, preventing any future retry attempts.
Implementing
AsyncLazy<T>
A robust async lazy implementation must ensure that the asynchronous
factory method runs only once, safely manages concurrency across
threads, and supports direct await syntax.
Here is a common, production-ready implementation extending
Lazy<Task<T>>:
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
public class AsyncLazy<T> : Lazy<Task<T>>
{
public AsyncLazy(Func<Task<T>> taskFactory)
: base(() => Task.Run(taskFactory))
{
}
public AsyncLazy(Func<T> valueFactory)
: base(() => Task.Run(valueFactory))
{
}
public TaskAwaiter<T> GetAwaiter()
{
return Value.GetAwaiter();
}
}Key Components
Task.RunOffloading: Passing the factory toTask.Rundecouples the initialization from the thread that evaluates the lazy wrapper, preventing context-capturing issues or thread-blocking side effects.GetAwaiter()Pattern: Adding theGetAwaiter()method allows instances ofAsyncLazy<T>to be awaited directly (e.g.,await myLazy), bypassing the need to access the.Valueproperty explicitly.
Advanced Implementation: Handling Retries on Failure
If an operation fails due to a transient network error, you may want
subsequent calls to retry the initialization rather than returning the
previously faulted Task. This can be achieved with a
lock-based approach:
using System;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
public class ResilientAsyncLazy<T>
{
private readonly Func<Task<T>> _factory;
private readonly object _syncLock = new();
private Task<T>? _instance;
public ResilientAsyncLazy(Func<Task<T>> factory)
{
_factory = factory ?? throw new ArgumentNullException(nameof(factory));
}
public Task<T> Value
{
get
{
lock (_syncLock)
{
if (_instance == null || _instance.IsFaulted || _instance.IsCanceled)
{
_instance = _factory();
}
return _instance;
}
}
}
public TaskAwaiter<T> GetAwaiter() => Value.GetAwaiter();
}How to Consume
AsyncLazy<T>
Below is an example demonstrating how to define and consume an asynchronous lazy resource inside a service:
public class ConfigurationService
{
private readonly AsyncLazy<RemoteConfig> _config;
public ConfigurationService()
{
_config = new AsyncLazy<RemoteConfig>(async () =>
{
// Simulate an expensive asynchronous network fetch
await Task.Delay(500);
return new RemoteConfig { ApiKey = "secret-token", TimeoutSeconds = 30 };
});
}
public async Task<string> GetApiKeyAsync()
{
// Initialization runs only once, on the first call to await
RemoteConfig config = await _config;
return config.ApiKey;
}
}
public class RemoteConfig
{
public string ApiKey { get; set; } = string.Empty;
public int TimeoutSeconds { get; set; }
}When multiple callers invoke GetApiKeyAsync()
simultaneously, only one network request is triggered. All concurrent
callers asynchronously await the same underlying Task,
receiving the initialized instance once it completes without blocking
any threads.