异步编程和多线程有什么区别?

446
我原本以为异步和多线程是基本相同的东西——编写可以在拥有2个或更多处理器的机器上分配任务的程序。但我正在阅读这篇文章,其中提到:

异步方法旨在成为非阻塞操作。异步方法中的await表达式在等待任务完成时不会阻塞当前线程。相反,该表达式将方法的其余部分注册为后续操作,并将控制权返回给异步方法的调用者。

async和await关键字不会创建额外的线程。异步方法不需要多线程,因为异步方法不在自己的线程上运行。该方法在当前同步上下文上运行,并仅在方法处于活动状态时使用线程时间。您可以使用Task.Run将CPU密集型工作移至后台线程,但后台线程无法帮助等待结果变得可用的进程。

我想知道是否有人能够为我翻译一下。它似乎区分了异步性(这是一个词吗?)和线程,并暗示您可以编写具有异步任务但没有多线程的程序。
现在我理解了异步任务的概念,例如Jon Skeet的《C# In Depth, Third Edition》第467页的示例。
async void DisplayWebsiteLength ( object sender, EventArgs e )
{
    label.Text = "Fetching ...";
    using ( HttpClient client = new HttpClient() )
    {
        Task<string> task = client.GetStringAsync("http://csharpindepth.com");
        string text = await task;
        label.Text = text.Length.ToString();
    }
}
async关键字的意思是“每当调用该函数时,不需要在需要其完成后才能调用其后面的所有内容的上下文中调用它”。
换句话说,在某些任务的中间编写它。
int x = 5; 
DisplayWebsiteLength();
double y = Math.Pow((double)x,2000.0);

由于DisplayWebsiteLength()xy无关,将会导致DisplayWebsiteLength()在“后台”执行,类似于:

                processor 1                |      processor 2
-------------------------------------------------------------------
int x = 5;                                 |  DisplayWebsiteLength()
double y = Math.Pow((double)x,2000.0);     |

显然这只是一个愚蠢的例子,但我是正确的还是完全混淆了?

(此外,我对为什么上述函数的主体中从未使用sendere感到困惑。)


20
这是一个不错的解释:http://blog.stephencleary.com/2013/11/there-is-no-thread.html - Jakub Lortz
1
"sender"和"e"表明这实际上是一个事件处理程序 - 几乎是唯一希望使用 "async void" 的地方。最可能的情况是,这将在按钮单击或类似操作时调用 - 结果是该操作完全异步地发生,与应用程序的其余部分无关。但它仍然全部在一个线程上 - UI线程(有一个小片刻的时间在IOCP线程上,该线程将回调发布到UI线程)。 - Luaan
可能是 C#中多线程和异步程序的区别 的重复内容。 - Alireza Zojaji
5
关于“DisplayWebsiteLength”代码示例的一个非常重要的注意事项:您不应该在using语句中使用HttpClient - 在负载较重的情况下,代码可能会用尽可用的套接字数量,导致SocketException错误。有关更多信息,请参阅Improper Instantiation - Gan
5
@JakubLortz 我不知道这篇文章到底是为谁写的。它不适合初学者,因为需要对线程、中断、与CPU相关的知识有良好的掌握。对于高级用户来说,他们已经很清楚了,所以也不适合他们。我相信这篇文章对于任何人理解主题都没有帮助——抽象层次太高了。 - Loreno
@Loreno 我不认为自己很高级,但我能够理解和学习什么是DPC以及它与“线程”有何不同。 - Alb
2个回答

1045

你的误解非常普遍。许多人被教导认为多线程和异步是同一回事,但它们并不相同。

通常用类比来帮助理解。你在餐馆做饭。有一个订单要求做鸡蛋和烤面包。

  • 同步:你先做鸡蛋,然后再做烤面包。
  • 异步,单线程:你开始煮鸡蛋并设置一个计时器。你开始烤面包并设置一个计时器。当它们都在烹饪时,你清洁厨房。当计时器响起时,你把鸡蛋从火上拿下来,把烤面包从烤箱中取出来,然后上菜。
  • 异步,多线程:你雇用另外两名厨师,一个煮鸡蛋,另一个烤面包。现在你需要解决协调厨师之间共享资源时的冲突问题。此外,你还需要支付更多的工资。

现在你明白多线程只是异步的一种形式吗? 线程关乎工人,异步关乎任务。在多线程工作流中,你将任务分配给工人。在异步单线程工作流中,你拥有一组任务的图形,其中某些任务依赖于其他任务的结果;每个任务完成时都会调用代码,以安排可以运行的下一个任务,给定刚刚完成的任务的结果。但你(希望)只需要一个工人来执行所有任务,而不是每个任务都需要一个工人。

需要意识到很多任务并不是处理器绑定的。对于处理器绑定的任务,雇用与处理器数相同的工人(线程),将一个任务分配给每个工人,为每个工人分配一个处理器,并让每个处理器尽可能快地计算结果。但对于没有等待处理器的任务,根本不需要分配工人。你只需等待消息到达,表示结果已可用,在等待期间 做其他事情 即可。当消息到达时,你可以将已完成任务的继续执行安排为待办列表上的下一项。

现在让我们更详细地看一下Jon的例子。会发生什么?

  • 有人调用了DisplayWebSiteLength。是谁并不重要。
  • 它设置一个标签,创建一个客户端,并要求客户端获取一些内容。客户端返回表示获取某些内容的任务的对象。该任务正在进行中。
  • 它是否在另一个线程上进行?可能不是。请阅读斯蒂芬的文章,了解为什么没有线程。
  • 现在我们等待任务。会发生什么?我们检查在创建任务和等待任务之间任务是否已经完成。如果是,则我们获取结果并继续运行。假设它还没有完成。我们将此方法的其余部分注册为该任务的延续,并返回。
  • 现在控制权已返回给调用者。它会做什么?随便它想做什么。
  • 现在假设任务完成了。它是如何完成的?也许它正在另一个线程上运行,或者也许我们刚刚返回的调用者允许它在当前线程上运行到完成。无论如何,我们现在有了一个已完成的任务。
  • 已完成的任务要求正确的线程再次运行任务的延续 -- 再次强调,很可能是唯一的线程。
  • 控制权立即传回到我们刚刚在await处离开的方法中。现在有一个可用的结果,因此我们可以分配text并运行该方法的其余部分。
  • 这就像我的类比一样。有人向您请求文档。您通过邮寄请求来获取文档,并继续做其他工作。当文档通过邮件送达时,您会被通知,然后再进行剩余的工作流程 -- 打开信封、支付运费等等。您不需要雇用另一个工人来为您完成所有这些工作。


13
硬件并不适合用来考虑任务。任务只是一个对象,它(1)表示将来会有一个值可用,并且(2)在该值可用时可以在正确的线程上运行代码。每个任务如何获得未来的结果由其自身决定。有些会使用特殊的硬件,比如“磁盘”和“网络卡”,有些会使用像 CPU 这样的硬件。 - Eric Lippert
20
再次考虑我的类比。当有人要求你煮鸡蛋和吐司时,你需要使用特殊的硬件--火炉和烤面包机--而当硬件在工作时你可以清洁厨房。如果有人要求你制作鸡蛋、吐司以及对最后一部霍比特电影的原创评论,你可以在鸡蛋和吐司烹饪的同时撰写评论,但你不需要使用硬件来完成这个任务。 - Eric Lippert
14
关于您关于“重新排列代码”的问题,考虑一下这个。假设您有一个具有yield return的方法P和一个对P结果进行foreach的方法Q。逐步执行代码,您会看到我们运行了一点Q,然后一点P,然后一点Q...您理解这一点了吗?await本质上就是穿着华丽服装的yield return。现在更清楚了吗? - Eric Lippert
14
烤面包机是硬件。硬件不需要线程来服务它;磁盘、网络卡等运行在比操作系统线程更低的级别上。 - Eric Lippert
13
@ShivprasadKoirala:这完全不是真的。如果你相信这个,那么你对异步有一些非常错误的看法。C#中异步的整个重点在于它不会创建线程。 - Eric Lippert
显示剩余33条评论

41

浏览器中的JavaScript是异步程序的一个很好的例子,它没有多线程。

你不必担心多个代码片段同时触及同一对象:每个函数将在任何其他JavaScript允许在页面上运行之前完成运行。(更新:自这篇文章写作以来,JavaScript已经添加了async functionsgenerator functions。这些函数并不总是在其他JavaScript执行之前完全运行:当它们到达yieldawait关键字时,它们会将执行权交给其他JavaScript,并且稍后可以继续执行,类似于C#'s async方法。

然而,在执行像AJAX请求这样的操作时,根本不执行任何代码,因此,其他JavaScript可以响应诸如单击事件之类的事情,直到该请求返回并调用与其关联的回调。如果其中一个其他事件处理程序在AJAX请求返回时仍然在运行,则它的处理程序将不会被调用,直到它们完成为止。只有一个JavaScript“线程”在运行,即使你能够有效地暂停你正在做的事情,直到你获得所需的信息。

在C#应用程序中,每当处理UI元素时,同样会发生这种情况-只有在UI线程上才允许与UI元素进行交互。如果用户点击了一个按钮,并且您想通过从磁盘读取一个大文件来响应它,一个缺乏经验的程序员可能会犯错误,在点击事件处理程序内部读取文件,这将导致应用程序“冻结”,直到文件加载完成,因为它不允许响应任何更多的单击、悬停或其他与UI相关的事件,直到该线程被释放。

程序员避免这个问题的一种选择是创建一个新线程来加载文件,然后告诉该线程的代码,在文件加载完成后需要再次在UI线程上运行剩余的代码,以便可以根据文件内容更新UI元素。直到最近,这种方法非常流行,因为C#库和语言使它易于实现,但它比必须更复杂。

如果您考虑CPU在硬件和操作系统级别读取文件时正在做什么,它基本上是发出指令从磁盘中读取数据块并将其存储到内存中,并在读取完成时向操作系统发出“中断”信号。换句话说,从磁盘(或任何I/O)读取是一种固有的异步操作。线程等待该I/O完成的概念是库开发人员创建的抽象,以使其更易于编程。这并非必要。

现在,.NET中的大多数I/O操作都有相应的...Async()方法,您可以调用该方法,该方法几乎立即返回一个任务。您可以向此任务添加回调,以指定当异步操作完成时要运行的代码。您还可以指定要在哪个线程上运行该代码,并提供一个标记,异步操作可以不时地检查该标记,以查看您是否决定取消异步任务,从而使其有机会快速而优雅地停止工作。

在添加async/await关键字之前,C#更容易理解回调代码的调用方式,因为这些回调是与任务相关联的委托。为了仍然获得使用...Async()操作的好处,同时避免代码复杂性,async/await抽象了创建这些委托的过程。但它们仍然存在于编译后的代码中。

因此,您可以将UI事件处理程序await一个I / O操作,释放UI线程以执行其他任务,并且在完成文件读取后几乎自动返回到UI线程--而无需创建新线程。


3
现在已经不准确了,因为有了Web Workers,可以运行多个JavaScript线程。原文是:There's only one JavaScript "thread" running - oleksii
12
从技术上讲,这是正确的,但我不想深入讨论,因为Web Workers API本身是异步的,而且Web Workers不能直接影响JavaScript值或调用它们的Web页面上的DOM,这意味着这个答案关键的第二段仍然是正确的。从程序员的角度来看,调用Web Worker和调用AJAX请求之间几乎没有区别。 - StriplingWarrior
2
在浏览器中的JavaScript是一个很好的异步程序的例子,它没有线程。有点苛求 - 至少有1个执行线程。 - Kejsi Struga
1
@KejsiStruga:哈哈,我明白了。已将“无线程”更改为“无多线程”。 - StriplingWarrior
创建新线程有什么不好的呢?纯粹是性能问题吗?换句话说,假设性能不是问题,那么使用异步编程而不是使用多个线程是否有任何理由呢?"线程等待I/O完成的概念是库开发人员创建的抽象,以使编程更容易。"确实更容易编程。当使用同步编程时,人们不需要阅读关于ConfigureAwait或异步安全锁的长篇文章。 - Jez
@Jez:好问题。我能想到一些方法,但有一个合理的争论是,没有一个方法可以证明将所有代码更改为在调用堆栈中返回“Task”是合理的。在许多情况下,将“async”/“await”相关内容限制在适用于专用UI线程(如果有)的代码中,并在该级别将所有长时间运行的操作转换为“await Task.Run(...)”可能是合理的,而不是重构整个代码库。假设其他所有内容都是线程安全的。 - StriplingWarrior

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