多线程处理术语太多,容易混淆,所以我们先把它们定义好。
类表示,后者从非泛型Task类型派生。它们都在System.Threading.Tasks命名空间中。多线程处理主要用于两个方面:实现多任务和解决延迟。
用户随随便便就会同时运行几十乃至上百个进程。他们可能一边编辑演示文稿和电子表格,一边在网上浏览、听音乐、接收即时消息和电子邮件通知,还不时看一看角落里的小时钟。每个进程都在干活——谁都不是机器唯一关注的任务。这种多任务处理通常是在进程级实现的,但有时也要在一个进程中进行这样的多任务处理。
我们主要用多线程技术解决延迟问题。例如,为了在导入大文件时允许用户点击“取消”,开发者可以创建一个额外的线程来执行导入。这样,用户随时都能点击“取消”,而不是冻结UI直至导入完成。
如果有足够的内核使得每一个线程都能分配到一个内核,那么每一个线程就都使用它们各自的CPU。然而,即使今天已经有更多的多核机器,线程数仍然大于内核的数目,每个进程要执行多个线程。
执行I/O受限操作的线程平时会被操作系统忽略,直至I/O子系统返回结果。所以,在等待时切换到一个不同的线程能提高执行效率,避免处理器无意义地闲置。
然而,(切换不同的线程)上下文切换是有代价的。必须将CPU当前的内部状态保存到内存,还必须加载与新线程关联的状态。如果线程太多,切换开销就会开始显著影响性能。添加更多的线程会进一步降低性能。处理器的大量时间被花在从一个线程切换到另一个线程上,而不是主要花在线程的执行上。
即使忽略上下文切换的开销,时间分片本身对性能也有巨大的影响。例如,假定有两个处理器受限的高延迟任务,分别计算10亿个数字的平均值。假定处理器每秒能执行10亿次运算。如果两个任务分别和一个线程关联,而且两个线程分别有自己的内核,那么显然能在1秒钟内获得两个结果。
但是,如果一个处理器由两个线程共享,时间分片将在一个线程上执行几十万次操作,再切换到另一个线程,再切换回来,以此类推。每个任务都要消耗1秒钟的处理器时间,所以两个结果要在2秒钟之后才能获得,造成平均完成时间是2秒。(同样地,我们在这里忽略了上下文切换的开销)。
如果分配两个任务都由一个线程执行,而且严格按前后顺序执行,则第一个任务的结果在1秒后获得,第二个在第2秒后获得,造成平均完成时间是1.5秒。
多线程的程序写起来既复杂又困难,但未曾提及原因。其实根本原因在于单线程程序中一些成立的假设在多线程程序中变得不成立了。问题包括缺乏原子性、竞态条件、复杂的内存模型以及死锁。
1. 大多数操作都不是原子性的
原子操作要么尚未开始,要么已经完成。从外部看,其状态永远不会是“进行中”。例如以下代码:
if (bankAccounts.Checking.Balance >= 1000.00m)
{
bankAccounts.Checking.Balance -= 1000.00m;
bankAccounts.Savings.Balance += 1000.00m;
}
上述代码检查银行账户余额,如符合条件就从一个账户中取钱,向另一个账户存钱。这个操作必须是原子性的。换言之,为了使代码能正确执行,永远不能发生操作只是部分完成的情况。例如,假定两个线程同时运行,可能两个都验证账户有足够的余额,所以两个都执行转账,而剩余的资金其实只够进行一次转账。事实上,局面会变得更糟。在上述代码中,没有任何一个操作是原子性的。就连复合加/减(或者读/写目标类型的属性)在C#中都不属于原子操作。因此,它们在多线程环境中全都属于“部分完成”——部分增大或减小。因为部分完成的非原子操作而造成的不一致状态是竞态条件的一种特例。
2. 竞态条件所造成的不确定性
如前所述,并发性一般通过时间分片来模拟。在缺少下一章要详细讨论的特殊控制流语句的情况下,操作系统会在它认为合适的任何时间,在任何两个线程之间切换上下文。这意味着当两个线程访问同一个对象时,无法预测哪个线程“竞争胜出”并抢先运行。例如,假定有两个线程运行上述代码段,可能一个胜出并一路运行到尾,第二个线程甚至还没有开始。也可能在第一个执行余额检查时发生上下文切换,第二个胜出,一路运行到尾。
对于包含竞态条件的代码,它的行为取决于上下文切换时机。这造成了程序执行的不确定性。一个线程中的指令相对于另一个线程中的指令,两者的执行顺序是未知的。最糟的情况是包含竞态条件的代码99.9%的时间都具有正确行为。1000次中只有那么1次,另一个线程在竞争中胜出。正是这种不确定性使多线程编程显得很难。
由于竞态条件难以复制,所以多线程代码的品质保证主要基于长期的压力测试、专门的代码分析工具以及专家对代码进行的大量分析和检查。
3. 内存模型的复杂性
竞态条件(两个控制点以无法预测而且不一致的速度“竞争”代码的执行)本来就已经很糟了,但还有更糟的。假定两个线程在两个不同的进程中运行,但都要访问同一个对象中的字段。现代处理器不会在每次要用一个变量的时候都去访问主内存。相反,是在处理器的“高速缓存”中生成一个本地副本。这个缓存会定时与主内存同步。这意味着在两个不同的处理器上,两个线程以为自己在读写相同的位置,其实看到的不是对方对那个位置的实时更新。两者获得的结果不一致。简单地说,这里是因为处理器同步缓存的时机而产生了竞态条件。
4. 锁定造成死锁
显然,肯定有什么机制能将非原子的操作转变成原子操作,要求操作系统对线程进行调度以防止竞态条件,并确保处理器的高速缓存在必要时进行同步。C#程序解决所有这些问题的主要机制是lock语句。该语句允许开发者将一部分代码设为“关键”代码,一次只有一个线程能执行它。如果多个线程试图进入临界区,操作系统只允许一个进入,其他将被挂起。操作系统还确保在遇到锁的时候处理器高速缓存能正确同步。
然而,锁自身也有问题。最容易想到的是,假如不同线程以不同的顺序来获取锁,就可能发生死锁(deadlock)。这个时候,线程会被冻结,彼此等待对方释放它们的锁。
此时,每个线程都只有在对方释放了锁之后才能继续运行,线程阻塞,造成该代码执行彻底死锁。
规范
不要无根据地以为普通代码中原子性的操作在多线程代码中也是原子性的。
不要以为所有线程看到的都是一致的共享内存。
要确保同时拥有多个锁的代码总是以相同的顺序获取它们。
避免所有竞态条件,即程序行为不能受操作系统调度线程的方式的影响。
并行扩展库相当有用,因为它允许使用更高级的抽象——任务,而不必直接和线程打交道。但有的时候,要处理的代码是在TPL和PLINQ问世之前(.NET 4.0之前)写的。也有可能某个编程问题不能直接用它们解决。本节简单地讨论一下用于直接操纵线程的API。
using System;
using System.Threading;
public class RunningASeparateThread
{
public const int Repetitions = 1000;
public static void Main()
{
ThreadStart threadStart = DoWork;
Thread thread = new Thread(threadStart);
thread.Start();
for(int count = 0; count < Repetitions; count++)
{
Console.Write('-');
}
thread.Join();
}
public static void DoWork()
{
for(int count = 0; count < Repetitions; count++)
{
Console.Write('+');
}
}
}
输出结果是一连串的减号和加号交替进行的画面。线程是在轮流执行,分别打印几百个字符,然后发生上下文切换。两个循环“并行”运行,而不是第一个运行完了第二个才开始(委托若同步而不是异步执行便会如此)。
代码为了在不同线程的上下文中运行,需要ThreadStart或者ParameterizedThreadStart类型的一个委托来标识要执行的代码(后者允许单个object类型的参数)。给定用ThreadStart委托构造器创建的一个Thread实例,可调用thread.Start来启动线程。(上述代码显式创建ThreadStart类型的一个变量,目的是在源代码中显示委托类型。但方法组DoWork实际可以直接传给线程构造器。)调用Thread.Start()是告诉操作系统开始并发地执行一个新线程;然后主线程中的控制立即返回,开始执行Main()方法中的for循环。两个线程现在独立运行,不会等待对方,直到调用Join()。
线程管理
线程包含大量方法和属性,用于管理它们的执行。下是一些基本的。
Join()。如上述代码所示,可以调用Join()使一个线程等待另一个线程。它告诉操作系统暂停执行当前线程,直到另一个线程终止。Join()方法的重载版本允许获取一个int或者一个TimeSpan作为参数,指定最多等待thread执行多长时间,过期不候。
IsBackGround。新线程默认为“前台”线程(线程池的线程默认为后台线程);操作系统将在进程的所有前台线程完成后终止进程。可将thread.IsBackGround属性设为true,从而将线程标记为“后台”线程。这样,即使后台线程仍在运行,操作系统也允许进程终止。不过,最好还是不要半路中止(abort)任何线程,而是在进程退出之前显式退出每个线程。更多细节可参考稍后关于线程中止的小节。
Priority。每个线程都关联了优先级,可将Priority属性设为新的ThreadPriority枚举值(Lowest、BelowNormal、Normal、AboveNormal或Highest),从而增大或减小线程的优先级。操作系统倾向于将时间片调拨给高优先级线程。但要注意,如果优先级设置不当,可能会出现“饥饿”情况,即一个高优先级线程快乐地运行,而其他许多低优先级线程只能眼睁睁看着它。
ThreadState。如果只是想知道一个线程是还“活着”,还是已完成了所有工作,可以使用布尔属性IsAlive。更全面的线程状态可通过ThreadState属性访问。ThreadState枚举值包括Aborted、AbortRequested、Background、Running、Stopped、StopRequested、Suspended、SuspendRequested、Unstarted和WaitSleepJoin。这些都是标志(flag),有的可以组合。有两个常用(而且经常被滥用)的方法是Sleep()和Abort(),值得用专门的小节讨论。
在生产代码中不要让线程进入睡眠
静态方法Thread.Sleep()使当前线程进入睡眠——其实就是告诉操作系统在指定时间内不要为该线程调度任何时间片。参数(毫秒数或者TimeSpan)指定了操作系统要等待它多长时间。等待期间,操作系统当然可以将时间片调度给其他线程。这个设计表面合理,实则不然,值得好好推敲一下。
线程睡眠的目的通常是和其他线程就某个事件进行同步。但是,操作系统不保证计时的精确度。也就是说,如果指定:“睡眠123毫秒”,操作系统至少让线程睡眠123毫秒,但这个时间可能更长。线程从进入睡眠到它被唤醒,所经历的实际时间无法确定,可能为任意长度。不要将Thread.Sleep()作为高精度计时器使用,因为它不是。
更糟的是,Thread.Sleep()经常作为“穷人的同步系统”使用。也就是说,如果有一些异步工作要做,而当前线程必须等这个工作完成才能继续,你可能试图让线程睡眠,寄希望于当前线程醒来后异步工作已完成。但这是一个糟糕的想法,异步工作花费的时间可能超出你的想象。下一章要描述正确的线程同步机制。
线程睡眠是不好的编程实践,因为睡眠的线程是不运行代码的。如果Windows应用程序的主线程睡眠,就不再处理来自UI的消息,感觉就像挂起了一样。另外,之所以分配像线程这样的昂贵资源,肯定是想物尽其用。没人花钱聘请员工来睡觉,所以不要分配昂贵的线程,目的只是让它睡眠大量的处理器周期。
不过,Thread.Sleep()还是有一些用处的。首先,将线程睡眠时间设为零,相当于告诉操作系统:“当前线程剩下的时间片就送给其他线程了。”然后,该线程会被正常调度,不会发生更多的延迟。其次,测试代码经常用Thread.Sleep()模拟高延迟操作,同时不必让处理器真的去做一些无意义的运算。除了这些,在生产代码中使用该方法都应仔细地检查,研究是否有其他替代方案。
在C# 5基于任务的异步编程中,可以为Task.Delay()方法的结果使用await操作符,在不阻塞当前线程的前提下引入异步延迟。
using System;
using System.Threading;
public class Program
{
public const int Repetitions = 1000;
public static void Main()
{
ThreadPool.QueueUserWorkItem(DoWork,'+');
for(int count = 0; count < Repetitions; count++)
{
Console.Write('-');
}
// Pause until the thread completes
// This is for illustrative purposes; do not
// use Thread.Sleep for synchronization in
// production code.
Thread.Sleep(1000);
}
public static void DoWork(object state)
{
for(int count = 0; count < Repetitions; count++)
{
Console.Write(state);
}
}
}
程序结果和上面相似,都是 “+”和“-”字符混合。如果多个不同的作业异步执行,线程池能在单处理器和多处理器计算机上获得更好的执行效率。效率是通过重用线程(而不是每个异步调用都重新构造线程)来获得的。遗憾的是,线程池并非没有缺点,有一些性能和同步问题需要考虑。
为了有效利用处理器,线程池假定你在线程池上调度的所有工作都能及时结束,使线程能回到池中并为其他任务所用。线程池还假定所有工作的运行时间都较短(耗费以毫秒或秒计的处理器时间,而非以小时或天计)。基于这些假设,就可保证每个处理器都全力以赴完成任务,而不是无效率地通过时间分片来进行多个任务。线程池通过确保线程的创建“刚刚好”,没有一个处理器因为运行太多线程而超出负荷,从而防止过度的时间分片。但这也意味着一旦用完池中的所有线程,正在排队的工作就只好延迟执行了。如果池中的所有线程都被长时间运行或者I/O受限的工作占用了,正在排队的工作必然会被延迟。
有别于能够直接操纵的Thread与Task对象,线程池不提供对正在执行给定工作的线程的引用。这就阻止了发出调用的线程通过前面描述的线程管理功能与工作者线程同步(或者控制工作者线程)。这段代码使用了前面描述的“穷人的同步”;这在生产代码中是不可取的,因为不知道工作要花多长时间完成。
简单地说,线程池能很好地完成作业,但该作业中不包括处理长时间运行的作业,或者处理需要与其他线程(包括主线程)同步的作业。开发人员真正要做的是构建高级抽象,将线程和线程池作为实现细节使用。这种抽象由任务并行库(Task Parallel Library,TPL)实现,本章剩余的大部分内容都会围绕这个主题展开。
多线程编程的复杂性主要反映在以下几个方面。
(1)监视异步操作的状态,知道它在何时完成。为了判断一个异步操作于何时完成,最好不要采取轮询线程状态的办法,也不要采取阻塞并等待的办法。
(2)线程池。线程池避免了启动和终止线程的巨大开销。此外,线程池避免了创建太多的线程,防止系统将大多数时间花在线程的切换上而不是线程的运行上。
(3)避免死锁:在避免死锁的同时,防止数据同时被两个不同的线程访问。
(4)为不同的操作提供原子性并同步数据访问。为不同的操作组(指令序列)提供同步,可以确保将一系列操作作为一个整体来执行,并可由另一个线程恰当地中断。锁定机制防止两个不同的线程同时访问数据。
此外,任何时候只要有方法需要长时间运行,就很可能需要多线程编程,即异步调用该方法。随着编写的多线程代码越来越多,开发人员总结出了一系列常见的情形以及对这些情形进行处理的编程模式。
C# 5.0利用来自.NET 4.0的TPL,并通过新增语言构造的方式,对其中一个称为TAP(task-based asyncronous pattern)的模式进行了增强,改进了它的可编程性。本节和下一节将详细讨论TPL,然后讨论如何利用上下文关键字async和await简化TAP编程。后面还将讨论其他多线程编程模式。如果不允许使用TPL和C# 5.0,或者要求使用不是基于TPL的API,这些编程模式就很重要了。
1. 从Thread到Task
创建线程代价高昂,而且每个线程都要占用大量虚拟内存(默认1 MB)。前面说过,更有效的做法是使用线程池,需要时分配线程,为线程分配异步工作,运行直至结束,再为后续的异步工作重用线程,而不是在工作结束后销毁再重新创建线程。
在.NET Framework 4中,TPL不是每次开始异步工作时都创建一个线程,而是创建一个Task,并告诉任务调度器有异步工作要执行。此时任务调度器可能采取多种策略,但默认是从线程池请求一个工作者线程。线程池会自行判断怎么做最高效。可能在当前任务结束后再运行新任务,也可能将新任务的工作者线程调度到特定处理器上。线程池还会判断是创建一个全新线程,还是重用一个之前已结束运行的现有线程。
通过将异步工作的概念抽象到Task对象中,TPL提供了一个能代表异步工作的对象,还提供了相应的API以便与工作进行交互。通过提供代表工作单元的对象,TPL使我们能通过编程将小任务合并成大任务,从而建立起一个工作流程,详情请参见稍后的讨论。
任务是对象,其中封装了以异步方式执行的工作。这听起来有点儿耳熟,委托不也是封装了代码的对象吗?区别在于,委托是同步的,而任务是异步的。如果执行一个委托(例如一个Action),当前线程的控制点会立即转移到委托的代码;除非委托结束,否则控制不会返回调用者。相反,启动一个任务,控制几乎立即返回调用者,无论任务要执行多少工作。任务在另一个线程上异步执行(本章稍后会讲到,可以只用一个线程来异步执行任务,而且这样做还有一些好处)。简单地说,任务将委托从同步执行模式转变成异步。(一语道破)
2. 理解异步任务
之所以知道在当前线程上执行的委托于何时完成,是由于除非委托完成,否则调用者什么也做不了。那么,怎样知道任务于何时完成,怎样获得结果呢(如果有的话)?下面来看一个将同步委托转换成异步任务的例子。它做的是和前面两个代码段相同的事情,但这一次用的是任务。工作者线程向控制台写入加号,主线程写入连字号。
启动任务将从线程池获取一个新线程,创建第二个“控制点”,并在那个线程上执行委托。主线程上的控制点和平常一样,从启动任务的调用(Task.Run())之后继续。这段代码总体输出和前面的差不多。
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
const int Repetitions = 10000;
// Use Task.Factory.StartNew() for
// TPL prior to .NET 4.5
Task task = Task.Run(() =>
{
for(int count = 0;
count < Repetitions; count++)
{
Console.Write('-');
}
});
for(int count = 0; count < Repetitions; count++)
{
Console.Write('+');
}
// Wait until the Task completes
task.Wait();//阻塞当前线程(main线程)这个代码实际上就是一个解决刚才提出的“如何确
//定Task什么时候完成”的问题,就是阻塞线程来等待完成,这个做法比较低级
}
}
新线程要运行的代码由传给Task.Run()方法的委托(本例是Action类型)来定义。这个委托(以Lambda表达式的形式)在控制台上反复打印虚线。主线程的循环几乎完全一样,只是它打印句点。
注意,调用Task.Run()之后,作为参数传递的Action几乎立即开始执行。这称为“热”任务,即已触发并开始执行。“冷”任务则相反,它需要在显式触发之后才开始异步工作。
虽然一个Task可通过Task构造器实例化成“冷”状态,但这一般只应作为返回“热”任务的API的内部实现细节。
注意,在调用Run()之后,“热”任务的确切状态是不确定的。相反,这个状态由操作系统来决定,操作系统选择是立即运行任务的工作者线程,还是推迟到有更多资源的时候。事实上,轮到调用线程再次执行时,说不定“热”任务已经完成了。调用Wait()将强迫主线程等待分配给任务的所有工作结束执行。这相当于代码清单前面的代码在工作者线程上调用Join()。
目前的例子只有一个任务,但理所当然可能有多个任务以异步方式运行。一个常见的情况是要等待一组任务完成,或者等待其中一个任务完成,当前线程才能继续。这是用Task.WaitAll()和Task.WaitAny()方法来实现的。
前面描述了任务如何获取一个Action并以异步方式运行它。如果任务中执行的工作要返回结果又该怎么办呢?可以使用Task
类型来异步运行一个Func
。以同步方式执行委托时,除非获得结果,否则控制不会返回。异步执行Task
时,可以从一个线程中轮询它,看它是否完成,若完成就获取结果 。下面的代码清单演示了如何在控制台应用程序中做这件事情。注意这个例子用到了PiCalculator.Calculate()方法,该方法的详情将在后面的“并行执行循环迭代”小节中讲述。
第一个方法是使用了Wait
方法,另一个方法是去轮询:
using System;
using System.Threading.Tasks;
using AddisonWesley.Michaelis.EssentialCSharp.Shared;
public class Program
{
public static void Main()
{
// Use Task.Factory.StartNew() for
// TPL prior to .NET 4.5
Task<string> task =
Task.Run<string>(
() => PiCalculator.Calculate(100));
foreach(
char busySymbol in Utility.BusySymbols())
{
if(task.IsCompleted)
{
Console.Write('\b');
break;
}
Console.Write(busySymbol);
}
Console.WriteLine();
Console.WriteLine(task.Result);
System.Diagnostics.Trace.Assert(
task.IsCompleted);
}
}
public class PiCalculator
{
public static string Calculate(int digits = 100)
{
\\ ...
}
}
public class Utility
{
public static IEnumerable<char> BusySymbols()
{
string busySymbols = @"-\|/-\|/";
int next = 0;
while(true)
{
yield return busySymbols[next];
next = (next + 1) % busySymbols.Length;
yield return '\b';
}
}
}
在这个代码清单中,任务的数据类型是Task
。泛型类型包含一个Result属性,可从中获取由Task
执行的Func
的返回值。
注意这段代码没有调用Wait()。相反,读取Result属性自动造成当前线程等待,直到结果可用(如果结果还不可用的话)。在这个例子中,我们知道在获取结果时,结果肯定已经准备好了。
除了Task
的IsCompleted和Result属性,还有其他几件事情需要注意。
任务结束后,IsCompleted属性被设true——不管是正常结束,还是出错(即引发异常并终止)。更详细的任务状态信息可通过读取Status属性来获得,该属性返回TaskStatus类型的值,可能的值包括Created、WaitingForActivation、WaitingToRun、Running、WaitingForChildrenToComplete、RanToCompletion、Canceled和Faulted。任何时候只要Status为RanToCompletion、Canceled或者Faulted,IsCompleted就为true。当然,如果任务在另一个线程上运行,读取的状态值是“正在运行”,那么任何时候状态都可能变成“已完成”,包括刚好在读取了属性值之后。这一点对于其他许多状态都是成立的——就连Created都可能改变(如果另一个线程把它启动的话)。只有RanToCompletion、Canceled和Faulted可被视为最终状态,不会再改变。
任务可用Id属性的值来唯一性地标识。静态属性Task.CurrentId返回当前正在执行的Task(发出Task.CurrentId调用的那个任务)的标识符。这些属性对于调试特别有用。
可用AsyncState为任务关联额外的数据。例如,假定一个List
中的值要由多个任务完成。为此,每个任务都将值的索引包含到AsyncState属性中。这样一来,当任务结束后,代码可使用AsyncState(先转型成int)访问列表中的特定索引位置
还有一些有用的属性将在本章稍后关于任务取消的一节进行讨论。
3. 任务的延续
之前多次提到程序的“控制流”,但一直没有把控制流最根本的地方说出来:控制流决定了接着要发生什么。对于Console.WriteLine(x.ToString());这样的一个简单的控制流,它指出如果ToString正常结束,接着发生的事情就是调用WriteLine,将刚才的返回值作为实参传给它。“接着发生的事情”就是一个延续(continuation)。控制流中的每个控制点都有一个延续。在我们的例子中,ToString的延续是WriteLine(WriteLine的延续是下一个语句中运行的代码)。延续的概念对于C#编程来过于平常,大多数程序员根本没有想过它,只是在不知不觉中就用了。C#编程其实就是在延续的基础上构造延续,直到整个程序的控制流结束。
注意,在普通的C#程序中,给定代码的延续会在那个代码完成后立即执行。一旦ToString()返回,当前线程的控制点立即执行对WriteLine的同步调用。还要注意,任何给定的代码实际上都有两个可能的延续:“正常”延续和“异常”延续。如果当前代码引发异常,执行的就是后者。
异步方法调用(比如开始一个Task)会为控制流添加一个新的维度。执行异步Task调用,控制流立即转到Task.Start()之后的语句。与此同时,Task委托的主体也开始执行了。换言之,在涉及异步任务的时候,“接着发生的事情”是多维的。发生异常时的延续仅仅是一个不同的执行路径。相反,异步延续是多了一个并行的执行路径。
异步任务使我们能将较小的任务合并成较大的任务,只需描述好异步延续就可以了。和普通控制流一样,任务可以用多个不同的延续来处理错误情形,而通过操纵延续,可以将多个任务合并到一起。有几个技术可以做到这一点,最显而易见的就是ContinueWith()方法:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
Console.WriteLine("Before");
// Use Task.Factory.StartNew() for
// TPL prior to .NET 4.5
Task taskA =
Task.Run( () =>
Console.WriteLine("Starting..."))
.ContinueWith(antecedent =>
Console.WriteLine("Continuing A..."));
Task taskB = taskA.ContinueWith( antecedent =>
Console.WriteLine("Continuing B..."));
Task taskC = taskA.ContinueWith( antecedent =>
Console.WriteLine("Continuing C..."));
Task.WaitAll(taskB,taskC);
Console.WriteLine("Finished!");
}
}
可用ContinueWith()“链接”两个任务,这样当先驱任务完成后,第二个任务(延续任务)自动以异步方式开始。例如上述代码中,Console.WriteLine(“Starting…”)是先驱任务的主体,而Console.WriteLine(“Continuing A…”)是它的延续任务主体。延续任务获取一个Task作为实参(antecedent),这样才能从延续任务的代码中访问先驱任务的完成状态。先驱任务完成时,延续任务自动开始,异步执行第二个委托,刚才完成的先驱任务作为实参传给那个委托。此外,由于ContinueWith()方法也返回一个Task,所以当然可以作为另一个Task的先驱使用。以此类推,便可以建立起任意长度的连续任务链。
为同一个先驱任务调用两次ContinueWith()(例如上述代码中,taskB和taskC都是taskA的延续任务),先驱任务(taskA)就有两个延续任务。先驱任务完成时,两个延续任务将异步执行。注意,同一个先驱的多个延续任务的执行顺序在编译时不确定的。输出或许只是恰巧taskC先于taskB执行。再次执行程序,就不一定是这个顺序了。不过,taskA总是先于taskB和taskC执行,因为后两者是taskA的延续任务。因此,它们不可能在taskA完成前开始。类似地,肯定是Console.WriteLine(“Starting…”)委托先执行完成,再执行taskA(Console.WriteLine(“Continuing A…”)),因为后者是前者的延续任务。此外,“Finished!”总是最后显示,因为对Task.WaitAll(taskB,taskC)的调用阻塞了控制流,直到taskB和taskC都完成才能继续。
ContinueWith()有许多重载版本,有的要获取一个TaskContinuationOptions值,以便对延续链的行为进行调整。这些值都是位标志,可用逻辑OR操作符(|)合并。下表列出了部分标志值,详情参见MSDN文档 。
TaskContinuationOptions枚举值列表
带星号(*)的项指出延续任务在什么情况下执行。可利用它们创建类似于“事件处理程序”的延续任务,根据先驱任务的行为来进行操作。下列代码演示了如何为先驱任务准备多个延续任务,根据先驱任务的完成情况来有条件地执行。
using System;
using System.Threading.Tasks;
using AddisonWesley.Michaelis.EssentialCSharp.Shared;
public class Program
{
public static void Main()
{
// Use Task.Factory.StartNew() for
// TPL prior to .NET 4.5
Task task =
Task.Run(
() => PiCalculator.Calculate(10));
Task faultedTask = task.ContinueWith(
(antecedentTask) =>
{
Trace.Assert(task.IsFaulted);
Console.WriteLine(
"Task State: Faulted");
},
TaskContinuationOptions.OnlyOnFaulted);
Task canceledTask = task.ContinueWith(
(antecedentTask) =>
{
Trace.Assert(task.IsCanceled);
Console.WriteLine(
"Task State: Canceled");
},
TaskContinuationOptions.OnlyOnCanceled);
Task completedTask = task.ContinueWith(
(antecedentTask) =>
{
Trace.Assert(task.IsCompleted);
Console.WriteLine(
"Task State: Completed");
}, TaskContinuationOptions.OnlyOnRanToCompletion);
completedTask.Wait();
}
}
这个代码清单实际是在登记有关先驱任务的“事件”。事件(任务正常或异常完成)一旦发生,就执行特定的“侦听”任务。这是一个很强大的功能,尤其是对于那些fire-and-forget (本来是一个军事术语,指导弹发射后便不理会,它会自动寻找目标的。)的任务——任务开始,和延续任务关联,然后就可以不管了。注意,代码没有等待原始先驱任务。相反,等待的是用于处理完成事件的延续任务。完全可以丢弃对原始任务的引用;任务以异步方式开始执行,不需要任何后续的代码来检查状态。本例是改为调用completedTask.Wait(),确保主线程不在输出“任务完成”信息前退出。
4. 用AggregateException处理Task上的未处理异常
同步调用的方法可以包装到try块中,用catch子句告诉编译器发生异常时应该执行什么代码。但异步调用不能这么做。不能用try块包装Start()调用来捕捉异常,因为控制会立即从调用返回,然后控制会离开try块,而这时距离工作者线程发生异常可能还有好久呢。一个解决方案是将任务的委托主体包装到try/catch块中。引发并被工作者线程捕捉的异常不会造成问题,因为try块在工作者线程上能正常地工作。但未处理的异常就麻烦了,工作者线程不捕捉它们。
自CLR 2.0起 (在CLR1.0中,工作者线程上的未处理异常会终止线程,但不终止应用程序。这样一来,有bug的应用程序可能所有工作者线程都死了,主线程却还在运行——即使程序已经不再做任何工作。这为用户带来了困扰。更好的策略是通知用户应用程序处于不良状态,并在它造成损害前将其终止。),任何线程上的未处理的异常都被视为严重错误,会触发“Windowds错误报告”对话框,并造成应用程序异常中止。所有线程上的所有异常都必须被捕捉,否则应用程序不允许继续。(要了解处理未处理异常的高级技术,请参见稍后的“高级主题:处理未处理异常”。)幸好,异步任务中的未处理异常有所不同。在这种情况下,任务调度器会用一个“catchall”异常处理程序来包装委托。如果任务引发了未处理的异常,catchall处理程序会捕捉并记录异常细节,防止CLR自动终止进程。
如下面代码清单所示,为了处理出错的任务,一个技术是显式创建延续任务作为那个任务的“错误处理程序”。检测到先驱任务引发未处理的异常,任务调度器会自动调度延续任务。但是,如果没有这种处理程序,同时在出错的任务上执行Wait()(或其他试图获取Result的动作),就会引发一个AggregateException:
using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
// Use Task.Factory.StartNew() for
// TPL prior to .NET 4.5
Task task = Task.Run(() =>
{
throw new InvalidOperationException();
});
try
{
task.Wait();
}
catch(AggregateException exception)
{
exception.Handle(eachException =>
{
Console.WriteLine(
"ERROR: {0}",
eachException.Message);
return true;
});
}
}
}
之所以叫集合异常(AggregateException),是因为它可能包含从一个或多个出错的任务收集到的异常。例如,异步执行10个任务,其中5个引发异常。为了报告所有5个异常,并用一个catch块处理它们,框架用AggregateException收集异常,并把它们当作一个异常来报告。除此之外,由于编译时不知道工作者任务将引发一个还是多个异常,所以未处理的出错任务总是引发一个AggregateException。虽然工作者线程上引发的未处理异常是InvalidOperationException类型,主线程捕捉的仍是一个AggregateException。另外,捕捉异常需要一个AggregateException catch块。
AggregateException包含的异常列表通过InnerExceptions属性来获取。可以遍历该属性来检查每个异常并采取相应的对策。另外,可以使用AggregateException.Handle()方法,为AggregateException中的每个异常都指定一个要执行的表达式。Handle()方法的重要特点在于它是一个断言。针对Handle()委托成功处理的任何异常,断言应返回true。任何异常处理调用若为一个异常返回false,Handle()方法将引发新的AggregateException,其中包含了由这种异常构成的列表。