什么时候应该使用Task.Yield()?

351

我经常使用async/await和Task,但从未使用过Task.Yield(),即使有所有的解释,我也不明白为什么需要这个方法。

有人能提供一个需要使用Yield()的好例子吗?


4
对于所有的js开发者来说,这与 setTimeout(_, 0) 相等。 - nawfal
1
我觉得这篇博客文章非常有帮助:https://duongnt.com/task-yield/ - Rzassar
一个真实世界的用法是防止BackgroundService.ExecuteAsync()阻塞IHostedService.StartAsync(),请参考https://blog.stephencleary.com/2020/05/backgroundservice-gotcha-startup.html和https://github.com/dotnet/runtime/issues/36063。 - n0099
6个回答

371
当您使用async/await时,调用await FooAsync()时,您调用的方法实际上可能不会异步运行。 内部实现可以自由地使用完全同步的路径返回。
如果您正在创建一个API,在该API中不允许阻塞且需要异步运行某些代码,并且有可能调用的方法将同步运行(有效地阻塞),则使用await Task.Yield()将强制使您的方法变为异步,并在该点返回控件。 代码的其余部分将在稍后执行(此时仍可能在当前上下文中同步运行)。
如果您创建了需要一些“长时间运行”初始化的异步方法,则也可以使用此方法:
 private async void button_Click(object sender, EventArgs e)
 {
      await Task.Yield(); // Make us async right away

      var data = ExecuteFooOnUIThread(); // This will run on the UI thread at some point later

      await UseDataAsync(data);
 }

如果没有 Task.Yield() 调用,该方法将同步执行直到第一次调用 await


43
我觉得我在这里对某些事情的理解有误。如果await Task.Yield()强制方法变成异步,那么我们为什么还要编写“真正”的异步代码呢?想象一下一个很耗时的同步方法,只需在开头添加asyncawait Task.Yield()即可使其变为异步?那就几乎像是把所有同步代码包装到Task.Run()中创建一个假的异步方法了。 - Krumelur
27
看看我的示例,有很大的区别。如果你使用Task.Run来实现它,ExecuteFooOnUIThread将在线程池上运行,而不是UI线程。通过await Task.Yield(),你可以强制它以一种异步的方式运行,这样后续代码仍然在当前上下文中运行(只是在稍后的时间点)。这不是你通常会做的事情,但如果出于某些奇怪的原因需要这样做,那么这个选项是很好的。 - Reed Copsey
12
还有一个问题:如果ExecuteFooOnUIThread()运行非常漫长,它仍然会在某个时候阻塞UI线程并使UI无响应,这是正确的吗? - Krumelur
8
是的,会有影响,但不会立即发生,而是在以后的时间。 - Reed Copsey
59
虽然这个回答在技术上是正确的,但是"其余代码将在稍后执行"的陈述过于抽象,可能会引起歧义。在 Task.Yield() 之后代码的执行顺序非常依赖具体的同步上下文。MSDN 文档明确指出:"在大多数 UI 环境中在 UI 线程上存在的同步上下文,通常会将发布到上下文的工作优先级高于输入和渲染工作。因此,不要依赖 await Task.Yield(); 来保持 UI 的响应性。" - Vitaliy Tsvayer
显示剩余9条评论

54

在内部,await Task.Yield()只是将继续项排队到当前同步上下文或随机池线程上,如果SynchronizationContext.Currentnull

它被有效地实现为自定义等待器。产生相同效果的不太高效的代码可能就像这样简单:

var tcs = new TaskCompletionSource<bool>();
var sc = SynchronizationContext.Current;
if (sc != null)
    sc.Post(_ => tcs.SetResult(true), null);
else
    ThreadPool.QueueUserWorkItem(_ => tcs.SetResult(true));
await tcs.Task;

Task.Yield()可以用作某些奇怪的执行流程修改的快捷方式。例如:

async Task DoDialogAsync()
{
    var dialog = new Form();

    Func<Task> showAsync = async () => 
    {
        await Task.Yield();
        dialog.ShowDialog();
    }

    var dialogTask = showAsync();
    await Task.Yield();

    // now we're on the dialog's nested message loop started by dialog.ShowDialog 
    MessageBox.Show("The dialog is visible, click OK to close");
    dialog.Close();

    await dialogTask;
    // we're back to the main message loop  
}

话虽如此,我想不出有任何情况可以用 Task.Yield() 代替 Task.Factory.StartNew + 适当的任务调度程序。

另请参见:


在你的例子中,var dialogTask = await showAsync();和原有代码有什么区别? - Erik Philips
@ErikPhilips,var dialogTask = await showAsync()无法编译,因为await showAsync()表达式不返回Task(与没有await时不同)。 也就是说,如果您使用await showAsync(),则在对话框关闭后才会恢复执行,这就是它的不同之处。 这是因为window.ShowDialog是同步API(尽管它仍然会泵送消息)。 在该代码中,我希望在对话框仍在显示时继续进行。 - noseratio - open to work
await Task.Yield()await Task.Delay(1)有相同的效果吗? - David Klempfner
1
@DavidKlempfner,在这种情况下,它们应该表现相同,但为什么你会选择 await Task.Delay(1) 而不是 await Task.Yield() - noseratio - open to work

7

Task.Yield() 的一个用途是防止在异步递归时发生堆栈溢出。 Task.Yield() 可以防止同步继续。但是请注意,这可能会导致 OutOfMemory 异常(如 Triynko 所指出的)。无限递归仍然不安全,您最好将递归重写为循环。

private static void Main()
    {
        RecursiveMethod().Wait();
    }

    private static async Task RecursiveMethod()
    {
        await Task.Delay(1);
        //await Task.Yield(); // Uncomment this line to prevent stackoverlfow.
        await RecursiveMethod();
    }

8
如果您让它长时间运行,这可能会防止堆栈溢出,但最终会耗尽系统内存。每次迭代都会创建一个永远不会完成的新任务,因为外部任务正在等待内部任务,内部任务又在等待另一个内部任务,以此类推。这是不可取的。或者,您可以仅有一个永远不会完成的最外层任务,并使其循环而不是递归。任务永远不会完成,但只会有一个任务。在循环内部,它可以暂停或等待任何您想要的东西。 - Triynko
4
我无法重现这个栈溢出错误。似乎 await Task.Delay(1) 足以预防它。(控制台应用,.NET Core 3.1,C# 8) - Theodor Zoulias
我本以为 await Task.Delay(1);await Task.Yield(); 做的事情几乎完全相同? - David Klempfner
@DavidKlempfner 我已经有一段时间没用 .Net 了,所以我的信息可能有点过时,但至少对于 .Net 4.7 来说,await Task.Delay(1) 让运行时来决定是否按顺序延迟执行而不放弃线程控制或者返回未完成的任务并放弃控制。另一方面,await Task.Yield() 确保执行上下文暂停当前线程的控制权。 - Joakim M. H.

5

Task.Yield()就像是async-await中Thread.Yield()的对应物,但具有更加具体的条件。你甚至需要多少次Thread.Yield()呢?我将首先广泛回答标题“何时使用Task.Yield()”。以下条件都满足时,您会使用它:

  • 希望将控制权返回到异步上下文(建议任务调度程序先执行队列中的其他任务)
  • 需要在异步上下文中继续
  • 希望在任务调度程序空闲时立即继续
  • 不想被取消
  • 更喜欢较短的代码

这里的“异步上下文”指的是“同步上下文优先,然后是任务调度程序”。Stephen Cleary使用了这个术语。

Task.Yield()大约做了this(许多帖子在此处或那里都有些错误):

await Task.Factory.StartNew( 
    () => {}, 
    CancellationToken.None, 
    TaskCreationOptions.PreferFairness,
    SynchronizationContext.Current != null?
        TaskScheduler.FromCurrentSynchronizationContext(): 
        TaskScheduler.Current);

如果这些条件中的任何一个被破坏了,你需要使用其他替代方案。
如果任务的延续应该在 Task.DefaultScheduler 中进行,通常使用 ConfigureAwait(false)。相反,Task.Yield() 会给你一个不带 ConfigureAwait(bool) 的可等待对象。你需要使用粗略代码与 TaskScheduler.Default
如果 Task.Yield() 阻塞了队列,你需要重构你的代码,就像 noseratio 解释的那样。
如果你需要在更长时间后才进行延续,比如毫秒级别,你可以使用 Task.Delay
如果你想让任务在队列中可取消但不想自己检查取消令牌或抛出异常,你需要使用带有取消令牌的粗略代码。

Task.Yield() 很小众,很容易被忽略。我只有一个虚构的例子,结合我的经验来解决一个由定制调度程序约束的异步餐 philosopher 问题。在我的多线程帮助库 InSync 中,它支持无序获取异步锁。如果当前的获取失败,则将异步获取排队。这里是代码 here。作为通用库,它需要 ConfigureAwait(false),因此我需要使用 Task.Factory.StartNew。在一个闭源项目中,我的程序需要执行大量混合同步和异步代码,并具有

  • 半实时工作的高线程优先级
  • 一些后台工作的低线程优先级
  • UI 的正常线程优先级
因此,我需要一个自定义调度程序。我可以轻松想象在另一个平行宇宙中有一些可怜的开发人员需要将同步和异步代码混合使用,并使用一些特殊的调度程序(一个宇宙可能不包含这样的开发人员); 但为什么他们不使用更健壮的近似代码,这样他们就不需要编写冗长的注释来解释为什么以及它是什么?

我认为Task.YieldTaskScheduler.Current没有任何关联。它会在当前的SynchronizationContext上返回,而不是在当前的TaskScheduler上。 - Theodor Zoulias
@TheodorZoulias 您是指近似代码吗?我不确定 "Task.YieldTaskScheduler.Current 有任何关联" 是什么意思。Task.Yield() 更喜欢在 SynchronizationContext.Current 上继续。如果 SynchronizationContext.Current 为 null,则继续将排队到原始队列。我不知道如何做与 Task.Yield() 相同的事情。最接近的方法,尽管仍然不同,就是上面的代码。这就是存在 Task.Yield() 的原因之一。 - keithyip
是的,我指的是近似代码,它在 SynchronizationContext.Current 为 null 时,在 TaskScheduler.Current 上安排继续。 - Theodor Zoulias
我刚刚检查了 YieldAwaitable源代码,我的看法是错误的。当没有 SynchronizationContext.Current 可以捕获时,Task.Yield 确实会捕获到 TaskScheduler.Current。所以你对 Task.Yield 的近似是正确的。 - Theodor Zoulias

-1
我发现 Yield 的一个有用的用例是在创建一个返回 IAsyncEnumerable 类型的方法时(特别是在测试中)。
private static async IAsyncEnumerable<Response> CreateAsyncEnumerable() {
        await Task.Yield();
        yield return new Response ("data");
    }

在这种情况下,如果我不添加await Task.Yield();,它仍然可以工作,但会出现以下警告信息:

此异步方法缺少'await'操作符,将以同步方式运行。考虑使用'await'操作符等待非阻塞的API调用,或者使用'await Task.Run(...)'在后台线程上执行CPU密集型工作。

其他示例通常通过添加Task.Delay()来模拟异步,但在测试中我宁愿不这样做。

-2

Task.Yield() 可以在模拟异步方法的实现中使用。


5
请提供一些细节。 - PJProudhon
5
为此,我宁愿使用Task.CompletedTask - 请参阅这篇MSDN博客文章中的Task.CompletedTask部分以获取更多考虑。 - Grzegorz Smulko
7
使用 Task.CompletedTask 或 Task.FromResult 的问题在于,当方法异步执行时,您可能会错过仅在此时出现的错误。 - Joakim M. H.

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