1. 介绍
在真正写代码之前,先用通俗的方式理解一下。记住,在 .NET 里创建一个对象(更别提 task 了!)会占用内存并消耗一点 CPU 时间。现在想象一个异步 API,其中一半情况下结果是立刻可用的(比如从缓存里取),另一半情况需要访问数据库,因此操作是真正异步的并且返回 Task。这种情况很常见——比如文件缓存、网络 API、对象池以及其他“异步但有时瞬时”的场景。
如果我们总是返回 Task,即使结果是瞬时的,我们也得多创建对象。要是能在结果已经准备好的时候直接返回结果而不用包装成 task 会怎么样?这就是 ValueTask 出现的原因。
事实: 标准的 Task.CompletedTask 和 Task.FromResult(…) 确实能节省一些执行时间,但它们也会创建共享对象,在高并发场景下可能不是最优的。
什么是 ValueTask
ValueTask 是一个特殊的 struct 包装,可以表示已经准备好的结果,或者(当操作真的异步时)包装一个 Task。简单来说,它是一个“包”,要么直接包含结果,要么包含指向 Task 的引用。
主要有两种形式:
- ValueTask — 不返回值的(像 Task 那种)
- ValueTask<TResult> — 包装返回值的(类似 Task<TResult>)
对比 Task 和 ValueTask
| 类型 | 分配次数 | 能同步完成吗 | 通常用于 |
|---|---|---|---|
|
一次(heap) | 是/否 | 几乎总是 |
|
零次/一次 | 是/否 | 优化用 |
|
零次/一次 | 是/否 | 优化用 |
什么时候使用 ValueTask
有条黄金规则:如果你写的异步方法总是在经过真正的 async 操作后才返回结果,就用 Task。这更简单、安全、也更直观。
使用 ValueTask 值得考虑的情况:
- 结果可能同步得到(比如从缓存、池或内存中),且你想避免额外分配。
- 真正异步的情况不太频繁(否则优化收益会被代码复杂度和结构复制的开销抵消)。
注意! 如果你总是返回异步结果——直接使用 Task。如果你希望 API 对常见的瞬时结果做到“超优化”——可以考虑 ValueTask。
2. 同步还是异步结果
看一个查找用户名字的函数示例。如果用户在缓存中——立刻返回;如果不在——异步从“数据库”加载:
// 模拟我们的用户
public class User
{
public string Name { get; set; }
}
// 我们的 cache(很简单)
private readonly Dictionary<string, User> _localCache = new();
public async ValueTask<User> FindUserAsync(string name)
{
// 检查本地缓存
if (_localCache.TryGetValue(name, out var user))
{
// 结果是瞬时的——不会分配 task!
return user;
}
// 这里是假装的耗时异步操作(比如从数据库)
user = await LoadUserFromDbAsync(name);
// 放入缓存以备下次
_localCache[name] = user;
return user;
}
private async Task<User> LoadUserFromDbAsync(string name)
{
// 模拟延迟
await Task.Delay(500);
return new User { Name = name };
}
重要: 在缓存命中的情况下我们返回普通结果——不会分配 task!只有在需要“取出”结果的时候,我们才会创建异步任务。
ValueTask 内部是怎么工作的
ValueTask 内部要么包含一个准备好的值,要么包含指向 Task 的引用:
ValueTask result = ValueTask.CompletedTask;
ValueTask<int> valueResult = new ValueTask<int>(42);
ValueTask<int> valueResult2 = new ValueTask<int>(Task.Run(() => 42));
当你用 await 时,编译器会处理这些情况:如果结果是瞬时的,就不会创建额外对象。
关于 await 和 ValueTask 的重要事项
async 方法和对 ValueTask 的 await 一般是兼容的:
public async ValueTask PingAsync()
{
// ...
await Task.Delay(10);
}
但是如果你保存了一个 ValueTask 实例并准备晚点再等待它,要记住:不能 对同一个实例做多次 await。Task 可以被多次等待,而 ValueTask 不行。
错误:对同一个 ValueTask 重复 await
ValueTask<int> task = ComputeAsync();
// 这是可以的
int a = await task;
// 这是错误!对同一个 ValueTask 实例做第二次 await 不被允许:
// int b = await task; // 不行!
3. 重写我们控制台应用的一部分
假设我们现在在做一个迷你读书器:部分文本已经缓存在内存,剩下的异步加载。用 ValueTask 优化获取书第一行的方法:
private readonly Dictionary<string, string> _bookCache = new();
public async ValueTask<string> GetFirstLineOfBookAsync(string title)
{
if (_bookCache.TryGetValue(title, out var bookText))
{
var firstLine = bookText.Split('\n')[0];
return firstLine;
}
// 假设异步下载书文本
var downloadedBook = await DownloadBookTextAsync(title);
_bookCache[title] = downloadedBook;
return downloadedBook.Split('\n')[0];
}
private async Task<string> DownloadBookTextAsync(string title)
{
// 模拟延迟(比如从网络下载)
await Task.Delay(1000);
return $"Book: {title}\nThis is the first line.\nSecond line...";
}
这样当书已经在缓存里时,ValueTask 节省了创建 task 的开销。
4. 有用的细节
标记:什么时候不必使用 ValueTask
如果你的 API 总是异步的(比如总是通过网络请求),就用 Task,更简单也更安全。
如何把 ValueTask 转成 Task,反之亦然
有时候 ValueTask 无法直接交给只接受 Task 的 API,这种情况下用 .AsTask():
ValueTask<int> valueTask = ComputeAsync();
Task<int> task = valueTask.AsTask();
如果要从 Task 创建 ValueTask —— 直接用 task 来构造 ValueTask:
Task<int> task = ComputeAsyncTask();
ValueTask<int> valueTask = new ValueTask<int>(task);
对比:ValueTask vs Task
| 特性 | |
|
|---|---|---|
| 重复 await | 允许 | 不允许 |
| 快速响应时的分配 | 有 | 没有(如果结果是瞬时的) |
| 兼容接口 | 广泛支持 | 需要额外包装 |
| 适用性 | 到处可用 | 仅用于优化 |
| 池(Pool) | 使用共享池 | 不是,struct |
| 简单程度 | 简单 | 更复杂 |
在真实场景中使用 ValueTask
public async ValueTask<string> GetMessageAsync(int id)
{
if (_messageCache.TryGetValue(id, out var value))
return value;
var result = await LoadMessageFromDbAsync(id);
_messageCache[id] = result;
return result;
}
面试应用: 如果面试官问你如何进一步提升带缓存的异步 API 的性能,可以提到 ValueTask。
与 LINQ 和 IAsyncEnumerable<T> 的兼容示例
如果你想在异步 LINQ(IAsyncEnumerable<T>)中使用 ValueTask,在 .NET 中这是支持的(比如 ToListAsync 方法):
public async ValueTask<List<int>> GetPrimeNumbersAsync()
{
// 模拟部分数字已计算,部分是异步计算
// ... (示例省略以保持简洁)
return new List<int> { 2, 3, 5, 7, 11 };
}
5. 总结与实现要点
- 当大部分时间结果是瞬时可得(比如来自缓存)时,使用 ValueTask 做优化。
- 不要无脑使用 ValueTask——会增加代码复杂度并引入错误。
- 不要对同一个 ValueTask 做多次 await。如果需要,先把它转换成 Task。
- 与 Task 的互操作主要靠 .AsTask()。
- 记住 ValueTask 是个 struct,复制时行为可能不同。
6. 使用 ValueTask 时的常见错误
错误 #1: 对同一个 ValueTask 实例重复做 await。ValueTask 是个 struct,不能被 await 两次。第二次 await 会导致异常或不正确的行为。
错误 #2: 在没有检查兼容性的情况下使用 ValueTask。有些第三方 API 需要 Task,直接传递 ValueTask 可能会出问题。
错误 #3: 不必要地使用 ValueTask。如果结果总是异步的,使用 ValueTask 只会让代码更复杂而无实际收益。
错误 #4: 复制 ValueTask(作为 struct)。被复制的结构在 await 时可能有意想不到的表现,务必操作原始实例。
GO TO FULL VERSION