c# 在 C# 中实现一个异步的 Lazy (AsyncLazy)

来源:这里教程网 时间:2026-02-21 17:37:12 作者:

为什么不能直接用
Lazy<task>></task>

很多人第一反应是套一层

Lazy<task>></task>
,比如:
var lazyTask = new Lazy<Task<string>>(() => FetchDataAsync());
这看似异步延迟初始化,但问题在于:它只懒加载 任务对象本身,不控制任务的 执行时机。一旦你调用
lazyTask.Value
,它就立刻返回一个已启动(可能已完成、正在运行或已失败)的
Task
,无法保证「首次访问时才真正开始执行」——尤其当多个线程并发访问时,
FetchDataAsync()
可能被多次触发,违背 lazy 语义。

AsyncLazy<t></t>
必须确保首次访问才执行且线程安全

核心诉求是:第一次调用

.Value
.Wait()
/
await
时,才真正启动异步工作,并且所有后续并发访问都复用同一个
Task
,不重复执行。推荐做法是内部封装一个
Task<t></t>
字段 +
object
锁 + 双重检查(或直接用
Lazy<task>></task>
配合惰性构造函数)。最简健壮实现如下:

public class AsyncLazy<T>
{
    private readonly Lazy<Task<T>> _lazy;
    public AsyncLazy(Func<Task<T>> factory)
    {
        _lazy = new Lazy<Task<T>>(() => factory());
    }
    public Task<T> Value => _lazy.Value;
    public async Task<T> GetValueAsync() => await Value;
}

关键点:

_lazy
Lazy<task>></task>
,其
Value
属性保证只执行一次工厂函数
工厂函数
Func<task>></task>
返回的是未启动的
Task
(如
Task.Run(...)
HttpClient.GetStringAsync(...)
),.NET 的
Task
构造即启动,所以必须确保工厂函数每次返回的是“新创建的、尚未 await 的 task”
不要在工厂里
await
—— 否则
Lazy.Value
会同步阻塞,失去异步意义

如何正确使用
AsyncLazy<t></t>
并避免常见错误

错误示范:

var bad = new AsyncLazy<string>(() => { 
    var result = await GetDataFromApi(); // ❌ 编译不过:lambda 不能是 async
    return result; 
});
正确写法是把
async
提到外部,用
Task.Run
或直接返回可等待的异步方法调用(前提是该方法返回
Task<t></t>
):

✅ 直接传异步方法组:
new AsyncLazy<string>(GetDataFromApi)</string>
✅ 匿名函数中不 await,只返回 task:
() => GetDataFromApi()
✅ 若需组合逻辑,用
Task.Run
(注意:仅限 CPU-bound 场景):
() => Task.Run(() => ExpensiveCalculation()).ContinueWith(t => t.Result.ToString())
❌ 不要用
Task.FromResult
包装同步结果再声称是 AsyncLazy —— 它失去了异步延迟的意义

调用时也注意:如果只是想等结果,用

await lazy.Value
;如果需要同步阻塞(极少见),用
lazy.Value.GetAwaiter().GetResult()
,而非
.Value.Result
(可能死锁)。

要不要支持取消和异常缓存?

标准

AsyncLazy<t></t>
不内置取消令牌,因为
Lazy<t></t>
本身也不支持。如需
CancellationToken
,必须扩展构造函数并把 token 传入工厂,例如:
public AsyncLazy(Func<CancellationToken, Task<T>> factory, CancellationToken cancellationToken = default)
{
    _lazy = new Lazy<Task<T>>(() => factory(cancellationToken));
}
但要注意:如果工厂抛出异常,
Lazy<task>></task>
会缓存该异常 task,后续访问仍抛相同异常 —— 这是预期行为(类似
Lazy<t></t>
缓存异常)。若需重试,就得自己封装 retry 逻辑,不在
AsyncLazy
职责范围内。

真正容易被忽略的是:

AsyncLazy<t></t>
的生命周期管理。它持有的
Task
不会自动释放,如果工厂返回的是长时运行或资源密集型 task(如未关闭的
HttpClient
请求),要确保上层控制好它的存活时间,必要时用
IDisposable
包装或依赖注入作用域来约束。

相关推荐