C-5-多线程秘籍-二-

58 阅读1小时+

C#5 多线程秘籍(二)

原文:zh.annas-archive.org/md5/B7D7E52064DCCDC9755A7421EE8385A4

译者:飞龙

协议:CC BY-NC-SA 4.0

第四章:使用任务并行库

在本章中,我们将深入研究一种新的异步编程范式,任务并行库。您将学习以下内容:

  • 创建任务

  • 执行任务的基本操作

  • 将任务组合在一起

  • 将 APM 模式转换为任务

  • 将 EAP 模式转换为任务

  • 实现取消选项

  • 处理任务中的异常

  • 并行运行任务

  • 使用 TaskScheduler 调整任务执行

介绍

在之前的章节中,我们学习了什么是线程,如何使用线程,以及为什么我们需要线程池。使用线程池允许我们节省操作系统资源,但代价是降低了并行度。我们可以将线程池视为一个抽象层,它将线程使用的细节隐藏起来,使我们能够集中精力在程序逻辑上,而不是线程问题上。

然而,使用线程池也是复杂的。没有简单的方法从线程池工作线程中获取结果。我们需要实现自己的方法来获取结果,并且在发生异常时,我们必须正确地将其传播到原始线程。除此之外,没有简单的方法来创建一组依赖的异步操作,其中一个操作在另一个操作完成后运行。

有几次尝试解决这些问题,结果产生了异步编程模型和基于事件的异步模式,这在第三章使用线程池中提到。这些模式使得获取结果更容易,并且在传播异常方面做得很好,但是将异步操作组合在一起仍然需要大量的工作,并且导致了大量的代码。

为了解决所有这些问题,在.Net Framework 4.0 中引入了一种新的用于异步操作的 API。它被称为任务并行库TPL)。它在.Net Framework 4.5 中略有改变,为了更清楚起见,我们将在我们的项目中使用.Net Framework 4.5 版本来使用最新版本的 TPL。TPL 可以被视为线程池上的另一种抽象层,它隐藏了与线程池一起工作的底层代码,使程序员无需关注,并提供了更方便和细粒度的 API。

TPL 的核心概念是任务。任务代表一个异步操作,可以以各种方式运行,使用单独的线程或不使用。我们将在本章中详细讨论所有可能性。

注意

默认情况下,程序员不知道任务的执行方式。TPL 通过隐藏任务的实现细节,提高了抽象级别。不幸的是,在某些情况下,这可能导致神秘的错误,比如在尝试从任务中获取结果时挂起应用程序。本章将帮助理解 TPL 底层的机制,以及如何避免以不当的方式使用它。

任务可以以不同的方式与其他任务组合。例如,我们可以同时启动几个任务,等待它们全部完成,然后运行一个任务,对所有先前任务的结果进行一些计算。与以前的模式相比,任务组合的便利 API 是 TPL 的关键优势之一。

还有几种处理任务异常的方法。由于一个任务可能由几个其他任务组成,它们又有自己的子任务,因此有一个AggregateException的概念。这种类型的异常包含了所有底层任务的异常,允许单独处理它们。

最后但并非最不重要的是,C# 5.0 内置了对 TPL 的支持,允许我们使用新的awaitasync关键字以非常流畅和舒适的方式处理任务。我们将在第五章使用 C# 5.0中讨论这个话题。

在本章中,我们将学习使用 TPL 执行异步操作。我们将学习任务是什么,覆盖创建任务的不同方式,以及如何将任务组合在一起。我们还将讨论如何将传统的 APM 和 EAP 模式转换为使用任务,如何正确处理异常,如何取消任务,以及如何同时处理多个任务。此外,我们将了解如何正确处理 Windows GUI 应用程序中的任务。

创建任务

这个配方展示了任务的基本概念。您将学习如何创建和执行任务。

准备工作

要按照这个配方进行,您将需要Visual Studio 2012。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter4\Recipe1中找到。

如何操作...

要创建和执行任务,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

注意

这次,请确保您使用的是.Net Framework 4.5。从现在开始,我们将为每个项目使用这个版本。

如何操作...

  1. Program.cs文件中,添加以下using指令:
using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static void TaskMethod(string name){
  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
}
  1. Main方法内添加以下代码片段:
var t1 = new Task(() =>TaskMethod("Task 1"));
var t2 = new Task(() =>TaskMethod("Task 2"));
t2.Start();
t1.Start();
Task.Run(() =>TaskMethod("Task 3"));
Task.Factory.StartNew(() => TaskMethod("Task 4"));
Task.Factory.StartNew(() => TaskMethod("Task 5"),TaskCreationOptions.LongRunning);
Thread.Sleep(TimeSpan.FromSeconds(1));
  1. 运行程序。

它是如何工作的...

当程序运行时,它使用构造函数创建两个任务。我们将 lambda 表达式作为Action委托传递;这允许我们向TaskMethod提供一个字符串参数。然后,我们使用Start方法运行这些任务。

注意

请注意,在调用这些任务的Start方法之前,它们不会开始执行。很容易忘记实际启动任务。

然后,我们使用Task.RunTask.Factory.StartNew方法运行另外两个任务。不同之处在于,创建的任务立即开始工作,因此我们不需要在任务上显式调用Start方法。所有任务,从Task 1Task 4,都放置在线程池工作线程上,并以未指定的顺序运行。如果多次运行程序,您会发现任务的执行顺序是不确定的。

Task.Run方法只是Task.Factory.StartNew的快捷方式,但后者有额外的选项。一般情况下,除非需要做一些特殊的事情,如Task 5的情况,否则使用前者方法。我们将这个任务标记为长时间运行,结果,这个任务将在一个单独的线程上运行,而不使用线程池。然而,这种行为可能会改变,取决于当前运行任务的任务调度程序。您将在本章的最后一个配方中了解什么是任务调度程序。

执行任务的基本操作

这个配方将描述如何从任务中获取结果值。我们将通过几种情景来理解在线程池或主线程上运行任务的区别。

准备工作

要开始这个配方,您将需要 Visual Studio 2012。没有其他先决条件。这个配方的源代码可以在BookSamples\Chapter4\Recipe2中找到。

如何操作...

要执行任务的基本操作,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static Task<int>CreateTask(string name){
  return new Task<int>(() =>TaskMethod(name));
}

static int TaskMethod(string name){
  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}",name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(2));
  return 42;
}
  1. Main方法内添加以下代码片段:
TaskMethod("Main Thread Task");
Task<int> task = CreateTask("Task 1");
task.Start();
int result = task.Result;
Console.WriteLine("Result is: {0}", result);

task = CreateTask("Task 2");
task.RunSynchronously();
result = task.Result;
Console.WriteLine("Result is: {0}", result);

task = CreateTask("Task 3");
task.Start();

while (!task.IsCompleted){
  Console.WriteLine(task.Status);
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
} 

Console.WriteLine(task.Status);
result = task.Result;
Console.WriteLine("Result is: {0}", result);
  1. 运行程序。

它是如何工作的...

首先,我们运行TaskMethod,而不将其包装成任务。结果,它是同步执行的,为我们提供了关于主线程的信息。显然,这不是一个线程池线程。

然后我们运行Task 1,使用Start方法启动它并等待结果。这个任务将放在线程池上,主线程会等待并被阻塞,直到任务返回。

我们对Task 2做同样的操作,只是我们使用RunSynchronously()方法来运行它。这个任务将在主线程上运行,我们得到的输出与当我们只是同步调用TaskMethod时完全相同。这是一个非常有用的优化,允许我们避免对非常短暂的操作使用线程池。

我们以与Task 1相同的方式运行Task 3,但是不阻塞主线程,只是旋转,打印出任务状态,直到任务完成。这显示了几个任务状态,分别是CreatedRunningRanToCompletion

将任务组合在一起

这个示例将展示如何设置相互依赖的任务。我们将学习如何创建一个任务,在父任务完成后运行。此外,我们将发现一种节省线程使用的可能性,用于非常短暂的任务。

准备工作

要执行此示例,您需要 Visual Studio 2012。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter4\Recipe3中找到。

如何做...

要将任务组合在一起,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# Console Application项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static int TaskMethod(string name, int seconds){
  Console.WriteLine("Task {0} is running on a thread id
    {1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(seconds));
  return 42 * seconds;
}
  1. Main方法内部添加以下代码片段:
var firstTask = new Task<int>(() =>TaskMethod("First Task",3));
var secondTask = new Task<int>(() =>TaskMethod("SecondTask", 2));

firstTask.ContinueWith(
  t =>Console.WriteLine("The first answer is {0}. Thread id{1}, is thread pool thread: {2}", t.Result,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread),TaskContinuationOptions.OnlyOnRanToCompletion);

firstTask.Start();
secondTask.Start();

Thread.Sleep(TimeSpan.FromSeconds(4));

Task continuation = secondTask.ContinueWith(
  t =>Console.WriteLine("The second answer is {0}. Threadid {1}, is thread pool thread: {2}", t.Result,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread),TaskContinuationOptions.OnlyOnRanToCompletion |TaskContinuationOptions.ExecuteSynchronously);

continuation.GetAwaiter().OnCompleted(
  () =>Console.WriteLine("Continuation Task Completed!Thread id {0}, is thread pool thread: {1}",Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread));

Thread.Sleep(TimeSpan.FromSeconds(2));
Console.WriteLine();

firstTask = new Task<int>(() => {varinnerTask = Task.Factory.StartNew(() =>TaskMethod("Second Task", 5), TaskCreationOptions.AttachedToParent);
  innerTask.ContinueWith(t =>TaskMethod("Third Task", 2),TaskContinuationOptions.AttachedToParent);
  return TaskMethod("First Task", 2);
});

firstTask.Start();

while (!firstTask.IsCompleted){
  Console.WriteLine(firstTask.Status);
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
}
Console.WriteLine(firstTask.Status);

Thread.Sleep(TimeSpan.FromSeconds(10));
  1. 运行程序。

它是如何工作的...

当主程序启动时,我们创建两个任务,对于第一个任务,我们设置了一个continuation(在前一个任务完成后运行的代码块)。然后我们启动这两个任务并等待 4 秒,这足够让两个任务都完成。然后我们对第二个任务运行另一个 continuation,并尝试通过指定TaskContinuationOptions.ExecuteSynchronously选项同步执行它。当 continuation 非常短暂时,这是一种有用的技术,它将更快地在主线程上运行而不是放在线程池中。我们能够做到这一点是因为第二个任务在那时已经完成。如果我们注释掉 4 秒的Thread.Sleep方法,我们将看到这段代码将被放在线程池中,因为我们还没有从前一个任务得到结果。

最后,我们以稍微不同的方式为前一个 continuation 定义一个 continuation,使用新的GetAwaiterOnCompleted方法。这些方法旨在与 C# 5.0 语言的异步机制一起使用。我们将在第五章中详细介绍这个主题,使用 C# 5.0

演示的最后部分是关于父子任务关系。我们创建一个新任务,同时运行这个任务,通过提供TaskCreationOptions.AttachedToParent选项来运行所谓的子任务。

提示

在运行父任务时必须创建子任务以正确附加到父任务!

这意味着父任务不会完成直到所有子任务完成其工作。我们还能够在子任务上运行 continuations,提供TaskContinuationOptions.AttachedToParent选项。这个 continuation 也会影响父任务,并且直到最后一个子任务结束才会完成。

将 APM 模式转换为任务

在这个示例中,我们将看到如何将老式的 APM API 转换为任务。有不同情况的示例可能发生在转换过程中。

准备工作

要开始这个示例,您需要 Visual Studio 2012。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter4\Recipe4中找到。

如何做...

要将 APM 模式转换为任务,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# Console Application项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
private delegate string AsynchronousTask(stringthreadName);
private delegate string IncompatibleAsynchronousTask(outint threadId);

private static void Callback(IAsyncResultar){
  Console.WriteLine("Starting a callback...");
  Console.WriteLine("State passed to a callback: {0}",ar.AsyncState);
  Console.WriteLine("Is thread pool thread: {0}",Thread.CurrentThread.IsThreadPoolThread);
  Console.WriteLine("Thread pool worker thread id: {0}",Thread.CurrentThread.ManagedThreadId);
}

private static string Test(string threadName){
  Console.WriteLine("Starting...");
  Console.WriteLine("Is thread pool thread: {0}",Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(2));
  Thread.CurrentThread.Name = threadName;
  return string.Format("Thread name: {0}",Thread.CurrentThread.Name);
}

private static string Test(out int threadId){
  Console.WriteLine("Starting...");
  Console.WriteLine("Is thread pool thread: {0}",Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(2));
  threadId = Thread.CurrentThread.ManagedThreadId;
  return string.Format("Thread pool worker thread id was:{0}", threadId);
}
  1. Main方法内部添加以下代码片段:
int threadId;
AsynchronousTask d = Test;
IncompatibleAsynchronousTask e = Test;

Console.WriteLine("Option 1");
Task<string> task = Task<string>.Factory.FromAsync(
  d.BeginInvoke("AsyncTaskThread", Callback, "a delegateasynchronous call"), d.EndInvoke);

task.ContinueWith(t =>Console.WriteLine("Callback isfinished, now running a continuation! Result: {0}",t.Result));

while (!task.IsCompleted){
  Console.WriteLine(task.Status);
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
}
Console.WriteLine(task.Status);
Thread.Sleep(TimeSpan.FromSeconds(1));

Console.WriteLine("----------------------------------------");
Console.WriteLine();
Console.WriteLine("Option 2");

task = Task<string>.Factory.FromAsync(
  d.BeginInvoke, d.EndInvoke, "AsyncTaskThread", "adelegate asynchronous call");
task.ContinueWith(t =>Console.WriteLine("Task is completed,now running a continuation! Result: {0}",t.Result));
while (!task.IsCompleted){
  Console.WriteLine(task.Status);
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
}
Console.WriteLine(task.Status);
Thread.Sleep(TimeSpan.FromSeconds(1));

Console.WriteLine("------------------------------------------");
Console.WriteLine();
Console.WriteLine("Option 3");

IAsyncResult ar = e.BeginInvoke(out threadId, Callback, "adelegate asynchronous call");
ar = e.BeginInvoke(out threadId, Callback, "a delegateasynchronous call");
task = Task<string>.Factory.FromAsync(ar, _ =>e.EndInvoke(out threadId, ar));
task.ContinueWith(t =>
  Console.WriteLine("Task is completed, now running acontinuation! Result: {0}, ThreadId: {1}",t.Result, threadId));

while (!task.IsCompleted){
  Console.WriteLine(task.Status);
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
}
Console.WriteLine(task.Status);

Thread.Sleep(TimeSpan.FromSeconds(1));
  1. 运行程序。

工作原理...

在这里,我们定义了两种类型的委托;其中一种使用了out参数,因此与将 APM 模式转换为任务的标准 TPL API 不兼容。然后我们有三个这样转换的示例。

将 APM 转换为 TPL 的关键点是Task<T>.Factory.FromAsync方法,其中T是异步操作的结果类型。该方法有几种重载;在第一种情况下,我们传递IAsyncResultFunc<IAsyncResult, string>,这是一个接受IAsyncResult实现并返回一个字符串的方法。由于第一个委托类型提供了与此签名兼容的EndMethod,因此我们可以毫无问题地将这个委托异步调用转换为任务。

在第二个示例中,我们做了几乎相同的事情,但使用了不同的FromAsync方法重载,它不允许指定在异步委托调用完成后将执行的回调。我们可以用延续来替换这个,但如果回调很重要,我们可以使用第一个示例。

最后一个示例展示了一个小技巧。这次,IncompatibleAsynchronousTask委托的EndMethod使用了out参数,并且与任何FromAsync方法重载都不兼容。然而,很容易将EndMethod调用包装成适用于任务工厂的 lambda 表达式。

为了查看底层任务的情况,我们在等待异步操作结果时打印其状态。我们看到第一个任务的状态是WaitingForActivation,这意味着任务实际上还没有被 TPL 基础架构启动。

将 EAP 模式转换为任务

本教程将描述如何将基于事件的异步操作转换为任务。在本教程中,您将找到一个适用于.NET Framework 类库中的每个基于事件的异步 API 的可靠模式。

准备工作

要开始本教程,您需要 Visual Studio 2012。没有其他先决条件。本教程的源代码可以在BookSamples\Chapter4\Recipe5中找到。

如何做...

要将 EAP 模式转换为任务,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.ComponentModel;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static int TaskMethod(string name, int seconds){
  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(seconds));
  return 42 * seconds;
}
  1. Main方法内添加以下代码片段:
var tcs = new TaskCompletionSource<int>();

var worker = new BackgroundWorker();
worker.DoWork += (sender, eventArgs) =>
{
  eventArgs.Result = TaskMethod("Background worker", 5);
};

worker.RunWorkerCompleted += (sender, eventArgs) =>{
  if (eventArgs.Error != null) {
    tcs.SetException(eventArgs.Error);
  }
  else if (eventArgs.Cancelled) {
    tcs.SetCanceled();
  }
    else {
      tcs.SetResult((int)eventArgs.Result);
    }
};

worker.RunWorkerAsync();

int result = tcs.Task.Result;

Console.WriteLine("Result is: {0}", result);
  1. 运行程序。

工作原理...

这是一个非常简单而优雅的将 EAP 模式转换为任务的例子。关键点是使用TaskCompletionSource<T>类型,其中T是异步操作的结果类型。

同样重要的是不要忘记将tcs.SetResult方法调用包装在try-catch块中,以确保错误信息始终设置到任务完成源对象中。也可以使用TrySetResult方法代替SetResult,以确保结果已成功设置。

实现取消选项

本教程是关于为基于任务的异步操作实现取消过程。我们将学习如何正确使用取消令牌来处理任务,以及如何在任务实际运行之前找出任务是否已取消。

准备工作

要开始本教程,您需要 Visual Studio 2012。没有其他先决条件。本教程的源代码可以在BookSamples\Chapter4\Recipe6中找到。

如何做...

要为基于任务的异步操作实现取消选项,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
private static int TaskMethod(string name, int seconds,CancellationToken token){

  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  for (int i = 0; i< seconds; i ++) {
    Thread.Sleep(TimeSpan.FromSeconds(1));
    if (token.IsCancellationRequested)
      return -1;
  }
  return 42*seconds;
}
  1. Main方法内添加以下代码片段:
var cts = new CancellationTokenSource();
var longTask = new Task<int>(() =>TaskMethod("Task 1", 10,cts.Token), cts.Token);
Console.WriteLine(longTask.Status);
cts.Cancel();
Console.WriteLine(longTask.Status);
Console.WriteLine("First task has been cancelled beforeexecution");
cts = new CancellationTokenSource();
longTask = new Task<int>(() =>TaskMethod("Task 2", 10,cts.Token), cts.Token);
longTask.Start();
for (int i = 0; i< 5; i++ ){
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
  Console.WriteLine(longTask.Status);
}
cts.Cancel();
for (int i = 0; i< 5; i++){
  Thread.Sleep(TimeSpan.FromSeconds(0.5));
  Console.WriteLine(longTask.Status);
}

Console.WriteLine("A task has been completed with result{0}.", longTask.Result);
  1. 运行程序。

工作原理...

这是另一个非常简单的例子,说明如何为 TPL 任务实现取消选项,因为你已经熟悉我们在第三章中讨论的取消标记概念,使用线程池

首先,让我们仔细看看 longTask 创建代码。我们将一次性传递一个取消标记给底层任务,然后第二次传递给任务构造函数。为什么我们需要两次提供这个标记?

答案是,如果我们在任务实际开始之前取消了任务,它的 TPL 基础结构负责处理取消,因为我们的代码根本不会执行。我们知道第一个任务被取消了,通过获取它的状态。如果我们尝试在这个任务上调用 Start 方法,我们将得到 InvalidOperationException

然后,我们从我们自己的代码中处理取消过程。这意味着我们现在完全负责取消过程,而在我们取消任务后,它的状态仍然是 RanToCompletion,因为从 TPL 的角度来看,任务正常完成了它的工作。在每种情况下理解责任差异非常重要。

处理任务中的异常

这个步骤描述了在异步任务中处理异常的非常重要的主题。我们将讨论从任务中抛出的异常发生的不同方面以及如何获取它们的信息。

准备就绪

要按照这个步骤,你需要 Visual Studio 2012。没有其他先决条件。这个步骤的源代码可以在 BookSamples\Chapter4\Recipe7 中找到。

如何做...

要处理任务中的异常,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# 控制台应用程序 项目。

  2. Program.cs 文件中,添加以下 using 指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main 方法下面添加以下代码片段:
static int TaskMethod(string name, int seconds){
  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(seconds));
  throw new Exception("Boom!");
  return 42 * seconds;
}
  1. Main 方法中添加以下代码片段:
Task<int> task;
try{
  task = Task.Run(() =>TaskMethod("Task 1", 2));
  int result = task.Result;
  Console.WriteLine("Result: {0}", result);
}
catch (Exception ex){
  Console.WriteLine("Exception caught: {0}", ex);
}
Console.WriteLine("----------------------------------------------");
Console.WriteLine();

try{
  task = Task.Run(() =>TaskMethod("Task 2", 2));
  int result = task.GetAwaiter().GetResult();
  Console.WriteLine("Result: {0}", result);
}
catch (Exception ex){
  Console.WriteLine("Exception caught: {0}", ex);
}
Console.WriteLine("----------------------------------------------");
Console.WriteLine();

var t1 = new Task<int>(() =>TaskMethod("Task 3", 3));
var t2 = new Task<int>(() =>TaskMethod("Task 4", 2));
var complexTask = Task.WhenAll(t1, t2);
var exceptionHandler = complexTask.ContinueWith(t =>Console.WriteLine("Exception caught: {0}", t.Exception),TaskContinuationOptions.OnlyOnFaulted);
t1.Start();
t2.Start();

Thread.Sleep(TimeSpan.FromSeconds(5));
  1. 运行程序。

它是如何工作的...

程序启动时,我们创建一个任务,并尝试同步获取任务结果。Result 属性的 Get 部分使当前线程等待任务完成,并将异常传播到当前线程。在这种情况下,我们很容易在 catch 块中捕获异常,但这个异常是一个名为 AggregateException 的包装异常。在这种情况下,它只包含一个异常,因为只有一个任务抛出了这个异常,可以通过访问 InnerException 属性来获取底层异常。

第二个例子大部分相同,但是为了访问任务结果,我们使用 GetAwaiterGetResult 方法。在这种情况下,我们没有包装异常,因为它被 TPL 基础结构解包了。我们一次性获得原始异常,如果只有一个底层任务,这是非常舒适的。

最后一个例子展示了我们有两个任务抛出异常的情况。为了处理异常,我们现在使用一个继续,只有在前置任务以异常结束时才执行。通过为继续提供 TaskContinuationOptions.OnlyOnFaulted 选项来实现这种行为。结果,我们打印出 AggregateException,并且其中包含来自两个任务的两个内部异常。

还有更多...

由于任务可能以非常不同的方式连接,因此生成的 AggregateException 异常可能包含其他聚合异常以及通常的异常。这些内部聚合异常本身可能包含其中的其他聚合异常。

为了摆脱这些包装器,我们应该使用根聚合异常的 Flatten 方法。它将返回层次结构中每个子聚合异常的所有内部异常的集合。

并行运行任务

这个示例展示了如何处理同时运行的许多异步任务。我们将学习如何在所有任务完成或任何正在运行的任务必须完成它们的工作时有效地得到通知。

准备工作

要开始这个示例,你需要 Visual Studio 2012。没有其他先决条件。这个示例的源代码可以在BookSamples\Chapter4\Recipe8中找到。

如何做...

要并行运行任务,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static int TaskMethod(string name, int seconds){
  Console.WriteLine("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  Thread.Sleep(TimeSpan.FromSeconds(seconds));
  return 42 * seconds;
}
  1. Main方法内部添加以下代码片段:
var firstTask = new Task<int>(() =>TaskMethod("First Task",3));
var secondTask = new Task<int>(() =>TaskMethod("SecondTask", 2));
var whenAllTask = Task.WhenAll(firstTask, secondTask);

whenAllTask.ContinueWith(t =>
  Console.WriteLine("The first answer is {0}, the second is{1}", t.Result[0], t.Result[1]),TaskContinuationOptions.OnlyOnRanToCompletion);

firstTask.Start();
secondTask.Start();

Thread.Sleep(TimeSpan.FromSeconds(4));

var tasks = new List<Task<int>>();
for (int i = 1; i< 4; i++)
{
  int counter = i;
  var task = new Task<int>(() =>TaskMethod(string.Format("Task {0}", counter), counter));
  tasks.Add(task);
  task.Start();
}

while (tasks.Count> 0){
  var completedTask = Task.WhenAny(tasks).Result;
  tasks.Remove(completedTask);
  Console.WriteLine("A task has been completed with result{0}.", completedTask.Result);
}

Thread.Sleep(TimeSpan.FromSeconds(1));
  1. 运行程序。

工作原理...

程序启动时,我们创建两个任务,然后借助Task.WhenAll方法创建一个第三个任务,该任务将在所有任务完成后完成。结果任务为我们提供了一个答案数组,其中第一个元素保存第一个任务的结果,第二个元素保存第二个结果,依此类推。

然后,我们创建另一个任务列表,并使用Task.WhenAny方法等待其中任何一个任务完成。在我们有一个完成的任务后,我们将其从列表中移除,并继续等待其他任务完成,直到列表为空。这种方法对于获取任务的完成进度或在运行任务时使用超时非常有用。例如,我们等待一些任务,其中一个任务正在计算超时。如果这个任务首先完成,我们就取消那些尚未完成的任务。

使用 TaskScheduler 调整任务执行

这个示例描述了处理任务的另一个非常重要的方面,即从异步代码中正确处理 UI 的方法。我们将学习任务调度程序是什么,为什么它如此重要,它如何损害我们的应用程序,以及如何使用它来避免错误。

准备工作

要完成这个示例,你需要 Visual Studio 2012。没有其他先决条件。这个示例的源代码可以在BookSamples\Chapter4\Recipe9中找到。

如何做...

通过使用TaskScheduler调整任务执行,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# WPF 应用程序项目。这一次,我们将需要一个带有消息循环的 UI 线程,这在控制台应用程序中是不可用的。

  2. MainWindow.xaml文件中,在一个网格元素内添加以下标记(即在<Grid></Grid>标记之间):

<TextBlock Name="ContentTextBlock"
HorizontalAlignment="Left"
Margin="44,134,0,0"
VerticalAlignment="Top"
Width="425"
Height="40"/>
<Button Content="Sync"
HorizontalAlignment="Left"
Margin="45,190,0,0"
VerticalAlignment="Top"
Width="75"
Click="ButtonSync_Click"/>
<Button Content="Async"
HorizontalAlignment="Left"
Margin="165,190,0,0"
VerticalAlignment="Top"
Width="75"
Click="ButtonAsync_Click"/>
<Button Content="Async OK"
HorizontalAlignment="Left"
Margin="285,190,0,0"
VerticalAlignment="Top"
Width="75"
Click="ButtonAsyncOK_Click"/>
  1. MainWindow.xaml.cs文件中,使用以下using指令:
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Input;
  1. MainWindow构造函数下面添加以下代码片段:
void ButtonSync_Click(object sender, RoutedEventArgs e){
  ContentTextBlock.Text = string.Empty;
  try {
    //string result = TaskMethod(TaskScheduler.//FromCurrentSynchronizationContext()).Result;
    string result = TaskMethod().Result;
    ContentTextBlock.Text = result;
  }
  catch (Exception ex) {
    ContentTextBlock.Text = ex.InnerException.Message;
  }
}

void ButtonAsync_Click(object sender, RoutedEventArgs e) {
  ContentTextBlock.Text = string.Empty;
  Mouse.OverrideCursor = Cursors.Wait;
  Task<string> task = TaskMethod();
  task.ContinueWith(t => {
    ContentTextBlock.Text = t.Exception.InnerException.Message;
    Mouse.OverrideCursor = null;
  }, 
  CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted,
  TaskScheduler.FromCurrentSynchronizationContext());
}

void ButtonAsyncOK_Click(object sender, RoutedEventArgs e){
  ContentTextBlock.Text = string.Empty;
  Mouse.OverrideCursor = Cursors.Wait;
  Task<string> task = TaskMethod(TaskScheduler.FromCurrentSynchronizationContext());
  task.ContinueWith(t =>Mouse.OverrideCursor = null,
    CancellationToken.None,
    TaskContinuationOptions.None,
    TaskScheduler.FromCurrentSynchronizationContext());
}

Task<string> TaskMethod() {
  return TaskMethod(TaskScheduler.Default);
}

Task<string> TaskMethod(TaskScheduler scheduler) {
  Task delay = Task.Delay(TimeSpan.FromSeconds(5));

  return delay.ContinueWith(t => {
    string str = string.Format("Task is running on a threadid {0}. Is thread pool thread: {1}",Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
    ContentTextBlock.Text = str;
    return str;
  }, scheduler);
}
  1. 运行程序。

工作原理...

在这里,我们遇到了许多新的东西。首先,我们创建了一个 WPF 应用程序,而不是控制台应用程序。这是必要的,因为我们需要一个用户界面线程和消息循环来演示异步运行任务的不同选项。

有一个非常重要的抽象叫做TaskScheduler。这个组件实际上负责任务的执行方式。默认的任务调度程序将任务放在线程池工作线程上。这是最常见的情况,也不奇怪它是 TPL 中的默认选项。我们还知道如何同步运行任务,以及如何将它们附加到父任务以一起运行。现在让我们看看我们可以用任务做什么。

程序启动时,我们创建一个带有三个按钮的窗口。第一个按钮调用同步任务执行。代码放在ButtonSync_Click方法中。当任务运行时,即使我们无法移动应用程序窗口。用户界面在任务运行时完全冻结,直到任务完成之前,用户界面线程无法响应任何消息循环。这是 GUI Windows 应用程序的一个常见的不良实践,我们需要找到一种解决这个问题的方法。

第二个问题是,我们试图从另一个线程访问 UI 控件。图形用户界面控件从未设计为从多个线程中使用,并且为了避免可能的错误,不允许您从创建它的线程之外的线程访问这些组件。当我们尝试这样做时,我们会收到异常,并且异常消息将在 5 秒钟后打印在主窗口中。

为了解决第一个问题,我们尝试异步运行任务。这就是第二个按钮的作用;其中的代码放在ButtonAsync_Click方法中。如果在调试器下运行任务,您将看到它被放置在线程池中,最后,我们将得到相同的异常。然而,用户界面在任务运行时始终保持响应。这是一件好事,但我们需要摆脱异常。

我们已经做到了!为了输出错误消息,使用了TaskScheduler.FromCurrentSynchronizationContext选项提供了一个继续。如果不这样做,我们将看不到错误消息,因为我们会得到与任务内部发生的相同异常。此选项指示 TPL 基础结构将代码放在 UI 线程的继续中,并借助 UI 线程消息循环异步运行它。这解决了从另一个线程访问 UI 控件的问题,但仍然保持了我们的 UI 响应性。

要检查这是否属实,我们按下最后一个按钮,运行ButtonAsyncOK_Click方法中的代码。唯一不同的是,我们为我们的任务提供了 UI 线程任务调度程序。任务完成后,您将看到它以异步方式在 UI 线程上运行。UI 保持响应,并且即使等待光标处于活动状态,也可以按下另一个按钮。

然而,对于在 UI 线程上运行任务有一些技巧。如果我们回到同步任务代码,并取消注释使用 UI 线程任务调度程序获取结果的行,我们将永远得不到任何结果。这是一个经典的死锁情况:我们正在将操作调度到 UI 线程的队列中,而 UI 线程等待此操作完成,但当它等待时,它无法运行操作,这将永远不会结束(甚至不会开始)。如果在任务上调用Wait方法,也会发生这种情况。为了避免死锁,永远不要在计划为 UI 线程的任务上使用同步操作;只使用ContinueWith,或者来自 C# 5.0 的async/await

第五章:使用 C# 5.0

在本章中,我们将研究 C# 5.0 编程语言中的本机异步编程支持。您将了解以下内容:

  • 使用 await 运算符获取异步任务结果

  • 在 lambda 表达式中使用 await 运算符

  • 使用 await 运算符与随后的异步任务

  • 使用 await 运算符执行并行异步任务

  • 处理异步操作中的异常

  • 避免使用捕获的同步上下文

  • 解决异步 void 方法的问题

  • 设计自定义可等待类型

  • 使用动态类型与 await

介绍

到目前为止,我们了解了来自 Microsoft 的最新异步编程基础设施——任务并行库。它允许我们以模块化的方式设计程序,将不同的异步操作组合在一起。

不幸的是,当阅读这样的程序时,仍然很难理解实际的程序流程。在一个大型程序中,将会有许多任务和依赖于彼此的延续,运行其他延续的延续,用于异常处理的延续,它们都聚集在程序代码中的非常不同的地方。因此,理解哪个操作先进行,接下来发生什么的顺序成为一个非常具有挑战性的问题。

另一个需要注意的问题是要查看是否将适当的同步上下文传播到可能触及用户界面控件的每个异步任务。只有从 UI 线程才允许使用这些控件;否则,我们将得到一个多线程访问异常。

谈到异常,我们还必须使用单独的延续任务来处理发生在前置异步操作或操作中的错误。这反过来导致了复杂的错误处理代码,分散在代码的不同部分,彼此之间没有逻辑关联。

为了解决这些问题,C# 5.0 的作者引入了称为异步函数的新语言增强功能。它们确实使异步编程变得简单,但同时,它是 TPL 的高级抽象。正如我们在第四章中提到的,使用任务并行库,抽象隐藏了重要的实现细节,并使异步编程更加容易,但却剥夺了程序员的许多重要内容。了解异步函数背后的概念对于创建健壮和可扩展的应用程序非常重要。

要创建一个异步函数,首先要用async关键字标记一个方法。在没有这样做之前,不可能拥有带有 async 属性或事件访问器方法和构造函数。代码将如下所示:

async Task<string> GetStringAsync()
{
  await Task.Delay(TimeSpan.FromSeconds(2));
  return "Hello, World!";
}

另一个重要的事实是,异步函数必须返回TaskTask<T>类型。可以有async void方法,但最好使用async Task方法。只有在应用程序中使用顶层 UI 控件事件处理程序时,才能使用async void函数。

在标记有async关键字的方法内部,可以使用await运算符。该运算符与 TPL 中的任务一起工作,并获取任务内部异步操作的结果。详细内容将在本章后面介绍。您不能在async方法之外使用await运算符;这将导致编译错误。此外,异步函数应该至少在其代码中有一个await运算符。但这只会导致编译警告,而不是错误。

重要的是要注意,在await调用的行之后,此方法立即返回。在同步执行的情况下,执行线程将被阻塞 2 秒,然后返回结果。在这里,我们在返回一个工作线程到线程池的同时异步等待,立即在执行await操作符后返回一个工作线程。2 秒后,我们再次从线程池中获取工作线程,并在其上运行其余的异步方法。这使我们能够在这 2 秒内重复使用这个工作线程来做一些其他工作,这对应用程序的可伸缩性非常重要。通过异步函数的帮助,我们有一个线性的程序控制流,但它仍然是异步的。这既非常舒适又非常令人困惑。本章的食谱将帮助您学习异步函数的每个重要方面。

注意

根据我的经验,如果程序中有两个连续的await操作符,人们普遍存在一个误解。许多人认为,如果我们在一个异步操作之后使用await函数,它们会并行运行。然而,它们实际上是顺序运行的;第二个操作只有在第一个操作完成后才开始。记住这一点非常重要,在本章的后面,我们将详细讨论这个话题。

在 C# 5.0 中使用asyncawait存在一些限制。例如,不可能将控制台应用程序的Main方法标记为async;您不能在catchfinallylockunsafe块中使用await操作符。异步函数上不允许有refout参数。还有更多微妙之处,但这些是主要的要点。

异步函数在幕后由 C#编译器转换为复杂的程序构造。我故意不会详细描述这一点;生成的代码与另一个 C#构造,称为迭代器,非常相似,并且实现为一种状态机。由于许多开发人员几乎在每个方法上都开始使用async修饰符,我想强调的是,如果一个方法不打算以异步或并行方式使用,那么将方法标记为async是没有意义的。调用async方法会带来显著的性能损失,通常方法调用将比标记为async关键字的相同方法快 40 到 50 倍。请注意这一点。

在本章中,我们将学习如何使用 C# 5.0 的asyncawait关键字来处理异步操作。我们将讨论如何顺序和并行等待异步操作。我们将讨论如何在 lambda 表达式中使用await,如何处理异常,以及在使用async void方法时如何避免陷阱。最后,我们将深入探讨同步上下文传播,并学习如何创建自己的可等待对象,而不是使用任务。

使用await操作符获取异步任务结果

这个食谱介绍了使用异步函数的基本场景。我们将比较如何使用 TPL 和await操作符获取异步操作结果。

准备就绪

要按照这个食谱,您需要 Visual Studio 2012。没有其他先决条件。此食谱的源代码可以在BookSamples\Chapter5\Recipe1中找到。

如何做...

使用await操作符获取异步任务结果的步骤如下:

  1. 启动 Visual Studio 2012。创建一个新的 C# 控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static Task AsynchronyWithTPL()
{
  Task<string> t = GetInfoAsync("Task 1");
  Task t2 = t.ContinueWith(task => Console.WriteLine(t.Result), TaskContinuationOptions.NotOnFaulted);
  Task t3 = t.ContinueWith(task => Console.WriteLine(t.Exception.InnerException), TaskContinuationOptions.OnlyOnFaulted);

  return Task.WhenAny(t2, t3);
}

async static Task AsynchronyWithAwait()
{
  try
  {
    string result = await GetInfoAsync("Task 2");
    Console.WriteLine(result);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
  }
}

async static Task<string> GetInfoAsync(string name)
{
  await Task.Delay(TimeSpan.FromSeconds(2));
  //throw new Exception("Boom!");

  return string.Format("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
}
  1. Main方法中添加以下代码片段:
Task t = AsynchronyWithTPL();
t.Wait();

t = AsynchronyWithAwait();
t.Wait();
  1. 运行程序。

工作原理...

当程序运行时,我们运行两个异步操作。其中一个是标准的 TPL 驱动代码,另一个使用新的asyncawait C#特性。AsynchronyWithTPL方法启动一个运行 2 秒钟的任务,然后返回一个包含有关工作线程信息的字符串。然后,我们定义一个继续打印异步操作结果的操作,另一个用于在发生错误时打印异常详细信息。最后,我们返回表示一个继续任务的任务,并在Main方法中等待其完成。

AsynchronyWithAwait方法中,我们通过使用await与任务实现了相同的结果。就好像我们只是编写了普通的同步代码-我们从任务中获取结果,打印结果,并在任务完成时捕获异常。关键区别在于我们实际上有一个异步程序。在使用await后立即,C#创建了一个任务,该任务具有在await运算符之后的所有剩余代码的继续任务,并处理异常传播。然后,我们将此任务返回给Main方法,并等待其完成。

注意

请注意,根据底层异步操作的性质和当前的同步上下文,执行异步代码的确切方式可能有所不同。我们将在本章后面解释这一点。

因此,我们可以看到程序的第一部分和第二部分在概念上是等价的,但在第二部分中,C#编译器隐式地处理异步代码。实际上,它甚至比第一部分更复杂,我们将在本章的接下来的几个食谱中详细介绍。

请记住,在诸如 Windows GUI 或 ASP.NET 之类的环境中,不建议使用Task.WaitTask.Result方法。如果程序员对代码的实际情况不是 100%了解,这可能会导致死锁。这在第四章的使用任务并行库中的使用 TaskScheduler 调整任务执行食谱中有所说明,当我们在 WPF 应用程序中使用Task.Result时。

要测试异常处理的工作原理,只需取消注释GetInfoAsync方法中的throw new Exception行。

在 lambda 表达式中使用 await 运算符

这个食谱将展示如何在 lambda 表达式中使用await。我们将编写一个使用await的匿名方法,并异步地获得方法执行的结果。

准备工作

要按照这个食谱进行操作,您需要 Visual Studio 2012。没有其他先决条件。此食谱的源代码可以在BookSamples\Chapter5\Recipe2中找到。

如何做...

要编写一个使用await的匿名方法,并通过在 lambda 表达式中使用await运算符异步地获得方法执行的结果,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
async static Task AsynchronousProcessing()
{
  Func<string, Task<string>> asyncLambda = async name => {
    await Task.Delay(TimeSpan.FromSeconds(2));
    return string.Format("Task {0} is running on a threadid {1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  };

  string result = await asyncLambda("async lambda");

  Console.WriteLine(result);
}
  1. Main方法中添加以下代码片段:
Task t = AsynchronousProcessing();
t.Wait();
  1. 运行程序。

工作原理...

首先,我们将异步函数移到AsynchronousProcessing方法中,因为我们不能在Main方法中使用async。然后,我们使用async关键字描述一个 lambda 表达式。由于任何 lambda 表达式的类型不能从 lambda 本身推断出来,我们必须明确地向 C#编译器指定其类型。在我们的情况下,类型意味着我们的 lambda 接受一个字符串参数,并返回一个Task<string>对象。

然后,我们定义 lambda 表达式的主体。一个异常是,该方法被定义为返回一个Task<string>对象,但实际上我们返回一个字符串,并且没有编译错误!C#编译器会自动生成一个任务并为我们返回它。

最后一步是等待异步 lambda 表达式的执行并打印出结果。

使用 await 操作符进行连续异步任务的执行

这个步骤将展示当代码中有几个连续的await方法时,程序流程是如何的。我们将学习如何阅读带有await方法的代码,并理解为什么await调用是一个异步操作。

准备工作

要按照这个步骤,你需要 Visual Studio 2012。没有其他先决条件。这个步骤的源代码可以在BookSamples\Chapter5\Recipe3找到。

如何做...

理解在连续的await方法存在的情况下程序流程,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static Task AsynchronyWithTPL()
{
  var containerTask = new Task(() => { 
    Task<string> t = GetInfoAsync("TPL 1");
    t.ContinueWith(task => {
      Console.WriteLine(t.Result);
      Task<string> t2 = GetInfoAsync("TPL 2");
      t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Result),TaskContinuationOptions.NotOnFaulted |TaskContinuationOptions.AttachedToParent);
      t2.ContinueWith(innerTask =>Console.WriteLine(innerTask.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted |TaskContinuationOptions.AttachedToParent);
      },
      TaskContinuationOptions.NotOnFaulted |TaskContinuationOptions.AttachedToParent);

    t.ContinueWith(task =>Console.WriteLine(t.Exception.InnerException),TaskContinuationOptions.OnlyOnFaulted |TaskContinuationOptions.AttachedToParent);
  });

  containerTask.Start();
  return containerTask;
}

async static Task AsynchronyWithAwait()
{
  try
  {
    string result = await GetInfoAsync("Async 1");
    Console.WriteLine(result);
    result = await GetInfoAsync("Async 2");
    Console.WriteLine(result);
  }
  catch (Exception ex)
  {
    Console.WriteLine(ex);
  }
}

async static Task<string> GetInfoAsync(string name)
{
  Console.WriteLine("Task {0} started!", name);
  await Task.Delay(TimeSpan.FromSeconds(2));
  if(name == "TPL 2")
    throw new Exception("Boom!");
  return string.Format("Task {0} is running on a thread id{1}. Is thread pool thread: {2}",name, Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
}
  1. Main方法内添加以下代码片段:
Task t = AsynchronyWithTPL();
t.Wait();

t = AsynchronyWithAwait();
t.Wait();
  1. 运行程序。

工作原理...

当程序运行时,我们运行两个异步操作,就像在第一个步骤中一样。然而,这一次我们将从AsynchronyWithAwait方法开始。它看起来仍然像通常的同步代码;唯一的区别是两个await语句。最重要的一点是,代码仍然是顺序的,Async 2任务只有在前一个任务完成后才会开始。当我们阅读代码时,程序流程非常清晰:我们看到什么先运行,然后是什么之后。那么,这个程序是如何异步的呢?嗯,首先,它并不总是异步的。如果一个任务在我们使用await时已经完成,我们将同步地得到它的结果。否则,当我们在代码中看到await语句时,通常的做法是注意到此时方法将立即返回,剩下的代码将在一个继续任务中运行。由于我们不阻塞等待操作的结果,这是一个异步调用。我们可以在Main方法中调用t.Wait之外的任何其他任务,而AsynchronyWithAwait方法中的代码正在执行。然而,主线程必须等待直到所有异步操作完成,否则它们将在后台线程上运行时被停止。

AsynchronyWithTPL方法模拟了与AsynchronyWithAwait方法相同的程序流程。我们需要一个容器任务来一起处理所有依赖任务。然后,我们启动主任务,并为其添加一组继续任务。当任务完成时,我们打印出结果;然后,我们启动另一个任务,该任务在第二个任务完成后继续工作。为了测试异常处理,我们故意在运行第二个任务时抛出异常,并打印出其信息。这一系列的继续任务创建了与第一种方法相同的程序流程,当我们将其与带有await方法的代码进行比较时,我们可以看到它更容易阅读和理解。唯一的诀窍是要记住,异步并不总是意味着并行执行。

使用 await 操作符执行并行异步任务执行

在这个步骤中,我们将学习如何使用await来并行运行异步操作,而不是通常的顺序执行。

准备工作

要按照这个步骤,你需要 Visual Studio 2012。没有其他先决条件。这个步骤的源代码可以在BookSamples\Chapter5\Recipe4找到。

如何做...

要理解使用await操作符进行并行异步任务执行,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码:
async static Task AsynchronousProcessing()
{
  Task<string> t1 = GetInfoAsync("Task 1", 3);
  Task<string> t2 = GetInfoAsync("Task 2", 5);

  string[] results = await Task.WhenAll(t1, t2);
  foreach (string result in results)
  {
    Console.WriteLine(result);
  }
}

async static Task<string> GetInfoAsync(string name, int seconds)
{
  await Task.Delay(TimeSpan.FromSeconds(seconds));
  /*await Task.Run(() =>Thread.Sleep(TimeSpan.FromSeconds(seconds)));*/
  return string.Format("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
}
  1. Main方法内添加以下代码片段:
Task t = AsynchronousProcessing();
t.Wait();
  1. 运行程序。

工作原理...

在这里,我们定义了两个分别运行 3 秒和 5 秒的异步任务。然后,我们使用Task.WhenAll辅助方法创建另一个任务,只有当所有底层任务完成时才会完成。然后,我们等待此组合任务的结果。5 秒后,我们得到了所有结果,这意味着任务是同时运行的。

然而,有一个有趣的观察。当您运行程序时,您可能会注意到两个任务很可能由线程池中的同一个工作线程提供服务。当我们并行运行任务时,这是如何可能的?为了使事情更有趣,让我们注释掉GetIntroAsync方法中的await Task.Delay行,并取消注释await Task.Run行,然后运行程序。

我们将看到在这种情况下,两个任务将由不同的工作线程提供服务。不同之处在于Task.Delay在内部使用了一个计时器,处理过程如下:我们从线程池中获取工作线程,它等待Task.Delay方法返回结果。然后,Task.Delay方法启动计时器,并指定在计时器计算Task.Delay方法指定的秒数时将调用的代码。然后我们立即将工作线程返回到线程池。当计时器事件运行时,我们再次从线程池中获取任何可用的工作线程(可能是我们首先使用的相同线程),并在其上运行计时器提供的代码。

当我们使用Task.Run方法时,我们从线程池中获取一个工作线程,并使其阻塞一段时间,提供给Thread.Sleep方法。然后,我们获取第二个工作线程并阻塞它。在这种情况下,我们消耗了两个工作线程,它们完全没有做任何事情,无法执行任何其他任务。

我们将在第九章中详细讨论第一种情况,使用异步 I/O,在那里我们将讨论一大堆与数据输入和输出一起工作的异步操作。在可能的情况下始终使用第一种方法是创建可扩展服务器应用程序的关键。

在异步操作中处理异常

本示例将描述如何在 C#中使用异步函数处理异常。我们将学习如何处理使用await进行多个并行异步操作时的聚合异常。

准备工作

要执行此示例,您需要 Visual Studio 2012。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter5\Recipe5中找到。

如何做到这一点...

要了解异步操作中的异常处理,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
async static Task AsynchronousProcessing()
{
  Console.WriteLine("1\. Single exception");

  try
  {
    string result = await GetInfoAsync("Task 1", 2);
    Console.WriteLine(result);
  }
  catch (Exception ex)
  {
    Console.WriteLine("Exception details: {0}", ex);
  }

  Console.WriteLine();
  Console.WriteLine("2\. Multiple exceptions");

  Task<string> t1 = GetInfoAsync("Task 1", 3);
  Task<string> t2 = GetInfoAsync("Task 2", 2);
  try
  {
    string[] results = await Task.WhenAll(t1, t2);
    Console.WriteLine(results.Length);
  }
  catch (Exception ex)
  {
    Console.WriteLine("Exception details: {0}", ex);
  }

  Console.WriteLine();
  Console.WriteLine("2\. Multiple exceptions with AggregateException");

  t1 = GetInfoAsync("Task 1", 3);
  t2 = GetInfoAsync("Task 2", 2);
  Task<string[]> t3 = Task.WhenAll(t1, t2);
  try
  {
    string[] results = await t3;
    Console.WriteLine(results.Length);
  }
  catch
  {
    var ae = t3.Exception.Flatten();
    var exceptions = ae.InnerExceptions;
    Console.WriteLine("Exceptions caught: {0}", exceptions.Count);
    foreach (var e in exceptions)
    {
      Console.WriteLine("Exception details: {0}", e);
      Console.WriteLine();
    }
  }
}

async static Task<string> GetInfoAsync(string name, int seconds)
{
  await Task.Delay(TimeSpan.FromSeconds(seconds));
  throw new Exception(string.Format("Boom from {0}!", name));
}
  1. Main方法中添加以下代码片段:
Task t = AsynchronousProcessing();
t.Wait();
  1. 运行程序。

它是如何工作的...

我们运行三个场景来说明在 C#中使用asyncawait处理错误的最常见情况。第一种情况非常简单,几乎与通常的同步代码相同。我们只是使用try/catch语句并获取异常的详细信息。

常见的错误是在等待多个异步操作时使用相同的方法。如果我们像以前一样使用catch块,我们将只从底层的AggregateException对象中得到第一个异常。

为了收集所有信息,我们必须使用等待任务的Exception属性。在第三种情况下,我们展平AggregateException层次结构,然后使用AggregateExceptionFlatten方法解开其中的所有异常。

避免使用捕获的同步上下文

本教程讨论了使用await获取异步操作结果时同步上下文行为的细节。我们将学习何时以及如何关闭同步上下文流。

准备就绪

要按照本教程进行,您需要 Visual Studio 2012。没有其他先决条件。本教程的源代码可以在BookSamples\Chapter5\Recipe6中找到。

如何做...

要了解使用await时同步上下文行为的细节,并学习何时以及如何关闭同步上下文流,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# Console Application项目。

  2. 添加对 Windows Presentation Foundation Library 的引用。

  3. 在项目中右键单击References文件夹,然后选择**Add reference…**菜单选项。

  4. 添加对以下库的引用:PresentationCorePresentationFrameworkSystem.XamlWindows.Base。您可以使用引用管理器对话框中的搜索功能,如下所示:

如何做...

  1. Program.cs文件中,添加以下using指令:
using System;
using System.Diagnostics;
using System.Text;
using System.Threading.Tasks;
using System.Windows;
using System.Windows.Controls;
  1. Main方法下面添加以下代码片段:
private static Label _label;

async static void Click(object sender, EventArgs e)
{
  _label.Content = new TextBlock {Text = "Calculating..."};
  TimeSpan resultWithContext = await Test();
  TimeSpan resultNoContext = await TestNoContext();
  /*TimeSpan resultNoContext = awaitTestNoContext().ConfigureAwait(false);*/
  var sb = new StringBuilder();
  sb.AppendLine(string.Format("With the context: {0}",resultWithContext));
  sb.AppendLine(string.Format("Without the context: {0}",resultNoContext));
  sb.AppendLine(string.Format("Ratio: {0:0.00}",resultWithContext.TotalMilliseconds/resultNoContext.TotalMilliseconds));
  _label.Content = new TextBlock {Text = sb.ToString()};
}

async static Task<TimeSpan> Test()
{
  const int iterationsNumber = 100000;
  var sw = new Stopwatch();
  sw.Start();
  for (int i = 0; i < iterationsNumber; i++)
  {
    var t = Task.Run(() => { });
    await t;
  }
  sw.Stop();
  return sw.Elapsed;
}

async static Task<TimeSpan> TestNoContext()
{
  const int iterationsNumber = 100000;
  var sw = new Stopwatch();
  sw.Start();
  for (int i = 0; i < iterationsNumber; i++)
  {
    var t = Task.Run(() => { });
    await t.ConfigureAwait(continueOnCapturedContext: false);
  }
  sw.Stop();
  return sw.Elapsed;
}
  1. 用以下代码片段替换Main方法:
[STAThread]
static void Main(string[] args)
{
  var app = new Application();
  var win = new Window();
  var panel = new StackPanel();
  var button = new Button();
  _label = new Label();
  _label.FontSize = 32;
  _label.Height = 200;
  button.Height = 100;
  button.FontSize = 32;
  button.Content = new TextBlock {Text = "Start asynchronous operations"};
  button.Click += Click;
  panel.Children.Add(_label);
  panel.Children.Add(button);
  win.Content = panel;
  app.Run(win);

  Console.ReadLine();
}
  1. 运行程序。

工作原理...

在这个例子中,我们将研究异步函数默认行为的最重要方面之一。我们已经从第四章使用任务并行库中了解了任务调度程序和同步上下文。默认情况下,await操作符会尝试捕获同步上下文,并在其上执行后续代码。正如我们已经知道的那样,这有助于我们通过使用用户界面控件编写异步代码。此外,使用await时不会发生死锁情况,因为我们在等待结果时不会阻塞 UI 线程,就像在上一章中描述的那样。

这是合理的,但让我们看看可能发生的情况。在这个例子中,我们通过编程方式创建了一个 Windows Presentation Foundation 应用程序,并订阅了它的按钮点击事件。单击按钮时,我们运行两个异步操作。其中一个使用常规的await操作符,而另一个使用ConfigureAwait方法,并将false作为参数值。它明确指示我们不应该使用捕获的同步上下文来在其上运行继续代码。在每个操作中,我们测量它们完成所需的时间,然后在主屏幕上显示相应的时间和比率。

结果是,我们看到常规的await操作符需要更长的时间才能完成。这是因为我们在 UI 线程上发布了十万个继续任务,它使用其消息循环来异步处理这些任务。在这种情况下,我们不需要此代码在 UI 线程上运行,因为我们不从异步操作中访问 UI 组件;使用ConfigureAwaitfalse将是一个更有效的解决方案。

还有一件值得注意的事情。尝试只点击按钮运行程序并等待结果。现在再做同样的事情,但这次在点击按钮的同时尝试随机拖动应用程序窗口的一侧。您会注意到捕获的同步上下文中的代码变得更慢!这个有趣的副作用完美地说明了异步编程是多么危险。很容易遇到这样的情况,如果您以前从未经历过这样的行为,几乎不可能进行调试。

公平起见,让我们看看相反的情况。在前面的代码片段中,在Click方法内部取消注释已注释的行,并注释其前面的行。运行应用程序时,我们将收到一个多线程控制访问异常,因为设置Label控件文本的代码不会发布在捕获的上下文上,而是在一个线程池工作线程上执行。

解决async void方法的问题

本示例描述了为什么使用async void方法非常危险。我们将学习在什么情况下可以使用此方法,以及在可能的情况下应该使用什么。

准备工作

要执行此示例,您需要 Visual Studio 2012。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter5\Recipe7中找到。

如何做...

要学习如何使用async void方法,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
async static Task AsyncTaskWithErrors()
{
  string result = await GetInfoAsync("AsyncTaskException",2);
  Console.WriteLine(result);
}

async static void AsyncVoidWithErrors()
{
  string result = await GetInfoAsync("AsyncVoidException",2);
  Console.WriteLine(result);
}

async static Task AsyncTask()
{
  string result = await GetInfoAsync("AsyncTask", 2);
  Console.WriteLine(result);
}

private static async void AsyncVoid()
{
  string result = await GetInfoAsync("AsyncVoid", 2);
  Console.WriteLine(result);
}

async static Task<string> GetInfoAsync(string name,int seconds)
{
  await Task.Delay(TimeSpan.FromSeconds(seconds));
  if(name.Contains("Exception"))
    throw new Exception(string.Format("Boom from {0}!",name));
  return string.Format("Task {0} is running on a thread id{1}. Is thread pool thread: {2}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
}
  1. Main方法内部添加以下代码片段:
Task t = AsyncTask();
t.Wait();

AsyncVoid();
Thread.Sleep(TimeSpan.FromSeconds(3));

t = AsyncTaskWithErrors();
while(!t.IsFaulted)
{
  Thread.Sleep(TimeSpan.FromSeconds(1));
}
Console.WriteLine(t.Exception);

//try
//{
//  AsyncVoidWithErrors();
//  Thread.Sleep(TimeSpan.FromSeconds(3));
//}
//catch (Exception ex)
//{
//  Console.WriteLine(ex);
//}

//int[] numbers = new[] {1, 2, 3, 4, 5};
//Array.ForEach(numbers, async number => {
//  await Task.Delay(TimeSpan.FromSeconds(1));
//  if (number == 3) throw new Exception("Boom!");
//  Console.WriteLine(number);
//});

Console.ReadLine();
  1. 运行程序。

它是如何工作的...

程序启动时,我们通过调用两个方法AsyncTaskAsyncVoid启动了两个异步操作。第一个方法返回一个Task对象,而另一个返回的是async void,因为它没有返回值。它们都立即返回,因为它们是异步的,但是第一个可以通过返回的任务状态轻松监视,或者只需调用其上的Wait方法。等待第二个方法完成的唯一方法是真正等待一段时间,因为我们没有声明任何可以用来监视异步操作状态的对象。当然,可以使用某种共享状态变量,并从async void方法中设置它,同时从调用方法中检查它,但最好还是返回一个Task对象。

最危险的部分是异常处理。在async void方法的情况下,异常处理方法将被发布到当前同步上下文;在我们的情况下,是线程池。线程池上的未处理异常将终止整个进程。可以使用AppDomain.UnhandledException事件拦截未处理的异常,但没有办法从那里恢复进程。要体验这一点,我们应该取消注释Main方法内部的try/catch块,然后运行程序。

关于使用async void lambda 表达式的另一个事实:它们与广泛使用的标准.NET Framework 类库中的Action类型兼容。很容易忘记在此 lambda 内部进行异常处理,这将再次使程序崩溃。要查看此示例,请取消注释Main方法内部的第二个已注释的块。

我强烈建议只在 UI 事件处理程序中使用async void。在所有其他情况下,请使用返回Task的方法。

设计自定义可等待类型

本示例展示了如何设计一个非常基本的可等待类型,与await运算符兼容。

准备工作

要执行此示例,您需要 Visual Studio 2012。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter5\Recipe8中找到。

如何做...

要设计自定义可等待类型,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中,添加以下using指令:

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
async static Task AsynchronousProcessing()
{
  var sync = new CustomAwaitable(true);
  string result = await sync;
  Console.WriteLine(result);

  var async = new CustomAwaitable(false);
  result = await async;

  Console.WriteLine(result);
}

class CustomAwaitable
{
  public CustomAwaitable(bool completeSynchronously)
  {
    _completeSynchronously = completeSynchronously;
  }

  public CustomAwaiter GetAwaiter()
  {
    return new CustomAwaiter(_completeSynchronously);
  }

  private readonly bool _completeSynchronously;
}

class CustomAwaiter : INotifyCompletion
{
  private string _result = "Completed synchronously";
  private readonly bool _completeSynchronously;

  public bool IsCompleted { get {return _completeSynchronously; } }

  public CustomAwaiter(bool completeSynchronously)
  {
    _completeSynchronously = completeSynchronously;
  }

  public string GetResult()
  {
    return _result;
  }

  public void OnCompleted(Action continuation)
  {
    ThreadPool.QueueUserWorkItem( state => {
      Thread.Sleep(TimeSpan.FromSeconds(1));
      _result = GetInfo();
      if(continuation != null) continuation();
    });
  }

  private string GetInfo()
  {
    return string.Format("Task is running on a thread id{0}. Is thread pool thread: {1}", name,Thread.CurrentThread.ManagedThreadId,Thread.CurrentThread.IsThreadPoolThread);
  }
}
  1. Main方法内部添加以下代码片段:
Task t = AsynchronousProcessing();
t.Wait();
  1. 运行程序。

它是如何工作的...

为了与await运算符兼容,类型应符合 C# 5.0 规范中规定的一些要求。如果您已安装 Visual Studio 2012,则可以在C:\Program Files\Microsoft Visual Studio 11.0\VC#\Specifications\1033文件夹中找到规范文档(假设您已使用默认安装路径)。

在第 7.7.7.1 段中,我们找到了可等待表达式的定义:

等待表达式的任务需要是可等待的。如果表达式 t 是可等待的,则满足以下条件之一:

  • t 是动态的编译时类型

  • t 具有一个名为 GetAwaiter 的可访问的实例或扩展方法,没有参数和类型参数,并且返回类型 A,对于该类型,满足以下所有条件:

  • A 实现了接口 System.Runtime.CompilerServices.INotifyCompletion(以下简称 INotifyCompletion)

  • A 具有可访问的、可读的 bool 类型的 IsCompleted 实例属性

  • A 具有一个名为 GetResult 的可访问的实例方法,没有参数和类型参数

这些信息足以让我们开始。首先,我们定义一个可等待类型CustomAwaitable并实现GetAwaiter方法,该方法反过来返回CustomAwaiter类型的实例。CustomAwaiter实现了INotifyCompletion接口;具有bool类型的IsCompleted属性,并且具有GetResult方法,该方法返回string类型。最后,我们编写了一段代码,创建了两个CustomAwaitable对象,并等待它们两个。

现在我们应该了解await表达式的评估方式。这次,为了避免不必要的细节,规范没有被引用。基本上,如果IsCompleted属性返回true,我们只需同步调用GetResult方法。这样,如果操作已经完成,我们就不需要为异步任务执行分配资源。我们通过向CustomAwaitable对象的构造方法提供completeSynchronously参数来覆盖这种情况。

否则,我们将一个回调操作注册到CustomAwaiterOnCompleted方法,并启动异步操作。当它完成时,它将调用提供的回调,该回调将通过在CustomAwaiter对象上调用GetResult方法来获取结果。

注意

此实现仅用于教育目的。每当编写异步函数时,最自然的方法是使用标准的Task类型。只有在您无法使用Task并且确切知道自己在做什么的情况下,才应该定义自己的可等待类型。

还有许多其他与设计自定义可等待类型相关的主题,例如ICriticalNotifyCompletion接口实现和同步上下文传播。在了解了可等待类型的基本设计原理之后,您将能够使用 C#语言规范和其他信息源轻松找到所需的详细信息。但我想强调的是,除非您有非常充分的理由,否则请使用Task类型。

使用动态类型与等待

这个示例展示了如何设计一个与await运算符和动态 C#类型兼容的非常基本的类型。

准备工作

要按照这个示例进行操作,您需要 Visual Studio 2012。您需要互联网访问以下载 NuGet 包。没有其他先决条件。此示例的源代码可以在BookSamples\Chapter5\Recipe9中找到。

如何做...

要了解如何使用dynamic类型与await,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C# 控制台应用程序项目。

  2. 通过以下步骤添加对ImpromptuInterface NuGet 包的引用:

  3. 在项目中右键单击引用文件夹,然后选择**管理 NuGet 包...**菜单选项。

  4. 现在将您喜欢的引用添加到ImpromptuInterface NuGet包中。您可以使用管理 NuGet 包对话框中的搜索功能,如下所示:

如何操作...

  1. Program.cs文件中,使用以下using指令:
using System;
using System.Dynamic;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using ImpromptuInterface;
  1. Main方法下面添加以下代码片段:
async static Task AsynchronousProcessing()
{
  string result = await GetDynamicAwaitableObject(true);
  Console.WriteLine(result);

  result = await GetDynamicAwaitableObject(false);
  Console.WriteLine(result);
}

static dynamic GetDynamicAwaitableObject(bool completeSynchronously)
{
  dynamic result = new ExpandoObject();
  dynamic awaiter = new ExpandoObject();

  awaiter.Message = "Completed synchronously";
  awaiter.IsCompleted = completeSynchronously;
  awaiter.GetResult = (Func<string>)(() => awaiter.Message);

  awaiter.OnCompleted = (Action<Action>) ( callback => 
    ThreadPool.QueueUserWorkItem(state => {
      Thread.Sleep(TimeSpan.FromSeconds(1));
      awaiter.Message = GetInfo();
      if (callback != null) callback();
    })
  );

  IAwaiter<string> proxy = Impromptu.ActLike(awaiter);

  result.GetAwaiter = (Func<dynamic>) ( () => proxy );

  return result;
}

static string GetInfo()
{
  return string.Format("Task is running on a thread id {0}. Is thread pool thread: {1}",
      Thread.CurrentThread.ManagedThreadId, Thread.CurrentThread.IsThreadPoolThread);
}

public interface IAwaiter<T> : INotifyCompletion
{
bool IsCompleted { get; }

T GetResult();
}
  1. Main方法内部添加以下代码片段:
Task t = AsynchronousProcessing();
t.Wait();
  1. 运行程序。

工作原理...

在这里,我们重复了上一个示例中的技巧,但这次是借助动态表达式的帮助。我们可以通过 NuGet 来实现这个目标——一个包管理器,其中包含许多有用的库。这次我们将使用一个动态创建包装器并实现我们需要的接口的库。

首先,我们创建两个ExpandoObject类型的实例,并将它们分配给动态局部变量。这些变量将是我们的 awaitable 和 awaiter 对象。由于 awaitable 对象只需要具有GetAwaiter方法,因此提供它没有问题。ExpandoObjectdynamic关键字结合使用,允许我们自定义它,并通过分配相应的值添加属性和方法。实际上,它是一种具有string类型键和object类型值的字典类型集合。如果您熟悉 JavaScript 编程语言,您可能会注意到这与 JavaScript 对象非常相似。

由于dynamic允许我们在 C#中跳过编译时检查,ExpandoObject是这样编写的,如果您将某些内容分配给属性,它会创建一个字典条目,其中键是属性名称,值是提供的任何值。当您尝试获取属性值时,它会进入字典并提供存储在相应字典条目中的值。如果值是ActionFunc类型,我们实际上存储了一个委托,该委托反过来可以像方法一样使用。因此,dynamic类型与ExpandoObject的组合允许我们创建一个对象,并动态为其提供属性和方法。

现在,我们需要构建我们的 awaiter 和 awaitable 对象。让我们从 awaiter 开始。首先,我们提供一个名为Message的属性,并为该属性提供一个初始值。然后,我们定义GetResult方法,使用Func<string>类型,我们分配一个 lambda 表达式,该表达式返回Message属性的值。接下来,我们实现IsCompleted属性。如果设置为true,我们可以跳过其余的工作并继续进行我们的 awaitable 对象,存储在result局部变量中。我们只需要添加一个返回dynamic对象的方法,并从中返回我们的 awaiter。然后,我们可以使用result作为 await 表达式;但是,它将以同步方式运行。

主要挑战是在我们的动态对象上实现异步处理。C#语言规范规定 awaiter 必须实现INotifyCompletionICriticalNotifyCompletion接口,而ExpandoObject并没有这样做。即使我们动态实现OnCompleted方法,并将其添加到 awaiter 对象中,我们也不会成功,因为我们的对象没有实现上述任何接口。

为了解决这个问题,我们使用了从 NuGet 获取的ImpromptuInterface库。它允许我们使用Impromptu.ActLike方法动态创建代理对象,这些对象将实现所需的接口。如果我们尝试创建一个实现INotifyCompletion接口的代理,我们仍然会失败,因为代理对象不再是动态的,而这个接口只有OnCompleted方法,但没有IsCompleted属性或GetResult方法。作为最后的解决方法,我们定义了一个通用接口IAwaiter<T>,它实现了INotifyCompletion并添加了所有必需的属性和方法。现在,我们将其用于代理生成,并将result对象更改为从GetAwaiter方法返回代理而不是 awaiter。程序现在可以工作了;我们刚刚构建了一个在运行时完全动态的可等待对象。

第六章:使用并发集合

在本章中,我们将浏览包含在.NET Framework 基类库中的并发编程的不同数据结构。您将学习以下内容:

  • 使用并发字典

  • 使用并发队列实现异步处理

  • 使用并发堆栈改变异步处理顺序

  • 使用并发包创建可扩展的网络爬虫

  • 使用阻塞集合泛化异步处理

介绍

编程需要理解和掌握基本的数据结构和算法。为了选择最适合并发情况的数据结构,程序员必须了解许多事情,比如算法时间、空间复杂度和大 O 符号。在不同的知名场景中,我们总是知道哪些数据结构更有效。

对于并发计算,我们需要适当的数据结构。这些数据结构必须是可扩展的,在可能的情况下避免锁,并且同时提供线程安全的访问。自.NET Framework 4 以来,具有几种数据结构的System.Collections.Concurrent命名空间。在本章中,我们将涵盖几种数据结构,并展示如何使用它们的非常简单的示例。

让我们从ConcurrentQueue开始。这个集合使用原子比较和交换CAS)操作和SpinWait来确保线程安全。它实现了一个先进先出FIFO)集合,这意味着项目以它们被添加到队列的顺序出队。要向队列添加项目,您调用Enqueue方法。TryDequeue方法尝试从队列中取出第一个项目,TryPeek方法尝试获取第一个项目而不从队列中移除它。

ConcurrentStack也是使用 CAS 操作而没有使用任何锁来实现的。它是一个后进先出LIFO)集合,这意味着最近添加的项目将首先返回。要添加项目,您可以使用PushPushRange方法,要检索,您可以使用TryPopTryPopRange,要检查,您可以使用TryPeek方法。

ConcurrentBag是一个支持重复项目的无序集合。它针对多个线程以每个线程产生和消耗自己的任务的方式进行分区的场景进行了优化,很少处理其他线程的任务(在这种情况下,它使用锁)。您可以使用Add方法向包中添加项目,使用TryPeek进行检查,并使用TryTake方法进行获取。

注意

请避免在提到的集合上使用Count属性。它们使用链表实现,而Count是一个O(N)操作。如果您需要检查集合是否为空,请使用IsEmpty属性,这是一个O(1)操作。

ConcurrentDictionary是一个线程安全的字典集合实现。它对读操作是无锁的。但是,它对写操作需要锁定。并发字典使用多个锁,实现了对字典桶的细粒度锁定模型。锁的数量可以通过使用带有参数concurrencyLevel的构造函数来定义,这意味着估计数量的线程将同时更新字典。

注意

由于并发字典使用锁定,有许多操作需要在字典内部获取所有锁。请避免在不需要的情况下使用这些操作。它们是:CountIsEmptyKeysValuesCopyToToArray

BlockingCollectionIProducerConsumerCollection泛型接口实现的高级包装器。它具有许多更先进的功能,并且在实现管道场景时非常有用,当您有一些步骤使用了处理前一步骤结果时。BlockingCollection类支持阻塞、限制内部集合容量、取消集合操作以及从多个阻塞集合中检索值等功能。

并发算法可能非常复杂,覆盖所有并发集合——无论是更先进还是更简单——都需要编写一本单独的书。在这里,我们只展示了使用并发集合的最简单的例子。

使用 ConcurrentDictionary

这个示例展示了一个非常简单的场景,在单线程环境中比较了普通字典集合与并发字典的性能。

准备工作

要按照这个示例,您需要 Visual Studio 2012。没有其他先决条件。这个示例的源代码可以在BookSamples\Chapter6\Recipe1中找到。

如何做...

为了理解普通字典集合与并发字典集合性能差异,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中添加以下using指令:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
  1. Main方法下面添加以下代码片段:
const string Item = "Dictionary item";
public static string CurrentItem;
  1. Main方法中添加以下代码片段:
var concurrentDictionary = new ConcurrentDictionary<int, string>();
var dictionary = new Dictionary<int, string>();

var sw = new Stopwatch();

sw.Start();
for (int i = 0; i < 1000000; i++)
{
  lock (dictionary)
  {
    dictionary[i] = Item;
  }
}
sw.Stop();
Console.WriteLine("Writing to dictionary with a lock: {0}", sw.Elapsed);

sw.Restart();
for (int i = 0; i < 1000000; i++)
{
  concurrentDictionary[i] = Item;
}
sw.Stop();
Console.WriteLine("Writing to a concurrent dictionary: {0}", sw.Elapsed);

sw.Restart();
for (int i = 0; i < 1000000; i++)
{
  lock (dictionary)
  {
    CurrentItem = dictionary[i];
  }
}
sw.Stop();
Console.WriteLine("Reading from dictionary with a lock: {0}", sw.Elapsed);

sw.Restart();
for (int i = 0; i < 1000000; i++)
{
  CurrentItem = concurrentDictionary[i];
}
sw.Stop();
Console.WriteLine("Reading from a concurrent dictionary: {0}", sw.Elapsed);
  1. 运行程序。

它是如何工作的...

当程序启动时,我们创建了两个集合。其中一个是标准字典集合,另一个是一个新的并发字典。然后我们开始添加到它,使用带锁的标准字典并测量一百万次迭代完成所需的时间。然后我们测量在相同情况下ConcurrentDictionary的性能,最后比较从两个集合中检索值的性能。

在这个非常简单的场景中,我们发现ConcurrentDictionary在写操作上比普通的带锁的字典慢得多,但在检索操作上更快。因此,如果我们需要从字典中进行许多线程安全的读取,ConcurrendDictionary集合是最佳选择。

注意

如果您只需要对字典进行只读、多线程访问,可能不需要执行线程安全读取。在这种情况下,最好只使用普通字典或ReadOnlyDictionary集合。

ConcurrentDictionary是使用细粒度锁定技术实现的,这使得它在多次写入时比使用带锁的常规字典更好地扩展(称为粗粒度锁定)。正如我们在这个例子中看到的,当我们只使用一个线程时,并发字典要慢得多,但当我们将其扩展到五六个线程时(如果我们有足够的 CPU 核心可以同时运行它们),并发字典实际上会表现得更好。

使用 ConcurrentQueue 实现异步处理

这个示例将展示一个创建一组任务,由多个工作线程异步处理的示例。

准备工作

要按照这个示例,您需要 Visual Studio 2012。没有其他先决条件。这个示例的源代码可以在BookSamples\Chapter6\Recipe2中找到。

如何做...

为了理解创建一组任务,由多个工作线程异步处理的工作原理,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中添加以下using指令:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static async Task RunProgram()
{
  var taskQueue = new ConcurrentQueue<CustomTask>();
  var cts = new CancellationTokenSource();

  var taskSource = Task.Run(() => TaskProducer(taskQueue));

  Task[] processors = new Task[4];
  for (int i = 1; i <= 4; i++)
  {
    string processorId = i.ToString();
    processors[i-1] = Task.Run(
    () => TaskProcessor(taskQueue, "Processor " + processorId, cts.Token));
  }

  await taskSource;
  cts.CancelAfter(TimeSpan.FromSeconds(2));

  await Task.WhenAll(processors);
}

static async Task TaskProducer(ConcurrentQueue<CustomTask> queue)
{
  for (int i = 1; i <= 20; i++)
  {
    await Task.Delay(50);
    var workItem = new CustomTask {Id = i};
    queue.Enqueue(workItem);
    Console.WriteLine("Task {0} has been posted", workItem.Id);
  }
}

static async Task TaskProcessor(ConcurrentQueue<CustomTask> queue, string name, CancellationToken token){
  CustomTask workItem;
  bool dequeueSuccesful = false;

  await GetRandomDelay();
  do
  {
    dequeueSuccesful = queue.TryDequeue(out workItem);
    if (dequeueSuccesful)
    {
    Console.WriteLine("Task {0} has been processed by {1}", workItem.Id, name);
    }

    await GetRandomDelay();
  }
  while (!token.IsCancellationRequested);
}

static Task GetRandomDelay()
{
  int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
  return Task.Delay(delay);
}

class CustomTask
{
  public int Id { get; set; }
}
  1. Main方法中添加以下代码片段:
Task t = RunProgram();
t.Wait();
  1. 运行程序。

它是如何工作的...

程序运行时,我们使用ConcurrentQueue集合创建了一个任务队列。然后我们创建了一个取消标记,用于在我们将任务发布到队列后停止工作。接下来,我们启动一个单独的工作者线程,将任务发布到任务队列。这部分产生了我们异步处理的工作负载。

现在让我们定义程序的任务消耗部分。我们创建四个工作者,它们将等待一段随机时间,然后从任务队列获取一个任务,处理它,并重复整个过程,直到我们发出取消标记。最后,我们启动任务生成线程,等待其完成,然后使用取消标记向消费者发出我们完成工作的信号。最后一步是等待所有消费者完成。

我们看到我们有任务从头到尾处理,但可能会出现一个后续任务在较早的任务之前被处理,因为我们有四个独立运行的工作者,任务处理时间不是恒定的。我们看到队列的访问是线程安全的;没有工作项被重复获取。

更改异步处理顺序 ConcurrentStack

这个食谱是对上一个的轻微修改。我们将再次创建一组任务,由多个工作者异步处理,但这次我们使用ConcurrentStack来实现,并看到其中的区别。

准备工作

要按照这个食谱进行操作,你需要 Visual Studio 2012。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter6\Recipe3中找到。

如何做...

为了理解使用ConcurrentStack实现的一组任务的处理,执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中添加以下using指令:

using System;
using System.Collections.Concurrent;
using System.Threading;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static async Task RunProgram()
{
  var taskStack = new ConcurrentStack<CustomTask>();
  var cts = new CancellationTokenSource();

  var taskSource = Task.Run(() => TaskProducer(taskStack));

  Task[] processors = new Task[4];
  for (int i = 1; i <= 4; i++)
  {
    string processorId = i.ToString();
    processors[i - 1] = Task.Run(
    () => TaskProcessor(taskStack, "Processor " + processorId, cts.Token));
  }

  await taskSource;
  cts.CancelAfter(TimeSpan.FromSeconds(2));

  await Task.WhenAll(processors);
}

static async Task TaskProducer(ConcurrentStack<CustomTask> stack)
{
  for (int i = 1; i <= 20; i++)
  {
    await Task.Delay(50);
    var workItem = new CustomTask { Id = i };
    stack.Push(workItem);
    Console.WriteLine("Task {0} has been posted", workItem.Id);
  }
}

static async Task TaskProcessor(
  ConcurrentStack<CustomTask> stack, string name, CancellationToken token)
{
  await GetRandomDelay();
  do
  {
    CustomTask workItem;
    bool popSuccesful = stack.TryPop(out workItem);
    if (popSuccesful)
    {
    Console.WriteLine("Task {0} has been processed by {1}", workItem.Id, name);
    }

    await GetRandomDelay();
  }
  while (!token.IsCancellationRequested);
}

static Task GetRandomDelay()
{
  int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
  return Task.Delay(delay);
}

class CustomTask
{
  public int Id { get; set; }
}
  1. Main方法中添加以下代码片段:
Task t = RunProgram();
t.Wait();
  1. 运行程序。

它是如何工作的...

当程序运行时,我们现在创建了ConcurrentStack集合的一个实例。其余部分几乎与上一个食谱相同,只是在并发栈上使用PushTryPop方法的地方,我们在并发队列上使用EnqueueTryDequeue

现在我们看到任务处理顺序已经改变。栈是一个后进先出的集合,工作者首先处理后续任务。在并发队列的情况下,任务几乎按照它们被添加的顺序进行处理。这意味着根据工作者的数量,我们肯定会在给定的时间范围内处理首先创建的任务。在栈的情况下,较早创建的任务优先级较低,可能在生产者停止向栈中添加更多任务之前不会被处理。这种行为非常特殊,最好在这种情况下使用队列。

使用 ConcurrentBag 创建可扩展的爬虫

这个食谱展示了如何在多个独立的工作者之间分配工作负载,他们既生产工作,又处理工作。

准备工作

要按照这个食谱进行操作,你需要 Visual Studio 2012。没有其他先决条件。这个食谱的源代码可以在BookSamples\Chapter6\Recipe4中找到。

如何做...

以下步骤演示了如何在多个独立的工作者之间分配工作负载,他们既生产工作,又处理工作:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中添加以下using指令:

using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static Dictionary<string, string[]> _contentEmulation = new Dictionary<string, string[]>();

static async Task RunProgram()
{
  var bag = new ConcurrentBag<CrawlingTask>();

  string[] urls = new[] {"http://microsoft.com/", "http://google.com/", "http://facebook.com/", "http://twitter.com/"};

  var crawlers = new Task[4];
  for (int i = 1; i <= 4; i++)
  {
    string crawlerName = "Crawler " + i.ToString();
    bag.Add(new CrawlingTask { UrlToCrawl = urls[i-1], ProducerName = "root"});
    crawlers[i - 1] = Task.Run(() => Crawl(bag, crawlerName));
  }

  await Task.WhenAll(crawlers);
}

static async Task Crawl(ConcurrentBag<CrawlingTask> bag, string crawlerName)
{
  CrawlingTask task;
  while (bag.TryTake(out task))
  {
    IEnumerable<string> urls = await GetLinksFromContent(task);
    if (urls != null)
    {
      foreach (var url in urls)
      {
        var t = new CrawlingTask
        {
          UrlToCrawl = url,
          ProducerName = crawlerName
        };

      bag.Add(t);
      }
    }
  Console.WriteLine("Indexing url {0} posted by {1} is completed by {2}!",
      task.UrlToCrawl, task.ProducerName, crawlerName);
  }
}

static async Task<IEnumerable<string>> GetLinksFromContent(CrawlingTask task)
{
  await GetRandomDelay();

  if (_contentEmulation.ContainsKey(task.UrlToCrawl)) return _contentEmulation[task.UrlToCrawl];

  return null;
}

static void CreateLinks()
{
  _contentEmulation["http://microsoft.com/"] = new [] { "http://microsoft.com/a.html", "http://microsoft.com/b.html" };
  _contentEmulation["http://microsoft.com/a.html"] = new[] { "http://microsoft.com/c.html", "http://microsoft.com/d.html" };
  _contentEmulation["http://microsoft.com/b.html"] = new[] { "http://microsoft.com/e.html" };

  _contentEmulation["http://google.com/"] = new[] { "http://google.com/a.html", "http://google.com/b.html" };
  _contentEmulation["http://google.com/a.html"] = new[] { "http://google.com/c.html", "http://google.com/d.html" };
  _contentEmulation["http://google.com/b.html"] = new[] { "http://google.com/e.html", "http://google.com/f.html" };
  _contentEmulation["http://google.com/c.html"] = new[] { "http://google.com/h.html", "http://google.com/i.html" };

  _contentEmulation["http://facebook.com/"] = new [] { "http://facebook.com/a.html", "http://facebook.com/b.html" };
  _contentEmulation["http://facebook.com/a.html"] = new[] { "http://facebook.com/c.html", "http://facebook.com/d.html" };
  _contentEmulation["http://facebook.com/b.html"] = new[] { "http://facebook.com/e.html" };

  _contentEmulation["http://twitter.com/"] = new[] { "http://twitter.com/a.html", "http://twitter.com/b.html" };
  _contentEmulation["http://twitter.com/a.html"] = new[] { "http://twitter.com/c.html", "http://twitter.com/d.html" };
  _contentEmulation["http://twitter.com/b.html"] = new[] { "http://twitter.com/e.html" };
  _contentEmulation["http://twitter.com/c.html"] = new[] { "http://twitter.com/f.html", "http://twitter.com/g.html" };
  _contentEmulation["http://twitter.com/d.html"] = new[] { "http://twitter.com/h.html" };
  _contentEmulation["http://twitter.com/e.html"] = new[] { "http://twitter.com/i.html" };
}

static Task GetRandomDelay()
{
  int delay = new Random(DateTime.Now.Millisecond).Next(150, 200);
  return Task.Delay(delay);
}

class CrawlingTask
{
  public string UrlToCrawl { get; set; }

  public string ProducerName { get; set; }
}
  1. Main方法中添加以下代码片段:
CreateLinks();
Task t = RunProgram();
t.Wait();
  1. 运行程序。

它是如何工作的...

该程序模拟了多个网络爬虫进行网页索引。网络爬虫是一个打开网页并索引内容的程序,并尝试访问该页面包含的所有链接,并索引这些链接页面。一开始,我们定义了一个包含不同网页 URL 的字典。这个字典模拟了包含指向其他页面链接的网页。实现非常天真;它不关心已经访问过的页面,但它很简单,可以让我们专注于并发工作负载。

然后我们创建一个包含爬行任务的并发包。我们创建四个爬虫,并为每个爬虫提供不同的站点根 URL。然后,我们等待所有爬虫竞争。现在,每个爬虫开始索引它所给定的站点 URL。我们通过等待一些随机时间来模拟网络 I/O 过程;然后,如果页面包含更多的 URL,爬虫会将更多的爬行任务发布到包中。然后,它检查包中是否还有任何任务需要爬行。如果没有,爬虫就完成了。

如果我们检查前四行下面的输出,这些行是根 URL,我们会发现通常由爬虫编号N发布的任务会被同一个爬虫处理。然而,后面的行会有所不同。这是因为内部ConcurrentBag针对有多个线程同时添加和删除项目的情况进行了优化。这是通过让每个线程使用自己的本地项目队列来实现的,因此,在这个队列被占用时,我们不需要任何锁。只有当本地队列中没有项目时,我们才会执行一些锁定,并尝试从另一个线程的本地队列中“窃取”工作。这种行为有助于在所有工作者之间分配工作并避免锁定。

使用 BlockingCollection 泛化异步处理

本示例将描述如何使用BlockingCollection来简化工作负载异步处理的实现。

准备工作

要执行此示例,您需要 Visual Studio 2012。不需要其他先决条件。此示例的源代码可以在BookSamples\Chapter6\Recipe5中找到。

如何做...

要理解BlockingCollection如何简化工作负载异步处理的实现,请执行以下步骤:

  1. 启动 Visual Studio 2012。创建一个新的 C#控制台应用程序项目。

  2. Program.cs文件中添加以下using指令:

using System;
using System.Collections.Concurrent;
using System.Threading.Tasks;
  1. Main方法下面添加以下代码片段:
static async Task RunProgram(IProducerConsumerCollection<CustomTask> collection = null)
{
  var taskCollection = new BlockingCollection<CustomTask>();
  if (null != collection)
  taskCollection= new BlockingCollection<CustomTask>(collection);

  var taskSource = Task.Run(() => TaskProducer(taskCollection));

  Task[] processors = new Task[4];
  for (int i = 1; i <= 4; i++)
  {
    string processorId = "Processor " + i;
    processors[i - 1] = Task.Run(() => TaskProcessor(taskCollection, processorId));
  }

  await taskSource;

  await Task.WhenAll(processors);
}

static async Task TaskProducer(BlockingCollection<CustomTask> collection)
{
  for (int i = 1; i <= 20; i++)
  {
    await Task.Delay(20);
    var workItem = new CustomTask { Id = i };
    collection.Add(workItem);
    Console.WriteLine("Task {0} have been posted", workItem.Id);
  }
  collection.CompleteAdding();
}

static async Task TaskProcessor(BlockingCollection<CustomTask> collection, string name)
{
  await GetRandomDelay();
  foreach (CustomTask item in collection.GetConsumingEnumerable())
  {
    Console.WriteLine("Task {0} have been processed by {1}", item.Id, name);
    await GetRandomDelay();
  }
}

static Task GetRandomDelay()
{
  int delay = new Random(DateTime.Now.Millisecond).Next(1, 500);
  return Task.Delay(delay);
}

class CustomTask
{
  public int Id { get; set; }
}
  1. Main方法中添加以下代码片段:
Console.WriteLine("Using a Queue inside of BlockingCollection");
Console.WriteLine();
Task t = RunProgram();
t.Wait();

Console.WriteLine();
Console.WriteLine("Using a Stack inside of BlockingCollection");
Console.WriteLine();
t = RunProgram(new ConcurrentStack<CustomTask>());
t.Wait();
  1. 运行程序。

它是如何工作的...

这里我们正好采用了第一种情况,但现在我们使用了一个提供许多有用好处的BlockingCollection类。首先,我们能够改变任务在阻塞集合中存储的方式。默认情况下,它使用ConcurrentQueue容器,但我们可以使用任何实现IProducerConsumerCollection泛型接口的集合。为了说明这一点,我们运行程序两次,第二次使用ConcurrentStack作为底层集合。

工作者通过迭代阻塞集合上的GetConsumingEnumerable方法调用结果来获取工作项。如果集合中没有项目,迭代器将阻塞工作者线程,直到有项目发布到集合中。当生产者在集合上调用CompleteAdding方法时,循环结束。这表示工作已完成。

注意

很容易犯一个错误,只是迭代BlockingCollection,因为它本身实现了IEnumerable。不要忘记使用GetConsumingEnumerable,否则你将只是迭代集合的“快照”,并得到完全意想不到的程序行为。

工作负载生产者将任务插入BlockingCollection,然后调用CompleteAdding方法,导致所有工作人员完成。现在在程序输出中,我们看到两个结果序列,说明并发队列和堆栈集合之间的区别。