C#中关于线程和异步方法是否真正异步的混淆

52
我正在研究异步/等待(async/await)以及何时需要使用Task.Yield,在这篇文章中遇到了以下问题:

当您使用async/await时,无法保证在调用时执行await FooAsync()的方法会实际异步运行。 内部实现可以自由地使用完全同步的路径返回。

这对我来说有点不清楚,可能是因为我脑海中异步的定义与之前所述的不符。
在我看来,由于我主要从事UI开发,异步代码是不在UI线程上运行,而是在其他线程上运行的代码。我想在引用的文本中,即使它阻塞在任何一个线程上(即使它是线程池线程),某个方法也不是真正的异步。 问题: 如果我有一个长时间运行的任务是CPU绑定的(假设它在进行大量复杂的数学计算),那么异步运行该任务肯定会阻塞某个线程,对吗?毕竟必须要有某个线程计算。如果我使用await,则某个线程会被阻塞。
什么是真正异步方法的例子,它们是如何工作的?它们是不是只限于利用某些硬件功能的I/O操作,以便永远不会阻塞任何线程?

22
强制阅读:https://blog.stephencleary.com/2013/11/there-is-no-thread.html这篇文章讨论了操作系统和线程之间的关系,以及为什么有时候把线程称作“轻量级进程”是不准确的。作者解释了线程与进程不同的特点,并提到了异步编程模式的重要性。最后,他鼓励开发人员理解操作系统如何管理线程和协作,并在编写代码时利用这些知识来创建高效、健壮的应用程序。 - NWard
我喜欢你的问题,但是在我看来,“.Net/C#中的异步方法”这个标题太笼统了。 - Julian
@Julian,我更新了原帖的问题以使其更具体。 - Brian Ogden
4
如果它受限于 CPU,则表示 CPU 正在运行一个线程,是的--所有代码都在线程上运行。如果它正在等待来自网络的数据包,那么那不是代码,因此不需要在线程上运行。(某处有代码可以开始等待和停止等待,但实际的等待不是代码)。 - user253751
7个回答

115
我有点不太清楚,可能是因为我对异步的定义和我的想法不太符合。 很好,你问了澄清问题的问题。
在我看来,由于主要从事UI开发,异步代码是不在UI线程上运行而在其他线程上运行的代码。 这种想法很常见,但是是错误的。 没有任何要求异步代码必须运行在第二个线程上。
想象一下你在煮早餐。 你把面包片放进烤面包机里,当你等待面包片弹出来时,你看看昨天的邮件、支付一些账单,哦,面包片弹出来了。 你完成了支付账单的工作,然后去涂上黄油。
那么你雇用第二个工人来看守你的烤面包机了吗?
没有。 线程就是工人。 异步工作流可以全部在一个线程上进行。 异步工作流的重点是避免尽可能不要雇用更多的工人。
如果我有一个长时间运行的任务是CPU绑定的(假设它进行了大量的数学计算),那么异步运行该任务必须会阻塞某个线程,对吗?有些东西必须实际执行计算。
在这里,我会给你一个难以解决的问题。 这是一个100个数字的列表,请您手动将它们相加。 所以你将第一个数字加到第二个数字上并得出总数。 然后,你将运行总数加到第三个数字上并得出总数。 后来,噢,天啊,数字的第二页不见了。 记住你在哪里,并去烤一些面包片。 哦,在烤面包片时,一封信件到达了,带有剩余的数字。 当你涂黄油时,继续相加这些数字,并记得在下一次有空闲时间时吃下面包片。
在哪里雇用另一个工人来相加数字?计算成本昂贵的工作不需要同步进行,也不需要阻塞线程。 计算工作可能异步的原因是能够停止它、记住你在哪里、做其他事情、记住之后该做什么,然后恢复之前的工作。
现在当然可以雇用另一个工人只做加法,然后被解雇。 并且你可以问工人:“你完成了吗?” 如果答案是否定的,你可以去做一个三明治直到他们完成为止。 这样你和工人都有事可做。 但是,并没有要求异步涉及多个工人。
如果我等待它,那么某个线程将被阻塞。

不不不。这是你最大的误解。await并不意味着“异步启动此作业”。await的意思是“我在这里有一个异步生成的结果,可能不可用。如果它不可用,请找到此线程上的其他工作,以便我们不会阻塞线程。 await与你刚才说的相反。

什么是真正异步方法的示例,它们如何工作?它们仅限于利用某些硬件能力进行的I/O操作,因此永远不会阻塞任何线程吗?

异步工作通常涉及自定义硬件或多个线程,但并非必须如此。

不要考虑工作者。考虑工作流程。异步性的本质是将工作流程拆分成小部分,以便您可以确定这些部分必须发生的顺序,然后依次执行每个部分,但允许没有彼此依赖关系的部分交错执行

在异步工作流程中,您可以轻松检测表达部分之间依赖关系的工作流程中的位置。这样的部分用await标记。这就是await的含义:接下来的代码取决于完成工作流程的此部分,因此如果未完成,请找到其他任务并在任务完成后再回到此处。整个重点是保持工作者工作,即使在未来生成所需结果的情况下也是如此。


12
我觉得有些有趣,我们在“只是做事情”的概念上创建了这个线程抽象(比如中断和DPC),现在我们又基于线程建立了一个“只是做事情”的抽象。 - user253751
2
@immibis 中断和DPC如何是抽象的?它们是工具,而不是抽象。Task才是一个抽象。 - Euphoric
6
据我所知,JavaScript 同时是单线程的,并且强调异步性。 - Stephen S
1
@Euphoric,你可能需要重新阅读一下评论,我并没有说中断和DPC是抽象的。 - user253751

27

我正在学习异步/等待(async/await)。

我可以向您推荐我的async入门吗?

还有一个问题,Task.Yield 什么时候会有用呢?

几乎从不。我只在进行单元测试时偶尔使用它。

在我的理解中,由于我主要进行UI开发,因此异步代码是不在UI线程上运行而是在其他线程上运行的代码。

异步代码可以没有线程

我认为,在我引用的文本中,如果方法在任何线程上阻塞(即使它是一个线程池线程),那么该方法就不是真正的异步方法。

我认为这是正确的。对于不阻塞任何线程(并且不是同步的)的操作,我使用术语“真正异步”。对于看似异步但实际上仅因为运行或阻塞线程池线程才能以异步方式工作的操作,我使用术语“伪异步”。

如果我有一个需要长时间运行的CPU绑定任务(比如正在进行大量的数学计算),那么以异步方式运行该任务一定会阻塞某个线程,对吗?必须有某个东西实际上执行这些计算。

是的,在这种情况下,您应该使用同步API定义该工作(因为它是同步工作),然后可以使用 Task.Run 从UI线程调用它,例如:

var result = await Task.Run(() => MySynchronousCpuBoundCode());

如果我使用 await,那么会有一些线程被阻塞。

不会;线程池线程将用于运行代码(实际上不会被阻塞),而UI线程异步等待该代码完成(也不会被阻塞)。

什么是真正的异步方法的例子,它们如何工作?

NetworkStream.WriteAsync(间接地)请求网络卡写出一些字节。没有线程负责逐个字节地写出并等待每个字节被写入。网络卡处理所有这些操作。当网络卡完成写入所有字节时,它(最终)完成从WriteAsync返回的任务。

它们是否仅限于利用某些硬件功能的I/O操作,以便没有线程被阻塞?

并非完全如此,尽管I/O操作是简单的示例。另一个相对简单的示例是计时器(例如Task.Delay)。但您可以基于任何类型的“事件”构建真正的异步API。


我认为 Task.Yield 可以用于有效地防止等待任务(awaited task)的内联。如果你想让等待的任务在当前(异步)方法返回后一定执行,那么在等待的任务中等待 Task.Yield 将有效地将等待的任务推迟到调度程序中(即不进行内联)。-- 对吗? - sjb-sjb
@sjb-sjb:Task.Yield 强制异步,没错。但是你什么时候需要这样做呢?几乎从不需要。只有在单元测试时偶尔会有用处。如果你发现在生产代码中需要它,那么请认真审视你的生产代码设计,并看看能做出哪些改进。 - Stephen Cleary
是的,我基本上同意,但这种情况仍会发生。我最近遇到了一个太复杂而无法在这里解释的情况,但我会说设计是有效的。虽然通常可以使用“运行”来获得所需的异步性,但在这种情况下我不能这样做,因为它必须在当前(UI)线程上完成。我还看到其他情况,在其中 UI 线程上的事情以错误的顺序发生,并且可以使用“产生”来修复问题。 - sjb-sjb
第一个例子是:在当前方法退出后,在UI线程上运行某些内容。为此,还有另一种方法,即使用Window.Current.Dispatcher.RunAsync。然而,另一个例子是:让其他东西在UI线程上运行,然后再运行此方法的其余部分(其中“此方法”是UI回调)。我认为Task.Yield最容易,尽管编写一个单独的方法并使用Dispatcher.RunAsync进行调度也可能有效。所以我想你是对的。在我考虑使用Task.Yield的情况下,可以使用Dispatcher.RunAsync。 - sjb-sjb
@sjb-sjb:由于Windows上消息队列的工作方式,Task.Yield对此无效。 - Stephen Cleary
显示剩余2条评论

10
当你使用async/await时,不能保证调用await FooAsync()时所调用的方法实际上会以异步方式运行。内部实现可以自由地使用完全同步的路径返回。
这可能有点不清楚,可能是因为我脑海中异步的定义与此不符。
这意味着调用异步方法时有两种情况。第一种情况是在将任务返回给您时,操作已经完成,这将是同步路径。第二种情况是操作仍在进行中,这是异步路径。
考虑下面的代码,它应该显示这两个路径。如果密钥在缓存中,则会同步返回。否则,将启动一个异步操作,调用数据库:
Task<T> GetCachedDataAsync(string key)
{
    if(cache.TryGetvalue(key, out T value))
    {
        return Task.FromResult(value); // synchronous: no awaits here.
    }
    
    // start a fully async op.
    return GetDataImpl();
    
    async Task<T> GetDataImpl()
    {
        value = await database.GetValueAsync(key);
        cache[key] = value;
        return value;
    }
}

因此,通过理解这一点,您可以推断出,在理论上,database.GetValueAsync() 的调用可能具有类似的代码,并且本身能够同步返回:因此,甚至您的异步路径最终可能会 100%以同步方式运行。但是您的代码不需要关心:async/await会无缝处理两种情况。
如果我有一个长时间运行的任务,它是CPU密集型的(比方说它要做很多艰难的数学计算),那么异步运行该任务肯定会阻塞某些线程,对吗?必须实际有某个东西来执行这些数学计算。如果我使用 await,则某个线程正在被阻塞。
阻塞是一个明确定义的术语 - 这意味着您的线程在等待某些内容(I/O,互斥等)时已经放弃了执行窗口。因此,执行数学计算的线程并不被视为已阻塞:实际上它在执行工作。
"真正的异步方法"是指从不阻塞的方法。它通常涉及I/O操作,但也可以意味着在您想要在UI开发中将当前线程用于其他操作(或者在尝试引入并行性时)使用 await 来等待您的繁重的数学代码:
async Task<double> DoSomethingAsync()
{
    double x = await ReadXFromFile();
    
    Task<double> a = LongMathCodeA(x);
    Task<double> b = LongMathCodeB(x);
    
    await Task.WhenAll(a, b);
    
    return a.Result + b.Result;
}

6
这个主题非常广泛,可能会有几个讨论。然而,在C#中使用asyncawait被认为是异步编程。然而,异步工作的方式是完全不同的讨论。直到.NET 4.5之前,没有async和await关键字,开发人员必须直接针对任务并行库(TPL)进行开发。在那里,开发人员可以完全控制何时以及如何创建新任务,甚至线程。然而,这也有一个缺点,因为不是真正的专家,应用程序可能会由于线程之间的竞争条件等问题而遭受严重的性能问题和错误。
从.NET 4.5开始引入了async和await关键字,采用了一种新的异步编程方法。async和await关键字不会导致创建额外的线程。异步方法不需要多线程,因为异步方法不在自己的线程上运行。该方法在当前同步上下文中运行,并且仅在方法处于活动状态时才使用线程上的时间。您可以使用Task.Run将CPU绑定的工作移动到后台线程,但是后台线程无法帮助正在等待结果可用的进程。
基于异步编程的async方法在几乎所有情况下都优于现有方法。特别是对于IO绑定操作,这种方法比BackgroundWorker更好,因为代码更简单,而且您不必防范竞争条件。您可以在此处阅读更多关于此主题的信息。
虽然我不认为自己是C#黑带,但一些更有经验的开发人员可能会提出一些进一步的讨论,但我希望我已经回答了您的问题。

4
虽然这篇文章很有信息量,但并没有回答问题提出者的问题。在我看来,问题提出者的问题已经足够具体了,一个包含最近C#异步编程历史概述和指向MSDN上关于C#异步编程的链接的回答并不是问题提出者想要的答案。我认为即使阅读了MSDN上关于C#异步编程的内容,问题提出者的问题可能仍然存在。 - Brian Ogden
很好,你指出了async和await关键字不会导致额外的线程被创建,但我认为这仍然没有完全回答OP的问题。 - Brian Ogden
在某种程度上,我认为你是正确的。然而,在我看来,这个问题不能真正被回答,或者至少现在的表述方式不行。什么是“重数学”对每个人的想象都是主观的。在大多数情况下,异步和等待将完成工作,它不会阻塞主线程,因此应用程序仍然可以响应,直到它“等待”结果。感谢您的反馈。希望下次我能做得更好 :) - Dan
我认为OP的问题实际上是在问为什么:“当你调用await FooAsync()时,没有保证你调用的方法实际上会异步运行”,尽管OP可能没有给出一个好的例子,使用“重型数学”场景。 - Brian Ogden

6

异步不意味着并行

异步只意味着并发。实际上,即使使用显式线程也不能保证它们将同时执行(例如当线程亲和力为同一单个核心时,或更常见的是当机器上只有一个核心时)。

因此,您不应该期望异步操作与其他内容同时发生。异步仅意味着它最终会在另一个时间a(希腊语)=没有,syn(希腊语)=一起,khronos(希腊语)=时间。 => 异步 = 不同时发生)。

注意:异步的思想是在调用时您不关心代码实际运行的时间。如果可能的话,这允许系统利用并行性来执行操作。它甚至可以立即运行。它甚至可能在同一线程上运行...稍后会详细介绍。

当您使用await等待异步操作时,您正在创建并发性(com(拉丁语)=一起,currere(拉丁语)=运行。 =>“Concurrent”= 一起运行)。这是因为您要求异步操作在继续之前完成。我们可以说执行汇聚。这类似于线程的概念。

当异步无法并行时

当您使用async/await时,不能保证调用await FooAsync()时执行的方法实际上是异步运行的。内部实现可以自由地使用完全同步的路径返回。

这种情况可能有三种方式:

  1. It is possible to use await on anything that returns Task. When you receive the Task it could have already been completed.

    Yet, that alone does not imply it ran synchronously. In fact, it suggest it ran asynchronously and finished before you got the Task instance.

    Keep in mind that you can await on an already completed task:

    private static async Task CallFooAsync()
    {
        await FooAsync();
    }
    
    private static Task FooAsync()
    {
        return Task.CompletedTask;
    }
    
    private static void Main()
    {
        CallFooAsync().Wait();
    }
    

    Also, if an async method has no await it will run synchronously.

    Note: As you already know, a method that returns a Task may be waiting on the network, or on the file system, etc… doing so does not imply to start a new Thread or enqueue something on the ThreadPool.

  2. Under a synchronization context that is handled by a single thread, the result will be to execute the Task synchronously, with some overhead. This is the case of the UI thread, I'll talk more about what happens below.

  3. It is possible to write a custom TaskScheduler to always run tasks synchronously. On the same thread, that does the invocation.

    Note: recently I wrote a custom SyncrhonizationContext that runs tasks on a single thread. You can find it at Creating a (System.Threading.Tasks.)Task scheduler. It would result in such TaskScheduler with a call to FromCurrentSynchronizationContext.

    The default TaskScheduler will enqueue the invocations to the ThreadPool. Yet when you await on the operation, if it has not run on the ThreadPool it will try to remove it from the ThreadPool and run it inline (on the same thread that is waiting... the thread is waiting anyway, so it is not busy).

    Note: One notable exception is a Task marked with LongRunning. LongRunning Tasks will run on a separate thread.


你的问题

如果我有一个长时间运行的任务是CPU绑定的(假设它正在进行大量的数学计算),那么异步运行该任务一定会阻塞某个线程,对吧?必须有某个线程实际执行这些计算。如果我等待它完成,那么某个线程就会被阻塞。

如果你正在进行计算,它们必须在某个线程上执行,这一点是正确的。

然而,asyncawait的美妙之处在于等待线程不必被阻塞(稍后会详细介绍)。然而,很容易犯错,让等待的任务在等待的同一线程上调度运行,导致同步执行(这在UI线程中是一个常见错误)。

asyncawait的关键特性之一是它们从调用者那里获取SynchronizationContext。对于大多数线程来说,这意味着使用默认的TaskScheduler(正如前面提到的,它使用ThreadPool)。然而,对于UI线程,这意味着将任务发布到消息队列中,这意味着它们将在UI线程上运行。这样做的好处是,您不必使用InvokeBeginInvoke来访问UI组件。
在介绍如何在UI线程中不阻塞地awaitTask之前,我想指出可以实现一个TaskScheduler,如果你await一个Task,你不会阻塞你的线程或让它空闲,而是让你的线程选择另一个等待执行的Task当我为.NET 2.0回溯任务时,我进行了一些实验。

什么是真正异步方法的例子?它们如何工作?这些方法是否局限于利用某些硬件功能的I/O操作,以便没有线程被阻塞?

你好像把“异步”和“不阻塞线程”混淆了。如果你想要在 .NET 中使用无需阻塞线程的异步操作的示例,一种易于理解的方法是使用 continuations 而不是 await。对于需要在 UI 线程上运行的 continuations,可以使用 TaskScheduler.FromCurrentSynchronizationContext不要实现花哨的自旋等待。我的意思是不要使用 TimerApplication.Idle 或类似的东西。
当你使用async时,你告诉编译器以允许中断的方式重写方法的代码。结果类似于continuations,并且具有更方便的语法。当线程达到await时,Task将被调度,线程可以在当前async调用之后(即离开该方法)继续进行。当Task完成时,将调度继续执行(在await之后)。
对于UI线程,这意味着一旦它达到await,它就可以继续处理消息。一旦等待的Task完成,将调度继续执行(在await之后)。因此,到达await不意味着阻塞线程。
然而,盲目地添加asyncawait并不能解决所有问题。

我向您提交一个实验。获取一个新的Windows窗体应用程序,放入一个按钮和一个文本框,并添加以下代码:

    private async void button1_Click(object sender, EventArgs e)
    {
        await WorkAsync(5000);
        textBox1.Text = @"DONE";
    }

    private async Task WorkAsync(int milliseconds)
    {
        Thread.Sleep(milliseconds);
    }

它会阻塞用户界面。正如早先提到的那样,await自动使用调用线程的SynchronizationContext。在这种情况下,即为UI线程。因此,WorkAsync将在UI线程上运行。
发生的情况如下:
  • UI线程接收到点击消息并调用点击事件处理程序
  • 在点击事件处理程序中,UI线程到达await WorkAsync(5000)
  • WorkAsync(5000)(和调度其继续运行)被安排在当前同步上下文中运行,即UI线程同步上下文...这意味着它会发布一条消息来执行它
  • UI线程现在可以处理更多的消息
  • UI线程选择要执行WorkAsync(5000)并安排其继续运行的消息
  • UI线程调用带有继续的WorkAsync(5000)
  • WorkAsync中,UI线程运行Thread.Sleep。 UI现在无响应5秒钟。
  • 继续安排运行剩余的点击事件处理程序,这是通过为UI线程发布另一条消息来完成的
  • UI线程现在可以处理更多的消息
  • UI线程选择在点击事件处理程序中继续的消息
  • UI线程更新文本框

结果是同步执行,但存在开销。

是的,你应该使用Task.Delay。这不是重点;把Sleep看作计算的替代品。重点是仅仅使用asyncawait并不能让你的应用程序自动并行。最好的方法是选择哪些任务要在后台线程上运行(例如在ThreadPool上),哪些任务要在UI线程上运行。

现在,请尝试以下代码:

    private async void button1_Click(object sender, EventArgs e)
    {
        await Task.Run(() => Work(5000));
        textBox1.Text = @"DONE";
    }

    private void Work(int milliseconds)
    {
        Thread.Sleep(milliseconds);
    }

你会发现,使用await不会阻塞UI。这是因为在这种情况下,Thread.Sleep现在在ThreadPool上运行,这要归功于Task.Run。而且,由于button1_Clickasync的,一旦代码到达await,UI线程就可以继续工作。在Task完成后,编译器重写该方法以允许恰好这样做,代码将在await之后恢复执行。

具体流程如下:

  • UI线程接收到点击消息并调用点击事件处理程序
  • 在点击事件处理程序中,UI线程遇到await Task.Run(() => Work(5000))
  • Task.Run(() => Work(5000))(并安排其继续运行)被安排在当前同步上下文(UI线程同步上下文)上运行...意味着它发布了一个消息来执行它
  • UI线程现在可以处理更多的消息
  • UI线程选择要执行的消息Task.Run(() => Work(5000))并在完成后安排其继续运行
  • UI线程使用继续运行调用Task.Run(() => Work(5000)),这将在ThreadPool上运行
  • UI线程现在可以处理更多的消息

ThreadPool完成时,继续运行将安排剩余的点击事件处理程序运行,这是通过为UI线程发布另一条消息来完成的。当UI线程选择继续在点击事件处理程序中运行时,它将更新文本框。


"a" 在希腊语中是表示“没有”的意思,而不是拉丁语。 ;) - Thanasis Ioannidis
@ThanasisIoannidis 已修复。 - Theraot

4
这是一段关于异步代码的例子,它展示了如何使用 async/await 让代码在阻塞和释放控制之间切换,并且不需要线程。
public static async Task<string> Foo()
{
    Console.WriteLine("In Foo");
    await Task.Yield();
    Console.WriteLine("I'm Back");
    return "Foo";
}


static void Main(string[] args)
{
    var t = new Task(async () =>
    {
        Console.WriteLine("Start");
        var f = Foo();
        Console.WriteLine("After Foo");        
        var r = await f;
        Console.WriteLine(r);
    });
    t.RunSynchronously();
    Console.ReadLine();
}

enter image description here

因此,异步/等待的关键在于释放控制并在需要结果时重新同步(它与线程很搭)。请注意:在编写此代码时,没有阻塞任何线程 :)

我认为有时候会出现“任务”的混乱,它不表示在独立的线程上运行的某些内容。它只是一件事要做而已。异步/等待允许将任务分解为阶段,并将这些各个阶段协调成一个流程。

这有点像烹饪,你要按照食谱来做。在组合菜肴进行烘烤之前,你需要完成所有的准备工作。所以你打开烤箱,开始切菜、刨干酪等。然后等待烤箱温度和准备工作完成。你可以自己完成工作并在任务/异步/等待之间交换,也可以让其他人帮助你搅拌奶酪而你切胡萝卜(线程),这样可以更快地完成任务。


4
史蒂芬的答案已经很好了,所以我不会重复他说的话;我在Stack Overflow(和其他地方)已经重复了很多次相同的论点。
相反,让我专注于异步代码的一个重要抽象概念:它不是绝对的限定词。没有必要说一段代码是异步的 - 它总是异步的,与其他东西有关。这非常重要。 await 的目的是在异步操作和一些连接同步代码之上构建同步工作流程。您的代码对于代码本身来说1看起来完全同步。
var a = await A();
await B(a);

事件的排序是由await调用指定的。B使用A的返回值,这意味着A必须在B之前运行。包含此代码的方法具有同步工作流,并且A和B两种方法在彼此之间是同步的。
这非常有用,因为同步工作流通常更容易思考,并且更重要的是,许多工作流程仅仅是同步的。如果B需要运行A的结果,则必须在A之后运行。如果您需要发出HTTP请求以获取另一个HTTP请求的URL,则必须等待第一个请求完成;这与线程/任务调度无关。也许我们可以将其称为“固有同步性”,除了“偶然同步性”之外,在其中强制对不需要有序的东西进行排序。
你说:
“在我看来,由于我主要从事UI开发,异步代码是不在UI线程上而在其他线程上运行的代码。”
您正在描述相对于UI异步运行的代码。这确实是一种非常有用的情况(人们不喜欢停止响应的UI)。但是,它只是更通用原则的特定案例 - 允许事件相互无序发生。再次说明,这并不是绝对的 - 您希望某些事件发生无序(例如,当用户拖动窗口或进度条更改时,窗口仍然应该重新绘制),而其他事件必须不按顺序发生(在加载操作完成之前不能单击“处理”按钮)。在这种用法中,await与原则上使用Application.DoEvents并没有太大区别 - 它引入了许多相同的问题和好处。
这也是原始引用变得有趣的部分。 UI需要线程进行更新。该线程调用事件处理程序,该处理程序可能正在使用await。这是否意味着使用await的行将允许UI根据用户输入进行更新?不。
首先,您需要了解await使用其参数,就像它是一个方法调用一样。在我的示例中,在由await生成的代码可以执行任何操作之前,必须已经调用了A,包括“释放控件返回到UI循环”。 A的返回值是Task而不仅仅是T,表示“可能的未来值”- await生成的代码检查是否已经存在该值(在这种情况下,它只是在同一个线程上继续进行)或者没有(这意味着我们要释放线程回到UI循环)。但是,在任何情况下,Task值本身都必须从A返回。

考虑这个实现:

public async Task<int> A()
{
  Thread.Sleep(1000);

  return 42;
}

调用者需要 A 返回一个值(一个 int 类型的任务);由于方法中没有 await,那么就意味着它会返回 return 42;。但是在睡眠完成之前,这是不可能发生的,因为这两个操作对于线程来说是同步的。无论调用者线程是否使用 await,它都会被阻塞一秒钟——阻塞发生在 A() 本身而不是 await theTaskResultOfA
相比之下,请考虑以下内容:
public async Task<int> A()
{
  await Task.Delay(1000);

  return 42;
}

一旦执行到await,它会发现被等待的任务尚未完成,于是将控制权返回给其调用者;而调用者中的await也会因此将控制权返回给它自己的调用者。我们已经成功地将一些代码与UI异步化了。UI线程和A之间的同步性是偶然的,我们已经将其删除。
这里的重要部分是:没有办法从外部区分两个实现,除非检查代码。只有返回类型是方法签名的一部分 - 它并不表示该方法将异步执行,只表示它可能。这可能是出于许多良好的原因,所以没有必要反对它 - 例如,当结果已经可用时,打断执行线程是没有意义的。
var responseTask = GetAsync("http://www.google.com");

// Do some CPU intensive task
ComputeAllTheFuzz();

response = await responseTask;

我们需要做一些工作。有些事件可以与其他事件异步运行(在这种情况下,ComputeAllTheFuzz 独立于 HTTP 请求并且是异步的)。但在某些时候,我们需要回到同步工作流程(例如,需要 ComputeAllTheFuzz 的结果和 HTTP 请求)。这就是 await 点,它再次同步执行(如果您有多个异步工作流程,可以使用类似 Task.WhenAll 的东西)。然而,如果 HTTP 请求在计算之前已经完成,则在 await 点释放控制没有意义 - 我们只需在同一个线程上继续执行。没有浪费 CPU - 没有阻塞线程; 它执行有用的 CPU 工作。但我们没有给 UI 更新的机会。
当然,这就是为什么在更一般的异步方法中通常避免使用此模式的原因。它对于某些异步代码的使用很有用(避免浪费线程和 CPU 时间),但对于另外一些(保持 UI 响应)则无效。如果您期望这样的方法使 UI 保持响应,则不会得到满意的结果。但是,如果将其用作 Web 服务的一部分,则非常适合 - 其重点在于避免浪费线程,而不是保持 UI 响应(通过异步调用服务端点已经提供了该功能 - 在服务端重复此操作没有任何好处)。
简而言之,await 允许您编写相对于其调用者异步的代码。它不会调用异步魔力,它并非针对所有内容都是异步的,它不会阻止您使用 CPU 或阻塞线程。它只是为您提供了将异步操作转换成同步工作流程的工具,并将整个工作流程的一部分与其调用者异步呈现。
让我们考虑一个 UI 事件处理程序。如果个别异步操作恰好不需要线程来执行(例如异步 I/O),则异步方法的某些部分可能允许其他代码在原始线程上执行(在这些部分中,UI 保持响应)。当操作再次需要 CPU/线程时,它可能需要 原始 线程继续工作。如果需要,则 UI 将再次被阻塞,直到 CPU 工作完成;如果不需要(awaiter 使用 ConfigureAwait(false) 指定此条件),则 UI 代码将并行运行。当然,假设有足够的资源来同时处理两者。如果您需要 UI 始终保持响应,则不能将 UI 线程用于任何需要注意的执行时间 - 即使这意味着您必须在 Task.Run 中包装一个不可靠的“通常是异步的,但有时会阻塞几秒钟”的异步方法。两种方法都有成本和收益 - 这是一个权衡,就像所有的工程一样 :)
  1. 当然,在抽象的范畴内看是完美的 - 但每个抽象都会有漏洞,而在await和其他异步执行方法中存在很多泄漏。
  2. 一个足够聪明的优化器可能会允许B的某个部分运行,直到实际需要A的返回值为止;这就是你的CPU对于正常的“同步”代码所做的事情(乱序执行)。尽管如此,这样的优化必须保持同步的外观 - 如果CPU误判了操作的顺序,它必须丢弃结果并呈现正确的顺序。

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