龙空技术网

c# 10 教程: 14 并发和异步

启辰8 160

前言:

眼前姐妹们对“c语言 异步函数”大概比较关注,我们都需要分析一些“c语言 异步函数”的相关知识。那么小编同时在网摘上收集了一些关于“c语言 异步函数””的相关内容,希望姐妹们能喜欢,姐妹们快快来了解一下吧!

大多数应用程序需要一次处理多件事()。在本章中,我们从基本的先决条件开始,即线程和任务的基础知识,然后详细描述异步和 C# 异步函数的原理。

在第章中,我们将更详细地重新讨论多线程,在第中,我们将介绍并行编程的相关主题。

介绍

以下是最常见的并发方案:

编写响应式用户界面

在 Windows Presentation Foundation (WPF)、移动和 Windows 窗体应用程序中,必须同时运行耗时的任务以及运行用户界面的代码以保持响应能力。

允许同时处理请求

在服务器上,客户端请求可以并发到达,因此必须并行处理以保持可伸缩性。如果使用 ASP.NET 核心或 Web API,运行时会自动执行此操作。但是,您仍然需要了解共享状态(例如,使用静态变量进行缓存的效果)。

并行编程

如果工作负载在内核之间分配,则执行密集型计算的代码可以在多核/多处理器计算机上更快地执行(专门讨论这一点)。

投机执行

在多核计算机上,有时可以通过预测可能需要完成的操作,然后提前执行来提高性能。LINQPad 使用此技术来加快新查询的创建速度。是并行运行许多不同的算法,这些算法都解决相同的任务。无论哪个先完成,“获胜”——当你无法提前知道哪种算法将执行得最快时,这是有效的。

程序可以同时执行代码的一般机制称为。多线程处理受 CLR 和操作系统的支持,并且是并发的基本概念。了解线程的基础知识,特别是线程对的影响,至关重要。

线程

线程是可以独立于其他进行的执行路径。

每个线程都在操作系统进程中运行,该进程提供了一个运行程序的独立环境。对于单线程程序,只有一个线程在进程的独立环境中运行,因此该对它具有独占访问权限。对于多线程程序,多个在单个进程中运行,共享相同的执行环境(特别是内存)。这在一定程度上就是多线程有用的原因:例如,一个线程可以在后台获取数据,而另一个线程在数据到达时显示数据。此数据称为。

创建线程

程序(控制台、WPF、UWP 或 Windows 窗体)在操作系统自动创建的单个线程(“主”线程)中启动。在这里,它作为单线程应用程序活出它的生命,除非您通过创建更多线程(直接或间接)来执行其他操作。1

可以通过实例化 Thread 对象并调用其 Start 方法来创建和启动新线程。Thread 最简单的构造函数采用 ThreadStart 委托:一个指示应从何处开始执行的无参数方法。下面是一个示例:

// NB: All samples in this chapter assume the following namespace imports:using System;using System.Threading;Thread t = new Thread (WriteY);          // Kick off a new threadt.Start();                               // running WriteY()// Simultaneously, do something on the main thread.for (int i = 0; i < 1000; i++) Console.Write ("x");void WriteY(){  for (int i = 0; i < 1000; i++) Console.Write ("y");}// Typical Output:xxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx...

主线程创建一个新线程 t,它在其上运行重复打印字符 的方法。同时,主线程重复打印字符 ,如图 所示。在单核计算机上,操作系统必须为每个线程分配“片”时间(在 Windows 中通常为 20 毫秒)以模拟并发,从而导致 和 块。在多核或多处理器计算机上,两个线程可以真正并行执行(受计算机上其他活动进程的竞争),尽管在此示例中,由于控制台处理并发请求的机制存在细微之处,您仍然会得到重复的 和 块。

启动新线程注意

线程被称为在其执行与另一个线程上的代码执行穿插在一起的点被。这个词经常出现在解释为什么出了问题的时候!

启动后,线程的 IsAlive 属性返回 true ,直到线程结束的点。当传递给线程的构造函数的委托完成执行时,线程结束。结束后,线程无法重新启动。

每个线程都有一个 Name 属性,您可以设置该属性以方便调试。这在 Visual Studio 中特别有用,因为线程的名称显示在“线程窗口”和“调试位置”工具栏中。您只能设置一次线程的名称;稍后尝试更改它将引发异常。

静态 Thread.CurrentThread 属性为您提供当前正在执行的线程:

Console.WriteLine (Thread.CurrentThread.Name);
加入和睡眠

您可以通过调用其 Join 方法来等待另一个线程结束:

Thread t = new Thread (Go);t.Start();t.Join();Console.WriteLine ("Thread t has ended!"); void Go() { for (int i = 0; i < 1000; i++) Console.Write ("y"); }

这将打印“y”1,000 次,紧接着是“线程 t 已结束!调用 Join 时可以包含超时,以毫秒为单位,也可以以时间跨度 为单位。然后,如果线程结束,则返回 true,如果超时,则返回 false。

Thread.Sleep 将当前线程暂停指定的时间段:

Thread.Sleep (TimeSpan.FromHours (1));  // Sleep for 1 hourThread.Sleep (500);                     // Sleep for 500 milliseconds

Thread.Sleep(0) 立即放弃线程的当前时间片,自愿将 CPU 移交给其他线程。Thread.Yield() 做同样的事情,只是它只让给处理器上运行的线程。

注意

Sleep(0) 或 Yield 在生产代码中偶尔可用于高级性能调整。它也是一个很好的诊断工具,有助于发现线程安全问题:如果在代码中的任何位置插入 Thread.Yield() 会破坏程序,则几乎可以肯定存在错误。

在等待睡眠或加入时,线程被阻塞。

阻塞

当线程的执行由于某种原因而暂停时,例如当休眠或等待另一个线程通过 Join 结束时,该线程被视为。被阻塞的线程会立即其处理器时间片,从那时起,在满足其阻塞条件之前,它不会消耗任何处理器时间。您可以通过其 ThreadState 属性测试线程是否被阻止:

bool blocked = (someThread.ThreadState & ThreadState.WaitSleepJoin) != 0;
注意

ThreadState 是一个标志枚举,以按位方式组合三个数据“层”。但是,大多数值都是冗余的、未使用的或已弃用的。以下扩展方法将 ThreadState 剥离为四个有用值之一:Unstarted 、Running 、WaitSleepJoin 和 Stop:

public static ThreadState Simplify (this ThreadState ts){  return ts & (ThreadState.Unstarted |               ThreadState.WaitSleepJoin |               ThreadState.Stopped);}

属性可用于诊断目的,但不适合同步,因为线程的状态可能会在测试 ThreadState 和处理该信息之间发生更改。

当线程阻塞或取消阻止时,OS 会执行。这会产生很小的开销,通常为一到两微秒。

I/O 绑定与计算绑定

花费大部分时间某些事情发生的操作称为 — 例如下载网页或调用 Console.ReadLine 。(I/O 绑定操作通常涉及输入或输出,但这不是硬性要求:Thread.Sleep 也被视为 I/O 绑定。相比之下,花费大部分时间执行 CPU 密集型工作的操作称为。

阻塞与旋转

I/O 绑定操作以以下两种方式之一工作:它在当前线程上等待,直到操作完成(例如 Console.ReadLine 、 Thread.Sleep 或 Thread.Join ),或者异步操作,完成时触发回调(稍后会详细介绍)。

同步等待的 I/O 绑定操作花费大部分时间阻塞线程。它们还可以周期性地循环“旋转”:

while (DateTime.Now < nextStartTime)  Thread.Sleep (100);

撇开有更好的方法(例如计时器或信令结构)不谈,另一种选择是线程可以连续旋转:

while (DateTime.Now < nextStartTime);

通常,这会浪费处理器时间:就 CLR 和 OS 而言,线程正在执行重要的计算,因此相应地分配了资源。实际上,我们已经将应该是 I/O 绑定的操作变成了计算绑定的操作。

注意

关于旋转与阻塞有一些细微差别。首先,当您期望很快满足条件(可能在几微秒内)时,的旋转可能是有效的,因为它避免了上下文切换的开销和延迟。.NET 提供了特殊的方法和类来提供帮助 — 请参阅联机补充

其次,阻止不会产生成本。这是因为每个线程只要存在就会占用大约 1 MB 的内存,并导致 CLR 和操作系统的持续管理开销。因此,在需要处理数百或数千个并发操作的大量 I/O 绑定程序的上下文中,阻塞可能会很麻烦。相反,此类程序需要使用基于回调的方法,在等待时完全取消其线程。这(部分)是我们稍后讨论的异步模式的目的。

本地状态与共享状态

CLR 为每个线程分配自己的内存堆栈,以便局部变量保持独立。在下一个示例中,我们使用局部变量定义一个方法,然后在主线程和新创建的线程上同时调用该方法:

new Thread (Go).Start();      // Call Go() on a new threadGo();                         // Call Go() on the main thread void Go(){  // Declare and use a local variable - 'cycles'  for (int cycles = 0; cycles < 5; cycles++) Console.Write ('?');}

在每个线程的内存堆栈上创建 cycle 变量的单独副本,因此可以预见的是,输出是 10 个问号。

如果线程对同一对象或变量具有公共引用,则线程共享数据:

bool _done = false;new Thread (Go).Start();Go();void Go(){   if (!_done) { _done = true; Console.WriteLine ("Done"); }}

两个线程共享_done变量,因此“完成”打印一次而不是两次。

也可以共享 lambda 表达式捕获的局部变量:

bool done = false;ThreadStart action = () =>{  if (!done) { done = true; Console.WriteLine ("Done"); }};new Thread (action).Start();action();

不过,更常见的是,字段用于在线程之间共享数据。在以下示例中,两个线程在同一个 ThreadTest 实例上调用 Go(),因此它们共享相同的_done字段:

var tt = new ThreadTest();new Thread (tt.Go).Start();tt.Go();class ThreadTest {  bool _done;  public void Go()  {    if (!_done) { _done = true; Console.WriteLine ("Done"); }  }}

静态字段提供了另一种在线程之间共享数据的方法:

class ThreadTest {  static bool _done;    // Static fields are shared between all threads                        // in the same process.  static void Main()  {    new Thread (Go).Start();    Go();  }   static void Go()  {    if (!_done) { _done = true; Console.WriteLine ("Done"); }  }}

所有四个示例都说明了另一个关键概念:线程安全(或者更确切地说,缺乏它!输出实际上是不确定的:“完成”有可能(尽管不太可能)打印两次。但是,如果我们在 Go 方法中交换语句的顺序,则“完成”被打印两次的几率会急剧上升:

static void Go(){  if (!_done) { Console.WriteLine ("Done"); _done = true; }}

问题在于,一个线程可以在另一个线程执行 WriteLine 语句的同时计算 if 语句 — 在它有机会将 done 设置为 true 之前。

注意

我们的示例说明了可能引入多线程臭名昭著的间歇性错误的多种方式之一。接下来,我们看看如何通过锁定来修复我们的程序;但是,最好尽可能完全避免共享状态。稍后我们将看到异步编程模式如何对此有所帮助。

锁定和螺纹安全注意

锁定和线程安全是大主题。有关完整讨论,请参阅中的“和

我们可以通过在读取和写入共享字段时获取来修复前面的示例。C# 仅为此目的提供了 lock 语句:

class ThreadSafe {  static bool _done;  static readonly object _locker = new object();   static void Main()  {    new Thread (Go).Start();    Go();  }   static void Go()  {    lock (_locker)    {      if (!_done) { Console.WriteLine ("Done"); _done = true; }    }  }}

当两个线程同时争用一个锁(可以在任何引用类型对象上;在本例中为 _locker)时,一个线程等待或阻塞,直到锁可用。在这种情况下,它确保一次只有一个线程可以进入其代码块,并且“完成”将只打印一次。以这种方式(避免在多线程上下文中出现不确定性)的代码称为代码。

警告

即使是自动递增变量的行为也不是线程安全的:表达式 x++ 作为不同的读-增量-写操作在底层处理器上执行。因此,如果两个线程在锁外部同时执行 x++,则变量最终可能会递增一次而不是两次(或者更糟糕的是,在某些情况下,x 可能会,最终导致新旧内容的按位混合)。

锁定不是线程安全的灵丹妙药 - 很容易忘记锁定访问字段,并且锁定本身会产生问题(例如死锁)。

何时可以使用锁定的一个很好的例子是访问 ASP.NET 应用程序中经常访问的数据库对象的共享内存中缓存。这种应用程序很容易正确,并且没有死锁的机会。我们在的中给出了一个例子。

将数据传递到线程

有时,您需要将参数传递给线程的启动方法。最简单的方法是使用 lambda 表达式,该表达式使用所需参数调用该方法:

Thread t = new Thread ( () => Print ("Hello from t!") );t.Start();void Print (string message) => Console.WriteLine (message);

使用此方法,可以将任意数量的参数传递给该方法。您甚至可以将整个实现包装在多语句 lambda 中:

new Thread (() =>{  Console.WriteLine ("I'm running on another thread!");  Console.WriteLine ("This is so easy!");}).Start();

另一种(不太灵活)的技术是将参数传递到 Thread 的 Start 方法中:

Thread t = new Thread (Print);t.Start ("Hello from t!");void Print (object messageObj){  string message = (string) messageObj;   // We need to cast here  Console.WriteLine (message);}

这是有效的,因为 Thread 的构造函数被重载以接受两个之一:

public delegate void ThreadStart();public delegate void ParameterizedThreadStart (object obj);
Lambda 表达式和捕获的变量

正如我们所看到的,lambda 表达式是将数据传递到线程的最方便、最强大的方式。但是,您必须小心,以免在启动线程后意外修改。例如,请考虑以下事项:

for (int i = 0; i < 10; i++)  new Thread (() => Console.Write (i)).Start();

输出是不确定的!下面是一个典型的结果:

0223557799

问题是 i 变量在循环的整个生命周期中引用内存位置。因此,每个线程都会调用 Console.Write 在一个变量上,该变量的值可以在运行时更改!解决方案是使用临时变量

for (int i = 0; i < 10; i++){  int temp = i;  new Thread (() => Console.Write (temp)).Start();}

然后,每个数字 0 到 9 只写一次。(仍未定义,因为线程可以在不确定的时间启动。

注意

这类似于我们在中描述的问题。问题与 C# 在循环中捕获变量的规则和多线程一样多。

可变温度现在是每个循环迭代的本地变量。因此,每个线程捕获不同的内存位置,没有问题。我们可以用下面的例子在前面的代码中更简单地说明这个问题:

string text = "t1";Thread t1 = new Thread ( () => Console.WriteLine (text) );text = "t2";Thread t2 = new Thread ( () => Console.WriteLine (text) );t1.Start(); t2.Start();

由于两个 lambda 表达式捕获相同的文本变量,因此 t2 打印两次。

异常处理

创建线程时生效的任何尝试/捕获/最终块在开始执行时与线程无关。请考虑以下程序:

try{  new Thread (Go).Start();}catch (Exception ex){  // We'll never get here!  Console.WriteLine ("Exception!");}void Go() { throw null; }   // Throws a NullReferenceException

此示例中的 try / catch 语句无效,新创建的线程将受到未处理的 NullReferenceException 的阻碍。当您认为每个线程都有独立的执行路径时,此行为是有意义的。

补救措施是将异常处理程序移动到 Go 方法中:

new Thread (Go).Start();void Go(){  try  {    ...    throw null;    // The NullReferenceException will get caught below    ...  }  catch (Exception ex)  {    // Typically log the exception, and/or signal another thread    // that we've come unstuck    ...  }}

您需要在生产应用程序中的所有线程入口方法上使用异常处理程序,就像在主线程上所做的那样(通常在执行堆栈中的更高级别)。未经处理的异常会导致整个应用程序关闭 - 并显示一个丑陋的对话框!

注意

在编写此类异常处理块时,您很少会错误:通常,您会记录异常的详细信息。对于客户端应用程序,您可能会显示一个对话框,允许用户自动将这些详细信息提交到 Web 服务器。然后,您可以选择重新启动应用程序,因为意外的异常可能会使程序处于无效状态。

集中式异常处理

在 WPF、UWP 和 Windows 窗体应用程序中,您可以分别订阅“全局”异常处理事件:Application.DispatcherUnhandledException 和 Application.ThreadException。在通过消息循环调用的程序的任何部分中发生未经处理的异常后,这些异常将触发(这相当于应用程序处于活动状态时在主线程上运行的所有代码)。这可用作日志记录和报告 bug 的后盾(尽管它不会针对您创建的非用户界面 [UI] 线程上的未经处理的异常触发)。处理这些事件可防止程序关闭,尽管您可以选择重新启动应用程序以避免可能从(或导致)未处理的异常导致状态的潜在损坏。

前台线程与后台线程

默认情况下,显式创建的线程是。只要前台线程中的任何一个正在运行,前台线程就会使应用程序保持活动状态,而则不会。所有前台线程完成后,应用程序结束,并且仍在运行的任何后台线程将突然终止。

注意

线程的前台/后台状态与其(执行时间分配)无关。

您可以使用线程的 IsBackground 查询或更改线程的背景状态:

static void Main (string[] args){  Thread worker = new Thread ( () => Console.ReadLine() );  if (args.Length > 0) worker.IsBackground = true;  worker.Start();}

如果在没有参数的情况下调用此程序,则工作线程将处于前台状态,并将等待 ReadLine 语句,以便用户按 Enter 键。同时,主线程退出,但应用程序继续运行,因为前台线程仍处于活动状态。另一方面,如果将参数传递给 Main(),则为工作线程分配后台状态,并且程序在主线程结束时几乎立即退出(终止 ReadLine)。

当进程以这种方式终止时,后台线程的执行堆栈中的任何 finally 块都会被规避。如果您的程序使用 final(或使用)块来执行清理工作,例如删除临时文件,您可以通过在退出应用程序时显式等待此类后台线程来避免这种情况,方法是通过加入线程或使用信令构造(请参阅)。无论哪种情况,您都应该指定超时,以便在叛徒线程拒绝完成时可以放弃它;否则,您的应用程序将无法关闭,而无需用户从任务管理器(或在 Unix 上为 kill 命令)寻求帮助。

前台线程不需要这种处理,但您必须注意避免可能导致线程无法结束的 bug。应用程序无法正确退出的常见原因是存在活动的前台线程。

线程优先级

线程的 Priority 属性确定相对于操作系统中的其他活动线程分配的执行时间量,具体比例如下:

enum ThreadPriority { Lowest, BelowNormal, Normal, AboveNormal, Highest }

当多个线程同时处于活动状态时,这变得很重要。提升线程的优先级时需要小心,因为它可能会使其他线程匮乏。如果希望某个线程的优先级高于进程中的线程,则还必须使用 System.Diagnostics 中的 Process 类提升进程优先级:

using Process p = Process.GetCurrentProcess();p.PriorityClass = ProcessPriorityClass.High;

这对于执行最少工作且需要低延迟(能够快速响应)的非 UI 进程非常有效。对于计算量大的应用程序(尤其是具有用户界面的应用程序),提升进程优先级可能会使其他进程匮乏,从而降低整个计算机的速度。

信号

有时,您需要一个线程等待,直到收到来自其他线程的通知。这称为。最简单的信令结构是 手动重置事件 。在 ManualResetEvent 上调用 WaitOne 会阻止当前线程,直到另一个线程通过调用 Set 来“打开”信号。在下面的示例中,我们启动一个等待 手动重置事件 .它保持阻塞两秒钟,直到主线程:

var signal = new ManualResetEvent (false);new Thread (() =>{  Console.WriteLine ("Waiting for signal...");  signal.WaitOne();  signal.Dispose();  Console.WriteLine ("Got signal!");}).Start();Thread.Sleep(2000);signal.Set();        // “Open” the signal

调用 Set 后,信号保持打开状态;您可以通过调用 重置 再次关闭它。

手动重置事件是 CLR 提供的几种信令构造之一;我们将在第中详细介绍所有这些。

胖客户端应用程序中的线程处理

在 WPF、UWP 和 Windows 窗体应用程序中,在主线程上执行长时间运行的操作会使应用程序无响应,因为主线程还处理执行呈现和处理键盘和鼠标事件的消息循环。

一种流行的方法是启动“worker”线程以进行耗时的操作。工作线程上的代码运行耗时的操作,然后在完成后更新 UI。但是,所有胖客户端应用程序都有一个线程模型,其中 UI 元素和控件只能从创建它们的线程(通常是主 UI 线程)访问。违反此规定会导致引发不可预知的行为或引发异常。

因此,当您想要从工作线程更新 UI 时,必须将请求转发到 UI 线程(技术术语是执行此操作的低级方法如下(稍后,我们将讨论基于这些解决方案的其他解决方案):

在 WPF 中,调用 BeginInvoke 或 Invoke,对元素的调度程序对象进行调用。在 UWP 应用中,在调度程序对象上调用 RunAsync 或 Invoke。在 Windows 窗体中,调用控件上的 BeginInvoke 或 Invoke。

所有这些方法都接受引用要运行的方法的委托。BeginInvoke/RunAsync 的工作原理是将委托排队到 UI 线程的消息队列(处理键盘、鼠标和计时器事件的同一)。Invoke 执行相同的操作,但随后会阻止,直到 UI 线程读取和处理消息。因此,Invoke 允许您从方法中获取返回值。如果你不需要返回值,BeginInvoke / RunAsync 更可取,因为它们不会阻止调用方,也不会引入死锁的可能性(参见中的)。

注意

可以想象,当您调用 Application.Run 时,将执行以下伪代码:

while (!thisApplication.Ended){  wait for something to appear in message queue  Got something: what kind of message is it?    Keyboard/mouse message -> fire an event handler    User BeginInvoke message -> execute delegate    User Invoke message -> execute delegate & post result}

正是这种循环使工作线程能够将委托封送到 UI 线程上执行。

为了演示,假设我们有一个 WPF 窗口,其中包含一个名为 txtMessage ,我们希望工作线程在执行耗时的任务后更新其内容(我们将通过调用 Thread.Sleep 来模拟)。以下是我们的做法:

partial class MyWindow : Window{  public MyWindow()  {    InitializeComponent();    new Thread (Work).Start();  }  void Work()  {    Thread.Sleep (5000);           // Simulate time-consuming task    UpdateMessage ("The answer");  }  void UpdateMessage (string message)  {    Action action = () => txtMessage.Text = message;    Dispatcher.BeginInvoke (action);  }}
多个 UI 线程

如果每个 UI 线程拥有不同的窗口,则可以有多个 UI 线程。主要方案是当您有一个具有多个顶级窗口的应用程序时,通常称为 (SDI) 应用程序,如 Microsoft Word。每个SDI窗口通常在任务栏上显示为单独的“应用程序”,并且在功能上与其他SDI窗口基本隔离。通过为每个此类窗口提供自己的 UI 线程,可以使每个窗口相对于其他窗口更具响应性。

运行此操作会导致立即出现响应窗口。五秒钟后,它将更新文本框。代码与Windows窗体类似,只是我们调用(窗体的)BeginInvoke方法,而不是:

  void UpdateMessage (string message)  {    Action action = () => txtMessage.Text = message;    this.BeginInvoke (action);  }
同步上下文

在 System.ComponentModel 命名空间中,有一个名为 SynchronizationContext 的类,它支持线程封送处理的泛化。

适用于移动和桌面的胖客户端 API(UWP、WPF 和 Windows 窗体)分别定义并实例化 SynchronizationContext 子类,您可以通过静态属性 SynchronizationContext.Current 获取这些子类(在 UI 线程上运行时)。通过捕获此属性,可以稍后从工作线程“发布”到 UI 控件:

partial class MyWindow : Window{  SynchronizationContext _uiSyncContext;  public MyWindow()  {    InitializeComponent();    // Capture the synchronization context for the current UI thread:    _uiSyncContext = SynchronizationContext.Current;    new Thread (Work).Start();  }  void Work()  {    Thread.Sleep (5000);           // Simulate time-consuming task    UpdateMessage ("The answer");  }  void UpdateMessage (string message)  {    // Marshal the delegate to the UI thread:    _uiSyncContext.Post (_ => txtMessage.Text = message, null);  }}

这很有用,因为相同的技术适用于所有富客户端用户界面 API。

调用帖子等效于在调度程序或控件上调用 BeginInvoke ;还有一个等效于 Invoke 的 Send 方法。

线程池

每当您启动线程时,都会花费几百微秒来组织诸如新的局部变量堆栈之类的东西。线程通过具有预先创建的可回收线程池来减少此开销。线程池对于高效的并行编程和细粒度并发至关重要;它允许短操作运行,而不会被线程启动的开销所淹没。

使用池线程时需要注意以下几点:

不能设置池线程的名称,从而使调试更加困难(尽管可以在 Visual Studio 的“线程”窗口中进行调试时附加说明)。池线程始终是。阻塞池化线程会降低性能(请参阅)。

您可以自由更改池线程的优先级 - 当释放回池时,它将恢复正常。

您可以通过属性 Thread.CurrentThread.IsThreadPoolThread 确定当前是否正在池线程上执行。

进入线程池

在池线程上显式运行某些内容的最简单方法是使用 Task.Run(我们将在下一节中更详细地介绍这一点):

// Task is in System.Threading.TasksTask.Run (() => Console.WriteLine ("Hello from the thread pool"));

由于任务在 .NET Framework 4.0 之前不存在,因此常见的替代方法是调用 ThreadPool.QueueUserWorkItem :

ThreadPool.QueueUserWorkItem (notUsed => Console.WriteLine ("Hello"));
注意

以下内容隐式使用线程池:

ASP.NET 核心和 Web API 应用程序服务器System.Timers.Timer 和 System.Threading.Timer我们在中描述的并行编程结构(遗留)背景工人类线程池中的卫生

线程池提供另一个功能,即确保临时超出计算密集型工作不会导致 CPU 。超额订阅是活动线程多于 CPU 内核的条件,操作系统必须对线程进行时间切片。超额订阅会损害性能,因为时间切片需要昂贵的上下文切换,并且可能会使 CPU 缓存失效,而 CPU 缓存对于为现代处理器提供性能至关重要。

CLR 通过对任务进行排队并限制其启动来防止线程池中的超额订阅。它首先运行与硬件内核一样多的并发任务,然后通过爬山算法调整并发级别,在特定方向上不断调整工作负载。如果吞吐量提高,则继续沿同一方向发展(否则将反转)。这可确保它始终跟踪最佳性能曲线,即使面对计算机上竞争的过程活动也是如此。

如果满足两个条件,CLR 的策略效果最佳:

工作项大多是短期运行的(<250 毫秒,理想情况下是<100 毫秒),因此 CLR 有很多机会进行度量和调整。大部分时间都被阻止的工作不会主导池。

阻塞很麻烦,因为它给 CLR 一个错误的想法,即它正在加载 CPU。CLR 足够智能,可以检测和补偿(通过将更多线程注入池),尽管这可能会使池容易受到后续超额订阅的影响。它还可能引入延迟,因为 CLR 会限制它注入新线程的速率,尤其是在应用程序生命周期的早期(在它倾向于降低资源消耗的客户端操作系统上更是如此)。

当您想要充分利用 CPU 时(例如,通过中的并行编程 API),在线程池中保持良好的卫生状况尤其重要。

任务

线程是用于创建并发的低级工具,因此,它有局限性。特别:

尽管将数据传递到您启动的线程中很容易,但没有简单的方法可以从您加入的线程中获取“返回值”。您需要设置某种共享字段。如果操作引发异常,捕获和传播该异常同样痛苦。你不能告诉一个线程在完成后开始其他事情;相反,您必须加入它(在此过程中阻止您自己的线程)。

这些限制阻碍了细粒度并发;换句话说,它们使得通过组合较小的并发操作来组合较大的并发操作变得困难(这对于我们在以下各节中介绍的异步编程至关重要)。这反过来又导致对手动同步(锁定、信令等)的更大依赖以及随之而来的问题。

直接使用线程也具有我们在中讨论的性能影响。如果您需要运行数百或数千个并发 I/O 绑定操作,基于线程的方法纯粹在线程开销中消耗数百或数千兆字节的内存。

Task 类有助于解决所有这些问题。与线程相比,Task 是更高级别的抽象 - 它表示线程可能支持也可能不支持的并发操作。任务是(您可以通过使用将它们链接在一起)。他们可以使用来减少启动延迟,并且使用 TaskCompletionSource ,他们可以采用回调方法,在等待 I/O 绑定操作时完全避免线程。

任务类型在框架 4.0 中作为并行编程库的一部分引入。但是,它们后来得到了增强(通过使用),以便在更一般的并发方案中同样出色地发挥作用,并且是 C# 异步函数的支持类型。

注意

在本节中,我们忽略了专门针对并行编程的任务的功能;我们将在第中介绍它们。

启动任务

启动由线程支持的任务的最简单方法是使用静态方法 Task.Run(Task 类位于 System.Threading.Tasks 命名空间中)。只需传入操作委托:

Task.Run (() => Console.WriteLine ("Foo"));
注意

默认情况下,任务使用池线程,即后台线程。这意味着当主线程结束时,您创建的任何任务也会结束。因此,要从控制台应用程序运行这些示例,您必须在启动任务后阻止主线程(例如,通过等待任务或调用 Console.ReadLine ):

Task.Run (() => Console.WriteLine ("Foo"));Console.ReadLine();

在本书的 LINQPad 配套示例中,省略了 Console.ReadLine,因为 LINQPad 进程使后台线程保持活动状态。

以这种方式调用 Task.Run 类似于启动线程,如下所示(除了我们稍后讨论的线程池含义):

new Thread (() => Console.WriteLine ("Foo")).Start();

Task.Run 返回一个 Task 对象,我们可以使用它来监视其进度,就像 Thread 对象一样。(但是请注意,我们没有在调用 Task.Run 后调用 Start,因为此方法创建“热”任务;您可以改用 Task 的构造函数来创建“冷”任务,尽管在实践中很少这样做。

可以通过任务的 Status 属性跟踪任务的执行状态。

调用任务块上的等待,直到它完成,相当于在线程上调用 Join:

Task task = Task.Run (() =>{  Thread.Sleep (2000);  Console.WriteLine ("Foo");});Console.WriteLine (task.IsCompleted);  // Falsetask.Wait();  // Blocks until task is complete

等待允许您选择指定超时和取消令牌以提前结束等待(请参阅)。

长时间运行的任务

默认情况下,CLR 在池线程上运行任务,这非常适合短期运行的计算密集型工作。对于运行时间较长的操作和阻塞操作(如前面的示例),可以阻止使用池化线程,如下所示:

Task task = Task.Factory.StartNew (() => ...,                                   TaskCreationOptions.LongRunning);
注意

在池线程上运行长时间运行的任务不会造成麻烦;当您并行运行多个长时间运行的任务(尤其是那些阻塞的任务)时,性能可能会受到影响。在这种情况下,通常有比TaskCreationOptions.LongRun更好的解决方案:

如果任务是 I/O 绑定的,则 TaskCompletionSource 和允许您使用回调(延续)而不是线程实现并发。如果任务是计算密集型的,则允许您限制这些任务的并发性,从而避免其他线程和进程的匮乏(请参阅中的)。返回值

Task有一个名为Task<TResult>的泛型子类,它允许任务发出返回值。您可以通过使用 Func<TResult> 委托(或兼容的 lambda 表达式)而不是 Action 调用 Task.Run 来获取 Task<TResult>:

Task<int> task = Task.Run (() => { Console.WriteLine ("Foo"); return 3; });// ...

以后可以通过查询 Result 属性来获取结果。如果任务尚未完成,则访问此属性将阻止当前线程,直到任务完成:

int result = task.Result;      // Blocks if not already finishedConsole.WriteLine (result);    // 3

在下面的示例中,我们创建一个任务,该任务使用 LINQ 计算前三百万 (+2) 个整数中的素数数:

Task<int> primeNumberTask = Task.Run (() =>  Enumerable.Range (2, 3000000).Count (n =>     Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));Console.WriteLine ("Task running...");Console.WriteLine ("The answer is " + primeNumberTask.Result);

这写着“任务正在运行...”然后几秒钟后写下216816的答案。

注意

任务<TResult>可以被认为是一个“未来”,因为它封装了一个稍后可用的结果。

异常

与线程不同,任务可以方便地传播异常。因此,如果任务中的代码抛出未经处理的异常(换句话说,如果任务),则该异常会自动重新抛出给调用 Wait() 或访问 Task<TResult 的 Result 属性的人> :

// Start a Task that throws a NullReferenceException:Task task = Task.Run (() => { throw null; });try {  task.Wait();}catch (AggregateException aex){  if (aex.InnerException is NullReferenceException)    Console.WriteLine ("Null!");  else    throw;}

(CLR 将异常包装在 AggregateException 中,以便很好地与并行编程方案配合使用;我们将在第 中对此进行讨论。

您可以通过任务的 IsFaulted 和 IsCanceled 属性测试出错的任务,而无需重新引发异常。如果两个属性都返回 false,则未发生错误;如果 IsCanceled 为 true,则为该任务抛出 OperationCanceledException(请参阅);如果 IsFaulted 为 true,则引发另一种类型的异常,并且 Exception 属性将指示错误。

异常和自主任务

对于自主的“设置并忘记”任务(那些您不通过 Wait() 或 Result 会合的任务,或者执行相同操作的延续),最好显式异常处理任务代码以避免静默失败,就像使用线程一样。

注意

当异常仅表示无法获得您不再感兴趣的结果时,忽略异常是可以的。例如,如果用户取消了下载网页的请求,我们不会关心该网页是否存在。

当异常指示程序中存在错误时,忽略异常是有问题的,原因有两个:

该错误可能使程序处于无效状态。由于 bug,以后可能会发生更多异常,并且未能记录初始错误可能会使诊断。

您可以通过静态事件 TaskScheduler.UnobservedTaskException 在全局级别订阅未观察到的异常;处理此事件并记录错误可能很有意义。

关于什么算作未观察到,有几个有趣的细微差别:

如果故障发生在超时间隔,则等待超时的任务将生成未观察到的异常。在任务出错后检查任务的 Exception 属性的操作会使异常“被观察到”。延续

延续对任务说:“当你完成时,继续做其他事情。延续通常由在操作完成后执行一次的回调实现。有两种方法可以将延续附加到任务。第一个特别重要,因为它被 C# 的异步函数使用,你很快就会看到。我们可以通过不久前在中编写的素数计数任务来演示它:

Task<int> primeNumberTask = Task.Run (() =>  Enumerable.Range (2, 3000000).Count (n =>     Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));var awaiter = primeNumberTask.GetAwaiter();awaiter.OnCompleted (() => {  int result = awaiter.GetResult();  Console.WriteLine (result);       // Writes result});

在任务上调用 GetAwaiter 会返回一个对象,其 OnCompleted 方法告诉任务 ( primeNumberTask ) 在完成(或错误)时执行委托。将延续附加到已完成的任务是有效的,在这种情况下,延续将计划为立即执行。

注意

是公开我们刚刚看到的两个方法(OnComplete和GetResult)和一个名为IsComplete的布尔属性的任何对象。没有接口或基类来统一所有这些成员(尽管OnComplete是接口INotifyComplete的一部分)。我们在中解释了该模式的重要性。

如果先前的任务出错,则在继续代码调用 awaiter 时将重新引发异常。获取结果() .与其调用 GetResult ,我们可以简单地访问前置的 Result 属性。调用 GetResult 的好处是,如果先验错误,则直接抛出异常,而不被包装在 AggregateException 中,从而允许更简单、更干净的捕获块。

对于非泛型任务,GetResult() 具有 void 返回值。它的有用功能只是重新引发异常。

如果存在同步上下文,OnDone 会自动捕获该上下文并将延续发布到该上下文。这在胖客户端应用程序中非常有用,因为它会将延续反弹回 UI 线程。但是,在编写库时,通常不希望这样做,因为相对昂贵的 UI 线程反弹应该只在离开库时发生一次,而不是在方法调用之间发生。因此,您可以使用 ConfigureAwait 方法击败它:

var awaiter = primeNumberTask.ConfigureAwait (false).GetAwaiter();

如果不存在同步上下文,或者您使用 ConfigureAwait(false),则延续(通常)将在与前置相同的线程上执行,从而避免不必要的开销。

附加延续的另一种方法是调用任务的 ContinueWith 方法:

primeNumberTask.ContinueWith (antecedent => {  int result = antecedent.Result;  Console.WriteLine (result);          // Writes 123});

ContinueWith 本身返回一个 Task ,如果你想附加进一步的延续,这很有用。但是,如果任务出错,则必须直接处理 AggregateException,并编写额外的代码以在 UI 应用程序中封送延续(请参阅中的)。在非 UI 上下文中,如果您希望延续在同一线程上执行,则必须指定 TaskContinuationOptions.ExecuteSyncly;否则它将反弹到线程池。继续在并行编程方案中特别有用;我们将在第中详细介绍它。

任务完成源

我们已经了解了 Task.Run 如何创建一个在池(或非池)线程上运行委托的任务。创建任务的另一种方法是使用 任务完成源 .

TaskCompletionSource 允许您从稍后开始和完成的任何操作中创建任务。它的工作原理是为您提供手动驱动的“从属”任务 - 通过指示操作何时完成或出现故障。这是 I/O 密集型工作的理想选择:您可以获得任务的所有好处(以及它们传播返回值、异常和延续的能力),而不会在操作期间阻塞线程。

要使用 TaskCompletionSource ,您只需实例化该类。它公开一个 Task 属性,该属性返回一个任务,您可以在该任务上等待并附加延续 — 就像任何其他任务一样。但是,该任务完全由 TaskCompletionSource 对象通过以下方法控制:

public class TaskCompletionSource<TResult>{  public void SetResult (TResult result);  public void SetException (Exception exception);  public void SetCanceled();  public bool TrySetResult (TResult result);  public bool TrySetException (Exception exception);  public bool TrySetCanceled();  public bool TrySetCanceled (CancellationToken cancellationToken);  ...}

调用这些方法中的任何一个都会发出,将其置于已完成、出错或已取消状态(我们将在一节中介绍后者)。您应该只调用一次这些方法之一:如果再次调用,SetResult 、SetException 或 SetCanceled 将引发异常,而 Try* 方法返回 false 。

下面的示例在等待五秒钟后打印 42:

var tcs = new TaskCompletionSource<int>();new Thread (() => { Thread.Sleep (5000); tcs.SetResult (42); })  { IsBackground = true }  .Start();Task<int> task = tcs.Task;         // Our "slave" task.Console.WriteLine (task.Result);   // 42

使用 任务完成源 ,我们可以编写自己的 Run 方法:

Task<TResult> Run<TResult> (Func<TResult> function){  var tcs = new TaskCompletionSource<TResult>();  new Thread (() =>   {    try { tcs.SetResult (function()); }    catch (Exception ex) { tcs.SetException (ex); }  }).Start();  return tcs.Task;}...Task<int> task = Run (() => { Thread.Sleep (5000); return 42; });

调用此方法等效于使用 TaskCreationOptions.LongRun 选项调用 Task.Factory.StartNew 来请求非池化线程。

TaskCompletionSource的真正力量在于创建不占用线程的任务。例如,假设一个任务等待五秒钟,然后返回数字 42。我们可以使用 Timer 类在没有线程的情况下编写它,该类在 CLR(反过来还有操作系统)的帮助下,在 毫秒内触发一个事件(我们将在第 中重新访问计时器):

Task<int> GetAnswerToLife(){  var tcs = new TaskCompletionSource<int>();  // Create a timer that fires once in 5000 ms:  var timer = new System.Timers.Timer (5000) { AutoReset = false };  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (42); };  timer.Start();  return tcs.Task;}

因此,我们的方法返回一个任务,该任务在五秒后完成,结果为 42。通过将延续附加到任务,我们可以在不阻塞线程的情况下编写其结果:

var awaiter = GetAnswerToLife().GetAwaiter();awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult()));

我们可以使其更有用,并通过参数化延迟时间和摆脱返回值将其转换为通用的 Delay 方法。这意味着让它返回一个任务而不是一个任务<int> 。但是,没有非泛型版本的 任务完成源 ,这意味着我们不能直接创建非泛型任务。解决方法很简单:因为 Task<TResult> 派生自 任务 ,我们创建一个,然后将其给你的隐式转换为任务,如下所示:TaskCompletionSource<anything>Task<anything>

var tcs = new TaskCompletionSource<object>();Task task = tcs.Task;

现在我们可以编写通用的 Delay 方法:

Task Delay (int milliseconds){  var tcs = new TaskCompletionSource<object>();  var timer = new System.Timers.Timer (milliseconds) { AutoReset = false };  timer.Elapsed += delegate { timer.Dispose(); tcs.SetResult (null); };  timer.Start();  return tcs.Task;}
注意

.NET 5 引入了一个非通用的 TaskCompletionSource,因此如果您的目标是 .NET 5 或更高版本,则可以将 TaskCompletionSource<object> 替换为 TaskCompletionSource。

以下是我们如何使用它在五秒后写“42”:

Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));

我们使用不带线程的 TaskCompletionSource 意味着线程仅在延续开始时(五秒后)才会参与。我们可以通过一次启动 10,000 个这样的操作来证明这一点,而不会出错或过度资源:

for (int i = 0; i < 10000; i++)  Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));
注意

计时器在池化线程上触发回调,因此五秒钟后,线程池将收到 10,000 个请求,以调用 TaskCompletionSource 上的 SetResult(null)。如果请求到达的速度快于处理速度,则线程池将通过排队然后以 CPU 的最佳并行级别处理它们来响应。如果线程绑定作业运行时间较短,这是理想的选择,在这种情况下也是如此:线程绑定作业只是对 SetResult 的调用,加上将延续发布到同步上下文的操作(在 UI 应用程序中)或其他延续本身( Console.WriteLine(42) )。

任务延迟

我们刚刚编写的 Delay 方法非常有用,可以作为 Task 类上的静态方法使用:

Task.Delay (5000).GetAwaiter().OnCompleted (() => Console.WriteLine (42));

Task.Delay (5000).ContinueWith (ant => Console.WriteLine (42));

Task.Delay 是 Thread.Sleep 的等价物。

异步原则

在演示 TaskCompletionSource 时,我们最终编写了方法。在本节中,我们将准确定义异步操作是什么,并解释这如何导致异步编程。

同步操作与异步操作

在返回到调用方完成其工作。

可以在返回到调用方完成(大部分或全部)工作。

您编写和调用的大多数方法是同步的。一个例子是List<T>。Add , or Console.WriteLine , or Thread.Sleep 。异步方法不太常见,它们会启动性,因为工作与调用方并行进行。异步方法通常快速(或立即)返回给调用方;因此,它们也称为。

到目前为止,我们看到的大多数异步方法都可以描述为通用方法:

线程启动任务运行将延续附加到任务的方法

此外,我们在中讨论的一些方法( 调度程序.开始调用 , 控制.开始调用 和 SynchronizationContext.Post )是异步的,我们在中编写的方法也是如此,包括延迟 。

什么是异步编程?

异步编程的原则是异步编写长时间运行(或可能长时间运行)的函数。这与同步编写长时间运行的函数,然后根据需要从新线程或任务调用这些函数以引入并发性的传统方法形成对比。

与异步方法的不同之处在于,并发性是在长时间运行的函数启动的,而不是从函数启动的。这有两个好处:

I/O 绑定并发可以在不占用线程的情况下实现(正如我们在中演示的那样),从而提高了可伸缩性和效率。富客户端应用程序最终在工作线程上的代码更少,从而简化了线程安全性。

这反过来又导致了异步编程的两种不同用途。第一种是编写(通常是服务器端)应用程序,以有效地处理大量并发 I/O。这里的挑战不是线程(因为通常共享状态最小),而是线程;特别是,不为每个网络请求消耗一个线程。因此,在此上下文中,只有 I/O 绑定操作才能从异步中受益。

第二个用途是简化富客户端应用程序中的线程安全。随着程序规模的扩大,这一点尤其重要,因为为了处理复杂性,我们通常会将较大的方法重构为较小的方法,从而导致相互调用的方法链()。

对于传统的调用图,如果图中的任何操作长时间运行,我们必须在工作线程上运行整个调用图以维护响应式 UI。因此,我们最终得到一个跨越许多方法的并发操作(这需要考虑图中每个方法的线程安全性。

对于调用图,我们不需要启动线程,直到实际需要它,通常在图中较低(或者在 I/O 绑定操作的情况下根本不启动)。所有其他方法都可以完全在 UI 线程上运行,线程安全性大大简化。这会导致 - 一系列小型并发操作,执行在这些操作之间反弹到 UI 线程。

注意

为了从中受益,需要异步编写 I/O 和计算绑定操作;一个好的经验法则是包括可能需要超过 50 毫秒的任何内容。

(另一方面,细粒度的异步可能会损害性能,因为异步操作会产生开销 — 请参阅

在本章中,我们将主要关注富客户端方案,这是两者中更复杂的方案。在第中,我们给出了两个示例来说明I/O绑定场景(参见“和)。

注意

UWP 框架鼓励异步编程,使某些长时间运行的方法的同步版本不公开或引发异常。相反,必须调用返回任务(或可通过 AsTask 扩展方法转换为任务的对象)的异步方法。

异步编程和延续

任务非常适合异步编程,因为它们支持延续,这对于异步至关重要(考虑我们在中编写的 Delay 方法)。在编写延迟时,我们使用了TaskCompletionSource,这是实现“底层”I/O绑定异步方法的标准方法。

对于计算绑定方法,我们使用 Task.Run 启动线程绑定并发。只需将任务返回给调用方,我们就会创建一个异步方法。异步编程的区别在于,我们的目标是在调用图中较低的位置执行此操作,以便在富客户端应用程序中,更高级别的方法可以保留在UI线程和访问控制以及共享状态上,而不会出现线程安全问题。为了说明这一点,请考虑以下使用所有可用内核计算和计数素数的方法(我们将在第 中讨论 ParallelEnumerable):

int GetPrimesCount (int start, int count){  return    ParallelEnumerable.Range (start, count).Count (n =>       Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0));}

这如何工作的细节并不重要;重要的是它可能需要一段时间才能运行。我们可以通过编写另一个方法来调用它来演示这一点:

void DisplayPrimeCounts(){  for (int i = 0; i < 10; i++)    Console.WriteLine (GetPrimesCount (i*1000000 + 2, 1000000) +      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));  Console.WriteLine ("Done!");}

下面是输出:

78498 primes between 0 and 99999970435 primes between 1000000 and 199999967883 primes between 2000000 and 299999966330 primes between 3000000 and 399999965367 primes between 4000000 and 499999964336 primes between 5000000 and 599999963799 primes between 6000000 and 699999963129 primes between 7000000 and 799999962712 primes between 8000000 and 899999962090 primes between 9000000 and 9999999

现在我们有一个,DisplayPrimeCounts调用GetPrimesCount。前者使用Console.WriteLine来简化,尽管实际上它更有可能更新富客户端应用程序中的UI控件,正如我们稍后演示的那样。我们可以为此调用图启动粗粒度并发,如下所示:

Task.Run (() => DisplayPrimeCounts());

使用细粒度异步方法,我们从编写 GetPrimesCount 的异步版本开始:

Task<int> GetPrimesCountAsync (int start, int count){  return Task.Run (() =>    ParallelEnumerable.Range (start, count).Count (n =>       Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));}
为什么语言支持很重要

现在我们必须修改 DisplayPrimeCounts,以便它调用 .这就是 C# 的 await 和 async 关键字发挥作用的地方,因为否则这样做比听起来更棘手。如果我们简单地修改循环如下GetPrimesCountAsync

for (int i = 0; i < 10; i++){  var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();  awaiter.OnCompleted (() =>    Console.WriteLine (awaiter.GetResult() + " primes between... "));}Console.WriteLine ("Done");

循环将快速旋转 10 次迭代(方法是非阻塞的),所有 10 个操作将并行执行(然后是过早的“完成”)。

注意

在这种情况下,并行执行这些任务是不可取的,因为它们的内部实现已经并行化;它只会让我们等待更长的时间才能看到第一个结果(并搞砸排序)。

但是,需要任务执行还有一个更常见的原因,即任务 B 依赖于任务 A 的结果。例如,在获取网页时,DNS 查找必须在 HTTP 请求之前进行。

为了使它们按顺序运行,我们必须从延续本身触发下一个循环迭代。这意味着消除 for 循环并在延续中诉诸递归调用:

void DisplayPrimeCounts(){  DisplayPrimeCountsFrom (0);}void DisplayPrimeCountsFrom (int i){  var awaiter = GetPrimesCountAsync (i*1000000 + 2, 1000000).GetAwaiter();  awaiter.OnCompleted (() =>   {    Console.WriteLine (awaiter.GetResult() + " primes between...");    if (++i < 10) DisplayPrimeCountsFrom (i);    else Console.WriteLine ("Done");  });}

如果我们想使 DisplayPrimesCount 异步,返回它在完成时发出信号的任务,情况会变得更糟。要完成此操作,需要创建一个 任务完成源:

Task DisplayPrimeCountsAsync(){  var machine = new PrimesStateMachine();  machine.DisplayPrimeCountsFrom (0);  return machine.Task;}class PrimesStateMachine{  TaskCompletionSource<object> _tcs = new TaskCompletionSource<object>();  public Task Task { get { return _tcs.Task; } }  public void DisplayPrimeCountsFrom (int i)  {    var awaiter = GetPrimesCountAsync (i*1000000+2, 1000000).GetAwaiter();    awaiter.OnCompleted (() =>     {      Console.WriteLine (awaiter.GetResult());      if (++i < 10) DisplayPrimeCountsFrom (i);      else { Console.WriteLine ("Done"); _tcs.SetResult (null); }    });  }}

幸运的是,C# 的为我们完成了所有这些工作。使用 async 和 await 关键字,我们只需要写这个:

async Task DisplayPrimeCountsAsync(){  for (int i = 0; i < 10; i++)    Console.WriteLine (await GetPrimesCountAsync (i*1000000 + 2, 1000000) +      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1));  Console.WriteLine ("Done!");}

因此,异步和等待对于实现异步至关重要,而不会过于复杂。现在让我们看看这些关键字是如何工作的。

注意

另一种看待这个问题的方法是命令式循环结构(for、foreach 等)不能很好地与延续混合,因为它们依赖于方法的(“这个循环还要运行多少次?”)。

尽管 async 和 await 关键字提供了一种解决方案,但有时可以通过将命令性循环构造替换为(换句话说,LINQ 查询)来以另一种方式解决它。这是 (Rx) 的基础,当您想要对结果执行查询运算符或组合多个序列时,这可能是一个不错的选择。要付出的代价是,为了防止阻塞,Rx在基于的序列上运行,这在概念上可能很棘手。

C 语言中的异步函数#

async 和 await 关键字允许您编写与同步代码具有相同结构和简单性的异步代码,同时消除异步编程的“管道”。

等待

await 关键字简化了延续的附加。从基本方案开始,编译器对此进行了扩展

var result = await expression;statement(s);

变成功能上与此类似的内容:

var awaiter = expression.GetAwaiter();awaiter.OnCompleted (() => {  var result = awaiter.GetResult();  statement(s);});
注意

编译器还会发出代码,以便在同步完成的情况下缩短延续(参见),并处理我们在后面的部分中了解到的各种细微差别。

为了演示,让我们重新审视我们之前编写的计算和计算素数的异步方法:

Task<int> GetPrimesCountAsync (int start, int count){  return Task.Run (() =>    ParallelEnumerable.Range (start, count).Count (n =>       Enumerable.Range (2, (int)Math.Sqrt(n)-1).All (i => n % i > 0)));}

使用 await 关键字,我们可以按如下方式调用它:

int result = await GetPrimesCountAsync (2, 1000000);Console.WriteLine (result);

要编译,我们需要将异步修饰符添加到包含方法中:

async void DisplayPrimesCount(){  int result = await GetPrimesCountAsync (2, 1000000);  Console.WriteLine (result);}

async 修饰符指示编译器在该方法中出现歧义时将 await 视为关键字而不是标识符(这可确保在 C# 5 之前编写的可能使用 await 作为标识符的代码仍将编译而不会出错)。异步修饰符只能应用于返回 void 或(稍后您将看到的)任务或任务<TResult> 的方法(和 lambda 表达式)。

注意

异步修饰符类似于不安全修饰符,因为它对方法的签名或公共元数据没有影响;它仅影响方法发生的情况。因此,在接口中使用异步是没有意义的。但是,例如,在覆盖非异步虚拟方法时引入异步是合法的,只要您保持签名相同。

具有异步修饰符的方法称为,因为它们本身通常是异步的。为了了解原因,让我们看看执行如何通过异步函数进行。

遇到 await 表达式后,执行(通常)返回到调用方,就像迭代器中的 yield return 一样。但在返回之前,运行时会将延续附加到等待的任务,确保在任务完成时,执行将跳回到方法中,并从中断的位置继续。如果任务出错,则重新引发其异常,否则将其返回值分配给 await 表达式。我们可以通过查看我们刚刚检查的异步方法的逻辑扩展来总结我们刚才所说的一切:

void DisplayPrimesCount(){  var awaiter = GetPrimesCountAsync (2, 1000000).GetAwaiter();  awaiter.OnCompleted (() =>      {    int result = awaiter.GetResult();    Console.WriteLine (result);  });}

您等待的表达式通常是一个任务;但是,任何具有返回 GetAwaiter 方法的对象(实现 INotifyCompletion.OnComplete,并使用适当类型的 GetResult 方法和布尔 IsCompleted 属性)将满足编译器的要求。

请注意,我们的 await 表达式的计算结果为 int 类型;这是因为我们等待的表达式是一个 Task<int>(其 GetAwaiter()。GetResult() 方法返回一个 int )。

等待非通用任务是合法的,并生成一个 void 表达式:

await Task.Delay (5000);Console.WriteLine ("Five seconds passed!");
捕获本地状态

await 表达式的真正强大之处在于它们几乎可以出现在代码中的任何位置。具体而言,await 表达式可以代替任何表达式(在异步函数中)出现,但锁表达式或不安全上下文中除外。

在下面的示例中,我们在循环中等待:

async void DisplayPrimeCounts(){  for (int i = 0; i < 10; i++)    Console.WriteLine (await GetPrimesCountAsync (i*1000000+2, 1000000));}

在第一次执行 GetPrimesCountAsync 时,执行会通过 await 表达式返回给调用方。当方法完成(或出错)时,执行将从中断的位置继续,并保留局部变量和循环计数器的值。

如果没有 await 关键字,最简单的等价物可能是我们在中写的示例。但是,编译器采用更通用的策略,将此类方法重构到状态机中(就像迭代器一样)。

编译器依赖于延续(通过等待者模式)在等待表达式之后恢复执行。这意味着,如果在富客户端应用程序的 UI 线程上运行,同步上下文可确保在同一线程上恢复执行。否则,将在任务完成的任何线程上恢复执行。线程的更改不会影响执行顺序,并且无关紧要,除非您以某种方式依赖于线程亲和性,也许通过使用线程本地存储(请参阅中的)。这就像游览一个城市,叫出租车从一个目的地到另一个目的地。使用同步上下文,您将始终获得相同的出租车;如果没有同步上下文,您通常每次都会得到不同的出租车。但是,无论哪种情况,旅程都是一样的。

在 UI 中等待

我们可以通过编写一个简单的 UI 在更实际的上下文中演示异步函数,该 UI 在调用计算绑定方法时保持响应。让我们从一个同步解决方案开始:

class TestUI : Window{  Button _button = new Button { Content = "Go" };  TextBlock _results = new TextBlock();      public TestUI()  {    var panel = new StackPanel();    panel.Children.Add (_button);    panel.Children.Add (_results);    Content = panel;    _button.Click += (sender, args) => Go();  }      void Go()  {    for (int i = 1; i < 5; i++)      _results.Text += GetPrimesCount (i * 1000000, 1000000) +        " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +        Environment.NewLine;  }      int GetPrimesCount (int start, int count)  {    return ParallelEnumerable.Range (start, count).Count (n =>       Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0));  }}

按下“Go”按钮后,应用程序在执行计算绑定代码所需的时间内变得无响应。异步有两个步骤;首先是切换到我们在前面的示例中使用的异步版本的 GetPrimesCount:

Task<int> GetPrimesCountAsync (int start, int count){  return Task.Run (() =>    ParallelEnumerable.Range (start, count).Count (n =>       Enumerable.Range (2, (int) Math.Sqrt(n)-1).All (i => n % i > 0)));}

第二步是修改 Go 以调用 GetPrimesCountAsync:

async void Go(){  _button.IsEnabled = false;  for (int i = 1; i < 5; i++)    _results.Text += await GetPrimesCountAsync (i * 1000000, 1000000) +      " primes between " + (i*1000000) + " and " + ((i+1)*1000000-1) +      Environment.NewLine;  _button.IsEnabled = true;}

这说明了使用异步函数编程的简单性:您可以像同步编程一样编程,但调用异步函数而不是阻塞函数并等待它们。只有 GetPrimesCountAsync 中的代码在工作线程上运行;Go 中的代码在 UI 线程上“租用”时间。我们可以说 Go 伪地执行消息循环(因为它的执行与 UI 线程处理的其他事件穿插在一起)。使用此伪并发时,唯一可能发生抢占的时间点是在等待期间。这简化了线程安全性:在我们的例子中,这可能导致的唯一问题是(在按钮运行时再次单击按钮,我们通过禁用按钮来防止)。真正的并发性发生在调用堆栈的较低位置,即 Task.Run 调用的代码中。为了从此模型中受益,真正的并发代码可防止访问共享状态或 UI 控件。

再举一个例子,假设我们不是计算质数,而是下载几个网页并将它们的长度相加。.NET 公开了许多任务返回异步方法,其中之一是 中的 WebClient 类。DownloadDataTaskAsync 方法异步下载一个 URI 到一个字节数组,返回一个 任务<字节[]> ,所以通过等待它,我们得到一个字节[]。现在让我们重写我们的 Go 方法:

async void Go() {  _button.IsEnabled = false;  string[] urls = "  ;.Split();  int totalLength = 0;  try  {    foreach (string url in urls)    {      var uri = new Uri ("; + url);      byte[] data = await new WebClient().DownloadDataTaskAsync (uri);      _results.Text += "Length of " + url + " is " + data.Length +                       Environment.NewLine;      totalLength += data.Length;    }    _results.Text += "Total length: " + totalLength;  }  catch (WebException ex)  {    _results.Text += "Error: " + ex.Message;  }  finally { _button.IsEnabled = true; }}

同样,这反映了我们如何同步编写它——包括使用 catch 和 finally 块。即使执行在第一个等待后返回给调用方,finally 块也不会执行,直到方法逻辑完成(由于其所有代码执行 - 或提前返回或未处理的异常)。

准确考虑下面发生的事情可能会有所帮助。首先,我们需要重新访问在 UI 线程上运行消息循环的伪代码:

Set synchronization context for this thread to WPF sync contextwhile (!thisApplication.Ended){  wait for something to appear in message queue  Got something: what kind of message is it?    Keyboard/mouse message -> fire an event handler    User BeginInvoke/Invoke message -> execute delegate}

我们附加到 UI 元素的事件处理程序通过此消息循环执行。当我们的 Go 方法运行时,执行一直持续到 await 表达式,然后返回到消息循环(释放 UI 以响应进一步的事件)。但是,编译器对 await 的扩展可确保在返回之前设置延续,以便在任务完成后从中断的位置恢复执行。由于我们在 UI 线程上等待,因此延续发布到同步上下文,同步上下文通过消息循环执行它,从而使我们的整个 Go 方法在 UI 线程上伪并发执行。True(I/O 绑定)并发发生在 DownloadDataTaskAsync 的实现中。

与粗粒度并发的比较

在 C# 5 之前,异步编程很困难,不仅因为没有语言支持,还因为 .NET Framework 通过称为 EAP 和 APM 的笨拙模式(请参阅而不是任务返回方法公开异步功能。

流行的解决方法是粗粒度并发(事实上,甚至还有一种称为 BackgroundWorker 的类型来帮助解决这个问题)。回到我们原来的示例 GetPrimesCount ,我们可以通过修改按钮的事件处理程序来演示粗粒度异步,如下所示:

  ...  _button.Click += (sender, args) =>  {    _button.IsEnabled = false;    Task.Run (() => Go());  };

(我们选择使用 Task.Run 而不是 BackgroundWorker ,因为后者不会简化我们的特定示例。无论哪种情况,最终结果都是我们的整个同步调用图(Go加GetPrimesCount)在工作线程上运行。由于 Go 更新了 UI 元素,我们现在必须使用 Dispatcher.BeginInvoke 乱扔代码:

void Go(){  for (int i = 1; i < 5; i++)  {    int result = GetPrimesCount (i * 1000000, 1000000);    Dispatcher.BeginInvoke (new Action (() =>      _results.Text += result + " primes between " + (i*1000000) +      " and " + ((i+1)*1000000-1) + Environment.NewLine));  }  Dispatcher.BeginInvoke (new Action (() => _button.IsEnabled = true));}

与异步版本不同,循环本身在工作线程上运行。这似乎无害,然而,即使在这种简单的情况下,我们对多线程的使用也引入了竞争条件。(你能发现它吗?如果没有,请尝试运行该程序:它几乎肯定会变得明显。

实现取消和进度报告会为线程安全错误创造更多可能性,方法中的任何其他代码也是如此。例如,假设循环的上限不是硬编码的,而是来自方法调用:

  for (int i = 1; i < GetUpperBound(); i++)

现在假设 GetUpperBound() 从延迟加载的配置文件中读取值,该文件在第一次调用时从磁盘加载。所有这些代码现在都在工作线程上运行,这些代码很可能不是线程安全的。这是在调用图中启动高位工作线程的危险。

编写异步函数

对于任何异步函数,都可以将 void 返回类型替换为 Task,以使方法本身(并且 await 可用)。无需进一步更改:

async Task PrintAnswerToLife()   // We can return Task instead of void{  await Task.Delay (5000);  int answer = 21 * 2;  Console.WriteLine (answer);  }

请注意,我们不会在方法主体中显式返回任务。编译器制造任务,并在方法完成(或未处理的异常)时发出信号。这使得创建异步调用链变得容易:

async Task Go(){  await PrintAnswerToLife();  Console.WriteLine ("Done");}

而且由于我们已经声明了带有任务返回类型的 Go,因此 Go 本身是可以等待的。

编译器扩展异步函数,这些函数将任务返回到代码中,该代码使用 TaskCompletionSource 创建任务,然后发出信号或出错。

撇开细微差别不谈,我们可以将PrintAnswerToLife扩展为以下功能等效项:

Task PrintAnswerToLife(){  var tcs = new TaskCompletionSource<object>();  var awaiter = Task.Delay (5000).GetAwaiter();  awaiter.OnCompleted (() =>  {    try    {      awaiter.GetResult();    // Re-throw any exceptions      int answer = 21 * 2;      Console.WriteLine (answer);      tcs.SetResult (null);    }    catch (Exception ex) { tcs.SetException (ex); }  });  return tcs.Task;}

因此,每当任务返回异步方法完成时,执行都会跳回到等待它的任何内容(通过延续)。

注意

在胖客户端方案中,此时执行将反弹回 UI 线程(如果它尚未在 UI 线程上)。否则,它会在延续返回的任何线程上继续。这意味着冒泡异步调用图没有延迟成本,如果它是 UI 线程启动的,则除了第一次“反弹”。

返回任务<返回任务>

如果方法体返回 TResult>则可以返回 Task<TResult:

async Task<int> GetAnswerToLife(){  await Task.Delay (5000);  int answer = 21 * 2;  return answer;    // Method has return type Task<int> we return int}

在内部,这会导致 TaskCompletionSource 发出值而不是 null 的信号。我们可以通过从 PrintAnswerToLife 调用它来演示 GetAnswerToLife(反过来,从 Go 调用):

async Task Go(){  await PrintAnswerToLife();  Console.WriteLine ("Done");}async Task PrintAnswerToLife(){  int answer = await GetAnswerToLife();  Console.WriteLine (answer);}async Task<int> GetAnswerToLife(){  await Task.Delay (5000);  int answer = 21 * 2;  return answer;}

实际上,我们已经将原始的PrintAnswerToLife重构为两种方法 - 就像我们同步编程一样容易。与同步编程的相似性是有意的;下面是我们的调用图的同步等价物,调用 Go() 在阻塞五秒后给出相同的结果:

void Go(){  PrintAnswerToLife();  Console.WriteLine ("Done");}void PrintAnswerToLife(){  int answer = GetAnswerToLife();  Console.WriteLine (answer);}int GetAnswerToLife(){  Thread.Sleep (5000);  int answer = 21 * 2;  return answer;}
注意

这也说明了如何在 C# 中使用异步函数进行设计的基本原理:

同步编写方法。将方法调用替换为方法调用,并等待它们。除了“顶级”方法(通常是 UI 控件的事件处理程序)之外,将异步方法的返回类型升级到 Task 或 Task<TResult>以便它们可以等待。

编译器为异步函数制造任务的能力意味着在大多数情况下,您只需要在启动 I/O 绑定并发的底层方法(相对罕见的情况下)显式实例化 TaskCompletionSource。(对于启动计算绑定并发的方法,您可以使用 Task.Run 创建任务。

异步调用图执行

要确切地了解其执行方式,按如下方式重新排列我们的代码会很有帮助:

async Task Go(){  var task = PrintAnswerToLife();  await task; Console.WriteLine ("Done");}async Task PrintAnswerToLife(){  var task = GetAnswerToLife();  int answer = await task; Console.WriteLine (answer);}async Task<int> GetAnswerToLife(){  var task = Task.Delay (5000);  await task; int answer = 21 * 2; return answer;}

Go 调用 PrintAnswerToLife,它调用 GetAnswerToLife,调用 Delay,然后等待。await 导致执行返回到 PrintAnswerToLife ,它本身在等待,返回到 Go ,它也在等待并返回给调用方。所有这些都同步发生,在名为 Go ;这是执行的简短阶段。

五秒钟后,延迟上的延续将触发,执行将返回到池线程上的 GetAnswerToLife。(如果我们从 UI 线程开始,执行现在会反弹到该线程。然后运行GetAnswerToLife中的其余语句,之后该方法的Task<int>以结果42完成,并在PrintAnswerToLife中执行,这将执行该方法中的其余语句。这个过程一直持续到 Go 的任务被标记为完成。

执行流与我们之前显示的同步调用图匹配,因为我们遵循一种模式,即在调用每个异步方法后立即等待它。这将创建一个顺序流,在调用图中没有并行性或重叠执行。每个 await 表达式都会在执行中创建一个“间隙”,之后程序将从中断的位置恢复。

排比

调用异步方法而不等待它允许以下代码并行执行。您可能已经注意到,在前面的示例中,我们有一个按钮,其事件处理程序名为 Go ,如下所示:

_button.Click += (sender, args) => Go();

尽管 Go 是一种异步方法,但我们并没有等待它,这确实有助于维护响应式 UI 所需的并发性。

我们可以使用相同的原理并行运行两个异步操作:

var task1 = PrintAnswerToLife();var task2 = PrintAnswerToLife();await task1; await task2;

(通过等待之后的两个操作,我们在这一点上“结束”并行性。稍后,我们将介绍 WhenAll 任务组合器如何帮助处理此模式。

无论操作是否在 UI 线程上启动,都会以这种方式创建的并发,尽管其发生方式有所不同。在这两种情况下,我们都会在启动它的底层操作(例如 Task.Delay 或 Task.Run 的代码)中获得相同的“真”并发性。仅当操作是在不存在同步上下文的情况下启动时,调用堆栈中高于此值的方法才受 true 并发性的约束;否则,它们将受制于我们之前讨论的伪并发(和简化的线程安全),其中唯一可以抢占的地方是 await 语句。例如,这让我们可以定义一个共享字段,_x ,并在 GetAnswerToLife 中递增它而不会锁定:

async Task<int> GetAnswerToLife(){  _x++;  await Task.Delay (5000);  return 21 * 2;}

(但是,我们无法假设_x在等待之前和之后具有相同的值。

异步 Lambda 表达式

就像普通的方法可以是异步的一样

async Task NamedMethod(){  await Task.Delay (1000);  Console.WriteLine ("Foo");}

的方法(Lambda 表达式和匿名方法)也是如此,如果前面有 async 关键字:

Func<Task> unnamed = async () =>{  await Task.Delay (1000);  Console.WriteLine ("Foo");};

我们可以以相同的方式调用和等待这些:

await NamedMethod();await unnamed();

我们可以在附加事件处理程序时使用异步 lambda 表达式:

myButton.Click += async (sender, args) =>{  await Task.Delay (1000);  myButton.Content = "Done";};

这比具有相同效果的以下内容更简洁:

myButton.Click += ButtonHandler;...async void ButtonHander (object sender, EventArgs args){  await Task.Delay (1000);  myButton.Content = "Done";};

异步 lambda 表达式也可以返回 Task<TResult> :

Func<Task<int>> unnamed = async () =>{  await Task.Delay (1000);  return 123;};int answer = await unnamed();
异步流

有了收益率回报,就可以写一个迭代器了;使用 await ,您可以编写一个异步函数。(来自 C# 8)结合了这些概念,并允许您编写等待的迭代器,异步生成元素。此支持基于以下接口对,这些接口是我们中描述的枚举接口的异步对应项:

public interface IAsyncEnumerable<out T>{  IAsyncEnumerator<T> GetAsyncEnumerator (...);}public interface IAsyncEnumerator<out T>: IAsyncDisposable{  T Current { get; }  ValueTask<bool> MoveNextAsync();}

ValueTask<T> 是一个包装 Task<T> 的结构,在行为上类似于 Task<T>同时在任务同步完成时实现更高效的执行(这在枚举序列时经常发生)。有关差异的讨论,请参阅 IAsyncDisposable 是 IDisposable 的异步版本;如果您选择手动实现接口,它提供了执行清理的机会:

public interface IAsyncDisposable{  ValueTask DisposeAsync();}
注意

从序列中获取每个元素的操作( MoveNextAsync )是一种异步操作,因此当元素以分段方式到达时(例如处理来自视频流的数据时),异步流是合适的。相反,当序列时,以下类型更合适,但元素在到达时会一起到达:

Task<IEnumerable<T>>

若要生成异步流,请编写一个结合了迭代器和异步方法原则的方法。换句话说,你的方法应该同时包括 yield return 和 await ,并且它应该返回 IAsyncEnumerable<T> :

async IAsyncEnumerable<int> RangeAsync (  int start, int count, int delay){  for (int i = start; i < start + count; i++)  {    await Task.Delay (delay);    yield return i;  }}

要使用异步流,请使用 await foreach 语句:

await foreach (var number in RangeAsync (0, 10, 500))  Console.WriteLine (number);

请注意,数据每 500 毫秒稳定到达一次(或者在现实生活中,当数据可用时)。将此与使用 Task<IEnumerable<T 的类似构造进行对比>>在最后一条数据可用之前不会返回任何数据:

static async Task<IEnumerable<int>> RangeTaskAsync (int start, int count,                                                    int delay){  List<int> data = new List<int>();  for (int i = start; i < start + count; i++)  {    await Task.Delay (delay);    data.Add (i);  }  return data;}

下面介绍如何将它与 foreach 语句一起使用:

foreach (var data in await RangeTaskAsync(0, 10, 500))  Console.WriteLine (data);
正在查询 IAsyncEnumerable<T>

NuGet 包定义了通过 IAsyncEnumerable<T> 运行的 LINQ 查询运算符,允许您像使用 IEnumerable<T> 一样编写查询。

例如,我们可以在上一节中定义的 RangeAsync 方法上编写 LINQ 查询,如下所示:

IAsyncEnumerable<int> query =  from i in RangeAsync (0, 10, 500)  where i % 2 == 0   // Even numbers only.  select i * 10;     // Multiply by 10.await foreach (var number in query)  Console.WriteLine (number);

这将输出 0、20、40 等。

注意

如果您熟悉反应式扩展,也可以通过调用 ToObservable 扩展方法从其(更强大的)查询运算符中受益,该方法将 IAsyncEnumerable<T> 转换为 IObservable<T>。还可以使用ToAsyncEnumerable扩展方法,以反向转换。

IAsyncEnumerable<T> in ASP.Net Core

ASP.Net 核心控制器操作现在可以返回 IAsyncEnumerable<T> 。此类方法必须标记为异步。例如:

[HttpGet]public async IAsyncEnumerable<string> Get(){    using var dbContext = new BookContext();    await foreach (var title in dbContext.Books                                         .Select(b => b.Title)                                         .AsAsyncEnumerable())       yield return title;}
WinRT 中的异步方法

如果你正在开发 UWP 应用程序,则需要使用操作系统中定义的 WinRT 类型。WinRT相当于Task的是IAsyncAction,相当于Task<TResult>是IAsyncOperation<TResult>。对于报告进度的操作,等效项是 IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 。它们都是在 Windows.Foundation 命名空间中定义的。

您可以通过 AsTask 扩展方法从 Task 转换为 Task 或 Task<Task>seult:

Task<StorageFile> fileTask = KnownFolders.DocumentsLibrary.CreateFileAsync                             ("test.txt").AsTask();

或者,或者您可以直接等待他们:

StorageFile file = await KnownFolders.DocumentsLibrary.CreateFileAsync                         ("test.txt");
注意

由于 COM 类型系统的限制,IAsyncActionWithProgress<TProgress> 和 IAsyncOperationWithProgress<TResult, TProgress> 并不像您期望的那样基于 IAsyncAction。相反,两者都继承自名为 IAsyncInfo 的公共基类型。

AsTask 方法也会重载以接受取消令牌(请参阅)。当链接到 WithProgress 变体时,它也可以接受 IProgress<T> 对象(请参阅)。

异步和同步上下文

我们已经看到同步上下文的存在在发布延续方面的重要性。还有其他几种更微妙的方式,此类同步上下文与 void 返回异步函数一起发挥作用。这些不是 C# 编译器扩展的直接结果,而是编译器在扩展异步函数时使用的 System.CompilerServices 命名空间中的 Async*MethodBuilder 类型的。

异常发布

在胖客户端应用程序中,通常的做法是依靠中心异常处理事件(WPF 中的 Application.DispatcherUnhandledException)来处理 UI 线程上引发的未经处理的异常。在 ASP.NET Core 应用程序中,ConfigureServices 方法中的自定义 ExceptionFilterAttribute 执行类似的工作。在内部,它们通过在自己的 try / catch 块中调用 UI 事件(或在 ASP.NET Core 中,页面处理方法的管道)来工作。

顶级异步函数使这变得复杂。请考虑以下用于按钮单击的事件处理程序:

async void ButtonClick (object sender, RoutedEventArgs args){  await Task.Delay(1000);  throw new Exception ("Will this be ignored?");}

单击按钮并运行事件处理程序时,执行将正常返回到 await 语句之后的消息循环,并且消息循环中的 catch 块无法捕获一秒钟后引发的异常。

为了缓解此问题,AsyncVoidMethodBuilder 捕获未经处理的异常(在返回 void 的异步函数中),并将其发布到同步上下文(如果存在),从而确保全局异常处理事件仍会触发。

注意

编译器仅将此逻辑应用于返回 的异步函数。因此,如果我们更改 ButtonClick 以返回任务而不是 void,则未处理的异常将错误生成的任务,然后该任务将无处可去(导致的异常)。

一个有趣的细微差别是,无论您是在等待之前还是之后投掷都没有区别。因此,在下面的示例中,异常将发布到同步上下文(如果存在),而不是发布到调用方:

async void Foo() { throw null; await Task.Delay(1000); }

(如果不存在同步上下文,则异常将在线程池上传播,从而终止应用程序。

异常不直接抛回调用方的原因是为了确保可预测性和一致性。在下面的示例中,InvalidOperationException 将始终具有与错误结果任务相同的效果 — 无论 :someCondition

async Task Foo(){  if (someCondition) await Task.Delay (100);  throw new InvalidOperationException();}

迭代器的工作方式类似:

IEnumerable<int> Foo() { throw null; yield return 123; }

在此示例中,永远不会将异常直接抛出回调用方:直到枚举序列才会引发异常。

操作已启动和操作已完成

如果存在同步上下文,则返回 void 的异步函数也会在进入函数时调用其 OperationStarted 方法,并在函数完成时调用其 OperationCompleted 方法。

如果为单元测试 void 返回异步方法编写自定义同步上下文,则重写这些方法很有用。上进行了讨论。

优化同步完成

异步函数可以在等待返回。请考虑以下缓存网页下载的方法:

static Dictionary<string,string> _cache = new Dictionary<string,string>();async Task<string> GetWebPageAsync (string uri){  string html;  if (_cache.TryGetValue (uri, out html)) return html;  return _cache [uri] =     await new WebClient().DownloadStringTaskAsync (uri);}

如果缓存中已存在 URI,则执行将返回到调用方,而不会发生等待,并且该方法返回。这称为。

当您等待同步完成的任务时,执行不会返回到调用方并通过延续反弹;相反,它会立即进入下一条语句。编译器通过检查等待器上的 IsCompleted 属性来实现此优化;换句话说,只要你等待

Console.WriteLine (await GetWebPageAsync (";));

编译器发出代码以在同步完成的情况下短路延续:

var awaiter = GetWebPageAsync().GetAwaiter();if (awaiter.IsCompleted)  Console.WriteLine (awaiter.GetResult());else  awaiter.OnCompleted (() => Console.WriteLine (awaiter.GetResult());
注意

等待同步返回的异步函数仍然会产生(非常)小的开销——在 20 年代的 PC 上可能是 2019 纳秒。

相比之下,弹到线程池会引入上下文切换的成本(可能是一到两微秒),而弹跳到 UI 消息循环的成本至少是其 10 倍(如果 UI 线程繁忙,则更长)。

编写等待的异步方法甚至是合法的,尽管编译器会生成警告:

async Task<string> Foo() { return "abc"; }

如果您的实现碰巧不需要异步,则在重写虚拟/抽象方法时,此类方法可能很有用。(一个例子是MemoryStream的ReadAsync / WriteAsync方法;见。实现相同结果的另一种方法是使用 Task.FromResult ,它返回一个已经发出信号的任务:

Task<string> Foo() { return Task.FromResult ("abc"); }

如果从 UI 线程调用,我们的 GetWebPageAsync 方法是隐式线程安全的,因为您可以连续多次调用它(从而启动多个并发下载),并且不需要锁定来保护缓存。但是,如果一系列调用是针对同一 URI,我们最终会启动多个冗余下载,所有这些下载最终都会更新相同的缓存条目(最后一个获胜)。虽然没有错误,但如果对同一 URI 的后续调用可以(异步)等待正在进行的请求的结果,则会更有效。

有一种简单的方法可以实现这一点 - 无需诉诸锁或信号结构。我们创建一个“期货”缓存(任务<字符串> ):

static Dictionary<string,Task<string>> _cache =    new Dictionary<string,Task<string>>();Task<string> GetWebPageAsync (string uri){  if (_cache.TryGetValue (uri, out var downloadTask)) return downloadTask;  return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);}

(请注意,我们不会将该方法标记为异步,因为我们直接返回从调用WebClient的方法中获得的任务。

如果我们使用相同的 URI 重复调用 GetWebPageAsync,我们现在保证会得到相同的 Task<string> 对象。(这还具有最小化垃圾回收负载的额外好处。如果任务完成,等待它很便宜,这要归功于我们刚刚讨论的编译器优化。

我们可以进一步扩展我们的示例,通过锁定整个方法主体,使其在没有同步上下文保护的情况下实现线程安全:

lock (_cache)  if (_cache.TryGetValue (uri, out var downloadTask))    return downloadTask;  else    return _cache [uri] = new WebClient().DownloadStringTaskAsync (uri);}

这是有效的,因为我们在下载页面期间没有锁定(这会损害并发性);我们将锁定检查缓存、在必要时启动新任务以及使用该任务更新缓存的短时间内。

ValueTask<T>注意

ValueTask<T> 适用于微优化方案,您可能永远不需要编写返回此类型的方法。但是,了解我们在下一节中概述的预防措施仍然是值得的,因为某些 .NET 方法返回 ValueTask<T> ,并且 IAsyncEnumerable<T> 也使用它。

我们刚刚描述了编译器如何在同步完成的任务上优化 await 表达式 — 通过缩短延续并立即继续执行下一条语句。如果同步完成是由于缓存,我们看到缓存任务本身可以提供优雅高效的解决方案。

但是,在所有同步完成方案中缓存任务是不切实际的。有时,必须实例化一个新任务,这会产生(微小的)潜在效率低下。这是因为 Task 和 Task<T> 是引用类型,因此实例化需要基于堆的内存分配和后续集合。优化的一种极端形式是编写免分配的代码;换句话说,这不会实例化任何引用类型,不会给垃圾回收增加负担。为了支持这种模式,引入了 ValueTask 和 ValueTask<T> 结构,编译器允许用它们代替 Task 和 Task<T> :

async ValueTask<int> Foo() { ... }

等待 ValueTask<T> 无需分配:

int answer = await Foo();   // (Potentially) allocation-free

如果操作未同步完成,ValueTask<T> 会在后台创建一个普通的 Task<T>(它将等待转发到该任务),并且不会获得任何结果。

可以通过调用 AsTask 方法将 ValueTask<T> 转换为普通 Task<T>。

还有一个非通用版本——ValueTask——类似于Task。

使用ValueTask<T时的注意事项>

ValueTask<T> 相对不寻常,因为它被定义为出于性能原因的结构。这意味着它被值类型语义所困扰,可能会导致意外。若要避免不正确的行为,必须避免以下情况:

多次等待相同的 ValueTask<T>叫。GetAwaiter()。GetResult() 当操作尚未完成时

如果需要执行这些操作,请调用 。AsTask() 并改为对生成的 Task 进行操作。

注意

避免这些陷阱的最简单方法是直接等待方法调用,例如:

await Foo();   // Safe

错误行为的大门在将(值)任务分配给变量时打开

ValueTask<int> valueTask = Foo();  // Caution!// Our use of valueTask can now lead to errors.

可以通过立即转换为普通任务来缓解:

Task<int> task = Foo().AsTask();   // Safe// task is safe to work with.
避免过度弹跳

对于在循环中多次调用的方法,可以通过调用 ConfigureAwait 来避免重复弹跳到 UI 消息循环的成本。这会强制任务不将延续反弹到同步上下文,从而将开销降低到更接近上下文切换的成本(如果您正在等待的方法同步完成,则开销要低得多):

async void A() { ... await B(); ... }async Task B(){  for (int i = 0; i < 1000; i++)    await C().ConfigureAwait (false);}async Task C() { ... }

这意味着对于 B 和 C 方法,我们取消了 UI 应用中的简单线程安全模型,其中代码在 UI 线程上运行,并且只能在 await 语句期间被抢占。但是,方法 A 不受影响,如果它在 UI 线程上启动,它将保留在 UI 线程上。

此优化在编写库时尤其重要:您不需要简化线程安全的好处,因为您的代码通常不与调用方共享状态,并且不访问 UI 控件。(在我们的示例中,如果方法 C 知道操作可能运行时间较短,则同步完成也是有意义的。)

异步模式取消

能够在并发操作启动后取消并发操作(可能是为了响应用户请求)通常很重要。实现这一点的一种简单方法是使用取消标志,我们可以通过编写这样的类来封装它:

class CancellationToken{  public bool IsCancellationRequested { get; private set; }  public void Cancel() { IsCancellationRequested = true; }  public void ThrowIfCancellationRequested()  {    if (IsCancellationRequested)      throw new OperationCanceledException();  }}

然后,我们可以编写一个可取消的异步方法,如下所示:

async Task Foo (CancellationToken cancellationToken){  for (int i = 0; i < 10; i++)  {    Console.WriteLine (i);    await Task.Delay (1000);    cancellationToken.ThrowIfCancellationRequested();  }}

当调用方想要取消时,它会在传递给 Foo 的取消令牌上调用 Cancel。这会将 IsCancelRequest 设置为 true,这会导致 Foo 在不久之后出现 OperationCanceledException(System 命名空间中为此目的设计的预定义异常)出错。

除了线程安全(我们应该锁定读取/写入IsCancelRequest),这种模式是有效的,CLR提供了一个名为CancelToken的类型,与我们刚刚展示的类型非常相似。但是,它缺少取消方法;相反,此方法在另一种名为 取消令牌源 。这种分离提供了一些安全性:只能访问 CancelToken 对象的方法可以检查但不能取消。

要获取取消令牌,我们首先实例化一个取消令牌源:

var cancelSource = new CancellationTokenSource();

这将公开一个 Token 属性,该属性返回一个 CancelToken 。因此,我们可以调用我们的 Foo 方法,如下所示:

var cancelSource = new CancellationTokenSource();Task foo = Foo (cancelSource.Token);...... (some time later)cancelSource.Cancel();

CLR 中的大多数异步方法都支持取消令牌,包括 延迟 。如果我们修改Foo,使其令牌传递到Delay方法中,则任务将在请求时立即结束(而不是最多一秒钟后):

async Task Foo (CancellationToken cancellationToken){  for (int i = 0; i < 10; i++)  {    Console.WriteLine (i);    await Task.Delay (1000, cancellationToken);  }}

请注意,我们不再需要调用 ThrowIfCancelRequest,因为 Task.Delay 正在为我们执行此操作。取消令牌很好地沿调用堆栈向下传播(就像取消请求通过例外在调用堆栈联一样)。

注意

UWP 依赖于 WinRT 类型,其异步方法遵循较差的取消协议,因此 IAsyncInfo 类型公开取消方法,而不是接受取消令牌。但是,AsTask 扩展方法已重载以接受取消令牌,从而弥合了差距。

同步方法也可以支持取消(例如任务的等待方法)。在这种情况下,取消指令需要异步发送(例如,来自另一个任务)。例如:

var cancelSource = new CancellationTokenSource();Task.Delay (5000).ContinueWith (ant => cancelSource.Cancel());...

事实上,您可以在构建 CancelTokenSource 时指定一个时间间隔,以便在设定的时间段后启动取消(正如我们演示的那样)。它对于实现超时(无论是同步还是异步)都很有用:

var cancelSource = new CancellationTokenSource (5000);try { await Foo (cancelSource.Token); }catch (OperationCanceledException ex) { Console.WriteLine ("Cancelled"); }

CancelToken 结构提供了一个 Register 方法,用于注册将在取消时触发的回调委托;它返回一个对象,可以释放该对象以撤消注册。

编译器的异步函数生成的任务在未处理的 OperationCanceledException 时自动进入“已取消”状态(IsCanceled 返回 true,IsFaulted 返回 false)。使用Task.Run创建的任务也是如此,您将(相同的)CancelToken传递给构造函数。在异步方案中,出错的任务和取消的任务之间的区别并不重要,因为两者都在等待时抛出操作取消异常;它在高级并行编程方案中很重要(特别是条件延续)。我们在中讨论这个主题。

进度报告

有时,你会希望异步操作在运行时报告进度。一个简单的解决方案是将 Action 委托传递给异步方法,每当进度更改时,该方法都会触发该方法:

Task Foo (Action<int> onProgressPercentChanged){  return Task.Run (() =>  {    for (int i = 0; i < 1000; i++)    {      if (i % 10 == 0) onProgressPercentChanged (i / 10);      // Do something compute-bound...    }  });}

以下是我们如何称呼它:

Action<int> progress = i => Console.WriteLine (i + " %");await Foo (progress);

尽管这在控制台应用程序中运行良好,但在富客户端方案中并不理想,因为它报告工作线程的进度,从而给使用者带来潜在的线程安全问题。(实际上,我们允许并发的副作用“泄漏”到外部世界,这是不幸的,因为如果从 UI 线程调用该方法,则会被隔离。

IProgress<T>和Progress<T>

CLR 提供了一对类型来解决此问题:一个名为 IProgress<T> 的接口和一个名为 Progress<T> 的实现此接口的类。实际上,它们的目的是“包装”委托,以便 UI 应用程序可以通过同步上下文安全地报告进度。

该接口仅定义一种方法:

public interface IProgress<in T>{  void Report (T value);}

使用 IProgress<T> 很简单:我们的方法几乎不会改变:

Task Foo (IProgress<int> onProgressPercentChanged){  return Task.Run (() =>  {    for (int i = 0; i < 1000; i++)    {      if (i % 10 == 0) onProgressPercentChanged.Report (i / 10);      // Do something compute-bound...    }  });}

Progress<T> 类有一个构造函数,该构造函数接受 Action<T> 类型的委托,它包装:

var progress = new Progress<int> (i => Console.WriteLine (i + " %"));await Foo (progress);

(Progress<T> 还有一个 ProgressChanged 事件,您可以订阅该事件,而不是 [或除了] 将操作委托传递给构造函数。在实例化 Progress<int> 时,该类会捕获同步上下文(如果存在)。当Foo调用报告时,委托是通过该上下文调用的。

异步方法可以通过将 int 替换为公开一系列属性的自定义类型来实现更详细的进度报告。

注意

如果您熟悉反应式扩展,您会注意到 IProgress<T> 与异步函数返回的任务一起提供了类似于 IObserver<T 的功能集> 。不同之处在于,除了 IProgress<T 发出的值,任务还可以公开“最终”返回值(并且类型不同>。

IProgress<T>发出的值通常是“一次性”值(例如,完成百分比或到目前为止下载的字节数),而IObserver<T>的OnNext推送的值通常包含结果本身,并且是调用它的原因。

WinRT 中的异步方法还提供进度报告,尽管该协议因 COM 的(相对)基元类型系统而变得复杂。报告进度的异步 WinRT 方法不接受 IProgress<T> 对象,而是返回以下接口之一,而不是 IAsyncAction 和 IAsyncOperation<TResult> :

IAsyncActionWithProgress<TProgress>IAsyncOperationWithProgress<TResult, TProgress>

有趣的是,两者都基于 IAsyncInfo(不是 IAsyncAction 和 IAsyncOperation<TResult> )。

好消息是,AsTask 扩展方法也重载以接受上述接口的 IProgress<T>,因此作为 .NET 使用者,您可以忽略 COM 接口并执行以下操作:

var progress = new Progress<int> (i => Console.WriteLine (i + " %"));CancellationToken cancelToken = ...var task = someWinRTobject.FooAsync().AsTask (cancelToken, progress);
基于任务的异步模式

.NET 公开了数百个可以等待的任务返回异步方法(主要与 I/O 相关)。这些方法中的大多数(至少部分)都遵循一种称为(TAP)的模式,它是我们迄今为止所描述的合理形式化。TAP 方法执行以下操作:

返回“热”(正在运行)任务或任务<任务>具有“异步”后缀(任务组合器等特殊情况除外)重载以接受取消令牌和/或 IProgress<T>如果它支持取消和/或进度报告快速返回给调用方(只有一个很小的初始)如果 I/O 绑定,则不占用线程

正如我们所看到的,TAP方法很容易使用C#的异步函数编写。

任务组合器

异步函数有一个一致的协议(它们一致地返回任务)的一个很好的结果是,可以使用和编写任务组合器——有效地组合任务的函数,而不考虑这些特定的作用。

CLR 包括两个任务组合器:Task.WhenAny 和 Task.WhenAll。在描述它们时,我们假设定义了以下方法:

async Task<int> Delay1() { await Task.Delay (1000); return 1; }async Task<int> Delay2() { await Task.Delay (2000); return 2; }async Task<int> Delay3() { await Task.Delay (3000); return 3; }
何时任何

Task.WhenAny 返回一个任务,该任务在一组任务中的任何一个完成时完成。以下内容在一秒钟内完成:

Task<int> winningTask = await Task.WhenAny (Delay1(), Delay2(), Delay3());Console.WriteLine ("Done");Console.WriteLine (winningTask.Result);   // 1

因为 Task.WhenAny 本身返回一个任务,所以我们等待它,它返回首先完成的任务。我们的示例是完全非阻塞的——包括我们访问 Result 属性时的最后一行(因为 winningTask 已经完成)。尽管如此,通常最好等待获胜任务

Console.WriteLine (await winningTask);   // 1

因为任何异常都会在没有聚合异常包装的情况下重新引发。实际上,我们可以在一个步骤中执行这两个 await s:

int answer = await await Task.WhenAny (Delay1(), Delay2(), Delay3());

如果非获胜任务随后出错,则除非随后等待该任务(或查询其 Exception 属性),否则将不观察到异常。

WhenAny 对于将超时或取消应用于不支持它的操作很有用:

Task<string> task = SomeAsyncFunc();Task winner = await (Task.WhenAny (task, Task.Delay(5000)));if (winner != task) throw new TimeoutException();string result = await task;   // Unwrap result/re-throw

请注意,因为在本例中我们使用不同类型的任务调用 WhenAny,因此将获胜者报告为普通任务(而不是 Task<string> )。

当所有

Task.WhenAll 返回一个任务,该任务在您传递给它任务完成时完成。以下内容在三秒后完成(并演示模式):

await Task.WhenAll (Delay1(), Delay2(), Delay3());

我们可以通过依次等待任务 1、任务 2 和任务 3 而不是使用 WhenAll 来获得类似的结果:

Task task1 = Delay1(), task2 = Delay2(), task3 = Delay3();await task1; await task2; await task3;

区别(除了由于需要三个等待而不是一个而效率较低)是,如果task1出错,我们将永远不会等待任务2 / task3,并且它们的任何异常都不会被观察到。

相比之下,Task.WhenAll 在所有任务完成之前不会完成,即使出现故障也是如此。如果有多个错误,它们的异常将合并到任务的 AggregateException 中(这是 AggregateException 真正变得有用的时候 - 也就是说,如果你对所有异常感兴趣)。但是,等待组合任务只会引发第一个异常,因此要查看所有异常,您需要执行以下操作:

Task task1 = Task.Run (() => { throw null; } );Task task2 = Task.Run (() => { throw null; } );Task all = Task.WhenAll (task1, task2);try { await all; }catch{  Console.WriteLine (all.Exception.InnerExceptions.Count);   // 2 }   

使用类型为 Task<TResult> 的任务调用 WhenAll,返回一个 Task<TResult[]> ,给出所有任务的组合结果。等待时,这将减少为 TResult[]:

Task<int> task1 = Task.Run (() => 1);Task<int> task2 = Task.Run (() => 2);int[] results = await Task.WhenAll (task1, task2);   // { 1, 2 }

为了给出一个实际示例,下面并行下载 URI 并对其总长度求和:

async Task<int> GetTotalSize (string[] uris){  IEnumerable<Task<byte[]>> downloadTasks = uris.Select (uri =>     new WebClient().DownloadDataTaskAsync (uri));          byte[][] contents = await Task.WhenAll (downloadTasks);  return contents.Sum (c => c.Length);}

但是,这里有一个轻微的低效率,因为我们不必要地挂在我们下载的字节数组上,直到每个任务都完成。如果我们在下载字节数组后立即将其折叠成它们的长度,那会更有效。这就是异步 lambda 派上用场的地方,因为我们需要将 await 表达式馈送到 LINQ 的选择查询运算符中:

async Task<int> GetTotalSize (string[] uris){  IEnumerable<Task<int>> downloadTasks = uris.Select (async uri =>    (await new WebClient().DownloadDataTaskAsync (uri)).Length);          int[] contentLengths = await Task.WhenAll (downloadTasks);  return contentLengths.Sum();}
定制组合器

编写自己的任务组合器可能很有用。最简单的“组合器”接受单个任务,如下所示,它允许您等待任何超时的任务:

async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,                                                 TimeSpan timeout){  Task winner = await Task.WhenAny (task, Task.Delay (timeout))                          .ConfigureAwait (false);  if (winner != task) throw new TimeoutException();  return await task.ConfigureAwait (false);   // Unwrap result/re-throw}

由于这在很大程度上是一种不访问外部共享状态的“库方法”,因此我们在等待时使用 ConfigureAwait(false) 以避免可能反弹到 UI 同步上下文。当任务按时完成时,我们可以通过取消 Task.Delay 来进一步提高效率(这避免了计时器的小开销):

async static Task<TResult> WithTimeout<TResult> (this Task<TResult> task,                                                 TimeSpan timeout){  var cancelSource = new CancellationTokenSource();  var delay = Task.Delay (timeout, cancelSource.Token);  Task winner = await Task.WhenAny (task, delay).ConfigureAwait (false);  if (winner == task)    cancelSource.Cancel();  else    throw new TimeoutException();  return await task.ConfigureAwait (false);   // Unwrap result/re-throw}

以下内容允许您通过取消令牌“放弃”任务:

static Task<TResult> WithCancellation<TResult> (this Task<TResult> task,                                          CancellationToken cancelToken){  var tcs = new TaskCompletionSource<TResult>();  var reg = cancelToken.Register (() => tcs.TrySetCanceled ());  task.ContinueWith (ant =>   {    reg.Dispose();    if (ant.IsCanceled)      tcs.TrySetCanceled();    else if (ant.IsFaulted)      tcs.TrySetException (ant.Exception.InnerException);    else      tcs.TrySetResult (ant.Result);  });  return tcs.Task;}

任务组合器编写起来可能很复杂,有时需要使用信号结构,我们将在第中介绍。这实际上是一件好事,因为它将与并发相关的复杂性排除在业务逻辑之外,并保留到可以单独测试的可重用方法中。

下一个组合器的工作方式类似于 WhenAll ,只是如果任何任务出错,则生成的任务会立即出错:

async Task<TResult[]> WhenAllOrError<TResult>   (params Task<TResult>[] tasks){  var killJoy = new TaskCompletionSource<TResult[]>();  foreach (var task in tasks)    task.ContinueWith (ant =>    {      if (ant.IsCanceled)         killJoy.TrySetCanceled();      else if (ant.IsFaulted)        killJoy.TrySetException (ant.Exception.InnerException);    });  return await await Task.WhenAny (killJoy.Task, Task.WhenAll (tasks))                         .ConfigureAwait (false);}

我们首先创建一个 TaskCompletionSource,它的唯一工作是在任务出错时结束参与方。因此,我们从不调用它的 SetResult 方法,只调用它的 TrySetCanceled 和 TrySetException 方法。在这种情况下,ContinueWith 比 GetAwaiter() 更方便。OnComplete,因为我们没有访问任务的结果,并且不想在此时反弹到 UI 线程。

异步锁定

在第 的中,我们描述了如何使用 SemaphoreSlim 异步锁定或限制并发性。

过时的模式

.NET 采用其他异步模式,这些模式位于任务和异步函数之前。现在很少需要这些,因为基于任务的异步已成为主导模式。

异步编程模型

最古老的模式称为(APM),它使用从“开始”和“结束”开始的一对方法以及一个名为IAsyncResult的接口。为了说明这一点,让我们以 Stream 类 System.IO 为例,并查看其 Read 方法。一、同步版本:

public int Read (byte[] buffer, int offset, int size);

您可以预测基于的异步版本是什么样子的:

public Task<int> ReadAsync (byte[] buffer, int offset, int size);

现在让我们检查一下 APM 版本:

public IAsyncResult BeginRead (byte[] buffer, int offset, int size,                               AsyncCallback callback, object state);public int EndRead (IAsyncResult asyncResult);

调用 Begin* 方法将启动该操作,并返回一个 IAsyncResult 对象,该对象充当异步操作的令牌。当操作完成(或出错)时,AsyncCallback 委托将触发:

public delegate void AsyncCallback (IAsyncResult ar);

然后,处理此委托的人员调用 End* 方法,该方法提供操作的返回值,并在操作出错时重新引发异常。

APM 不仅使用起来很笨拙,而且很难正确实现。处理 APM 方法的最简单方法是调用 Task.Factory.FromAsync 适配器方法,该方法将 APM 方法对转换为 Task 。在内部,它使用 TaskCompletionSource 为您提供一个任务,该任务在 APM 操作完成或出错时发出信号。

FromAsync 方法需要以下参数:

指定方法的委托BeginXXX指定方法的委托EndXXX将传递给这些方法的其他参数

FromAsync 重载以接受与 .NET 中找到的几乎所有异步方法签名匹配的委托类型和参数。例如,假设流是一个流,缓冲区是一个字节[],我们可以这样做:

Task<int> readChunk = Task<int>.Factory.FromAsync (  stream.BeginRead, stream.EndRead, buffer, 0, 1000, null);
基于事件的异步模式

(EAP) 于 2005 年引入,旨在为 APM 提供更简单的替代方案,尤其是在 UI 方案中。然而,它只在少数几种类型中实现,最著名的是 System.Net 中的 WebClient。EAP 只是一种模式;不提供任何类型来提供帮助。本质上,模式是这样的:类提供一系列在内部管理并发的成员,类似于:

// These members are from the WebClient class:public byte[] DownloadData (Uri address);    // Synchronous versionpublic void DownloadDataAsync (Uri address);public void DownloadDataAsync (Uri address, object userToken);public event DownloadDataCompletedEventHandler DownloadDataCompleted;public void CancelAsync (object userState);  // Cancels an operationpublic bool IsBusy { get; }                  // Indicates if still running

*异步方法异步启动操作。操作完成后,将触发事件(如果存在,则自动发布到捕获的同步上下文)。此事件传回包含以下内容的事件参数对象:*Completed

指示操作是否已取消的标志(由调用 CancelAsync 的使用者)一个 Error 对象,指示引发的异常(如果有)用户令牌对象(如果在调用异步方法时提供)

EAP 类型还可以公开进度报告事件,该事件在进度更改时触发(也通过同步上下文发布):

public event DownloadProgressChangedEventHandler DownloadProgressChanged;

实现 EAP 需要大量样板代码,这使得模式的组成很差。

后台工作者

System.ComponentModel 中的 BackgroundWorker 是 EAP 的通用实现。它允许富客户端应用启动工作线程并报告完成情况和基于百分比的进度,而无需显式捕获同步上下文。下面是一个示例:

var worker = new BackgroundWorker { WorkerSupportsCancellation = true };worker.DoWork += (sender, args) =>{                                      // This runs on a worker thread  if (args.Cancel) return;  Thread.Sleep(1000);   args.Result = 123;};worker.RunWorkerCompleted += (sender, args) =>    {                                                  // Runs on UI thread  // We can safely update UI controls here...  if (args.Cancelled)    Console.WriteLine ("Cancelled");  else if (args.Error != null)    Console.WriteLine ("Error: " + args.Error.Message);  else    Console.WriteLine ("Result is: " + args.Result);};worker.RunWorkerAsync();   // Captures sync context and starts operation

RunWorkerAsync 启动该操作,在池工作线程上触发 DoWork 事件。它还捕获同步上下文,当操作完成(或出错)时,将通过该同步上下文(如延续)调用 RunWorkerCompleted 事件。

BackgroundWorker 创建粗粒度并发,因为 DoWork 事件完全在工作线程上运行。如果需要更新该事件处理程序中的 UI 控件(而不是发布完成百分比消息),则必须使用 Dispatcher.BeginInvoke 或类似内容)。

我们将在 更详细地描述BackgroundWorker 。

标签: #c语言 异步函数