如果我仍然使用TaskCompletionSource来实现异步操作,那么应该返回ValueTask吗?

8

如果我使用 TaskCompletionSource 实现实际异步,那么返回 ValueTask 还有好处吗?

据我所知,ValueTask 的目的是减少分配,但是当等待 TaskCompletionSource.Task 时仍然存在分配。以下是一个简单的示例来说明这个问题:

async ValueTask DispatcherTimerDelayAsync(TimeSpan delay) 
{
    var tcs = new TaskCompletionSource<bool>();
    var timer = new DispatcherTimer();
    timer.Interval = delay;
    timer.Tick += (s, e) => tcs.SetResult(true);
    timer.Start();
    try
    {
        await tcs.Task;
    }
    finally
    {
        timer.Stop();
    }
}

我应该更倾向于从 DispatcherTimerDelayAsync 返回 Task 而不是 ValueTask,因为它本身总是期望异步的。

6
我认为你的方法可以简化为 await Task.Delay(delay);。我的理解是否正确? - Tanveer Badar
5
删掉 async,改为 return Task.Delay(delay);:能进一步减少分配内存的情况! - Marc Gravell
1
相关阅读:https://blog.marcgravell.com/2019/08/prefer-valuetask-to-task-always-and.html - Marc Gravell
@TanveerBadar,确实可以,这只是一个虚构的例子,只是为了说明我的意思。 - avo
1
我应该提到,在现实生活中,我访问一个自定义的I/O控制器,通过interop和供应商提供的C++ Win32 DLL进行访问,但最终仍然需要将回调转换为任务,并使用TaskCompletionSource来完成。 - avo
2个回答

10
每种方法都有利与弊。在“优点”方面:
  1. 同步返回结果(即Task<T>),使用ValueTask<T>避免了任务的分配——然而,对于“void”(非泛型Task)并不适用,因为您可以只返回Task.CompletedTask
  2. 当您发出多个连续的可等待对象时,可以使用单个“值任务源”和顺序令牌来减少分配

(类似“2”的一个特殊情况可能是通过像PooledAwait这样的工具进行分摊)

感觉好像这两者都不适用于此处,但如果您有疑问,请记住,将Task作为ValueTask返回(return new ValueTask(task);)不需要分配内存,这将使您考虑稍后更改实现以实现无分配,而不会破坏签名。 当然,您仍然需要支付原始的Task等 - 但将它们公开为 ValueTask 不会增加任何额外开销。

老实说,在这种情况下,我并不确定是否要过于担心这个问题,因为延迟总是会导致分配。


1
@avo 如果我们谈论的是顺序(而不是重叠)操作,并且如果减少分配非常关键(坦白地说,通常并不是这样),那么是的:这肯定会有所帮助。但很多时候你甚至不需要像这样的缓存 - 如果你可以简单地将ManualResetValueTaskSourceCore作为实例上的一个字段来保持和使用。 - Marc Gravell
1
不在v5中,我相信几个月前这些位被合并了,但据我所知,该功能在v5时间轴上通过标志关闭。 - Tanveer Badar
@MarcGravell,实现基于请求响应的协议是否适用于第二种情况?例如,支持窗口化的协议(在接收响应之前发送多个请求,使用序列号将请求与响应匹配)。 - uriDium
@uriDium,这取决于上下文;通常情况下,每个ivaluetasksource只能有一个未完成的结果 - 因此,如果您有重叠的请求,则需要小心 - 您需要仔细抽象复用。 - Marc Gravell
是的,该协议支持重叠请求。我考虑维护一个以请求序列号为键的ValueTask字典。我使用TaskCompletionSource很容易实现了这一点,但希望减少对GC的压力。这是一个高吞吐量的服务,但仍然是一种过度优化,但出于我自己的兴趣和理解而做出了这样做。 - uriDium
显示剩余4条评论

5

ValueTask 是一个结构体,而 Task 是一个类,这就是为什么它可能会减少分配内存的原因。(struct 大部分时候都是在堆栈上分配的,因此当方法退出时(退出堆栈帧),它们将自动消失。而 class 则大部分在堆上分配,需要进行 GC 的收集或清除来搜集无法访问的对象。因此,如果你可以在堆栈上分配,则由于该对象,你的堆内存分配不会增长,这就是为什么 GC 不会被触发的原因。

ValueTask 的主要优点在于当你的代码主要以同步方式执行时才能看到它的好处。这意味着如果期望值已经存在,则无需创建一个在未来将被实现的 promise (TaskCompletionSource)。

Task<bool> 的情况下,你也不需要担心分配,因为两种情况下相应的 Task 对象都由运行时自己缓存。对于 Task<int> 也是如此,但仅适用于数字介于 -1 和 9 之间。参考资料

因此,简而言之,在这种情况下,你应该使用 Task 而不是 ValueTask


2
这是一个很好的答案,当我开始阅读时我非常怀疑,但你让我感到惊喜。唯一需要补充的是,除非您的基准测试和分析告诉您在热路径上使用ValueTask会带来好处,并且下游代码可以遵守其要求,否则您应该只使用普通任务。 - TheGeneral
2
@TheGeneral,“以异步方式多次但顺序访问某些内容”的情况也是使用“source+token”方法的ValueTask[<T>]的一个非常有吸引力的候选方案。 - Marc Gravell
2
@MarcGravell 的确很有说服力。生活中要遵循三件事:善待你的母亲、分配和线程池。并且时刻问自己:“MarcGravell会怎么做” :P - TheGeneral
3
@TheGeneral,我不确定我是否信任那个白痴,但我认为他总是会更喜欢使用 ValueTask 而非 Task。 (文章链接:https://blog.marcgravell.com/2019/08/prefer-valuetask-to-task-always-and.html) - Marc Gravell
2
"对于 Task<int> 也是一样,但仅适用于0到10之间的数字。" <== 这是文档记录的,还是您通过研究源代码得知的? - Theodor Zoulias
显示剩余6条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接