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:

  1. Blocking on Async Code: Calling .Result or .GetAwaiter().GetResult() inside a synchronous factory blocks the thread, which introduces latency and can cause deadlocks.
  2. Using Lazy<Task<T>>: Supplying an async factory produces Lazy<Task<T>>. While this compiles, calling .Value returns the Task<T>, requiring the caller to await 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.Run Offloading: Passing the factory to Task.Run decouples the initialization from the thread that evaluates the lazy wrapper, preventing context-capturing issues or thread-blocking side effects.
  • GetAwaiter() Pattern: Adding the GetAwaiter() method allows instances of AsyncLazy<T> to be awaited directly (e.g., await myLazy), bypassing the need to access the .Value property 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.