TaskEx.Yield(TaskScheduler)

3
上个月我提出了以下问题,得知了TaskEx.Yield的相关内容:

异步方法可以在第一个“await”之前拥有昂贵的代码吗?

但是,我后来发现该方法实际上将所有后续代码提交给环境中的TaskScheduler。出于真正的 DI 精神,我们的团队已经同意尽可能避免使用环境实例,因此我想知道是否可以明确指定要使用的TaskScheduler

类似以下内容会很好:

public static YieldAwaitable Yield(TaskScheduler taskScheduler)
{
    return new YieldAwaitable(taskScheduler);
}

然而,Async CTP的当前实现仅提供以下功能:
public static YieldAwaitable Yield()
{
    return new YieldAwaitable(SynchronizationContext.Current ?? TaskScheduler.Current);
}

以下方案是否提供了可接受的高效替代方法?
await Task.Factory.StartNew(() => { }, CancellationToken.None, TaskCreationOptions.None, this.TaskScheduler);

我理解您的顾虑,但我认为在大多数情况下使用环境中的 TaskScheduler 是可以的。 - svick
2个回答

5

为了贯彻 DI 精神,我们的团队已经同意尽可能避免使用环境实例...

异步语言支持是基于隐式调度上下文的。我认为这里不需要依赖注入。如果必要,调用您的 async 方法的任何方法都可以提供自己的上下文。

您的替代方案:

await Task.Factory.StartNew(() => { }, CancellationToken.None, TaskCreationOptions.None, this.TaskScheduler);

这将无法按预期工作。这将在特定的TaskScheduler上排队一个noop lambda,然后在隐式调度上下文中恢复该方法。

Async CTP的早期版本确实提供了一个“切换到另一个上下文”的方法,称为SwitchTo。它已被删除,因为太容易被误用。

个人认为,保持您的async代码使用其调用方法提供的隐式调度上下文更加清晰。

P.S. 创建和安装自己的上下文并不(太)困难,例如用于测试目的。我为单元测试和控制台程序编写了 AsyncContext 作为简单的调度上下文。异步 CTP 配有 GeneralThreadAffineContextWindowsFormsContextWpfContext 用于测试。可以使用 SynchronizationContext.SetSynchronizationContext 安装其中任何一个。在我看来,DI 过度。


根据我的测试,代码似乎总是继续使用由最后一个.StartNew或.Run指定的相同TaskScheduler。我错了吗?...然后在隐式调度上下文中恢复该方法。 - Lawrence Wagerfield
这不是我所看到的。请注意,如果未从计划任务中调用(对于大多数异步方法而言),TaskScheduler.Current将返回TaskScheduler.Default参考链接 - Stephen Cleary
我不同意的原因是我们最近遇到了一个问题,即等待CopyToAsync(Stream)调用会导致所有后续代码在不同的任务调度程序上执行。我们一直在为自己的任务明确使用注入的TaskScheduler,该任务使用UI线程。但是,由于没有定义默认调度程序,CopyToAsync方法(CTP扩展)将切换到ThreadPool调度程序,在所有后续代码中引起并发混乱。为了切换回我们自己的调度程序,我们使用了你在这个答案中引用的替代方法,看起来已经解决了这个问题。 - Lawrence Wagerfield
这听起来像是一个严重的问题,不应该需要任何解决方法。我建议您在Async论坛上发布一个小的可复现示例以供讨论。 - Stephen Cleary
解释了删除 SwitchTo() 的链接还说明了你如何自己重新实现它。 - svick

2
异步语言支持可以让可等待对象控制自己的调度。当您等待一个任务时,会有默认的调度方式,但是您可以通过一些额外的代码来改变默认行为。
具体来说,await 的目的是:
1. 检查可等待对象是否“完成”(GetAwaiter().IsCompleted)。 2. 如果(且仅如果)可等待对象没有完成,则请求它安排方法的其余部分(GetAwaiter().OnCompleted(...))。 3. “实现”可等待对象的结果。这意味着要么返回返回值,要么确保遇到的异常重新出现。
因此请记住,如果可等待对象声称已经“完成”,则永远不会安排任何事情。
Task.Yield() 是新颖的,因为它会返回一个永远未完成的可等待对象,特别用于提供一种明确停止执行的方法,并立即安排其余部分进行执行。它使用环境上下文,但是还有许多其他方法可以在没有环境上下文的情况下进行类似的操作。
覆盖默认行为的示例是,当您等待一个不完整的任务时,使用ConfigureAwait(false) 方法。 ConfigureAwait(false)将任务包装在一个特殊的可等待对象中,始终使用默认任务调度程序,有效地始终在线程池上恢复。 'false' 在这里是为了明确忽略环境同步上下文。
虽然没有 Task.Yield().ConfigureAwait(false),但请考虑以下假设情况:
// ... A ...
await Task.Yield().ConfigureAwait(false);
// ... B ...

以上的内容可以通过以下方式实现:
// ... A ...
await Task.Run(() => {
    // ... B ...
});

这里有更多的明确性和嵌套,但考虑到发生的情况,这并不一定是坏事。部分'A'始终在调用线程上运行,而部分'B'始终在线程池上运行。你应该如何看待处于A和B部分的代码存在明显的差异,因此在它们之间加入一些括号,希望能让人们在假设两个部分具有相同上下文之前暂停。


1
await的意义在于你不再需要编写像那样的嵌套代码了。 - svick
1
有时嵌套是合适的 - 这就是为什么 SwitchTo(...) 被移除的整个前提(在早期 CTP 中存在,但在后来被移除),并且在 .NET 4.5 开发人员预览版中也不存在。在一个块与前一个块明显不同的上下文中,分离块实际上是有益的。例如,想象一下如果 SwitchTo(...) 仍然存在。这意味着对于 "A() if (...) { SwitchTo(...); } B();" 在这种情况下,A() 处于一个上下文中,并且可能或可能不会在 B() 之前切换到另一个上下文。 - Theo Yaung
SwitchTo(...) 对于 catch 块的推理影响更加严重。现在,您需要推理在抛出异常之前命中了哪些条件,以便查看 catch 块的上下文。要求使用 Task.Run(...) 或 Dispatcher.InvokeAsync(...) 使替代上下文更加明确,并有助于防止 catch 块中的模糊上下文。 - Theo Yaung

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