C#学习笔记(9)—–多线程基础(下)

C#5.0基于任务的异步模式

处理异步工作时,任务提供了比线程更好的抽象。任务自动调度为恰当数量的线程,而且大型任务可由多个小任务组成,就和大量程序由多个小方法组成一样。
但任务有自己的缺点。其中最麻烦的是它“颠倒”了程序逻辑。为了演示这个问题,先来考虑一个同步方法,它因为一个Web请求(I/O受限的、高延迟的操作)而阻塞。然后,将它和C# 5.0之前的异步版本和基于任务的异步模式(Task-based Asynchronous Pattern,TAP)进行比较。最后,使用C# 5.0和async/await上下文关键字来实现同一个例子。

  1. 以同步方式调用高延迟操作

【代码清单1】

using System;
using System.IO;
using System.Net;
using System.Linq;
public class Program
{
public static void Main(string[] args)
{
string url = "http://www.IntelliTect.com";
if(args.Length > 0)
{
url = args[0];
}
try
{
Console.Write(url);
WebRequest webRequest =
WebRequest.Create(url);
WebResponse response =
webRequest.GetResponse();
Console.Write(".....");
using(StreamReader reader =
new StreamReader(
response.GetResponseStream()))
{
string text =
reader.ReadToEnd();
Console.WriteLine(
FormatBytes(text.Length));
}
}
catch(WebException)
{
// ...
}
catch(IOException )
{
// ...
}
catch(NotSupportedException )
{
// ...
}
}
static public string FormatBytes(long bytes)
{
string[] magnitudes =
new string[] { "GB","MB","KB","Bytes" };
long max =
(long)Math.Pow(1024,magnitudes.Length);
return string.Format("{1:##.##} {0}",
magnitudes.FirstOrDefault(
magnitude =>
bytes > (max /= 1024)) ?? "0 Bytes",
(decimal)bytes / (decimal)max).Trim();
}
}

这段代码的逻辑很清楚,就是使用try/catch块和return语句等基本的东西来描述控制流。给定一个WebRequest,在它上面调用GetResponse()即可下载网页。要对网页进行流访问,调用GetResponseStream()并将结果赋给一个StreamReader。最后,用ReadToEnd()读到流的末尾,以便判断网页大小并打印结果。
这个方式的问题在于调用线程会被阻塞,直至I/O操作结束。异步工作进行期间,线程被白白浪费了,它本可做一些更有用的工作(如显示进度)。

  1. 使用TPL异步调用高延迟操作

【代码清单2】

using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;

namespace ConsoleApp11
{
    class Program
    {
        static void Main(string[] args)
        {
            string url = "http://www.baidu.com";          
            Console.WriteLine(url);
            Task task = GetWebRequestSizeAsync(url);
            try
            {
                while (!task.Wait(100))
                {
                    Console.Write(".");
                }               
            }
            catch (AggregateException e)
            {
                e.Handle(eachException =>
                {
                    Console.WriteLine(eachException.Message);
                    return true;
                });
            }
            Console.ReadKey();
        }

        static Task GetWebRequestSizeAsync(string url)
        {
            WebRequest request = WebRequest.Create(url);
            StreamReader reader = null;
            Task task = request.GetResponseAsync().ContinueWith(antecedent =>
            {
                WebResponse response = antecedent.Result;
                reader = new StreamReader(response.GetResponseStream());
                return reader.ReadToEndAsync();
            }).Unwrap().ContinueWith(antecedent =>
            {
                string result = antecedent.Result;
                Console.WriteLine(FormatBytes(result.Length));
            });
            return task;
        }
        public static string FormatBytes(long bytes)
        {
            string[] magnitudes =
                new string[] { "GB", "MB", "KB", "Bytes" };
            long max =
                (long)Math.Pow(1024, magnitudes.Length);
            return string.Format("{1:##.##} {0}",
                magnitudes.FirstOrDefault(
                    magnitude =>
                        bytes > (max /= 1024)) ?? "0 Bytes",
                (decimal)bytes / (decimal)max).Trim();
        }
    }
}

会在网页下载期间向控制台打印句点符号。所以不是简单地打印几个句点( 如“……”),而是在下载文件、从流中读取以及判断大小期间,一直地打印句点。
遗憾的是,异步操作的代价是复杂性的增加。代码中到处都是和TPL相关的代码,读起来比较费劲。不是直接在WebRequest.GetResponseAsync()调用之后就获取StreamReader并调用ReadToEndAsync(),这个异步版本要求使用ContinueWith()语句。第一个ContinueWith()语句指出WebRequest.GetResponseAsync()之后要执行什么。注意在第一个ContinueWith()表达式中,return语句返回StreamReader.ReadToEndAsync(),后者返回另一个Task。
所以,如果没有Unwrap()调用,第二个ContinueWith()语句中的先驱任务(antecedent)就是一个Task<Task<string>>,光看这个就知道有多复杂了。这种情况必须调用Result两次,一次直接在antecedent上调用,一次在antecedent.Result返回的Task<string>.Result属性上调用,后者会一直阻塞,直至ReadToEnd()操作结束。为了避免复杂的Task<Task<TResult>>结构,可以在调用ContinueWith()之前调用Unwrap(),这样外层的Task就可以脱掉了,而且能恰当地处理任何错误或取消请求。
但是,不仅仅是Task和ContinueWith()在搅局,异常处理使局面变得更复杂。本章前面说过,TPL通常引发AggregateException异常,因为异步操作可能出现多个异常。但是,由于是从ContinueWith()块中调用Result属性,所以工作者线程中也可能引发AggregateException。
如本章前面所述,可用多种方式处理这些异常。
(1)用ContinueWith()方法为返回任务的所有*Async方法都添加延续任务。但这样就无法很流畅地一个接一个写ContinueWith()了。除此之外,还会被迫将错误处理逻辑嵌入控制流中,而不是简单地依赖异常处理。
(2)用try/catch块包围每个委托主体,这样任务中就没有异常会成为未处理异常。遗憾的是,这个方式也不甚理想。首先,有的异常(比如调用antecedent.Result所造成的)会引发AggregateException,需要对InnerException(s)进行解包才能单独处理这些异常。解包时,要么重新引发以便捕捉特定类型的异常,要么单独用其他catch块(甚至针对同一个类型的多个catch块)条件性地检查异常类型。其次,每个委托主体都需要自己的try/catch处理程序,即使块和块之间的有些异常类型是相同的。最后,Main中的task.Wait()调用仍会引发异常,因为WebRequest.GetResponseAsync()可能引发异常,但没有办法用try/catch块来包围它。因此,没有办法在Main中消除围绕task.Wait()的try/catch块。
(3)在WriteWebRequesSizeAsync()中忽略所有异常处理,只依赖围绕Main的task.Wait()的try/catch块。由于异常必然是AggregateException,所以只捕捉它就好。catch块调用AggregateException.Handle()来处理异常,并用Exception DispatchInfo对象引发每个异常,以确保不会丢失原始的栈跟踪。这些异常随即由预期的异常处理程序捕捉和处理。但要注意的是,处理AggregateException的InnerException(s)之前要先调用AggregateException.Flatten()。这是因为在AggregateException中包装的内部异常也是AggregateException类型(以此类推)。调用Flatten()之后,所有异常都跑到第一级,包含的所有AggregateException都被删除。
对于上面这段代码来说,选项3或许是最好的,因为它在很大程度上使异常处理独立于控制流。虽然不能完全消除错误处理的复杂性,但却在很大程度上防止了在控制流中零散地嵌入异常处理。
这段代码的异步版本和上一段那个同步版本具有几乎完全一样的控制流逻辑,两个版本都试图从服务器下载资源,下载成功就返回结果,失败就检查异常类型来确定对策。然而,很明显异步版本较难阅读、理解和更改。同步版本使用标准的控制流语句,而异步处理被迫创建多个Lambda表达式,以委托的形式表达延续逻辑。
遗憾的是,这还是一个相当简单的例子!想象一下,假定要用循环来重试3次操作(如果失败的话)、要访问多个服务器、要获取资源集合而不是一个资源或者将所有这些功能都集成到一起。用同步代码实现这些功能很简单,但用异步代码来实现就非常复杂。为每个任务显式指定延续,从而将同步方法重写成异步方法,这个过程很快就会变得不堪其扰。

  1. 通过async和await实现基于任务的异步模式
    幸好,写一个程序来帮助自己完成这些复杂的代码转换也不是太难。C#语言的设计者意识到了这一点,在C# 5.0编译器中添加了这个功能。现在可以使用基于任务的异步模式(Task-based Asynchronous Pattern,TAP)将同步程序轻松重写为异步程序;将由C#编译器负责将方法转换成一系列任务延续。下面这段代码展示了如何将同步的的方法重写为异步版本,同时不像使用continuewith那样对主结构进行大手术。

【代码清单3】

using System;
using System.IO;
using System.Net;
using System.Linq;
using System.Threading.Tasks;
public class Program
{
private static async Task WriteWebRequestSizeAsync(
string url)
{
try
{
WebRequest webRequest =
WebRequest.Create(url);
WebResponse response =
await webRequest.GetResponseAsync();
using(StreamReader reader =
new StreamReader(
response.GetResponseStream()))
{
string text =
await reader.ReadToEndAsync();
Console.WriteLine(
FormatBytes(text.Length));
}
}
catch(WebException)
{
// ...
}
catch(IOException )
{
// ...
}
catch(NotSupportedException )
{
// ...
}
}
public static void Main(string[] args)
{
string url = "http://www.IntelliTect.com";
if(args.Length > 0)
{
url = args[0];
}
Console.Write(url);
Task task = WriteWebRequestSizeAsync(url);
while(!task.Wait(100))
{
Console.Write(".");
}
}
// ...
}

注意代码清单1代码清单3的一些小区别。首先将Web请求功能的主体重构为新方法(WriteWebRequestSizeAsync()),并在方法声明中添加了新的上下文关键字async。用这个关键字修饰的方法必须返回Task、Task<T>或void。在本例中,由于方法主体无数据返回,但我们想把有关异步活动的信息返回给调用者,所以WriteWebRequestSizeAsync()返回Task。注意方法名使用了Async后缀,虽然并非必须,但根据约定,应该用这个后缀标识异步方法。最后,针对和同步方法等价的每个异步版本,都在调用异步版本之前插入了await关键字。
除了这些,代码清单1和3就没有其他区别了。方法的异步版本表面上返回和以前一样的数据类型,但实际返回的是一个Task<T>。这不是通过隐式类型转换来实现的。GetResponseAsync()的声明如下:
public virtual Task<WebResponse> GetResponseAsync() { ...}
在调用点处,我们将返回值赋给WebResponse:
WebResponse response = await webRequest.GetResponseAsync()
这里的关键在于async上下文关键字,它指示编译器将表达式重写为一个状态机来代表如代码清单2所示的全部控制流(以及更多)。
注意,try/catch逻辑也比代码清单2好得多。在代码清单3中,根本没有必要捕捉AggregateException。catch子句直接捕捉确切的异常类型,不需要对内部异常进行解包。经编译器重写之后,任务中的AggregateExceptionin似乎被当作普通的、以同步方式引发的异常进行处理。但事实上,AggregateException(及其内部异常集合)的工作方式和以前一样,只是在等待任务时,编译器重写的版本会从集合中取出第一个异常并引发它。目的就是使异步代码块尽可能地像同步代码。
为了更好地理解控制流,下表展示了每个任务中的控制流。
这里写图片描述
这个表格很好地澄清了以下两个错误观念。
错误观念#1:用async关键字修饰的方法一旦调用,就会自动在一个工作者线程上执行。这绝对是不成立的,方法在调用线程上正常执行。如果方法的实现不等待任何未完成的、可等待的任务,就会在同一个线程上同步地完成。是由方法的实现决定是否启动任何异步工作。仅仅使用async关键字,改变不了方法的代码在哪里执行。此外,从调用者的角度看,对async方法的调用没有任何特别之处,就是一个返回Task的方法,该方法正常调用,最后正常返回指定返回类型的对象。
**错误观念#2:**await关键字造成当前线程阻塞,直到被等待的任务完成。这也绝对不成立。要阻塞当前线程直到任务完成,应该调用Wait()方法;事实上,Main线程在等待其他任务完成时一直都在做这件事情。但是,while(!task.Wait(100)) { }是和其他任务并发执行的——而不是同步。await关键字对它后面的表达式进行求值,该表达式一般是Task或Task<T>类型,为最终的任务添加延续,然后立即将控制返回给调用者。创建任务将开始异步工作;await关键字意味着开发人员希望在异步工作进行期间,方法的调用者在这个线程上继续执行它的工作。异步工作完成之后的某个时间,从await表达式之后的控制点恢复执行。
事实上,async关键字最主要的作用就是:(1)向阅读代码的人清楚说明它所修饰的方法将自动由编译器重写;(2)告诉编译器方法中的上下文关键字await要被视为异步控制流,不能当成普通的标识符。

异步Lambda

我们知道,转换成委托的Lambda表达式是声明普通方法的一种精简语法。类似地,C# 5.0允许包含await表达式的Lambda表达式转换成委托——为Lambda表达式附加async关键字前缀即可。代码清单4重写了代码清单3的GetResourceAsync()方法,把它从async方法转换成async Lambda。
【代码清单4】

Task task = Task.Run(async () =>
            {
                try
                {
                    WebRequest request = WebRequest.Create(url);
                    WebResponse response = await request.GetResponseAsync();
                    StreamReader reader = new StreamReader(response.GetResponseStream());
                    string text = await reader.ReadToEndAsync();
                    Console.WriteLine(FormatBytes(text.Length));
                }
                catch (WebException e)
                {
                    Console.WriteLine(e);

                }
                catch (IOException e)
                {
                    Console.WriteLine(e);
                }
                catch (NotSupportedException e)
                {
                    Console.WriteLine(e.Message);
                }


            });
            while (!task.Wait(100))
            {
                Console.Write(".");
            }

也可以将上面那个task.run(async ()=>.........)指定为Func<string,Task> getWebRequestSizeAsync=async(webRequestUrl)=>.......
async Lambda表达式具有和具名async方法一样的限制。
async Lambda表达式必须转换成返回类型为void、Task或Task<T>的委托。
Lambda进行了重写,使return语句成为“Lambda返回的任务已经完成并获得给定结果”的信号。
Lambda表达式中的执行最初是同步进行的,直到遇到第一个针对“未完成的可等待任务”的await为止。
await之后的指令作为被调用异步方法所返回的任务的延续而执行。但假如可等待任务已经完成,就以同步方式执行而不是作为延续。
async Lambda表达式可用await调用(代码清单4未演示)。
通常,await关键字后面的表达式是Task或Task<T>类型。在迄今为止的await例子中,关键字后面的表达式都是返回Task<T>。从语法的角度看,作用于Task类型的await相当于返回void的表达式。事实上,编译器连任务是否有结果都不知道,更别说结果的类型了。所以,像这样的表达式相当于调用一个返回void的方法,只能在语句的上下文中使用它。
事实上,关于await所要求的返回类型,规则比单单Task或Task宽泛得多。它只要求类型支持GetAwaiter。该方法生成一个对象,其中包含特定的属性和方法以便由编译器的重写逻辑使用。这使系统能由第三方进行扩展 [10];可以设计自己的、不是基于Task的异步系统,用其他类型来表示异步工作。与此同时,还是能使用await语法。
但要注意,async方法不能返回除void、Task或Task<T>之外的东西——不管方法内部等待的是什么类型。
精准地掌握async方法中发生的事情可能比较难,但相较于异步代码用显式的延续来写Lambda表达式,前者理解起来还是容易多了。注意下面这些要点:
控制抵达await关键字时,后面的表达式会生成一个任务。控制随即返回调用者,在任务异步完成期间,继续做自己的事情。
任务完成后的某个时间,控制从await之后的位置恢复。如果等待的任务生成结果,就获取那个结果。出错则引发异常。
async方法中的return语句造成与方法调用关联的任务变成“已完成”状态。如果return语句有一个值,返回值成为任务的结果。

任务调度器和同步上下文

本章偶尔提到任务调度器及其在为线程高效分配工作时所扮演的角色。从编程的角度看,任务调度器是System.Threading.Tasks.TaskScheduler的实例。该类默认用线程池调度任务,决定如何安全有效地执行它们——何时重用、何时进行资源清理(dispose)以及何时创建额外的线程。
从TaskScheduler派生出一个新类型,即可创建自己的任务调度器,从而对任务调度做出不同的选择。可以获取一个TaskScheduler,使用静态方法FromCurrentSynchronization Context()将任务调度给当前线程(更准确地说,调度给和当前线程关联的同步上下文),而不是调度给不同的工作者线程 。
用于执行任务(进而执行延续任务)的同步上下文之所以重要,是因为正在等待的任务会查询同步上下文(如果有的话)才能高效和安全地执行。

using System;
using System.Threading.Tasks;
public class Program
{
public static void Main()
{
DisplayStatus("Before");
Task taskA =
Task.Run(() =>
DisplayStatus("Starting..."))
.ContinueWith( antecedent =>
DisplayStatus("Continuing A..."));
Task taskB = taskA.ContinueWith( antecedent =>
DisplayStatus("Continuing B..."));
Task taskC = taskA.ContinueWith( antecedent =>
DisplayStatus("Continuing C..."));
Task.WaitAll(taskB,taskC);
DisplayStatus("Finished!");
}
private static void DisplayStatus(string message)
{
string text =
string.Format("{0}: {1}",
Thread.CurrentThread.ManagedThreadId,
message);
Console.WriteLine(text);
}
}

输出18-7
1: Before
3: Starting…
4: Continuing A…
3: Continuing C…
4: Continuing B…
1: Finished!
在输出中,注意事件ID时而改变,时而重复以前的。控制台应用程序的同步上下文(通过SynchronizationContext.Current属性访问)为null——造成由线程池处理线程分配。这就解释了为何在任务和任务之间线程ID会发生变化;有时线程池觉得使用新线程更有效,有时又觉得应该使用现有线程。
幸好,同步上下文会根据应用程序的类型自动设置。例如,如果创建任务的代码在ASP.NET创建的线程中运行,线程将关联AspNetSynchronizationContext类型的一个同步上下文。如果代码在WPF应用程序创建的线程中运行,线程将关联DispatcherSynchronizationContext的一个实例。(控制台应用程序默认没有同步上下文。)由于TPL要查询同步上下文,而同步上下文会随执行环境而变化,所以TPL能调度延续任务,使其在高效和安全的上下文中执行。
要修改代码来利用同步上下文,首先要设置同步上下文,再用async/await关键字使同步上下文得以查询 。
也可以定义自己的同步上下文,或者修改现有同步上下文来提升特定情况下的性能,但具体如何做超出了本文的范围。

async/await和Windows UI

同步在UI和Web编程中特别重要。例如在Windows UI的情况下,是由一个消息泵处理鼠标点击/移动等事件消息。另外,UI是单线程的,和任何UI组件(如文本框)的交互都肯定在同一个UI线程中发生。async/await模式的一个重要优势在于它利用了同步上下文,使延续工作(await语句后的工作)总是在调用await语句的那个同步任务上执行。这一点非常重要,因为它避免了要显式切换回UI线程来更新控件。
为了体会这一点,来考虑下列代码清单中的按钮点击UI事件。

using System;
private void PingButton_Click(
object sender,RoutedEventArgs e)
{
StatusLabel.Content = "Pinging…";
Ping ping = new Ping();
PingReply pingReply =
ping.Send("www.IntelliTect.com");
StatusLabel.Content = pingReply.Status.ToString();
}

假定StatusLabel是一个WPF System.Windows.Label控件,PingButton_Click()事件处理程序更新Content属性两次。合理的假设是第一个“Pinging…”会一直显示,直到Ping.Send()返回。随即,标签再次更新,显示Send()的应答。但正如WPF经验人士知道的那样,实情并非如此。相反,是一条消息被发布到Windows消息泵,要求用“Pinging…”更新内容,但由于UI线程正忙于执行PingButton_Click()方法,所以Windows消息泵不会得到处理。等UI线程有空检查Windows消息泵时,第二个Content属性更新请求又加入队列,造成用户只看到最后一个应答状态。
为了用TAP修正这个问题,要像下面的代码清单突出显示的那样修改代码。

using System;
async private void PingButton_Click(
object sender,RoutedEventArgs e)
{
StatusLabel.Content = "Pinging…";
Ping ping = new Ping();
PingReply pingReply =
await ping.SendPingAsync("www.IntelliTect.com");
StatusLabel.Content = pingReply.Status.ToString();
}

像这样修改有两方面的好处。第一,ping调用的异步本质解放了调用者线程,使其能返回Windows消息泵调用者的同步上下文,处理对StatusLabel.Content的更新,向用户显示“Pinging…”。其次,在等待ping.SendTaskAsync()完成期间,它总是在和调用者相同的同步上下文中执行。同步上下文特别适合WPF,它是单线程的,所以总是返回到同一个线程——UI线程。换言之,TPL不是立即执行延续任务,而是查询同步上下文,将关于延续工作的消息发送给消息泵。第二,UI线程监视消息泵,获得延续工作消息后,它调用await调用之后的代码。(结果是延续代码在和处理消息泵的调用者一样的线程上调用。)
TAP语言模式内建了一个关键的代码可读性增强。注意在代码清单中,pingReply.Status调用自然地出现在await之后,清楚地指明它会紧接着上一个语句执行。然而,如果真要自己从头写,恐怕代码是许多人看不懂的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值