C-8-和--NET-Core3-并行编程实用指南-全-
C#8 和 .NET Core3 并行编程实用指南(全)
原文:
zh.annas-archive.org/md5/BE48315910DEF416E754F7470D0341EA
译者:飞龙
前言
Packt 几乎一年前首次联系我撰写这本书。这是一段漫长的旅程,有时比我预期的更艰难,我学到了很多。你现在拥有的这本书是许多漫长日子的结晶,我很自豪能最终呈现它。
撰写这本关于 C#的书对我意义重大,因为我一直梦想着写关于我职业生涯起步的语言。自从首次推出以来,C#确实有了长足的发展。.NET Core 实际上增强了 C#在开发者社区中的力量和声誉。
为了使这本书对广大读者有意义,我们将涵盖经典线程模型和任务并行库(TPL),并使用代码来解释它们。我们将首先研究使编写多线程代码成为可能的操作系统的基本概念。然后我们将仔细研究经典线程和 TPL 之间的区别。
在这本书中,我特别注意以现代最佳编程实践的背景来处理并行编程。示例被保持简短和简单,以便于您的理解。这些章节的写作方式使得即使您对它们没有太多先前的了解,也很容易学习这些主题。
希望您阅读这本书时能像我写作时一样享受。
这本书适合谁
这本书适用于希望学习多线程和并行编程概念,并希望在使用.NET Core 构建的企业应用程序中使用它们的 C#程序员。它还适用于希望了解现代硬件如何与并行编程配合的学生和专业人士。
假设您已经对 C#编程语言有一定了解,并且对操作系统的工作原理有一些基本知识。
这本书涵盖了什么
第一章,并行编程简介,介绍了多线程和并行编程的重要概念。本章包括操作系统如何发展以支持现代并行编程构造的内容。
第二章,任务并行性,演示了如何将程序分解为任务,以有效利用 CPU 资源和实现高性能。
第三章,实现数据并行性,侧重于使用并行循环实现数据并行性。本章还涵盖了扩展方法,以帮助实现并行性,以及分区策略。
第四章,使用 PLINQ,解释了如何利用 PLINQ 支持。这包括查询排序和取消查询,以及使用 PLINQ 的陷阱。
第五章,同步原语,介绍了 C#中用于处理多线程代码中共享资源的同步构造。
第六章,使用并发集合,描述了如何利用.NET Core 中可用的并发集合,而无需担心手动同步编码的工作。
第七章,使用延迟初始化提高性能,探讨了如何实现利用延迟模式的内置构造。
第八章,异步编程简介,探讨了如何在较早版本的.NET 中编写异步代码。
第九章,异步、等待和基于任务的异步编程基础,介绍了如何利用.NET Core 中的新构造来实现异步代码。
第十章,使用 Visual Studio 调试任务,着重介绍了 Visual Studio 2019 中可用的各种工具,使并行任务的调试更加容易。
第十一章,编写并行和异步代码的单元测试用例,介绍了在 Visual Studio 和.NET Core 中编写单元测试用例的各种方法。
第十二章,ASP.NET Core 中的 IIS 和 Kestrel,介绍了 IIS 和 Kestrel 的概念。本章还介绍了对异步流的支持。
第十三章,并行编程中的模式,解释了 C#语言中已经实现的各种模式。这还包括自定义模式实现。
第十四章,分布式内存管理,探讨了内存在分布式程序中的共享方式。
充分利用本书
您需要在系统上安装 Visual Studio 2019 以及.NET Core 3.1。同时也建议具备 C#和操作系统概念的基本知识。
下载示例代码文件
您可以从www.packt.com的账户中下载本书的示例代码文件。如果您在其他地方购买了本书,可以访问www.packtpub.com/support注册并直接将文件发送到您的邮箱。
您可以按照以下步骤下载代码文件:
-
在www.packt.com登录或注册。
-
选择支持选项卡。
-
点击代码下载。
-
在搜索框中输入书名并按照屏幕上的说明操作。
文件下载后,请确保使用最新版本的解压缩或提取文件夹:
-
Windows 上的 WinRAR/7-Zip
-
Mac 上的 Zipeg/iZip/UnRarX
-
Linux 上的 7-Zip/PeaZip
该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3
。如果代码有更新,将在现有的 GitHub 存储库上进行更新。
我们还提供了来自我们丰富书籍和视频目录的其他代码包,可以在github.com/PacktPublishing/
上查看!
下载彩色图像
我们还提供了一个 PDF 文件,其中包含本书中使用的屏幕截图/图表的彩色图像。您可以在这里下载:static.packt-cdn.com/downloads/9781789132410_ColorImages.pdf
。
使用的约定
本书中使用了许多文本约定。
CodeInText
:指示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“将下载的WebStorm-10*.dmg
磁盘映像文件挂载为系统中的另一个磁盘。”
代码块设置如下:
private static void PrintNumber10Times()
{
for (int i = 0; i < 10; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:
private static void PrintNumber10Times()
{
for (int i = 0; i < 10; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
粗体:表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词会以这种形式出现在文本中。这是一个例子:“与其自己找到最佳线程数,
我们可以把它留给公共语言运行时。
警告或重要说明会出现在这样的形式中。
提示和技巧会以这种形式出现。
第一部分:线程、多任务和异步性的基础
在本节中,您将熟悉线程、多任务和异步编程的概念。
本节包括以下章节:
-
第一章,并行编程简介
-
第二章,任务并行性
-
第三章,实现数据并行性
-
第四章,使用 PLINQ
第一章:并行编程简介
自.NET 开始就支持并行编程,并自.NET 框架 4.0 引入任务并行库(TPL)以来,它已经获得了牢固的基础。
多线程是并行编程的一个子集,也是编程中最不被理解的方面之一;许多新开发人员很难理解。C#自诞生以来已经发生了很大的变化。它不仅对多线程有很强的支持,还对异步编程有很强的支持。C#的多线程可以追溯到 C# 1.0。C#主要是同步的,但从 C# 5.0 开始增加了强大的异步支持,使其成为应用程序程序员的首选。而多线程只涉及如何在进程内并行化,而并行编程还涉及进程间通信的场景。
在 TPL 引入之前,我们依赖于Thread
、BackgroundWorker
和ThreadPool
来提供多线程能力。在 C# v1.0 时,它依赖于线程来分割工作并释放用户界面(UI),从而使用户能够开发响应式应用程序。这个模型现在被称为经典线程。随着时间的推移,这个模型为另一个编程模型让路,称为 TPL,它依赖于任务,并且在内部仍然使用线程。
在本章中,我们将学习各种概念,这些概念将帮助您从头开始学习编写多线程代码。
我们将涵盖以下主题:
-
多核计算的基本概念,从介绍与操作系统(OS)相关的概念和进程开始。
-
线程以及多线程和多任务之间的区别
-
编写并行代码的优缺点以及并行编程有用的场景
技术要求
本书中演示的所有示例都是在使用 C# 8 的 Visual Studio 2019 中创建的。所有源代码都可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter01
。
为多核计算做准备
在本节中,我们将介绍操作系统的核心概念,从进程开始,线程所在和运行的地方。然后,我们将考虑随着硬件能力的引入,多任务处理是如何演变的,这使得并行编程成为可能。之后,我们将尝试理解使用代码创建线程的不同方式。
进程
通俗地说,进程一词指的是正在执行的程序。然而,在操作系统方面,进程是内存中的地址空间。无论是 Windows、Web 还是移动应用程序,每个应用程序都需要进程来运行。进程为程序提供安全性,防止其他在同一系统上运行的程序意外访问分配给另一个程序的数据。它们还提供隔离,使得程序可以独立于其他程序和底层操作系统启动和停止。
有关操作系统的更多信息
应用程序的性能在很大程度上取决于硬件的质量和配置。这包括以下内容:
-
CPU 速度
-
RAM 的数量
-
硬盘速度(5400/7200 RPM)
-
磁盘类型,即 HDD 或 SSD
在过去的几十年里,我们已经看到了硬件技术的巨大飞跃。例如,微处理器过去只有一个核心,即一个中央处理单元(CPU)的芯片。到了世纪之交,我们看到了多核处理器的出现,这是具有两个或更多处理器的芯片,每个处理器都有自己的缓存。
多任务处理
多任务处理是指计算机系统同时运行多个进程(应用程序)的能力。系统可以运行的进程数量与系统中的核心数量成正比。因此,单核处理器一次只能运行一个任务,双核处理器一次可以运行两个任务,四核处理器一次可以运行四个任务。如果我们将 CPU 调度的概念加入其中,我们可以看到 CPU 通过基于 CPU 调度算法进行调度或切换来同时运行更多应用程序。
超线程
超线程(HT)技术是英特尔开发的专有技术,它改进了在 x86 处理器上执行的计算的并行化。它首次在 2002 年的至强服务器处理器中引入。HT 启用的单处理器芯片运行具有两个虚拟(逻辑)核心,并且能够同时执行两个任务。以下图表显示了单核和多核芯片之间的区别:
以下是一些处理器配置的示例以及它们可以执行的任务数量:
-
单核芯片的单处理器:一次一个任务
-
HT 启用的单核芯片的单处理器:一次两个任务
-
双核芯片的单处理器:一次两个任务
-
HT 启用的双核芯片的单处理器:一次四个任务
-
四核芯片的单处理器:一次四个任务
-
HT 启用的四核芯片的单处理器:一次八个任务
以下是 HT 启用的四核处理器系统的 CPU 资源监视器的屏幕截图。在右侧,您可以看到有八个可用的 CPU:
您可能想知道,仅通过从单核处理器转换到多核处理器,您可以提高计算机的性能多少。在撰写本文时,大多数最快的超级计算机都是基于多指令,多数据(MIMD)架构构建的,这是迈克尔·J·弗林在 1966 年提出的计算机架构分类之一。
让我们试着理解这个分类。
弗林的分类
弗林根据并发指令(或控制)流和数据流的数量将计算机架构分为四类:
-
单指令,单数据(SISD):在这种模型中,有一个单一的控制单元和一个单一的指令流。这些系统只能一次执行一个指令,没有任何并行处理。所有单核处理器机器都基于 SISD 架构。
-
单指令,多数据(SIMD):在这种模型中,我们有一个单一的指令流和多个数据流。相同的指令流并行应用于多个数据流。这在猜测性方法的场景中很方便,其中我们有多个数据的多个算法,我们不知道哪一个会更快。它为所有算法提供相同的输入,并在多个处理器上并行运行它们。
-
多指令,单数据(MISD):在这种模型中,多个指令在一个数据流上操作。因此,可以并行地在相同的数据源上应用多个操作。这通常用于容错和航天飞行控制计算机。
-
多指令,多数据(MIMD):在这种模型中,正如名称所示,我们有多个指令流和多个数据流。因此,我们可以实现真正的并行,其中每个处理器可以在不同的数据流上运行不同的指令。如今,大多数计算机系统都使用这种架构。
现在我们已经介绍了基础知识,让我们把讨论转移到线程上。
线程
线程是进程内的执行单元。在任何时候,程序可能由一个或多个线程组成,以获得更好的性能。基于 GUI 的 Windows 应用程序,如传统的Windows Forms(WinForms)或Windows Presentation Foundation(WPF),都有一个专用线程来管理 UI 和处理用户操作。这个线程也被称为 UI 线程或前台线程。它拥有所有作为 UI 一部分创建的控件。
线程的类型
有两种不同类型的托管线程,即前台线程和后台线程。它们之间的区别如下:
-
前台线程:对应用程序的生命周期有直接影响。只要有前台线程存在,应用程序就会继续运行。
-
后台线程:对应用程序的生命周期没有影响。应用程序退出时,所有后台线程都会被终止。
一个应用程序可以包含任意数量的前台或后台线程。在活动状态下,前台线程保持应用程序运行;也就是说,应用程序的生命周期取决于前台线程。当最后一个前台线程停止或中止时,应用程序将完全停止。应用程序退出时,系统会停止所有后台线程。
公寓状态
理解线程的另一个重要方面是公寓状态。这是线程内部的一个区域,组件对象模型(COM)对象驻留在其中。
COM 是一个面向对象的系统,用于创建用户可以交互的二进制软件,并且是分布式和跨平台的。COM 已被用于创建 Microsoft OLE 和 ActiveX 技术。
你可能知道,所有的 Windows 窗体控件都是基于 COM 对象封装的。每当你创建一个.NET WinForms 应用程序时,实际上是在托管 COM 组件。线程公寓是应用程序进程内的一个独立区域,用于创建 COM 对象。以下图表展示了线程公寓和 COM 对象之间的关系:
正如你从前面的图表中所看到的,每个线程都有线程公寓,COM 对象驻留在其中。
一个线程可以属于两种公寓状态之一:
-
单线程公寓(STA):底层 COM 对象只能通过单个线程访问
-
多线程公寓(MTA):底层 COM 对象可以同时通过多个线程访问
以下列表突出了关于线程公寓状态的一些重要点:
-
进程可以有多个线程,可以是前台或后台。
-
每个线程可以有一个公寓,可以是 STA 或 MTA。
-
每个公寓都有一个并发模型,可以是单线程或多线程的。我们也可以通过编程方式改变线程状态。
-
一个应用程序可能有多个 STA,但最多只能有一个 MTA。
-
STA 应用程序的一个示例是 Windows 应用程序,MTA 应用程序的一个示例是 Web 应用程序。
-
COM 对象是在公寓中创建的。一个 COM 对象只能存在于一个线程公寓中,公寓不能共享。
通过在主方法上使用STAThread
属性,可以强制应用程序以 STA 模式启动。以下是一个传统 WinForm 的Main
方法的示例:
static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.Run(new Form1());
}
}
STAThread
属性也存在于 WPF 中,但对用户隐藏。以下是编译后的App.g.cs
类的代码,可以在 WPF 项目编译后的obj/Debug
目录中找到:
/// <summary>
/// App
/// </summary>
public partial class App : System.Windows.Application {
/// <summary>
/// InitializeComponent
/// </summary>
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute(
"PresentationBuildTasks", "4.0.0.0")]
public void InitializeComponent() {
#line 5 "..\..\App.xaml"
this.StartupUri = new System.Uri("MainWindow.xaml",
System.UriKind.Relative);
#line default
#line hidden
}
/// <summary>
/// Application Entry Point.
/// </summary>
[System.STAThreadAttribute()]
[System.Diagnostics.DebuggerNonUserCodeAttribute()]
[System.CodeDom.Compiler.GeneratedCodeAttribute(
"PresentationBuildTasks", "4.0.0.0")]
public static void Main() {
WpfApp1.App app = new WpfApp1.App();
app.InitializeComponent();
app.Run();
}
}
正如你所看到的,Main
方法被STAThread
属性修饰。
多线程
在.NET 中实现代码的并行执行是通过多线程实现的。一个进程(或应用程序)可以利用任意数量的线程,取决于其硬件能力。每个应用程序,包括控制台、传统的 WinForms、WPF,甚至 Web 应用程序,默认情况下都是由单个线程启动的。我们可以通过在需要时以编程方式创建更多线程来轻松实现多线程。
多线程通常使用称为线程调度器的调度组件来运行,该组件跟踪线程何时应该在进程内运行。创建的每个线程都被分配一个System.Threading.ThreadPriority
,可以具有以下有效值之一。Normal
是分配给任何线程的默认优先级:
-
最高
-
AboveNormal
-
Normal
-
BelowNormal
-
Lowest
在进程内运行的每个线程都根据线程优先级调度算法由操作系统分配一个时间片。每个操作系统可以有不同的运行线程的调度算法,因此在不同的操作系统中执行顺序可能会有所不同。这使得更难以排除线程错误。最常见的调度算法如下:
-
找到具有最高优先级的线程并安排它们运行。
-
如果有多个具有最高优先级的线程,则每个线程被分配固定的时间片段来执行。
-
一旦最高优先级的线程执行完毕,低优先级线程开始被分配时间片,可以开始执行。
-
如果创建了一个新的最高优先级线程,则低优先级线程将再次被推迟。
时间片切换是指在活动线程之间切换执行。它可以根据硬件配置而变化。单核处理器机器一次只能运行一个线程,因此线程调度器执行时间片切换。时间片的大小很大程度上取决于 CPU 的时钟速度,但在这种系统中仍然无法通过多线程获得很多性能提升。此外,上下文切换会带来性能开销。如果分配给线程的工作跨越多个时间片,那么线程需要在内存中切换进出。每次切换出时,它都需要捆绑和保存其状态(数据),并在切换回时重新加载。
并发是一个主要用于多核处理器的概念。多核处理器具有更多可用的 CPU,因此不同的线程可以同时在不同的 CPU 上运行。更多的处理器意味着更高的并发度。
程序中可以有多种方式创建线程。这些包括以下内容:
-
线程类
-
线程池类
-
BackgroundWorker
类 -
异步委托
-
TPL
我们将在本书的过程中深入介绍异步委托和 TPL,但在本章中,我们将解释剩下的三种方法。
线程类
创建线程的最简单和最简单的方法是通过Thread
类,该类定义在System.Threading
命名空间中。这种方法自.NET 1.0 版本以来一直在使用,并且在.NET 核心中也可以使用。要创建一个线程,我们需要传递一个线程需要执行的方法。该方法可以是无参数或带参数的。框架提供了两个委托来包装这些函数:
-
System.Threading.ThreadStart
-
System.Threading.ParameterizedThreadStart
我们将通过示例学习这两个概念。在向您展示如何创建线程之前,我将尝试解释同步程序的工作原理。之后,我们将介绍多线程,以便了解异步执行的方式。创建线程的示例如下:
using System;
namespace Ch01
{
class _1Synchronous
{
static void Main(string[] args)
{
Console.WriteLine("Start Execution!!!");
PrintNumber10Times();
Console.WriteLine("Finish Execution");
Console.ReadLine();
}
private static void PrintNumber10Times()
{
for (int i = 0; i < 10; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
}
}
在上述代码中,一切都在主线程中运行。我们从Main
方法中调用了PrintNumber10Times
方法,由于Main
方法是由主 GUI 线程调用的,代码是同步运行的。如果代码运行时间很长,这可能会导致无响应的行为,因为主线程在执行期间将会很忙。
代码的输出如下:
在以下时间表中,我们可以看到一切都发生在主线程中:
前面的图表显示了在Main
线程上的顺序代码执行。
现在,我们可以通过创建一个线程来使程序成为多线程。主线程打印在Main
方法中编写的语句:
using System;
namespace Ch01
{
class _2ThreadStart
{
static void Main(string[] args)
{
Console.WriteLine("Start Execution!!!");
//Using Thread without parameter
CreateThreadUsingThreadClassWithoutParameter();
Console.WriteLine("Finish Execution");
Console.ReadLine();
}
private static void CreateThreadUsingThreadClassWithoutParameter()
{
System.Threading.Thread thread;
thread = new System.Threading.Thread(new
System.Threading.ThreadStart(PrintNumber10Times));
thread.Start();
}
private static void PrintNumber10Times()
{
for (int i = 0; i < 10; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
}
}
在上述代码中,我们已经将PrintNumber10Times()
的执行委托给了通过Thread
类创建的新线程。Main
方法中的Console.WriteLine
语句仍然通过主线程执行,但PrintNumber10Times
不是通过子线程调用的。
代码的输出如下:
此过程的时间表如下。您可以看到Console.WriteLine
在主线程上执行,而循环在子线程上执行:
前面的图表是多线程执行的一个示例。
如果我们比较输出,我们可以看到程序在主线程中完成所有操作,然后开始打印数字 10 次。在这个例子中,操作非常小,因此以确定的方式工作。然而,如果在完成执行被打印之前,主线程中有耗时的语句,结果可能会有所不同。我们将在本章后面详细了解多线程的工作原理以及它与 CPU 速度和数字的关系,以充分理解这个概念。
以下是另一个示例,向您展示如何使用System.Threading.ParameterizedThreadStart
委托将数据传递给线程:
using System;
namespace Ch01
{
class _3ParameterizedThreadStart
{
static void Main(string[] args)
{
Console.WriteLine("Start Execution!!!");
//Using Thread with parameter
CreateThreadUsingThreadClassWithParameter();
Console.WriteLine("Finish Execution");
Console.ReadLine();
}
private static void CreateThreadUsingThreadClassWithParameter()
{
System.Threading.Thread thread;
thread = new System.Threading.Thread(new
System.Threading.ParameterizedThreadStart(PrintNumberNTimes));
thread.Start(10);
}
private static void PrintNumberNTimes(object times)
{
int n = Convert.ToInt32(times);
for (int i = 0; i < n; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
}
}
上述代码的输出如下:
使用Thread
类有一些优点和缺点。让我们试着理解它们。
线程的优缺点
Thread
类具有以下优点:
-
线程可用于释放主线程。
-
线程可用于将任务分解为可以并发执行的较小单元。
Thread
类具有以下缺点:
-
使用更多线程,代码变得难以调试和维护。
-
线程创建会在内存和 CPU 资源方面对系统造成负担。
-
我们需要在工作方法内部进行异常处理,因为任何未处理的异常都可能导致程序崩溃。
线程池类
线程创建在内存和 CPU 资源方面是昂贵的操作。平均而言,每个线程消耗大约 1 MB 的内存和几百微秒的 CPU 时间。应用程序性能是一个相对的概念,因此通过创建大量线程不一定会提高性能。相反,创建大量线程有时可能会严重降低应用程序性能。我们应该始终根据目标系统的 CPU 负载,即系统上运行的其他程序,来创建一个最佳数量的线程。这是因为每个程序都会获得 CPU 的时间片,然后将其分配给应用程序内部的线程。如果创建了太多线程,它们可能无法在被换出内存之前完成任何有益的工作,以便将时间片给其他具有相似优先级的线程。
找到最佳线程数可能会很棘手,因为它可能因系统配置和同时在系统上运行的应用程序数量而异。在一个系统上可能是最佳数量的东西可能会对另一个系统产生负面影响。与其自己找到最佳线程数,不如将其留给公共语言运行时(CLR)。CLR 有一个算法来确定基于任何时间点的 CPU 负载的最佳数量。它维护一个线程池,称为ThreadPool
。ThreadPool
驻留在一个进程中,每个应用程序都有自己的线程池。线程池的优势在于它维护了一个最佳数量的线程,并将它们分配给一个任务。当工作完成时,线程将返回到池中,可以分配给下一个工作项,从而避免创建和销毁线程的成本。
以下是在ThreadPool
中可以创建的不同框架内的最佳线程数列表:
-
.NET Framework 2.0 中每核 25 个
-
.NET Framework 3.5 中每核 250 个
-
在 32 位环境中的.NET Framework 4.0 中为 1,023
-
.NET Framework 4.0 及以后版本中每核 32,768 个,以及 64 位环境中的.NET core
在与投资银行合作时,我们遇到了一个场景,一个交易流程几乎需要 1,800 秒来同步预订近 1,000 笔交易。在尝试了各种最佳数量后,我们最终切换到ThreadPool
并使流程多线程化。使用.NET Framework 2.0 版本,应用程序在接近 72 秒内完成。使用 3.5 版本,同一应用程序在几秒内完成。这是一个典型的例子,使用提供的框架而不是重新发明轮子。通过更新框架,您可以获得所需的性能提升。
我们可以通过调用ThreadPool.QueueUserWorkItem
来通过ThreadPool
创建一个线程,如下例所示。
这是我们想要并行调用的方法:
private static void PrintNumber10Times(object state)
{
for (int i = 0; i < 10; i++)
{
Console.Write(1);
}
Console.WriteLine();
}
以下是我们如何使用ThreadPool.QueueUserWorkItem
创建一个线程,同时传递WaitCallback
委托:
private static void CreateThreadUsingThreadPool()
{
ThreadPool.QueueUserWorkItem(new WaitCallback(PrintNumber10Times));
}
这是Main
方法中的一个调用:
using System;
using System.Threading;
namespace Ch01
{
class _4ThreadPool
{
static void Main(string[] args)
{
Console.WriteLine("Start Execution!!!");
CreateThreadUsingThreadPool();
Console.WriteLine("Finish Execution");
Console.ReadLine();
}
}
}
上述代码的输出如下:
每个线程池都维护最小和最大线程数。可以通过调用以下静态方法来修改这些值:
-
ThreadPool.SetMinThreads
-
ThreadPool.SetMaxThreads
通过System.Threading
创建一个线程。Thread
类不属于ThreadPool
。
让我们看看使用ThreadPool
类的优点和缺点以及何时避免使用它。
优点、缺点以及何时避免使用 ThreadPool
ThreadPool
的优点如下:
-
线程可以用来释放主线程。
-
线程由 CLR 以最佳方式创建和维护。
ThreadPool
的缺点如下:
-
随着线程数量的增加,代码变得难以调试和维护。
-
我们需要在工作方法内部进行异常处理,因为任何未处理的异常都可能导致程序崩溃。
-
需要从头开始编写进度报告、取消和完成逻辑。
以下是我们应该避免使用ThreadPool
的原因:
-
当我们需要一个前台线程时。
-
当我们需要为线程设置显式优先级时。
-
当我们有长时间运行或阻塞的任务时。在池中有大量阻塞的线程将阻止新任务启动,因为
ThreadPool
中每个进程可用的线程数量有限。 -
如果我们需要 STA 线程,因为
ThreadPool
线程默认为 MTA。 -
如果我们需要为任务分配一个独特的标识来专门提供一个线程,因为我们无法为
ThreadPool
线程命名。
BackgroundWorker
BackgroundWorker
是.NET 提供的一个构造,用于从ThreadPool
创建更可管理的线程。在解释基于 GUI 的应用程序时,我们看到Main
方法被装饰了STAThread
属性。这个属性保证了控件的安全性,因为控件是在线程所拥有的单元中创建的,不能与其他线程共享。在 Windows 应用程序中,有一个主执行线程,它拥有 UI 和控件,这在应用程序启动时创建。它负责接受用户输入,并根据用户的操作来绘制或重新绘制 UI。为了获得良好的用户体验,我们应该尽量使 UI 不受线程的影响,并将所有耗时的任务委托给工作线程。通常分配给工作线程的一些常见任务如下:
-
从服务器下载图像
-
与数据库交互
-
与文件系统交互
-
与 Web 服务交互
-
复杂的本地计算
正如您所看到的,这些大多数是输入/输出(I/O)操作。I/O 操作由 CPU 执行。当我们调用封装 I/O 操作的代码时,执行从线程传递到 CPU,CPU 执行任务。当任务完成时,操作的结果将返回给调用线程。这段时间从传递权杖到接收结果是线程的无活动期,因为它只需等待操作完成。如果这发生在主线程中,应用程序将变得无响应。因此,将这些任务委托给工作线程是有意义的。在响应式应用程序方面仍然有一些挑战需要克服。让我们看一个例子。
案例研究:
我们需要从流数据的服务中获取数据。我们希望更新用户工作完成的百分比。一旦工作完成,我们需要向用户更新所有数据。
挑战:
服务调用需要时间,因此我们需要将调用委托给工作线程,以避免 UI 冻结。
解决方案:
BackgroundWorker
是System.ComponentModel
中提供的一个类,可以用来创建一个利用ThreadPool
的工作线程,正如我们之前讨论的那样。这意味着它以一种高效的方式工作。BackgroundWorker
还支持进度报告和取消,除了通知操作的结果。
这种情况可以通过以下代码进一步解释:
using System;
using System.ComponentModel;
using System.Text;
using System.Threading;
namespace Ch01
{
class _5BackgroundWorker
{
static void Main(string[] args)
{
var backgroundWorker = new BackgroundWorker();
backgroundWorker.WorkerReportsProgress = true;
backgroundWorker.WorkerSupportsCancellation = true;
backgroundWorker.DoWork += SimulateServiceCall;
backgroundWorker.ProgressChanged += ProgressChanged;
backgroundWorker.RunWorkerCompleted +=
RunWorkerCompleted;
backgroundWorker.RunWorkerAsync();
Console.WriteLine("To Cancel Worker Thread Press C.");
while (backgroundWorker.IsBusy)
{
if (Console.ReadKey(true).KeyChar == 'C')
{
backgroundWorker.CancelAsync();
}
}
}
// This method executes when the background worker finishes
// execution
private static void RunWorkerCompleted(object sender,
RunWorkerCompletedEventArgs e)
{
if (e.Error != null)
{
Console.WriteLine(e.Error.Message);
}
else
Console.WriteLine($"Result from service call
is {e.Result}");
}
// This method is called when background worker want to
// report progress to caller
private static void ProgressChanged(object sender,
ProgressChangedEventArgs e)
{
Console.WriteLine($"{e.ProgressPercentage}% completed");
}
// Service call we are trying to simulate
private static void SimulateServiceCall(object sender,
DoWorkEventArgs e)
{
var worker = sender as BackgroundWorker;
StringBuilder data = new StringBuilder();
//Simulate a streaming service call which gets data and
//store it to return back to caller
for (int i = 1; i <= 100; i++)
{
//worker.CancellationPending will be true if user
//press C
if (!worker.CancellationPending)
{
data.Append(i);
worker.ReportProgress(i);
Thread.Sleep(100);
//Try to uncomment and throw error
//throw new Exception("Some Error has occurred");
}
else
{
//Cancels the execution of worker
worker.CancelAsync();
}
}
e.Result = data;
}
}
}
BackgroundWorker
提供了对原始线程的抽象,为用户提供了更多的控制和选项。使用BackgroundWorker
的最好之处在于它使用了基于事件的异步模式(EAP),这意味着它能够比原始线程更有效地与代码交互。代码多多少少是不言自明的。为了引发进度报告和取消事件,您需要将以下属性设置为true
:
backgroundWorker.WorkerReportsProgress = true;
backgroundWorker.WorkerSupportsCancellation = true;
您需要订阅ProgressChanged
事件以接收进度,DoWork
事件以传递需要由线程调用的方法,以及RunWorkerCompleted
事件以接收线程执行的最终结果或任何错误消息:
backgroundWorker.DoWork += SimulateServiceCall;
backgroundWorker.ProgressChanged += ProgressChanged;
backgroundWorker.RunWorkerCompleted += RunWorkerCompleted;
设置好这些之后,您可以通过调用以下命令来调用工作线程:
backgroundWorker.RunWorkerAsync();
在任何时候,您都可以通过调用backgroundWorker.CancelAsync()
方法来取消线程的执行,这会在工作线程上设置CancellationPending
属性。我们需要编写一些代码来不断检查这个标志,并优雅地退出。
如果没有异常,线程执行的结果可以通过设置以下内容返回给调用者:
e.Result = data;
如果程序中有任何未处理的异常,它们会被优雅地返回给调用者。我们可以通过将其包装成RunWorkerCompletedEventArgs
并将其作为参数传递给RunWorkerCompleted
事件处理程序来实现这一点。
我们将在下一节讨论使用BackgroundWorker
的优缺点。
使用 BackgroundWorker 的优缺点
使用BackgroundWorker
的优点如下:
-
线程可以用来释放主线程。
-
线程由
ThreadPool
类的 CLR 以最佳方式创建和维护。 -
优雅和自动的异常处理。
-
使用事件支持进度报告、取消和完成逻辑。
使用BackgroundWorker
的缺点是,使用更多线程后,代码变得难以调试和维护。
多线程与多任务处理
我们已经看到了多线程和多任务处理的工作原理。两者都有优缺点,您可以根据具体的用例选择使用。以下是一些多线程可能有用的示例:
-
如果您需要一个易于设置和终止的系统:当您有一个具有大量开销的进程时,多线程可能很有用。使用线程,您只需复制线程堆栈。然而,创建一个重复的进程意味着在单独的内存空间中重新创建整个数据过程。
-
如果您需要快速任务切换:在进程中,CPU 缓存和程序上下文可以在线程之间轻松维护。然而,如果必须将 CPU 切换到另一个进程,它必须重新加载。
-
如果您需要与其他线程共享数据:进程内的所有线程共享相同的内存池,这使它们更容易共享数据以比较进程。如果进程想要共享数据,它们需要 I/O 操作和传输协议,这是昂贵的。
在本节中,我们讨论了多线程和多任务处理的基础知识,以及在较早版本的.NET 中用于创建线程的各种方法。在下一节中,我们将尝试了解一些可以利用并行编程技术的场景。
并行编程可能有用的场景
以下是并行编程可能有用的场景:
-
为基于 GUI 的应用程序创建响应式 UI:我们可以将所有繁重和耗时的任务委托给工作线程,从而允许 UI 线程处理用户交互和 UI 重绘任务。
-
处理同时请求:在服务器端编程场景中,我们需要处理大量并发用户。我们可以创建一个单独的线程来处理每个请求。例如,我们可以使用
ThreadPool
和为命中服务器的每个请求分配一个线程的 ASP.NET 请求模型。然后,线程负责处理请求并向客户端返回响应。在客户端场景中,我们可以通过多线程调用多个互斥的 API 调用来节省时间。 -
充分利用 CPU 资源:使用多核处理器时,如果不使用多线程,通常只有一个核被利用,而且负担过重。通过创建多个线程,每个线程在单独的 CPU 上运行,我们可以充分利用 CPU 资源。以这种方式分享负担会提高性能。这对于长时间运行和复杂计算非常有用,可以通过分而治之的策略更快地执行。
-
推测性方法:涉及多个算法的场景,例如对一组数字进行排序,我们希望尽快获得排序好的集合。唯一的方法是将输入传递给所有算法并并行运行它们,先完成的算法被接受,而其余的被取消。
并行编程的优缺点
多线程导致并行性,具有自己的编程和缺陷。现在我们已经掌握了并行编程的基本概念,了解其优缺点非常重要。
并行编程的好处:
-
性能提升:由于任务分布在并行运行的线程中,我们可以实现更好的性能。
-
改进的 GUI 响应性:由于任务执行非阻塞 I/O,这意味着 GUI 线程始终空闲以接受用户输入。这会导致更好的响应性。
-
任务的同时和并行发生:由于任务并行运行,我们可以同时运行不同的编程逻辑。
-
通过利用资源更好地使用缓存存储和更好地利用 CPU 资源。任务可以在不同的核心上运行,从而确保最大化吞吐量。
并行编程也有以下缺点:
-
复杂的调试和测试过程:没有良好的多线程工具支持,调试线程不容易,因为不同的线程并行运行。
-
上下文切换开销:每个线程都在分配给它的时间片上工作。一旦时间片到期,就会发生上下文切换,这也会浪费资源。
-
死锁发生的机会很高:如果多个线程在共享资源上工作,我们需要应用锁来实现线程安全。如果多个线程同时锁定并等待共享资源,这可能导致死锁。
-
编程困难:与同步版本相比,使用代码分支,并行程序可能更难编写。
-
结果不可预测:由于并行编程依赖于 CPU 核心,因此在不同配置的机器上可能会得到不同的结果。
我们应该始终明白并行编程是一个相对的概念,对别人有效的方法未必对你有效。建议你实施这种方法并自行验证。
总结
在本章中,我们讨论了并行编程的场景、好处和陷阱。计算机系统在过去几十年里从单核处理器发展到多核处理器。芯片中的硬件已经启用了 HT,从而提高了现代系统的性能。
在开始并行编程之前,了解与操作系统相关的基本概念,如进程、任务以及多线程和多任务之间的区别,是一个好主意。
在下一章中,我们将完全专注于 TPL 及其相关实现的讨论。然而,在现实世界中,仍然有很多依赖于旧构造的遗留代码,因此对这些代码的了解将会很有用。
问题
-
多线程是并行编程的一个超集。
-
正确
-
错误
-
在启用超线程的单处理器双核机器上会有多少个核心?
-
2
-
4
-
8
-
当应用程序退出时,所有前台线程也会被终止。在应用程序退出时不需要单独的逻辑来关闭前台线程。
-
正确
-
错误
-
当线程尝试访问它没有拥有/创建的控件时会抛出哪个异常?
-
ObjectDisposedException
-
InvalidOperationException
-
CrossThreadException
-
哪个提供了取消支持和进度报告?
-
线程
-
BackgroundWorker
-
ThreadPool
第二章:任务并行性
在上一章中,我们介绍了并行编程的概念。在本章中,我们将继续讨论 TPL 和任务并行性。
.NET 作为一个编程框架的主要目标之一是通过将所有常见的任务封装为 API 来使开发人员的生活更轻松。正如我们已经看到的,线程自.NET 的早期版本以来就存在,但最初它们非常复杂,并且伴随着很多开销。微软引入了许多新的并行原语,使得从头开始编写、调试和维护并行程序变得更加容易,而无需处理与传统线程相关的复杂性。
本章将涵盖以下主题:
-
创建和启动任务
-
从已完成的任务获取结果
-
如何取消任务
-
如何等待运行任务
-
处理任务异常
-
将异步编程模型(APM)模式转换为任务
-
将基于事件的异步模式(EAPs)转换为任务
-
更多关于任务的内容:
-
继续任务
-
父任务和子任务
-
本地和全局队列和存储
-
工作窃取队列
技术要求
要完成本章,您应该对 C#和一些高级概念(如委托)有很好的理解。
本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter02
。
任务
任务是.NET 中提供异步单元的抽象,就像 JavaScript 中的 promise 一样。在.NET 的初始版本中,我们只能依赖于线程,这些线程是直接创建或使用ThreadPool
类创建的。ThreadPool
类提供了对线程的托管抽象层,但开发人员仍然依赖于Thread
类来获得更好的控制。通过使用Thread
类创建线程,我们可以获得底层对象,可以等待、取消或移动到前台或后台。然而,在实时中,我们需要线程持续执行工作。这要求我们编写大量难以维护的代码。Thread
类也是不受管理的,这对内存和 CPU 都造成了很大的负担。我们需要两全其美,这就是任务的用武之地。任务只是通过ThreadPool
创建的线程的包装器。任务提供了等待、取消和继续等功能,这些功能在任务完成后运行。
任务具有以下重要特点:
-
任务由
TaskScheduler
执行,默认调度程序简单地在ThreadPool
上运行。 -
我们可以从任务中返回值。
-
任务让您知道它们何时完成,不像
ThreadPool
或线程。 -
可以使用
ContinueWith()
构造来运行任务的后续任务。 -
我们可以通过调用
Task.Wait()
来等待任务。这会阻塞调用线程,直到任务完成为止。 -
与传统线程或
ThreadPool
相比,任务使代码更易读。它们还为引入 C# 5.0 中的异步编程构造铺平了道路。 -
当一个任务从另一个任务启动时,我们可以建立父/子关系。
-
我们可以将子任务的异常传播到父任务。
-
可以使用
CancellationToken
类取消任务。
创建和启动任务
我们可以使用 TPL 的许多方法来创建和运行任务。在本节中,我们将尝试理解所有这些方法,并在可能的情况下进行比较分析。首先,您需要向System.Threading.Tasks
命名空间添加引用:
using System.Threading.Tasks;
我们将尝试使用以下方法创建任务:
-
System.Threading.Tasks.Task
类 -
System.Threading.Tasks.Task.Factory.StartNew
方法 -
System.Threading.Tasks.Task.Run
方法 -
System.Threading.Tasks.Task.Delay
-
System.Threading.Tasks.Task.Yield
-
System.Threading.Tasks.Task.FromResult<T>方法
-
System.Threading.Tasks.Task.FromException
和Task.FromException<T>
-
System.Threading.Tasks.Task.FromCancelled
和Task.FromCancelled<T>
System.Threading.Tasks.Task 类
任务类是一种以ThreadPool
线程异步执行工作的方式,它基于基于任务的异步模式(TAP)。非泛型的Task
类不返回结果,所以每当我们需要从任务中返回值时,我们需要使用泛型版本Task<T>
。通过Task
类创建的任务直到我们调用Start
方法才被安排运行。
我们可以通过Task
类的各种方式创建一个任务,所有这些方式我们将在以下小节中讨论。
使用 lambda 表达式语法
在以下代码中,我们通过调用Task
构造函数并传递包含我们要执行的方法的 lambda 表达式来创建一个任务:
Task task = new Task (() => PrintNumber10Times ());
task.Start();
使用 Action delegate
在以下代码中,我们通过调用Task
构造函数并传递包含我们要执行的方法的 delegate 来创建一个任务:
Task task = new Task (new Action (PrintNumber10Times));
task.Start();
使用 delegate
在以下代码中,我们通过调用Task
构造函数并传递包含我们要执行的方法的匿名delegate
来创建一个task
对象:
Task task = new Task (delegate {PrintNumber10Times ();});
task.Start();
在所有这些情况下,输出将如下所示:
所有前面的方法都是做同样的事情 - 它们只是有不同的语法。
我们只能对以前未运行过的任务调用Start
方法。如果您需要重新运行已经完成的任务,您需要创建一个新的任务并在其上调用Start
方法。
System.Threading.Tasks.Task.Factory.StartNew 方法
我们也可以使用TaskFactory
类的StartNew
方法创建一个任务,如下所示。在这种方法中,任务被创建并安排在ThreadPool
内执行,并将该任务的引用返回给调用者。
我们可以使用Task.Factory.StartNew
方法创建一个任务。我们将在以下小节中讨论这个问题。
使用 lambda 表达式语法
在以下代码中,我们通过在TaskFactory
上调用StartNew()
方法并传递包含我们要执行的方法的 lambda 表达式来创建一个Task
:
Task.Factory.StartNew(() => PrintNumber10Times());
使用 Action delegate
在以下代码中,我们通过在TaskFactory
上调用StartNew()
方法并传递包装我们要执行的方法的 delegate 来创建一个Task
:
Task.Factory.StartNew(new Action( PrintNumber10Times));
使用 delegate
在以下代码中,我们通过在TaskFactory
上调用StartNew()
方法并传递我们要执行的delegate
包装方法来创建一个Task
:
Task.Factory.StartNew(delegate { PrintNumber10Times(); });
所有前面的方法都是做同样的事情 - 它们只是有不同的语法。
System.Threading.Tasks.Task.Run 方法
我们也可以使用Task.Run
方法创建一个任务。这与StartNew
方法的工作方式相同,并返回一个ThreadPool
线程。
我们可以通过以下方式使用Task.Run
方法创建一个Task
,所有这些方式将在以下小节中讨论。
使用 lambda 表达式语法
在以下代码中,我们通过在Task
上调用静态的Run()
方法并传递包含我们要执行的方法的 lambda 表达式来创建一个Task
:
Task.Run(() => PrintNumber10Times ());
使用 Action delegate
在以下代码中,我们通过在Task
上调用静态的Run()
方法并传递包含我们要执行的方法的 delegate 来创建一个Task
:
Task.Run(new Action (PrintNumber10Times));
使用 delegate
在以下代码中,我们通过在Task
上调用静态的Run()
方法并传递包含我们要执行的方法的 delegate 来创建一个Task
:
Task.Run(delegate {PrintNumber10Times ();});
System.Threading.Tasks.Task.Delay 方法
我们可以创建一个在指定时间间隔后完成或可以随时被用户取消的任务,使用CancellationToken
类。过去,我们使用Thread
类的Thread.Sleep()
方法创建阻塞构造以等待其他任务。然而,这种方法的问题是它仍然使用 CPU 资源并且同步运行。Task.Delay
提供了一个更好的等待任务的替代方法,而不利用 CPU 周期。它也是异步运行的:
Console.WriteLine("What is the output of 20/2\. We will show result in 2 seconds.");
Task.Delay(2000);
Console.WriteLine("After 2 seconds delay");
Console.WriteLine("The output is 10");
前面的代码询问用户一个问题,然后等待两秒钟才呈现答案。在这两秒钟内,主线程不必等待,但必须执行其他任务以改善用户体验。代码在系统时钟上异步运行,一旦时间到期,其余代码就会被执行。
前面代码的输出如下:
在查看我们可以用来创建任务的其他方法之前,我们将看一下在 C# 5.0 中引入的两个异步编程构造:async
和await
关键字。
async
和await
是代码标记,使我们更容易编写异步程序。我们将在第九章中深入学习这些关键字,异步、等待和基于任务的异步编程基础。顾名思义,我们可以使用await
关键字等待任何异步调用。一旦执行线程在方法内遇到await
关键字,它就返回到ThreadPool
,将方法的其余部分标记为继续委托,并开始执行其他排队的任务。一旦异步任务完成,ThreadPool
中的任何可用线程都会完成方法的其余部分。
System.Threading.Tasks.Task.Yield 方法
这是创建await
任务的另一种方式。底层任务对调用者不直接可访问,但在涉及与程序执行相关的异步编程的某些场景中使用。它更像是一个承诺而不是一个任务。使用Task.Yield
,我们可以强制我们的方法是异步的,并将控制返回给操作系统。当方法的其余部分在以后的时间点执行时,它可能仍然作为异步代码运行。我们可以使用以下代码实现相同的效果:
await Task.Factory.StartNew(() => {},
CancellationToken.None,
TaskCreationOptions.None,
SynchronizationContext.Current != null?
TaskScheduler.FromCurrentSynchronizationContext():
TaskScheduler.Current);
这种方法可以通过在长时间运行的任务中不时地将控制权交给 UI 线程来使 UI 应用程序响应。然而,这不是 UI 应用程序的首选方法。有更好的替代方法,例如 WinForms 中的Application.DoEvents()
和 WPF 中的Dispatcher.Yield(DispatcherPriority.ApplicationIdle)
:
private async static void TaskYield()
{
for (int i = 0; i < 100000; i++)
{
Console.WriteLine(i);
if (i % 1000 == 0)
await Task.Yield();
}
}
在控制台或 Web 应用程序的情况下,当我们运行代码并在任务的 yield 上应用断点时,我们会看到随机线程池线程切换上下文来运行代码。以下截图描述了各个阶段控制执行的各个线程。
以下截图显示了程序流中所有线程同时执行。我们可以看到当前线程 ID 为 1664:
如果我们按下F5并允许断点命中i
的另一个值,我们会看到代码现在由 ID 为 10244 的另一个线程执行:
我们将在第十一章中学习更多关于线程窗口和调试技术,为并行和异步代码编写单元测试用例。
System.Threading.Tasks.Task.FromResult方法
这种方法是最近在.NET 框架 4.5 中引入的,它非常被低估。我们可以通过这种方法返回带有结果的完成任务,如下所示:
static void Main(string[] args)
{
StaticTaskFromResultUsingLambda();
}
private static void StaticTaskFromResultUsingLambda()
{
Task<int> resultTask = Task.FromResult<int>( Sum(10));
Console.WriteLine(resultTask.Result);
}
private static int Sum (int n)
{
int sum=0;
for (int i = 0; i < 10; i++)
{
sum += i;
}
return sum;
}
如前面的代码所示,我们实际上将同步的Sum
方法转换为使用Task.FromResult<int>
类以异步方式返回结果。这种方法经常用于 TDD 中模拟异步方法,以及在异步方法内根据条件返回默认值。我们将在第十一章中进一步解释这些方法,编写并行和异步代码的单元测试用例**.
System.Threading.Tasks.Task.FromException 和 System.Threading.Tasks.Task.FromException方法
这些方法创建了由预定义异常完成的任务,并用于从异步任务中抛出异常,以及在 TDD 中。我们将在第十一章中进一步解释这种方法,编写并行和异步代码的单元测试用例**.
return Task.FromException<long>(
new FileNotFoundException("Invalid File name."));
正如你在前面的代码中看到的,我们将FileNotFoundException
包装为一个任务并将其返回给调用者。
System.Threading.Tasks.Task.FromCanceled 和 System.Threading.Tasks.Task.FromCanceled方法
这些方法用于创建由取消令牌导致完成的任务:
CancellationTokenSource source = new CancellationTokenSource();
var token = source.Token;
source.Cancel();
Task task = Task.FromCanceled(token);
Task<int> canceledTask = Task.FromCanceled<int>(token);
如前面的代码所示,我们使用CancellationTokenSource
类创建了一个取消令牌。然后,我们从该令牌创建了一个任务。这里需要考虑的重要事情是,在我们可以使用Task.FromCanceled
方法之前,令牌需要被取消。
如果我们想要从异步方法中返回值,以及在 TDD 中,这种方法是有用的。
从已完成的任务中获取结果
为了从任务中返回值,TPL 提供了我们之前定义的所有类的泛型变体:
-
Task<T>
-
Task.Factory.StartNew<T>
-
Task.Run<T>
任务完成后,我们应该能够通过访问Task.Result
属性来获取结果。让我们尝试使用一些代码示例来理解这一点。我们将创建各种任务,并在完成后尝试返回值:
using System;
using System.Threading.Tasks;
namespace Ch02
{
class _2GettingResultFromTasks
{
static void Main(string[] args)
{
GetResultsFromTasks();
Console.ReadLine();
}
private static void GetResultsFromTasks()
{
var sumTaskViaTaskOfInt = new Task<int>(() => Sum(5));
sumTaskViaTaskOfInt.Start();
Console.WriteLine($"Result from sumTask is
{sumTaskViaTaskOfInt.Result}" );
var sumTaskViaFactory = Task.Factory.StartNew<int>(() =>
Sum(5));
Console.WriteLine($"Result from sumTask is
{sumTaskViaFactory.Result}");
var sumTaskViaTaskRun = Task.Run<int>(() => Sum(5));
Console.WriteLine($"Result from sumTask is
{sumTaskViaTaskRun.Result}");
var sumTaskViaTaskResult = Task.FromResult<int>(Sum(5));
Console.WriteLine($"Result from sumTask is
{sumTaskViaTaskResult.Result}");
}
private static int Sum(int n)
{
int sum = 0;
for (int i = 0; i < n; i++)
{
sum += i;
}
return sum;
}
}
}
如前面的代码所示,我们使用了泛型变体创建了任务。一旦它们完成,我们就能够使用结果属性获取结果:
在下一节中,我们将学习如何取消任务。
如何取消任务
TPL 的另一个重要功能是为开发人员提供现成的数据结构来取消运行中的任务。那些有经典线程背景的人会意识到,以前要使线程支持取消是多么困难,需要使用自定义的逻辑,但现在不再是这样。.NET Framework 提供了两个类来支持任务取消:
-
CancellationTokenSource
**: **这个类负责创建取消令牌,并将取消请求传递给通过该源创建的所有令牌 -
CancellationToken
**: **这个类被监听器用来监视请求的当前状态
要创建可以取消的任务,我们需要执行以下步骤:
-
创建
System.Threading.CancellationTokenSource
类的实例,该类通过Token Property
进一步提供System.Threading.CancellationToken
。 -
在创建任务时传递令牌。
-
在需要时,调用
Cancel()
方法取消CancellationTokenSource
上的任务。
让我们试着理解如何创建一个令牌并将其传递给任务。
创建令牌
可以使用以下代码创建令牌:
CancellationTokenSource tokenSource = new CancellationTokenSource();
CancellationToken token = tokenSource.Token;
首先,我们使用CancellationTokenSource
构造函数创建了一个tokenSource
。然后,我们使用tokenSource
的 token 属性获取了我们的令牌。
使用令牌创建任务
我们可以通过将CancellationToken
作为任务构造函数的第二个参数来创建任务,如下所示:
var sumTaskViaTaskOfInt = new Task<int>(() => Sum(5), token);
var sumTaskViaFactory = Task.Factory.StartNew<int>(() => Sum(5), token);
var sumTaskViaTaskRun = Task.Run<int>(() => Sum(5), token);
在经典的线程模型中,我们曾经在非确定性的线程上调用Abort()
方法。这会突然停止线程,从而导致资源未受管理时内存泄漏。使用 TPL,我们可以调用Cancel
方法,这是一个取消令牌源,将进而在令牌上设置IsCancellationRequested
属性。任务执行的底层方法应该监视此属性,并且如果设置了,应该优雅地退出。
有各种方法可以监视令牌源是否请求了取消:
-
通过轮询令牌的
IsCancellationRequested
属性的状态 -
注册请求取消回调
通过轮询令牌的状态来检查IsCancellationRequested
属性
这种方法在涉及递归方法或包含通过循环进行长时间计算逻辑的方法的场景中非常有用。在我们的方法或循环中,我们编写代码以在某些最佳间隔时轮询IsCancellationRequested
。如果设置了,它通过调用token
类的ThrowIfCancellationRequested
方法来中断循环。
以下代码是通过轮询令牌来取消任务的示例:
private static void CancelTaskViaPoll()
{
CancellationTokenSource cancellationTokenSource =
new CancellationTokenSource();
CancellationToken token = cancellationTokenSource.Token;
var sumTaskViaTaskOfInt = new Task(() =>
LongRunningSum(token), token);
sumTaskViaTaskOfInt.Start();
//Wait for user to press key to cancel task
Console.ReadLine();
cancellationTokenSource.Cancel();
}
private static void LongRunningSum(CancellationToken token)
{
for (int i = 0; i < 1000; i++)
{
//Simulate long running operation
Task.Delay(100);
if (token.IsCancellationRequested)
token.ThrowIfCancellationRequested();
}
}
在前面的代码中,我们通过CancellationTokenSource
类创建了一个取消令牌。然后,我们通过传递令牌创建了一个任务。该任务执行一个长时间运行的方法LongRunningSum
(模拟),该方法不断轮询令牌的IsCancellationRequested
属性。如果用户在方法完成之前调用了cancellationTokenSource.Cancel()
,它会抛出异常。
轮询不会带来任何显著的性能开销,并且可以根据您的需求使用。当您对任务执行的工作有完全控制时使用它,例如如果它是您自己编写的核心逻辑。
使用回调委托注册请求取消
这种方法利用了一个Callback
委托,当底层令牌请求取消时会被调用。我们应该将其与那些以一种使得无法以常规方式检查CancellationToken
值的方式阻塞的操作一起使用。
让我们看一下以下代码,它从远程 URL 下载文件:
private static void DownloadFileWithoutToken()
{
WebClient webClient = new WebClient();
webClient.DownloadStringAsync(new
Uri("http://www.google.com"));
webClient.DownloadStringCompleted += (sender, e) =>
{
if (!e.Cancelled)
Console.WriteLine("Download Complete.");
else
Console.WriteLine("Download Cancelled.");
};
}
从前面的方法中可以看到,一旦我们调用WebClient
的DownloadStringAsync
方法,控制权就离开了用户。虽然WebClient
类允许我们通过webClient.CancelAsync()
方法取消任务,但我们无法控制何时调用它。
前面的代码可以修改为使用Callback
委托,以便更好地控制任务取消,如下所示:
static void Main(string[] args)
{
CancellationTokenSource cancellationTokenSource = new
CancellationTokenSource();
CancellationToken token = cancellationTokenSource.Token;
DownloadFileWithToken(token);
//Random delay before we cancel token
Task.Delay(2000);
cancellationTokenSource.Cancel();
Console.ReadLine();
}
private static void DownloadFileWithToken(CancellationToken token)
{
WebClient webClient = new WebClient();
//Here we are registering callback delegate that will get called
//as soon as user cancels token
token.Register(() => webClient.CancelAsync());
webClient.DownloadStringAsync(new
Uri("http://www.google.com"));
webClient.DownloadStringCompleted += (sender, e) => {
//Wait for 3 seconds so we have enough time to cancel task
Task.Delay(3000);
if (!e.Cancelled)
Console.WriteLine("Download Complete.");
else
Console.WriteLine("Download Cancelled.");};
}
如您所见,在这个修改后的版本中,我们传递了一个取消令牌,并通过Register
方法订阅了取消回调。
一旦用户调用cancellationTokenSource.Cancel()
方法,它将通过调用webClient.CancelAsync()
取消下载操作。
CancellationTokenSource
也可以与传统的ThreadPool.QueueUserWorkItem
很好地配合使用。
以下是创建CancellationTokenSource
的代码,可以传递给ThreadPool
以支持取消:
// Create the token source.
CancellationTokenSource cts = new CancellationTokenSource();
// Pass the token to the cancellable operation.
ThreadPool.QueueUserWorkItem(new WaitCallback(DoSomething), cts.Token);
在本节中,我们讨论了取消任务的各种方法。取消任务可以在任务可能变得多余的情况下节省大量 CPU 时间。例如,假设我们创建了多个任务,使用不同的算法对一组数字进行排序。虽然所有算法都会返回相同的结果(一组排序好的数字),但我们希望尽快获得结果。我们将接受第一个(最快的)算法的结果,并取消其余的任务以提高系统性能。在下一节中,我们将讨论如何等待运行中的任务。
如何等待运行中的任务
在之前的示例中,我们调用了Task.Result
属性来从已完成的任务中获取结果。这会阻塞调用线程,直到结果可用。TPL 为我们提供了另一种等待一个或多个任务的方法。
TPL 中有各种 API 可供我们等待一个或多个任务。这些包括:
-
Task.Wait
-
Task.WaitAll
-
Task.WaitAny
-
Task.WhenAll
-
Task.WhenAny
这些 API 将在以下子节中定义。
Task.Wait
这是一个实例方法,用于等待单个任务。我们可以指定调用者等待任务完成的最长时间,然后在超时异常中解除阻塞。我们还可以通过向方法传递取消令牌来完全控制已取消的监视事件。调用方法将被阻塞,直到线程完成、取消或抛出异常:
var task = Task.Factory.StartNew(() => Console.WriteLine("Inside Thread"));
//Blocks the current thread until task finishes.
task.Wait();
Wait
方法有五个重载版本:
-
Wait()
:无限期地等待任务完成。调用线程将被阻塞,直到子线程完成。 -
Wait(CancellationToken)
:等待任务无限期地执行或取消令牌被取消时。 -
Wait(int)
:在指定的时间段内等待任务完成执行,以毫秒为单位。 -
Wait(TimeSpan)
:在指定的时间间隔内等待任务完成执行。 -
Wait(int, CancellationToken)
:在指定的时间段内等待任务完成执行,以毫秒为单位,或者取消令牌被取消时。
Task.WaitAll
这是Task
类中定义的静态方法,用于等待多个任务。任务作为数组传递给方法,调用者将被阻塞,直到所有任务完成。该方法还支持超时和取消令牌。使用此方法的一些示例代码如下:
Task taskA = Task.Factory.StartNew(() =>
Console.WriteLine("TaskA finished"));
Task taskB = Task.Factory.StartNew(() =>
Console.WriteLine("TaskB finished"));
Task.WaitAll(taskA, taskB);
Console.WriteLine("Calling method finishes");
上述代码的输出如下:
正如您所看到的,当两个任务都完成执行时,调用方法完成语句被执行。
该方法的一个示例用例可能是当我们需要来自多个来源的数据(我们为每个来源都有一个任务),并且我们希望将所有任务的数据组合起来,以便在 UI 上显示。
Task.WaitAny
这是Task
类中定义的另一个静态方法。就像WaitAll
一样,WaitAny
用于等待多个任务,但只要传递给方法的任何任务完成执行,调用者就会解除阻塞。与其他方法一样,WaitAny
支持超时和取消令牌。使用此方法的一些示例代码如下:
Task taskA = Task.Factory.StartNew(() =>
Console.WriteLine("TaskA finished"));
Task taskB = Task.Factory.StartNew(() =>
Console.WriteLine("TaskB finished"));
Task.WaitAny(taskA, taskB);
Console.WriteLine("Calling method finishes");
在上面的代码中,我们启动了两个任务,并使用WaitAny
等待它们。这个方法会阻塞当前线程。一旦任何一个任务完成,调用线程就会解除阻塞。
该方法的一个示例用例可能是当我们需要的数据来自不同的来源并且我们需要尽快获取它时。在这里,我们创建了请求不同来源的任务。一旦任何一个任务完成,我们将解除调用线程的阻塞并从完成的任务中获取结果。
Task.WhenAll
这是WaitAll
方法的非阻塞变体。它返回一个代表所有指定任务的等待操作的任务。与阻塞调用线程的WaitAll
不同,WhenAll
可以在异步方法中等待,从而释放调用线程以执行其他操作。使用此方法的一些示例代码如下:
Task taskA = Task.Factory.StartNew(() =>
Console.WriteLine("TaskA finished"));
Task taskB = Task.Factory.StartNew(() =>
Console.WriteLine("TaskB finished"));
Task.WhenAll(taskA, taskB);
Console.WriteLine("Calling method finishes");
这段代码的工作方式与Task.WaitAll
相同,除了调用线程返回到ThreadPool
而不是被阻塞。
Task.WhenAny
这是WaitAny
的非阻塞变体。它返回一个封装了对单个基础任务的等待操作的任务。与WaitAny
不同,它不会阻塞调用线程。调用线程可以在异步方法内调用 await。使用此方法的一些示例代码如下:
Task taskA = Task.Factory.StartNew(() =>
Console.WriteLine("TaskA finished"));
Task taskB = Task.Factory.StartNew(() =>
Console.WriteLine("TaskB finished"));
Task.WhenAny(taskA, taskB);
Console.WriteLine("Calling method finishes");
这段代码的工作方式与Task.WaitAny
相同,除了调用线程返回到ThreadPool
而不是被阻塞。
在本节中,我们讨论了如何在处理多个线程时编写高效的代码,而不需要代码分支。代码流看起来是同步的,尽管在需要的地方是并行的。在下一节中,我们将学习任务如何处理异常。
处理任务异常
异常处理是并行编程中最重要的方面之一。所有良好的干净代码从业者都专注于高效处理异常。这在并行编程中变得更加重要,因为线程或任务中的任何未处理异常都可能导致应用程序突然崩溃。幸运的是,TPL 提供了一个很好的、高效的设计来处理和管理异常。在任务中发生的任何未处理异常都会被延迟,然后传播到一个观察任务异常的加入线程。
任何在任务内部发生的异常都会被包装在AggregateException
类下,并返回给观察异常的调用者。如果调用者正在等待单个任务,AggregateException
类的内部异常属性将返回原始异常。然而,如果调用者正在等待多个任务,比如Task.WaitAll
、Task.WhenAll
、Task.WaitAny
或Task.WhenAny
,所有来自任务的异常都将作为集合返回给调用者。它们可以通过InnerExceptions
属性访问。
现在,让我们看看在任务内部处理异常的各种方法。
从单个任务处理异常
在下面的代码中,我们创建了一个简单的任务,试图将一个数字除以 0,从而引发DivideByZeroException
。异常被返回给调用者,并在 catch 块内处理。由于它是一个单一任务,异常对象被包装在AggregateException
对象的InnerException
属性下:
class _4HandlingExceptions
{
static void Main(string[] args)
{
Task task = null;
try
{
task = Task.Factory.StartNew(() =>
{
int num = 0, num2 = 25;
var result = num2 / num;
});
task.Wait();
}
catch (AggregateException ex)
{
Console.WriteLine($"Task has finished with
exception {ex.InnerException.Message}");
}
Console.ReadLine();
}
}
当我们运行上述代码时,输出如下:
从多个任务处理异常
现在,我们将创建多个任务,然后尝试从中抛出异常。然后,我们将学习如何从调用者列出来自不同任务的不同异常:
static void Main(string[] args)
{
Task taskA = Task.Factory.StartNew(()=> throw
new DivideByZeroException());
Task taskB = Task.Factory.StartNew(()=> throw
new ArithmeticException());
Task taskC = Task.Factory.StartNew(()=> throw
new NullReferenceException());
try
{
Task.WaitAll(taskA, taskB, taskC);
}
catch (AggregateException ex)
{
foreach (Exception innerException in ex.InnerExceptions)
{
Console.WriteLine(innerException.Message);
}
}
Console.ReadLine();
}
当我们运行上述代码时,输出如下:
在上述代码中,我们创建了三个抛出不同异常的任务,并使用Task.WaitAll
等待所有线程。正如你所看到的,通过调用WaitAll
观察异常,而不仅仅是启动任务,这就是为什么我们将WaitAll
包装在try
块中。WaitAll
方法将在所有传递给它的任务都通过抛出异常而故障,并执行相应的catch
块时返回。我们可以通过迭代AggregateException
类的InnerExceptions
属性找到所有任务产生的异常。
使用回调函数处理任务异常
找出这些异常的另一个选项是使用回调函数来访问和处理来自任务的异常:
static void Main(string[] args)
{
Task taskA = Task.Factory.StartNew(() => throw
new DivideByZeroException());
Task taskB = Task.Factory.StartNew(() => throw
new ArithmeticException());
Task taskC = Task.Factory.StartNew(() => throw
new NullReferenceException());
try
{
Task.WaitAll(taskA, taskB, taskC);
}
catch (AggregateException ex)
{
ex.Handle(innerException =>
{
Console.WriteLine(innerException.Message);
return true;
});
}
Console.ReadLine();
}
在 Visual Studio 中运行上述代码时,输出如下:
如前面的代码所示,我们订阅了AggregateException
上的处理回调函数,而不是整合InnerExceptions
。这对所有抛出异常的任务都会触发,我们可以返回true
,表示异常已经得到了优雅处理。
将 APM 模式转换为任务
传统的 APM 方法使用IAsyncResult
接口来创建使用两种方法设计模式的异步方法:BeginMethodName
和EndMethodName
。让我们试着理解程序从同步到 APM 再到任务的过程。
以下是一个从文本文件中读取数据的同步方法:
private static void ReadFileSynchronously()
{
string path = @"Test.txt";
//Open the stream and read content.
using (FileStream fs = File.OpenRead(path))
{
byte[] b = new byte[1024];
UTF8Encoding encoder = new UTF8Encoding(true);
fs.Read(b, 0, b.Length);
Console.WriteLine(encoder.GetString(b));
}
}
在前面的代码中没有什么花哨的。首先,我们创建了一个FileStream
对象并调用了Read
方法,该方法将文件从磁盘同步读入缓冲区,然后将缓冲区写入控制台。我们使用UTF8Encoding
类将缓冲区转换为字符串。然而,这种方法的问题在于一旦调用Read
,线程就会被阻塞,直到读取操作完成。I/O 操作由 CPU 使用 CPU 周期来管理,因此没有必要让线程等待 I/O 操作完成。让我们试着理解 APM 的做法:
private static void ReadFileUsingAPMAsyncWithoutCallback()
{
string filePath = @"Test.txt";
//Open the stream and read content.
using (FileStream fs = new FileStream(filePath,
FileMode.Open, FileAccess.Read, FileShare.Read,
1024, FileOptions.Asynchronous))
{
byte[] buffer = new byte[1024];
UTF8Encoding encoder = new UTF8Encoding(true);
IAsyncResult result = fs.BeginRead(buffer, 0,
buffer.Length, null, null);
Console.WriteLine("Do Something here");
int numBytes = fs.EndRead(result);
fs.Close();
Console.WriteLine(encoder.GetString(buffer));
}
}
如前面的代码所示,我们用异步版本替换了同步的Read
方法,即BeginRead
。一旦编译器遇到BeginRead
,就会向 CPU 发送指令开始读取文件,并解除线程阻塞。我们可以在同一方法中执行其他任务,然后通过调用EndRead
再次阻塞线程,等待Read
操作完成并收集结果。这是一个简单而有效的方法,以便制作响应式应用程序,尽管我们也在阻塞线程以获取结果。我们可以使用Overload
而不是在同一方法中调用EndRead
,它接受一个回调方法,当读取操作完成时会自动调用,以避免阻塞线程。该方法的签名如下:
public override IAsyncResult BeginRead(
byte[] array,
int offset,
int numBytes,
AsyncCallback userCallback,
object stateObject)
在这里,我们已经看到了我们是如何从同步方法转换为 APM 的。现在,我们将把 APM 实现转换为一个任务。这在以下代码中进行了演示:
private static void ReadFileUsingTask()
{
string filePath = @"Test.txt";
//Open the stream and read content.
using (FileStream fs = new FileStream(filePath, FileMode.Open,
FileAccess.Read, FileShare.Read, 1024,
FileOptions.Asynchronous))
{
byte[] buffer = new byte[1024];
UTF8Encoding encoder = new UTF8Encoding(true);
//Start task that will read file asynchronously
var task = Task<int>.Factory.FromAsync(fs.BeginRead,
fs.EndRead, buffer, 0, buffer.Length,null);
Console.WriteLine("Do Something while file is read
asynchronously");
//Wait for task to finish
task.Wait();
Console.WriteLine(encoder.GetString(buffer));
}
}
如前面的代码所示,我们用Task<int>.Factory.FromAsync
替换了BeginRead
方法。这是一种实现 TAP 的方法。该方法返回一个任务,在我们在同一方法中继续做其他工作的同时在后台运行,然后通过task.Wait()
再次阻塞线程以获取结果。这就是你可以轻松地将任何 APM 代码转换为 TAP 的方法。
将 EAP 转换为任务
EAP 用于创建包装昂贵和耗时操作的组件。因此,它们需要被异步化。这种模式已经被用于.NET Framework 中创建诸如BackgroundWorker
和WebClient
等组件。
实现这种模式的方法在后台异步执行长时间运行的任务,但通过事件不断通知用户它们的进度和状态,这就是为什么它们被称为基于事件的。
以下代码显示了一个使用 EAP 的组件的实现:
private static void EAPImplementation()
{
var webClient = new WebClient();
webClient.DownloadStringCompleted += (s, e) =>
{
if (e.Error != null)
Console.WriteLine(e.Error.Message);
else if (e.Cancelled)
Console.WriteLine("Download Cancel");
else
Console.WriteLine(e.Result);
};
webClient.DownloadStringAsync(new
Uri("http://www.someurl.com"));
}
在前面的代码中,我们订阅了DownloadStringCompleted
事件,一旦webClient
从 URL 下载文件,该事件就会触发。正如你所看到的,我们尝试使用 if-else 结构来读取各种结果选项,如异常、取消和结果。与 APM 相比,将 EAP 转换为 TAP 更加棘手,因为它需要对 EAP 组件的内部性质有很好的理解,因为我们需要将新代码插入到正确的事件中使其工作。让我们来看一下转换后的实现:
private static Task<string> EAPToTask()
{
var taskCompletionSource = new TaskCompletionSource<string>();
var webClient = new WebClient();
webClient.DownloadStringCompleted += (s, e) =>
{
if (e.Error != null)
taskCompletionSource.TrySetException(e.Error);
else if (e.Cancelled)
taskCompletionSource.TrySetCanceled();
else
taskCompletionSource.TrySetResult(e.Result);
};
webClient.DownloadStringAsync(new
Uri("http://www.someurl.com"));
return taskCompletionSource.Task;
}
将 EAP 转换为 TAP 的最简单方法是使用TaskCompletionSource
类。我们已经插入了所有的情景,并将结果、异常或取消结果设置为TaskCompletionSource
类的实例。然后,我们将包装的实现作为任务返回给用户。
更多关于任务
现在,让我们学习一些关于任务的更重要的概念,这可能会派上用场。到目前为止,我们创建的任务是独立的。然而,为了创建更复杂的解决方案,有时我们需要在任务之间定义关系。我们可以创建子任务、子任务以及继续任务来做到这一点。让我们通过例子来理解每一个。在本节的后面,我们将学习有关线程存储和队列的知识。
继续任务
继续任务更像是承诺。当我们需要链接多个任务时,我们可以利用它们。第二个任务在第一个任务完成时开始,并且第一个任务的结果或异常被传递给子任务。我们可以链式地创建多个任务,也可以使用 TPL 提供的方法创建选择性的继续链。TPL 提供了以下任务继续构造:
-
Task.ContinueWith
-
Task.Factory.ContinueWhenAll
-
Task.Factory.ContinueWhenAll<T>
-
Task.Factory.ContinueWhenAny
-
Task.Factory.ContinueWhenAny<T>
使用 Task.ContinueWith 方法继续任务
通过 TPL 提供的ContinueWith
方法可以轻松实现任务的继续。
让我们通过一个例子来理解简单的链接:
var task = Task.Factory.StartNew<DataTable>(() =>
{
Console.WriteLine("Fetching Data");
return FetchData();
}).ContinueWith(
(e) => {
var firstRow = e.Result.Rows[0];
Console.WriteLine("Id is {0} and Name is {0}",
firstRow["Id"], firstRow["Name"]);
});
在上面的例子中,我们需要获取并显示数据。主任务调用FetchData
方法。当它完成时,结果作为输入传递给继续任务,负责打印数据。输出如下:
我们也可以链式地创建多个任务,从而创建一系列任务,如下所示:
var task = Task.Factory.StartNew<int>(() => GetData()).
.ContinueWith((i) => GetMoreData(i.Result)).
.ContinueWith((j) => DisplayData(j.Result)));
我们可以通过将System.Threading.Tasks.TaskContinuationOptions
枚举作为参数传递来控制继续任务何时运行,该枚举具有以下选项:
-
None
: 这是默认选项。当主任务完成时,继续任务将运行。 -
OnlyOnRanToCompletion
: 当主任务成功完成时,继续任务将运行,这意味着它未被取消或出现故障。 -
NotOnRanToCompletion
: 当主任务已被取消或出现故障时,继续任务将运行。 -
OnlyOnFaulted
: 当主任务出现故障时,继续任务将运行。 -
NotOnFaulted
: 当主任务未出现故障时,继续任务将运行。 -
OnlyOnCancelled
: 当主任务已被取消时,继续任务将运行。 -
NotOnCancelled
: 当主任务未被取消时,继续任务将运行。
使用 Task.Factory.ContinueWhenAll 和 Task.Factory.ContinueWhenAll继续任务
我们可以等待多个任务,并链式地继续代码,只有当所有任务都成功完成时才会运行。让我们看一个例子:
private async static void ContinueWhenAll()
{
int a = 2, b = 3;
Task<int> taskA = Task.Factory.StartNew<int>(() => a * a);
Task<int> taskB = Task.Factory.StartNew<int>(() => b * b);
Task<int> taskC = Task.Factory.StartNew<int>(() => 2 * a * b);
var sum = await Task.Factory.ContinueWhenAll<int>(new Task[]
{ taskA, taskB, taskC }, (tasks)
=>tasks.Sum(t => (t as Task<int>).Result));
Console.WriteLine(sum);
}
在上面的代码中,我们想要计算a*a + b*b +2 *a *b
。我们将任务分解为三个单元:a*a
、b*b
和2*a*b
。每个单元由三个不同的线程执行:taskA
、taskB
和taskC
。然后,我们等待所有任务完成,并将它们作为第一个参数传递给ContinueWhenAll
方法。当所有线程完成执行时,由ContinueWhenAll
方法的第二个参数指定的继续委托执行。继续委托对所有线程执行的结果进行求和,并将其返回给调用者,然后在下一行打印出来。
使用 Task.Factory.ContinueWhenAny 和 Task.Factory.ContinueWhenAny继续任务
我们可以等待多个任务,并链式地继续代码,只有当任何一个任务成功完成时才会运行:
private static void ContinueWhenAny()
{
int number = 13;
Task<bool> taskA = Task.Factory.StartNew<bool>(() =>
number / 2 != 0);
Task<bool> taskB = Task.Factory.StartNew<bool>(() =>
(number / 2) * 2 != number);
Task<bool> taskC = Task.Factory.StartNew<bool>(() =>
(number & 1) != 0);
Task.Factory.ContinueWhenAny<bool>(new Task<bool>[]
{ taskA, taskB, taskC }, (task) =>
{
Console.WriteLine((task as Task<bool>).Result);
}
);
}
如前面的代码所示,我们有三种不同的逻辑来判断一个数字是否为奇数。假设我们不知道哪种逻辑会最快。为了计算结果,我们创建了三个任务,每个任务封装了不同的奇数查找逻辑,并并发运行它们。由于一个数字同时可以是奇数或偶数,所有线程的结果将是相同的,但在执行速度上会有所不同。因此,只需获取第一个结果并丢弃其余结果是有意义的。这就是我们使用ContinueWhenAny
方法实现的。
父任务和子任务
线程之间可能发生的另一种关系是父子关系。子任务作为父任务主体内的嵌套任务创建。子任务可以作为附加或分离创建。默认情况下,创建的任务是分离的。我们可以通过将任务的AttachedToParent
属性设置为true
来创建附加任务。您可能希望在以下情况之一中考虑创建附加任务:
-
所有在子任务中抛出的异常都需要传播到父任务
-
父任务的状态取决于子任务
-
父任务需要等待子任务完成
创建一个分离的任务
创建分离类的代码如下:
Task parentTask = Task.Factory.StartNew(() =>
{
Console.WriteLine(" Parent task started");
Task childTask = Task.Factory.StartNew(() => {
Console.WriteLine(" Child task started");
});
Console.WriteLine(" Parent task Finish");
});
//Wait for parent to finish
parentTask.Wait();
Console.WriteLine("Work Finished");
如您所见,我们在一个任务的主体内创建了另一个任务。默认情况下,子任务或嵌套任务是作为分离的创建的。我们通过调用parentTask.Wait()
等待父任务完成。在以下输出中,您可以看到父任务不等待子任务完成,先完成,然后是子任务的开始:
创建一个附加任务
附加任务的创建方式与分离任务类似。唯一的区别是我们将任务的AttachedParent
属性设置为true
。这在以下代码片段中得到了演示:
Task parentTask = Task.Factory.StartNew(() =>
{
Console.WriteLine("Parent task started");
Task childTask = Task.Factory.StartNew(() => {
Console.WriteLine("Child task started");
},TaskCreationOptions.AttachedToParent);
Console.WriteLine("Parent task Finish");
});
//Wait for parent to finish
parentTask.Wait();
Console.WriteLine("Work Finished");
输出如下:
在这里,您可以看到父任务直到子任务执行完成才结束。
在本节中,我们讨论了任务的高级方面,包括创建任务之间的关系。在下一节中,我们将更深入地了解任务内部的工作,理解工作队列的概念以及任务如何处理它们。
工作窃取队列
工作窃取是线程池的性能优化技术。每个线程池维护一个任务的全局队列,这些任务是在进程内创建的。在第一章中,并行编程简介,我们了解到线程池维护了一定数量的工作线程来处理任务。ThreadPool
还维护一个线程全局队列,在这里它将所有工作项排队,然后才能分配给可用线程。由于这是一个单一队列,并且我们在多线程场景中工作,我们需要使用同步原语来实现线程安全。由于存在单一全局队列,同步会导致性能损失。
.NET Framework 通过引入本地队列的概念来解决这种性能损失,本地队列由线程管理。每个线程都可以访问全局队列,并且还维护自己的线程本地队列来存储工作项。父任务可以在全局队列中调度。当任务执行并且需要创建子任务时,它们可以堆叠在本地队列上,并且在线程执行完成后立即使用 FIFO 算法进行处理。
下图描述了全局队列、本地队列、线程和Threadpool
之间的关系:
假设主线程创建了一组任务。所有这些任务都排队到全局队列中,以便根据线程池中线程的可用性稍后执行。以下图表描述了带有所有排队任务的全局队列:
假设任务 1被安排在线程 1上,任务 2被安排在线程 2上,依此类推,如下图所示:
如果任务 1和任务 2生成更多的任务,新任务将被存储在线程本地队列中,如下图所示:
同样,如果这些子任务创建了更多的任务,它们将进入本地队列而不是全局队列。一旦线程 1完成了任务 1,它将查看其本地队列并选择最后一个任务(LIFO)。最后一个任务可能仍然在缓存中,因此不需要重新加载。这再次提高了性能。
一旦线程(T1)耗尽了其本地队列,它将在全局队列中搜索。如果全局队列中没有项目,它将在其他线程(比如 T2)的本地队列中搜索。这种技术称为工作窃取,是一种优化技术。这次,它不会从 T2 中选择最后一个任务(LIFO),因为最后一个项目可能仍然在 T2 线程的缓存中。相反,它选择第一个任务(FIFO),因为线程已经移出了 T2 的缓存,这样可以提高性能。这种技术通过使缓存任务可用于本地线程和使缓存之外的任务可用于其他线程来提高性能。
总结
在本章中,我们讨论了如何将任务分解为更小的单元,以便每个单元可以由一个线程独立处理。我们还学习了利用ThreadPool
创建任务的各种方法。我们介绍了与任务的内部工作相关的各种技术,包括工作窃取和任务创建或取消的概念。我们将在本书的其余部分利用本章中获得的知识。
在下一章中,我们将介绍数据并行性的概念。这将包括使用并行循环和处理其中的异常。
第三章:实现数据并行性
到目前为止,我们已经了解了并行编程、任务和任务并行性的基础知识。在本章中,我们将涵盖并行编程的另一个重要方面,即处理数据的并行执行:数据并行性。虽然任务并行性为每个参与线程创建了一个单独的工作单元,但数据并行性创建了一个由源集合中的每个参与线程执行的共同任务。这个源集合被分区,以便多个线程可以同时对其进行处理。因此,了解数据并行性对于从循环/集合中获得最大性能至关重要。
在本章中,我们将讨论以下主题:
-
在并行循环中处理异常
-
在并行循环中创建自定义分区策略
-
取消循环
-
理解并行循环中的线程存储
技术要求
要完成本章,您应该对 TPL 和 C#有很好的理解。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter03
。
从顺序循环转换为并行循环
TPL 通过System.Threading.Tasks.Parallel
类支持数据并行性,该类提供了For
和Foreach
循环的并行实现。作为开发人员,您不需要担心同步或创建任务,因为这由并行类处理。这种语法糖使您可以轻松地编写并行循环,方式类似于您一直在编写顺序循环。
以下是一个顺序for
循环的示例,它通过将交易对象发布到服务器来预订交易:
foreach (var trade in trades)
{
Book(trade);
}
由于循环是顺序的,完成循环所需的总时间是预订一笔交易所需的时间乘以交易的总数。这意味着随着交易数量的增加,循环会变慢,尽管交易预订时间保持不变。在这里,我们处理的是大量数据。由于我们将在服务器上预订交易,并且所有服务器都支持多个请求,将这个循环从顺序循环转换为并行循环是有意义的,因为这将给我们带来显著的性能提升。
可以将先前的代码转换为并行代码,如下所示:
Parallel.ForEach(trades, trade => Book(trade));
在运行并行循环时,TPL 对源集合进行分区,以便循环可以同时在多个部分上执行。任务的分区是由TaskScheduler
类完成的,该类在创建分区时考虑系统资源和负载。我们还可以创建一个自定义分区器或调度器,正如我们将在本章的创建自定义分区策略部分中看到的。
数据并行性表现更好,如果分区单元是独立的。通过一种称为减少的技术,我们还可以创建依赖分区单元,以最小的性能开销将一系列操作减少为标量值。有三种方法可以将顺序代码转换为并行代码:
-
使用
Parallel.Invoke
方法 -
使用
Parallel.For
方法 -
使用
Parallel.ForEach
方法
让我们试着了解Parallel
类可以用于展示数据并行性的各种方式。
使用 Parallel.Invoke 方法
这是以并行方式执行一组操作的最基本方式,也是并行for
和foreach
循环的基础。Parallel.Invoke
方法接受一个操作数组作为参数并执行它们,尽管它不能保证操作将并行执行。在使用Parallel.Invoke
时有一些重要的要点需要记住:
-
并行性不能保证。操作是并行执行还是按顺序执行将取决于
TaskScheduler
。 -
Parallel.Invoke
不能保证传递的操作的顺序。 -
它会阻塞调用线程,直到所有的动作都完成。
Parallel.Invoke
的语法如下:
public static void Invoke(
params Action[] actions
)
我们可以传递一个动作或一个 lambda 表达式,如下例所示:
try
{
Parallel.Invoke(() => Console.WriteLine("Action 1"),
new Action(() => Console.WriteLine("Action 2")));
}
catch(AggregateException aggregateException)
{
foreach (var ex in aggregateException.InnerExceptions)
{
Console.WriteLine(ex.Message);
}
}
Console.WriteLine("Unblocked");
Console.ReadLine();
Invoke
方法的行为就像一个附加的子任务,因为它被阻塞,直到所有的动作都完成。所有的异常都被堆叠在System.AggregateException
中,并抛出给调用者。在前面的代码中,由于没有异常,我们将看到以下输出:
我们可以使用Task
类来实现类似的效果,尽管与Parallel.Invoke
的工作方式相比,这可能看起来非常复杂:
Task.Factory.StartNew(() => {
Task.Factory.StartNew(() => Console.WriteLine("Action 1"),
TaskCreationOptions.AttachedToParent);
Task.Factory.StartNew(new Action(() => Console.WriteLine("Action 2"))
, TaskCreationOptions.AttachedToParent);
});
Invoke
方法的行为就像一个附加的子任务,因为它被阻塞,直到所有的动作都完成。所有的异常都被堆叠在System.AggregateException
中,并抛出给调用者。
使用 Parallel.For 方法
Parallel.For
是顺序for
循环的一个变体,不同之处在于迭代是并行运行的。Parallel.For
返回ParallelLoopResult
类的一个实例,一旦循环执行完成,它提供了循环完成状态。我们还可以检查ParallelLoopResult
的IsCompleted
和LowestBreakIteration
属性,以找出方法是否已完成或取消,或者用户是否已调用了 break。以下是可能的情况:
IsCompleted |
LowestBreakIteration |
原因 |
---|---|---|
True | N/A | 运行完成 |
False | Null | 循环在匹配前停止 |
False | 非空整数值 | 在循环中调用 Break |
Parallel.For
方法的基本语法如下:
public static ParallelLoopResult For
{
Int fromIncalme,
Int toExclusiveme,
Action<int> action
}
这个例子如下所示:
Parallel.For (1, 100, (i) => Console.WriteLine(i));
如果你不想取消、中断或维护任何线程本地状态,并且执行顺序不重要,这种方法可能很有用。例如,想象一下我们想要计算今天在一个目录中创建的文件的数量。代码如下:
int totalFiles = 0;
var files = Directory.GetFiles("C:\\");
Parallel.For(0, files.Length, (i) =>
{
FileInfo fileInfo = new FileInfo(files[i]);
if (fileInfo.CreationTime.Day == DateTime.Now.Day)
Interlocked.Increment(ref totalFiles);
});
Console.WriteLine($"Total number of files in C: drive are {files.Count()} and {totalFiles} files were created today.");
这段代码迭代了C:
驱动器中的所有文件,并计算了今天创建的文件的数量。以下是我机器上的输出:
在下一节中,我们将尝试理解Parallel.ForEach
方法,它提供了ForEach
循环的并行变体。
对于一些集合,根据循环的语法和正在进行的工作的类型,顺序执行可能更快。
使用 Parallel.ForEach 方法
这是ForEach
循环的一个变体,其中迭代可以并行运行。源集合被分区,然后工作被安排在多个线程上运行。Parallel.ForEach
适用于通用集合,并且像for
循环一样返回ParallelLoopResult
。
Parallel.ForEach
循环的基本语法如下:
Parallel.ForEach<TSource>(
IEnumerable<TSource> Source,
Action<TSource> body
)
这个例子如下所示。我们有一个需要监视的端口列表。我们还需要更新它们的状态:
List<string> urls = new List<string>() {"www.google.com" , "www.yahoo.com","www.bing.com" };
Parallel.ForEach(urls, url =>
{
Ping pinger = new Ping();
Console.WriteLine($"Ping Url {url} status is {pinger.Send(url).Status}
by Task {Task.CurrentId}");
});
在前面的代码中,我们使用了System.Net.NetworkInformation.Ping
类来 ping 一个部分,并在控制台上显示状态。由于这些部分是独立的,如果代码并行执行并且顺序也不重要,我们可以实现很好的性能。
以下屏幕截图显示了前面代码的输出:
并行性可能会使单核处理器上的应用程序变慢。我们可以通过使用并行度来控制并行操作中可以利用多少核心,接下来我们将介绍这个。
理解并行度
到目前为止,我们已经学习了数据并行性如何使我们能够在系统的多个核心上并行运行循环,从而有效利用可用的 CPU 资源。您应该知道还有另一个重要的概念,可以用来控制您想要在循环中创建多少任务。这个概念叫做并行度。这是一个指定可以由并行循环创建的最大任务数的数字。您可以通过一个名为MaxDegreeOfParallelism
的属性来设置并行度,这是ParallelOptions
类的一部分。以下是Parallel.For
的语法,您可以通过它传递ParallelOptions
实例:
public static ParallelLoopResult For(
int fromInclusive,
int toExclusive,
ParallelOptions parallelOptions,
Action<int> body
)
以下是Parallel.For
和Parallel.ForEach
方法的语法,您可以通过它传递ParallelOptions
实例:
public static ParallelLoopResult ForEach<TSource>(
IEnumerable<TSource> source,
ParallelOptions parallelOptions,
Action<TSource> body
)
并行度的默认值为 64,这意味着并行循环可以通过创建这么多任务来利用系统中多达 64 个处理器。我们可以修改这个值来限制任务的数量。让我们通过一些例子来理解这个概念。
让我们看一个MaxDegreeOfParallelism
设置为4
的Parallel.For
循环的例子:
Parallel.For(1, 20, new ParallelOptions { MaxDegreeOfParallelism = 4 }, index =>
{
Console.WriteLine($"Index {index} executing on Task Id
{Task.CurrentId}");
});
输出如下:
正如您所看到的,循环由四个任务执行,分别用任务 ID 1、2、3 和 4 表示。
这是一个MaxDegreeOfParallelism
设置为4
的Parallel.ForEach
循环的例子:
var items = Enumerable.Range(1, 20);
Parallel.ForEach(items, new ParallelOptions { MaxDegreeOfParallelism = 4 }, item =>
{
Console.WriteLine($"Index {item} executing on Task Id
{Task.CurrentId}");
});
输出如下:
正如您所看到的,这个循环由四个任务执行,分别用任务 ID 1、2、3 和 4 表示。
我们应该修改这个设置以适应高级场景,例如我们知道运行的算法不能跨越超过一定数量的处理器。如果我们同时运行多个算法并且希望限制每个算法只利用一定数量的处理器,我们也应该修改这个设置。接下来,我们将学习如何通过引入分区策略的概念在集合中创建自定义分区。
创建自定义分区策略
分区是数据并行性中的另一个重要概念。为了在源集合中实现并行性,它需要被分割成称为范围或块的较小部分,这些部分可以被各个线程同时访问。没有分区,循环将串行执行。分区器可以分为两类,我们也可以创建自定义分区器。这些类别如下:
-
范围分区
-
块分区
让我们详细讨论这些。
范围分区
这种类型的分区主要用于长度预先已知的集合。顾名思义,每个线程都会得到一系列元素来处理,或者源集合的起始和结束索引。这是分区的最简单形式,在某种程度上非常高效,因为每个线程都会执行其范围而不会覆盖其他线程。虽然在创建范围时会有一些性能损失,但没有同步开销。这种类型的分区在每个范围中的元素数量相同时效果最佳,这样它们将花费相似的时间来完成。对于不同数量的元素,一些任务可能会提前完成并处于空闲状态,而其他任务可能在范围内有很多待处理的元素。
块分区
这种类型的分区主要用于LinkedList
等集合,其中长度事先不知道。分块分区在您有不均匀的集合的情况下提供更多的负载平衡。每个线程都会挑选一块元素进行处理,然后再回来挑选其他线程尚未挑选的另一块。块的大小取决于分区器的实现,并且有同步开销来确保分配给两个线程的块不包含重复项。
我们可以更改Parallel.ForEach
循环的默认分区策略,以执行自定义的分块分区,如下例所示:
var source = Enumerable.Range(1, 100).ToList();
OrderablePartitioner<Tuple<int,int>> orderablePartitioner= Partitioner.Create(1, 100);
Parallel.ForEach(orderablePartitioner, (range, state) =>
{
var startIndex = range.Item1;
var endIndex = range.Item2;
Console.WriteLine($"Range execution finished on task
{Task.CurrentId} with range
{startRange}-{endRange}");
});
在前面的代码中,我们使用OrderablePartitioner
类在一系列项目(这里是从1
到100
)上创建了分块分区器。我们将分区器传递给ForEach
循环,其中每个块都传递给一个线程并执行。输出如下:
到目前为止,我们对并行循环的工作原理有了很好的理解。现在,我们需要讨论一些高级概念,以便更多地了解如何控制循环执行;也就是说,如何根据需要停止循环。
取消循环
我们在顺序循环中使用了break
和continue
等结构;break
用于通过完成当前迭代并跳过其余部分来跳出循环,而continue
则跳过当前迭代并移动到其余的迭代。这些结构可以使用,因为顺序循环由单个线程执行。在并行循环的情况下,我们不能使用break
和continue
关键字,因为它们在多个线程或任务上运行。要中断并行循环,我们需要使用ParallelLoopState
类。要取消循环,我们需要使用CancellationToken
和ParallelOptions
类。
在本节中,我们将讨论取消循环所需的选项:
-
Parallel.Break
-
ParallelLoopState.Stop
-
CancellationToken
让我们开始吧!
使用 Parallel.Break 方法
Parallel.Break
试图模仿顺序执行的结果。让我们看看如何从并行循环中break
。在以下代码中,我们需要搜索一个数字列表以查找特定数字。当找到匹配项时,我们需要中断循环的执行:
var numbers = Enumerable.Range(1, 1000);
int numToFind = 2;
Parallel.ForEach(numbers, (number, parallelLoopState) =>
{
Console.Write(number + "-");
if (number == numToFind)
{
Console.WriteLine($"Calling Break at {number}");
parallelLoopState.Break();
}
});
如前面的代码所示,循环应该在找到数字2
之前运行。使用顺序循环,它将在第二次迭代时精确中断。对于并行循环,由于迭代在多个任务上运行,实际上会打印出大于 2 的值,如下面的输出所示:
为了跳出循环,我们调用了parallelLoopState.Break()
,它试图模仿顺序循环中实际break
关键字的行为。当任何一个核心遇到Break()
方法时,它将在ParallelLoopState
对象的LowestBreakIteration
属性中设置一个迭代号。这成为可以执行的最大数字或最后一个迭代。所有其他任务将继续迭代,直到达到这个数字。
通过并行运行迭代来连续调用Break
方法,进一步减少LowestBreakIteration
,如下面的代码所示:
var numbers = Enumerable.Range(1, 1000);
Parallel.ForEach(numbers, (i, parallelLoopState) =>
{
Console.WriteLine($"For i={i} LowestBreakIteration =
{parallelLoopState.LowestBreakIteration} and
Task id ={Task.CurrentId}");
if (i >= 10)
{
parallelLoopState.Break();
}
});
当我们在 Visual Studio 中运行前面的代码时,我们会得到以下输出:
在这里,我们在多核处理器上运行代码。正如您所看到的,许多迭代得到了LowestBreakIteration
的空值,因为代码是在多个核上执行的。在第 17 次迭代时,一个核心调用了Break()
方法,并将LowestBreakIteration
的值设置为 17。在第 10 次迭代时,另一个核心调用Break()
并进一步将数字减少到 10。后来,在第 9 次迭代时,另一个核心调用了Break()
,并进一步将数字减少到 9。
使用 ParallelLoopState.Stop
如果你不想模仿顺序循环的结果,而是想尽快退出循环,你可以调用ParallelLoopState.Stop
。就像我们用Break()
方法一样,所有并行运行的迭代在循环退出之前都会完成:
var numbers = Enumerable.Range(1, 1000);
Parallel.ForEach(numbers, (i, parallelLoopState) =>
{
Console.Write(i + " ");
if (i % 4 == 0)
{
Console.WriteLine($"Loop Stopped on {i}");
parallelLoopState.Stop();
}
});
在 Visual Studio 中运行上述代码时,输出如下:
正如你所看到的,一个核心在第 4 次迭代时调用了Stop
,另一个核心在第 8 次迭代时调用了Stop
,第三个核心在第 12 次迭代时调用了Stop
。迭代 3 和 10 仍然执行,因为它们已经被安排执行。
使用 CancellationToken 取消循环
与普通任务一样,我们可以使用CancellationToken
类来取消Parallel.For
和Parallel.ForEach
循环。当我们取消令牌时,循环将完成当前可能并行运行的迭代,但不会开始新的迭代。一旦现有的迭代完成,并行循环会抛出OperationCanceledException
。
让我们举个例子来看看。首先,我们将创建一个取消令牌源:
CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
然后,我们将创建一个在五秒后取消令牌的任务:
Task.Factory.StartNew(() =>
{
Thread.Sleep(5000);
cancellationTokenSource.Cancel();
Console.WriteLine("Token has been cancelled");
});
之后,我们将通过传递取消令牌来创建一个并行选项对象:
ParallelOptions loopOptions = new ParallelOptions()
{
CancellationToken = cancellationTokenSource.Token
};
接下来,我们将运行一个持续时间超过五秒的操作:
try
{
Parallel.For(0, Int64.MaxValue, loopOptions, index =>
{
Thread.Sleep(3000);
double result = Math.Sqrt(index);
Console.WriteLine($"Index {index}, result {result}");
});
}
catch (OperationCanceledException)
{
Console.WriteLine("Cancellation exception caught!");
}
在 Visual Studio 中运行上述代码时,输出如下:
正如你所看到的,即使取消令牌已被调用,预定的迭代仍然会执行。希望这能让你对我们如何根据程序要求取消循环有一个很好的理解。并行编程的另一个重要方面是存储的概念。我们将在下一节讨论这个问题。
理解并行循环中的线程存储
默认情况下,所有并行循环都可以访问全局变量。然而,访问全局变量会带来同步开销,因此在可能的情况下,最好使用线程范围的变量。我们可以创建一个线程本地或分区本地变量来在并行循环中使用。
线程本地变量
线程本地变量就像特定任务的全局变量。它们的生命周期跨越循环要执行的迭代次数。
在下面的例子中,我们将使用for
循环来查看线程本地变量。在Parallel.For
循环的情况下,会创建多个任务来运行迭代。假设我们需要通过并行循环找出 60 个数字的总和。
举个例子,假设有四个任务,每个任务有 15 次迭代。实现这一点的一种方法是创建一个全局变量。每次迭代后,运行的任务都应该更新全局变量。这将需要同步开销。对于四个任务,将会有四个对每个任务私有的线程本地变量。任务将更新变量,并且最后更新的值可以返回给调用程序,然后可以用来更新全局变量。
以下是要遵循的步骤:
- 创建一个包含 60 个数字的集合,其中每个项目的值都等于索引:
var numbers = Enumerable.Range(1, 60);
- 创建一个完成的操作,一旦任务完成了所有分配的迭代,就会执行。该方法将接收线程本地变量的最终结果,并将其添加到全局变量
sumOfNumbers
中:
long sumOfNumbers = 0;
Action<long> taskFinishedMethod = (taskResult) =>
{
Console.WriteLine($"Sum at the end of all task iterations for task
{Task.CurrentId} is {taskResult}");
Interlocked.Add(ref sumOfNumbers, taskResult);
};
- 创建一个
For
循环。前两个参数是startIndex
和endIndex
。第三个参数是一个委托,为线程本地变量提供种子值。这是一个需要任务执行的操作。在我们的例子中,我们只是将索引分配给subtotal
,这是我们的线程本地变量。
假设有一个任务TaskA,它获取索引从 1 到 5 的迭代。TaskA将这些迭代相加为 1+2+3+4+5。这等于 15,将作为任务的结果返回,并作为参数传递给taskFinishedMethod
:
Parallel.For(0,numbers.Count(),
() => 0,
(j, loop, subtotal) =>
{
subtotal += j;
return subtotal;
},
taskFinishedMethod
);
Console.WriteLine($"The total of 60 numbers is {sumOfNumbers}");
在 Visual Studio 中运行上述代码时,输出如下:
请记住,输出可能因可用核心数量不同而在不同的机器上有所不同。
分区本地变量
这类似于线程本地变量,但适用于分区。正如您所知,ForEach
循环将源集合分成多个分区。每个分区将有其自己的分区本地变量副本。对于线程本地变量,每个线程只有一个变量副本。然而,在这里,由于单个线程上可以运行多个分区,因此每个线程可以有多个副本。
首先,我们需要创建一个ForEach
循环。第一个参数是源集合,即数字。第二个参数是为线程本地变量提供种子值的委托。第三个参数是任务需要执行的操作。在我们的情况下,我们只是将索引分配给subtotal
,这是我们的线程本地变量。
为了理解,假设有一个任务TaskA,它获取索引从 1 到 5 的迭代。TaskA将这些迭代相加,即 1+2+3+4+5。这等于 15,将作为任务的结果返回,并作为参数传递给taskFinishedMethod
。
以下是代码:
Parallel.ForEach<int, long>(numbers,
() => 0, // method to initialize the local variable
(j, loop, subtotal) => // Action performed on each iteration
{
subtotal += j; //Subtotal is Thread local variable
return subtotal; // value to be passed to next iteration
},
taskFinishedMethod);
Console.WriteLine($"The total of 60 numbers is {sumOfNumbers}");
同样,在这种情况下,输出将因可用核心数量不同而在不同的机器上有所不同。
总结
在本章中,我们详细介绍了使用 TPL 实现任务并行性。我们首先介绍了如何使用 TPL 提供的一些内置方法,如Parallel.Invoke
、Parallel.For
和Parallel.ForEach
,将顺序循环转换为并行循环。接下来,我们讨论了如何通过了解并行度和分区策略来充分利用可用的 CPU 资源。然后,我们讨论了如何使用内置构造(如取消标记、Parallel.Break
和ParallelLoopState.Stop
)取消并跳出并行循环。在本章末尾,我们讨论了 TPL 中可用的各种线程存储选项。
TPL 提供了一些非常令人兴奋的选项,我们可以通过For
和ForEach
循环的并行实现来实现数据并行性。除了ParallelOptions
和ParallelLoopState
等功能外,我们还可以在不丢失太多同步开销的情况下实现显著的性能优势和控制。
在下一章中,我们将看到并行库的另一个令人兴奋的特性,称为PLINQ。
问题
-
以下哪个不是 TPL 中提供
for
循环的正确方法? -
Parallel.Invoke
-
Parallel.While
-
Parallel.For
-
Parallel.ForEach
-
哪个不是默认的分区策略?
-
批量分区
-
范围分区
-
块分区
-
并行度的默认值是多少?
-
1
-
64
-
Parallel.Break
保证一旦执行就立即返回。 -
真
-
假
-
一个线程能看到另一个线程的线程本地或分区本地值吗?
-
是
-
不
第四章:使用 PLINQ
PLINQ 是Language Integrate Query(LINQ)的并行实现。PLINQ 首次在.NET Framework 4.0 中引入,此后已经变得功能丰富。在 LINQ 之前,开发人员很难从各种数据源(如 XML 或数据库)中获取数据,因为每个源都需要不同的技能。LINQ 是一种语言语法,依赖于.NET 委托和内置方法来查询或修改数据,而无需担心学习低级任务。
在本章中,我们将首先了解.NET 中的 LINQ 提供程序。随着 PLINQ 成为程序员的首选,我们将涵盖其各种编程方面,以及与之相关的一些缺点。最后,我们将了解影响 PLINQ 性能的因素。
我们将涵盖以下主题:
-
.NET 中的 LINQ 提供程序
-
编写 PLINQ 查询
-
在 PLINQ 中保持顺序
-
PLINQ 中的合并选项
-
在 PLINQ 中处理异常
-
组合并行和顺序查询
-
PLINQ 的缺点
-
PLINQ 中的加速
技术要求
要完成本章,您应该对 TPL 和 C#有很好的了解。本章的源代码可在 GitHub 上找到github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter04
。
.NET 中的 LINQ 提供程序
LINQ 是一组 API,帮助我们更轻松地处理 XML、对象和数据库。LINQ 有许多提供程序,包括以下常用的:
-
对象的 LINQ:LINQ 到对象允许开发人员查询内存中的对象,如数组、集合、泛型类型等。它返回一个
IEnumerable
,支持排序、过滤、分组、排序和聚合函数等功能。其功能在System.Linq
命名空间中定义。 -
LINQ 到 XML:LINQ 到 XML 允许开发人员查询或修改 XML 数据源。它在
System.Xml.Linq
命名空间中定义。 -
LINQ 到 ADO.NET:LINQ 到 ADO.NET 不是一个技术,而是一组技术,允许开发人员查询或修改关系数据源,如 SQL Server、MySQL 或 Oracle。
-
LINQ 到 SQL:也称为 DLINQ。DLINQ 使用对象关系映射(ORM),是微软支持但不再增强的传统技术。它仅适用于 SQL Server,并允许用户将数据库表映射到.NET 类。它还有一个适配器,类似于开发人员接口到数据库。
-
LINQ 到数据集:这允许开发人员查询或修改内存中的数据集。它与 ADO.NET 支持的任何数据库一起工作。
-
实体的 LINQ:这是最先进和最受追捧的技术。它允许开发人员使用任何关系数据库,包括 SQL Server、Oracle、IBM Db2 和 MySQL。LINQ to entities 还支持 ORM。
-
PLINQ:也称为 PLINQ。PLINQ 是对象的 LINQ 的并行实现。LINQ 查询是顺序执行的,对于大量计算操作来说可能非常慢。PLINQ 通过在多个线程上调度任务,并且可选地在多个核心上运行,支持查询的并行执行。
.NET 支持使用AsParallel()
方法将 LINQ 无缝转换为 PLINQ。PLINQ 是进行大量计算操作的非常好的选择。它通过将源数据分成块,然后由运行在多个核心上的不同线程执行来工作。PLINQ 还支持 XLINQ 和 LINQ 到对象。
编写 PLINQ 查询
要理解 PLINQ 查询,我们需要先了解ParallelEnumerable
类。一旦我们了解了ParallelEnumerable
类,我们将学习如何编写并行查询。
介绍 ParallelEnumerable 类
ParallelEnumerable
类位于System.Linq
命名空间和System.Core
程序集中。
除了支持 LINQ 定义的大多数标准查询操作符之外,ParallelEnumerable
类还支持许多额外的支持并行执行的方法:
-
AsParallel()
: 这是并行化所需的种子方法。 -
AsSequential()
:通过改变并行行为,启用并行查询的顺序执行。 -
AsOrdered()
: 默认情况下,PLINQ 不保留任务执行和结果返回的顺序。我们可以通过调用AsOrdered()
方法来保留这个顺序。 -
AsUnordered()
:这是ParallelQuery
的默认行为,可以通过AsOrdered()
方法覆盖。我们可以通过调用这个方法将行为从有序改为无序。 -
ForAll()
:启用并行执行查询。 -
Aggregate()
: 这个方法可以用来聚合并行查询中各个线程本地分区的结果。 -
WithDegreesOfParallelism()
:使用这个方法,我们可以指定用于并行化查询执行的最大处理器数量。 -
WithParallelOption()
:使用这个方法,我们可以缓冲并行查询产生的结果。 -
WithExecutionMode()
:使用这个方法,我们可以强制查询的并行执行,或者让 PLINQ 决定查询是否需要以顺序或并行方式执行。
我们将通过代码示例在本章后面学习更多关于这些方法的内容。这里值得一提的是一个非常方便的工具叫做 LINQPad。LINQPad 帮助我们学习关于 LINQ/PLINQ 查询,因为它有 500 多个可用的示例和连接到各种数据源的能力。您可以从www.linqpad.net/
下载它。
我们的第一个 PLINQ 查询
假设我们想要找到所有可以被三整除的数字。
首先,我们定义一个范围为 100,000 的数字:
var range = Enumerable.Range(1, 100000);
要顺序找到所有可以被三整除的数字,使用以下 LINQ 查询:
var resultList = range.Where(i => i % 3 == 0).ToList();
以下是使用AsParallel
方法的相同查询的并行版本,但使用方法语法:
var resultList = range.AsParallel().Where(i => i % 3 == 0).ToList();
以下是在 LINQ 中使用查询语法选项的相同版本:
var resultList = (from i in range.AsParallel()
where i % 3 == 0
select i).ToList();
以下是完整的代码:
var range = Enumerable.Range(1, 100000);
//Here is sequential version
var resultList = range.Where(i => i % 3 == 0).ToList();
Console.WriteLine($"Sequential: Total items are {resultList.Count}");
//Here is Parallel Version using .AsParallel method
resultList = range.AsParallel().Where(i => i % 3 == 0).ToList();
resultList = (from i in range.AsParallel()
where i % 3 == 0
select i).ToList();
Console.WriteLine($"Parallel: Total items are {resultList.Count}" );
Console.WriteLine($"Parallel: Total items are {resultList.Count}");
这将产生以下输出:
在进行并行执行时保留 PLINQ 中的顺序
PLINQ 并行执行工作项,并且默认情况下不关心保留项目的顺序以提高并行查询的性能。然而,有时重要的是项目按照它们在源集合中的顺序执行。例如,想象一下,您正在向服务器发送多个请求以按块下载文件,然后在客户端合并这些块以重新创建文件。由于文件是分部分下载的,每个部分都需要按正确的顺序下载和合并。在并行执行项目时保留顺序对性能有直接影响,因为我们需要在整个分区中保留原始顺序,并在合并项目时确保顺序一致。
我们可以通过在源集合上使用AsOrdered()
方法来覆盖默认行为并打开顺序保留。如果在任何时候,我们想要关闭顺序保留,我们可以调用AsUnOrdered()
方法。
让我们看一个例子:
var range = Enumerable.Range(1, 10);
Console.WriteLine("Sequential Ordered");
range.ToList().ForEach(i => Console.Write(i + "-"));
这段代码是顺序的,所以当我们运行它时,我们得到以下输出:
我们可以使用AsParallel()
方法制作一个并行版本:
Console.WriteLine("Parallel Unordered");
var unordered = range.AsParallel().Select(i => i).ToList();
unordered.ForEach(i => Console.WriteLine(i));
上面的代码是并行执行的,但是顺序全乱了:
为了兼顾并行执行和顺序,我们可以修改代码如下:
var range = Enumerable.Range(1, 10);
Console.WriteLine("Parallel Ordered");
var ordered = range.AsParallel().AsOrdered().Select(i => i).ToList(); ordered.ForEach(i => Console.WriteLine(i));
以下是输出:
如您所见,当我们调用AsOrdered()
方法时,它会并行执行所有工作项,同时保留原始顺序,而在默认情况下,顺序未被保留。使用AsOrdered()
方法的性能影响巨大,因为顺序在执行的每个步骤中都得到恢复。
使用 AsUnOrdered()方法进行顺序执行
一旦我们在 PLINQ 上调用了AsOrdered
,查询将会顺序执行。可能会有一些情况,我们希望在一定时间内按顺序执行查询,但之后改为无序以获得性能。
假设我们想要生成前 100 个数字的平方,我们可以并行执行如下:
var range = Enumerable.Range(100, 10000);
var ordered = range.AsParallel().AsOrdered().Take(100).Select(i => i * i);
我们需要AsOrdered()
来获取前 100 个数字。问题在于Select
查询也将按顺序执行。我们可以通过结合AsOrdered()
和AsUnOrdered()
来提高性能:
var range = Enumerable.Range(100, 10000);
var ordered = range.AsParallel().AsOrdered().Take(100).AsUnordered().Select(i => i * i).ToList();
现在,前 100 个项目将并行按顺序检索。之后,查询将在不保留任何顺序的情况下执行。
PLINQ 中的合并选项
正如我们之前提到的,当我们创建并行查询时,源集合被分区,以便多个任务可以同时处理部分。一旦查询完成,结果就需要合并,以便它们可以提供给消费线程。根据查询运算符,可以指定如何显式合并结果,使用ParallelMergeOperation
枚举和WithMergeOption()
扩展方法。
让我们看看我们可以使用的各种合并选项。
使用 NotBuffered 合并选项
并发任务的结果不会被缓冲。一旦任何任务完成,它们就会将结果返回给消费线程:
var range = ParallelEnumerable.Range(1, 100);
Stopwatch watch = null;
ParallelQuery<int> notBufferedQuery = range.WithMergeOptions(ParallelMergeOptions.NotBuffered)
.Where(i => i % 10 == 0)
.Select(x => {
Thread.SpinWait(1000);
return x;
});
watch = Stopwatch.StartNew();
foreach (var item in notBufferedQuery)
{
Console.WriteLine( $"{item}:{watch.ElapsedMilliseconds}");
}
Console.WriteLine($"\nNotBuffered Full Result returned in {watch.ElapsedMilliseconds} ms");
输出如下:
使用 AutoBuffered 合并选项
并发任务的结果被缓冲,并且缓冲区定期提供给消费线程。根据集合的大小,可能会返回多个缓冲区。使用此选项,消费线程需要等待更长时间才能获得第一个结果。这也是默认选项。
考虑以下代码:
var range = ParallelEnumerable.Range(1, 100);
Stopwatch watch = null;
ParallelQuery<int> query = range.WithMergeOptions(ParallelMergeOptions.AutoBuffered)
.Where(i => i % 10 == 0)
.Select(x => {
Thread.SpinWait(1000);
return x;
});
watch = Stopwatch.StartNew();
foreach (var item in query)
{
Console.WriteLine($"{item}:{watch.ElapsedMilliseconds}");
}
Console.WriteLine($"\nAutoBuffered Full Result returned in {watch.ElapsedMilliseconds} ms");
watch.Stop();
输出如下:
使用 FullyBuffered 合并选项
并发任务的结果在提供给消费线程之前完全缓冲。这提高了整体性能,尽管获得第一个结果所需的时间会更长:
var range = ParallelEnumerable.Range(1, 100);
Stopwatch watch = null;
ParallelQuery<int> fullyBufferedQuery = range.WithMergeOptions(ParallelMergeOptions.FullyBuffered)
.Where(i => i % 10 == 0)
.Select(x => {
Thread.SpinWait(1000);
return x;
});
watch = Stopwatch.StartNew();
foreach (var item in fullyBufferedQuery)
{
Console.WriteLine($"{item}:{watch.ElapsedMilliseconds}");
}
Console.WriteLine($"\nFullyBuffered Full Result returned in {watch.ElapsedMilliseconds} ms");
watch.Stop();
输出如下:
并非所有查询运算符都支持所有合并模式。以下是一些运算符及其限制的列表:
此信息可在msdn.microsoft.com/en-us/library/dd997424(v=vs.110).aspx
找到。
除了前面的运算符外,ForAll()
始终为NotBuffered
,OrderBy
始终为FullyBuffered
。如果在这些运算符上指定了任何自定义合并选项,则它们将被忽略。
使用 PLINQ 抛出和处理异常
与其他并行原语一样,每当 PLINQ 遇到异常时,都会抛出System.AggregateException
。异常处理在很大程度上取决于您的设计。您可能希望程序尽快失败,或者您可能希望所有异常都返回给调用者。
在以下示例中,我们将在try
-catch
块中包装一个并行查询。当查询引发异常时,它将传播回调用者,包装在System.AggregateException
中:
var range = ParallelEnumerable.Range(1, 20);
ParallelQuery<int> query= range.Select(i => i / (i - 10)).WithDegreeOfParallelism(2);
try
{
query.ForAll(i => Console.WriteLine(i));
}
catch (AggregateException aggregateException)
{
foreach (var ex in aggregateException.InnerExceptions)
{
Console.WriteLine(ex.Message);
if (ex is DivideByZeroException)
Console.WriteLine("Attempt to divide by zero. Query
stopped.");
}
}
输出如下:
我们还可以在委托内部指定一个try
-catch
块,这样可以尽快通知我们有关错误条件。它还可以用于一种情况,即我们只想记录异常并通过在异常情况下提供默认值作为查询结果来继续查询的执行:
var range = ParallelEnumerable.Range(1, 20);
Func<int, int> selectDivision = (i) =>
{
try
{
return i / (i - 10);
}
catch (DivideByZeroException ex)
{
Console.WriteLine($"Divide by zero exception for {i}");
return -1;
}
};
ParallelQuery<int> query = range.Select(i => selectDivision(i)).WithDegreeOfParallelism(2);
try
{
query.ForAll(i => Console.WriteLine(i));
}
catch (AggregateException aggregateException)
{
foreach (var ex in aggregateException.InnerExceptions)
{
Console.WriteLine(ex.Message);
if (ex is DivideByZeroException)
Console.WriteLine("Attempt to divide by zero. Query stopped.");
}
}
输出如下:
异常处理对于维护应用程序中的正确流程以及通知用户应用程序中的错误条件非常重要。通过适当的异常处理和日志记录,我们可以在生产环境中排除应用程序错误。在下一节中,我们将讨论如何合并并行和顺序查询。
合并并行和顺序 LINQ 查询
我们已经讨论了使用AsParallel()
创建并行查询的用法。有时,我们可能希望按顺序执行操作。我们可以使用AsSequential()
方法强制 PLINQ 按顺序操作。一旦这个方法应用到任何并行查询中,后续的操作将按顺序执行。考虑以下代码:
var range = Enumerable.Range(1, 1000);
range.AsParallel().Where(i => i % 2 == 0).AsSequential().Where(i => i % 8 == 0).AsParallel().OrderBy(i => i);
这里,第一个Where
类,Where(i => i % 2 == 0)
,将并行执行。然而,第二个Where
类,Where(i => i % 8 == 0)
,将顺序执行。OrderBy
也将切换到并行执行模式。
如下图所示:
现在,我们应该对如何合并同步和并行 LINQ 查询有了一个很好的了解。在下一节中,我们将学习如何取消 PLINQ 查询以节省 CPU 资源。
取消 PLINQ 查询
我们可以使用CancellationTokenSource
和CancellationToken
类取消 PLINQ 查询。取消令牌通过WithCancellation
子句传递给 PLINQ 查询,然后我们可以调用CancellationToken.Cancel
来取消查询操作。当查询被取消时,会抛出OperationCancelledException
。
操作如下:
- 创建一个取消令牌源:
CancellationTokenSource cs = new CancellationTokenSource();
Create a task that starts immediately and cancel the token after 4 seconds
Task cancellationTask = Task.Factory.StartNew(() =>
{
Thread.Sleep(4000);
cs.Cancel();
});
- 将 PLINQ 查询包装在
try
块内:
try
{
var result = range.AsParallel()
.WithCancellation(cs.Token)
.Select(number => number)
.ToList();
}
- 添加两个
catch
块;一个用于捕获OperationCanceledException
,另一个用于捕获AggregateException
:
catch (OperationCanceledException ex)
{
Console.WriteLine(ex.Message);
}
catch (AggregateException ex)
{
foreach (var inner in ex.InnerExceptions)
{
Console.WriteLine(inner.Message);
}
}
- 将范围设置为一个非常大的值,需要超过四秒才能执行:
var range = Enumerable.Range(1,1000000);
- 运行代码。四秒后,我们将看到以下输出:
并行编程有其自己的注意事项。在下一节中,我们将介绍使用 PLINQ 编写并行代码的缺点。
使用 PLINQ 的并行编程的缺点
在大多数情况下,PLINQ 的性能要比其非并行对应的 LINQ 快得多。然而,与将 LINQ 并行化相关的分区和合并会带来一些性能开销。在使用 PLINQ 时,我们需要考虑以下一些事项:
-
并不总是并行更快:并行化是一种开销。除非你的源集合很大或者它有计算密集型操作,否则按顺序执行操作更有意义。始终测量顺序和并行查询的性能,以做出明智的决定。
-
避免涉及原子性的 I/O 操作:所有涉及写入文件系统、数据库、网络或共享内存位置的 I/O 操作都应该避免在 PLINQ 内部进行。这是因为这些方法不是线程安全的,因此使用它们可能会导致异常。一个解决方案是使用同步原语,但这也会严重降低性能。
-
你的查询可能并不总是并行运行:在 PLINQ 中进行并行化是 CLR 做出的决定。即使我们在查询中调用了
AsParallel()
方法,也不能保证它会采用并行路径,可能会顺序运行。
了解影响 PLINQ 性能的因素(加速)
PLINQ 的主要目的是通过将任务拆分并并行执行来加速查询执行。然而,有许多因素可能会影响 PLINQ 的性能。这些因素包括与分块和分区相关的同步开销,以及来自线程的调度和收集结果的开销。PLINQ 在令人愉快地并行的场景中表现最佳,其中线程不必共享状态,也不必担心执行顺序。令人愉快地并行是理想的,但由于工作的性质,不一定总是可行的。让我们试着了解可能影响 PLINQ 性能的因素。
并行度
有了更多的核心可供我们使用,我们可以实现显著的性能提升,因为 TPL 确保多个任务可以在多个核心上并发执行。性能的提升可能不是指数级的,因此在调整性能时,我们应该尝试在具有多个核心的不同系统上运行并比较结果。
合并选项
我们可以在结果经常变化且用户希望尽快看到结果而不必等待的情况下显著改善用户体验。PLINQ 的默认选项是缓冲结果,然后合并并将其返回给用户。我们可以通过选择适当的合并选项来修改此行为。
分区类型
我们应该始终检查我们的工作项是平衡的还是不平衡的。对于不平衡的工作项场景,可以引入自定义分区器来提高性能。
决定何时使用 PLINQ 保持顺序
我们应该始终计算每个工作项和整个操作的计算成本,以便决定是保持顺序还是转移到并行。并行查询可能并不总是快速的,因为存在分区、调度等额外开销:
计算成本 = 执行 1 个工作项的成本 * 总工作项数
并行查询可以在每个项目的计算成本增加时提供显著的性能提升。然而,如果性能提升非常低,那么按顺序执行查询是有意义的。
PLINQ 决定是按顺序还是并行执行取决于查询中操作符的组合。简单来说,如果查询中有以下任何一个操作符,PLINQ 可能决定按顺序运行查询:
-
Take
、TakeWhile
、Skip
、SkipWhile
、First
、Last
、Concat
、Zip
或ElementAt
-
索引的
Where
和Select
,它们分别是Where
和Select
的重载
以下代码演示了使用索引的Where
和Select
:
IEnumerable<int> query =
numbers.AsQueryable()
.Where((number, index) => number <= index * 10);
IEnumerable<bool> query =
range.AsQueryable()
.Select((number, index) => number <= index * 10);
操作顺序
与无序集合相比,PLINQ 在性能上提供了更好的表现,因为使集合按顺序执行会带来性能成本。这种性能成本包括分区、调度和收集结果,以及调用GroupJoin
和过滤器。作为开发人员,您应该考虑何时使用AsOrdered()
。
ForAll 与调用 ToArray()或 ToList()的区别
当我们调用ToList()
或ToArray()
或在循环中枚举结果时,我们强制 PLINQ 将所有并行线程的结果合并为单个数据结构。这是一种性能开销。如果我们只是想对一组项目执行一些操作,最好使用ForAll()
方法。
强制并行
PLINQ 并不保证每次都进行并行执行。它可能决定按顺序执行,这取决于查询的类型。我们可以使用WithExecutionMode
方法来控制这一点。WithExecutionMode
是一个作用于ParallelQuery
类型对象的扩展方法。它以ParallelExecutionMode
作为参数,这是一个枚举。ParallelExecutionMode
的默认值让 PLINQ 决定最佳的执行模式。我们可以使用ForceParallelism
选项强制执行模式为并行:
var range = Enumerable.Range(1, 10);
var squares = range.AsParallel().WithExecutionMode
(ParallelExecutionMode.ForceParallelism).Select(i => i * i);
squares.ToList().ForEach(i => Console.Write(i + "-"));
生成序列
在整本书中,我们使用Enumerable.Range()
方法来生成一系列数字。我们也可以使用ParallelEnumerable
类来并行生成数字。让我们对Enumerable
和ParallelEnumerable
类进行一个简单的测试比较:
Stopwatch watch = Stopwatch.StartNew();
IEnumerable<int> parallelRange = ParallelEnumerable.Range(0, 5000).Select(i => i);
watch.Stop();
Console.WriteLine($"Time elapsed {watch.ElapsedMilliseconds}");
Stopwatch watch2 = Stopwatch.StartNew();
IEnumerable<int> range = Enumerable.Range(0, 5000);
watch2.Stop();
Console.WriteLine($"Time elapsed {watch2.ElapsedMilliseconds}");
Console.ReadLine();
输出如下:
如你所见,ParallelEnumerable
比Enumerable
更快地创建了一个范围。
在类似的情况下,我们可能希望生成一定数量的数字。我们可以使用ParallelEnumerable.Repeat()
方法来实现这种情况,如下所示:
IEnumerable<int> rangeRepeat = ParallelEnumerable.Repeat(1, 5000);
现在我们已经了解了影响 PLINQ 性能的因素,我们已经到达了本章的结尾。现在,让我们总结一下我们学到的东西。
摘要
在本章中,我们讨论了 LINQ 的基础知识,然后继续了解如何使用 PLINQ 编写并行查询。我们了解到 PLINQ 可以很好地提高整个应用程序的性能,但重要的是要记住它的缺点。作为程序员,通过编写 LINQ 和 PLINQ 查询并比较它们的性能,权衡你的选择总是一个好主意。
在下一章中,我们将学习如何使用同步原语来保持数据的一致性和状态,当数据在多个线程之间共享时。
问题
-
哪个 LINQ 提供程序对关系对象有更好的支持?
-
LINQ 到 SQL
-
实体的 LINQ
-
我们可以通过使用
AsParallel()
轻松将 LINQ 转换为并行 LINQ。 -
真
-
假
-
在 PLINQ 中无法在有序和无序执行之间切换。
-
真
-
假
-
其中一个允许并发任务的结果被缓冲并定期提供给消费线程?
-
完全缓冲
-
自动缓冲
-
非缓冲
-
如果在任务内执行以下代码,将抛出哪个异常?
int i=5;
i = i/i -5;
-
AggregateException
-
DivideByZeroException
第二部分:.NET Core 中支持并行性的数据结构
在本节中,您将更深入地了解支持并行性、并发性和同步的语言和框架构造。
本节包括以下章节:
-
第五章,同步原语
-
第六章,使用并发集合
-
第七章,使用延迟初始化提高性能
第五章:同步原语
在上一章中,我们讨论了并行编程的潜在缺陷之一是同步开销。当我们将工作分解为由多个工作项处理的任务时,就会出现需要同步每个线程的结果的情况。我们讨论了线程本地存储和分区本地存储的概念,可以在一定程度上解决这个同步问题。然而,仍然需要同步线程,以便我们可以将数据写入共享内存位置,并执行 I/O 操作。
在本章中,我们将讨论.NET Framework 和 TPL 提供的同步原语。
在本章中,我们将涵盖以下主题:
-
同步原语
-
原子操作
-
锁原语
-
信号原语
-
轻量级同步原语
-
屏障和倒计时事件
通过本章结束时,您将对.NET Framework 提供的各种锁定和信号原语有很好的理解,包括一些轻量级同步原语,应尽可能在需要同步的地方使用。
技术要求
要完成本章,您应该对 TPL 有很好的理解,主要是并行循环。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter05
。
什么是同步原语?
在理解同步原语之前,我们需要了解临界区。临界区是线程执行路径的一部分,必须受到保护,以维护一些不变量。临界区本身不是同步原语,但依赖于同步原语。
同步原语是由底层平台(操作系统)提供的简单软件机制。它们有助于在内核中进行多线程处理。同步原语内部使用低级原子操作和内存屏障。这意味着同步原语的用户不必担心自己实现锁和内存屏障。一些常见的同步原语示例包括锁、互斥锁、条件变量和信号量。监视器是一种更高级的同步工具,它在内部使用其他同步原语。
.NET Framework 提供了一系列同步原语,用于处理线程之间的交互,以及避免潜在的竞争条件。同步原语可以大致分为五类:
-
原子操作
-
锁定
-
信号
-
轻量级同步类型
-
SpinWait
在接下来的章节中,我们将讨论每个类别及其各自的低级原语。
原子操作
Interlocked 类封装了同步原语,并用于为跨线程共享的变量提供原子操作。它提供了Increment
、Decrement
、Add
、Exchange
和CompareExchange
等方法。
考虑以下代码,它尝试在并行循环中递增一个计数器:
Parallel.For(1, 1000, i =>
{
Thread.Sleep(100);
_counter++;
});
Console.WriteLine($"Value for counter should be 999 and
is {_counter}");
如果我们运行此代码,将会看到以下输出:
如您所见,预期值和实际值不匹配。这是因为线程之间存在竞争条件,这是因为线程想要从一个变量中读取一个值,而该值尚未被提交。
我们可以使用Interlocked
类修改上述代码,使其线程安全,如下所示:
Parallel.For(1, 1000, i =>
{
Thread.Sleep(100);
Interlocked.Increment(ref _counter);
});
Console.WriteLine($"Value for counter should be 999 and
is {_counter}");
预期输出如下:
同样,我们可以使用Interlocked.Decrement(ref _counter)
以线程安全的方式减少值。
以下代码显示了完整的操作列表:
//_counter becomes 1
Interlocked.Increment(ref _counter);
// _counter becomes 0
Interlocked.Decrement(ref _counter);
// Add: _counter becomes 2
Interlocked.Add(ref _counter, 2);
//Subtract: _counter becomes 0
Interlocked.Add(ref _counter, -2);
// Reads 64 bit field
Console.WriteLine(Interlocked.Read(ref _counter));
// Swaps _counter value with 10
Console.WriteLine(Interlocked.Exchange(ref _counter, 10));
//Checks if _counter is 10 and if yes replace with 100
Console.WriteLine(Interlocked.CompareExchange(ref _counter, 100, 10));
// _counter becomes 100
除了前面的方法,.NET Framework 4.5 中还添加了两个新方法:Interlocked.MemoryBarrier()
和Interlocked.MemoryBarrierProcessWide()
。
在下一节中,我们将学习更多关于.NET 中的内存屏障。
.NET 中的内存屏障
单核处理器和多核处理器上的线程模型工作方式不同。在单核处理器上,只有一个线程获得 CPU 时间片,而其他线程则等待它们的轮次。这确保了每当一个线程访问内存(用于加载和存储)时,它都是按正确的顺序进行的。这个模型也被称为顺序一致性模型。在多核处理器系统中,多个线程同时运行。在这些系统中,无法保证顺序一致性,因为硬件或即时(JIT)编译器可能会重新排序内存指令以提高性能。内存指令也可能会因为缓存、加载推测或延迟存储操作而进行重新排序以提高性能。
加载推测的示例如下:
a=b;
存储操作的示例如下:
c=1;
当编译器遇到加载和存储语句时,并不总是按照它们被编写的顺序执行。编译器会进行一些重新排序以获得性能上的好处。让我们试着更多地了解重新排序。
什么是重新排序?
对于给定的代码语句序列,编译器可以选择按照接收到的顺序执行它们,或者重新排序它们以提高性能,如果多个线程正在处理相同的代码。例如,看一下以下代码:
a = b;
c = 1;
前面的代码可以被重新排序并以以下顺序执行给另一个线程:
c = 1;
a = b;
对于具有弱内存模型的多核处理器(如英特尔 Itanium 处理器),代码重新排序是一个问题。然而,对于单核处理器来说,由于顺序一致性模型,它没有影响。代码被重组,以便另一个线程可以利用或存储已经在内存中的指令。代码重新排序可以由硬件或 JIT 编译器来完成。为了保证代码重新排序,我们需要某种内存屏障。
内存屏障的类型
内存屏障确保屏障上方或下方的任何代码语句都不会越过屏障,从而强制执行代码的顺序。有三种类型的内存屏障:
- 存储(写入)内存屏障:存储内存屏障确保不允许存储操作越过屏障。它对加载操作没有影响;这些操作仍然可以被重新排序。实现此效果的等效 CPU 指令是SFENCE:
- 加载(读取)内存屏障:加载屏障确保不允许加载操作越过屏障,但对存储操作不做任何强制。实现此效果的等效 CPU 指令是LFENCE:
-
完整内存屏障:完整内存屏障通过不允许存储或加载操作越过内存屏障来确保顺序。实现此效果的等效 CPU 指令是MFENCE。完整内存屏障的行为通常由.NET 同步构造实现,例如以下内容:
-
Task.Start
、Task.Wait
和Task.Continuation
-
Thread.Sleep
、Thread.Join
、Thread.SpinWait
、Thread.VolatileRead
和Thread.VolatileWrite
-
Thread.MemoryBarrier
-
Lock
、Monitor.Enter
和Monitor.Exit
-
Interlocked
类的操作
Volatile
关键字和Volatile
类方法提供了半屏障。.NET Framework 提供了一些内置模式,使用类中的Volatile
字段,如Lazy<T>
和LazyInitializer
。我们将在第七章中进一步讨论这些,使用延迟初始化提高性能。
使用构造避免代码重排序
我们可以使用Thread.MemoryBarrier
避免重排序,如下面的代码所示:
static int a = 1, b = 2, c = 0;
private static void BarrierUsingTheadBarrier()
{
b = c;
Thread.MemoryBarrier();
a = 1;
}
Thread.MemoryBarrier
创建一个不允许加载或存储操作通过的完整屏障。它已经包装在Interlocked.MemoryBarrier
中,因此可以将相同的代码编写如下:
private static void BarrierUsingInterlockedBarrier()
{
b = c;
Interlocked.MemoryBarrier();
a = 1;
}
如果我们想创建一个进程范围和系统范围的屏障,我们可以使用.NET Core 2.0 中引入的Interlocked.MemoryBarrierProcessWide
。这是对FlushProcessWriteBuffer
Windows API 或 Linux 内核上的sys_membarrier
的包装:
private static void BarrierUsingInterlockedProcessWideBarrier()
{
b = c;
Interlocked.MemoryBarrierProcessWide();
a = 1;
}
前面的例子向我们展示了如何创建一个进程范围的屏障。现在,让我们来看看锁定原语是什么。
锁定原语简介
锁可以用来限制对受保护资源的访问,只允许单个线程或一组线程。为了能够有效地实现锁定,我们需要识别可以通过锁定原语保护的适当的临界区。
锁定的工作原理
当我们对共享资源应用锁时,执行以下步骤:
-
一个线程或一组线程通过获取锁来访问共享资源。
-
无法访问锁定的其他线程进入等待状态。
-
一旦锁被一个线程释放,另一个线程就会获取它,并开始执行。
要理解锁定原语,我们需要了解各种线程状态,以及阻塞和自旋等概念。
线程状态
在线程的生命周期的任何时刻,我们都可以使用线程的ThreadState
属性查询线程状态。线程可以处于以下任一状态:
-
未启动
:线程已被 CLR 创建,但尚未调用System.Threading.Thread.Start
方法。 -
运行
:线程已通过调用Thread.Start
启动。它不在等待任何未决操作。 -
WaitSleepJoin
:由于调用Wait()
、Sleep()
或Join()
方法,线程处于阻塞状态。 -
停止请求
:线程已被请求停止。 -
已停止
:线程已停止执行。 -
中止请求
:在线程上调用了Abort()
方法,但线程尚未被中止,因为它正在等待ThreadAbortException
来尝试终止它。 -
中止
:线程已被中止。 -
暂停请求
:由于调用Suspend
方法,线程被请求暂停。 -
已暂停
:线程已被暂停。 -
后台
:线程在后台执行。
让我们尝试探索线程从初始状态未启动
到最终状态已停止
的过程:
当 CLR 创建线程时,它处于未启动
状态。当外部线程调用Thread.Start()
方法时,它从未启动
状态转换到运行
状态。从运行
状态,线程可以转换到以下状态:
-
WaitSleepJoin
-
中止请求
-
已停止
当线程处于WaitSleepJoin
状态时,就说它被阻塞了。被阻塞的线程的执行被暂停,因为它正在等待一些外部条件的满足,这可能是一些 CPU 绑定的 I/O 操作或其他线程的结果。一旦被阻塞,线程立即放弃 CPU 时间片,并且在满足阻塞条件之前不使用处理器时间片。在这一点上,线程被解除阻塞。阻塞和解除阻塞构成了性能开销,因为这需要 CPU 进行上下文切换。
线程可以在以下事件中解除阻塞:
-
如果满足阻塞条件
-
通过在被阻塞的线程上调用
Thread.Interrupt
-
通过使用
Thread.Abort
中止线程 -
当达到指定的超时时间
阻塞与自旋
阻塞的线程放弃处理器时间片段一段时间。这通过使其可用于其他线程来提高性能,但会产生上下文切换的开销。在线程必须被阻塞一段时间的情况下,这是很好的。如果等待时间较短,选择自旋而不放弃处理器时间片段是有意义的。例如,以下代码简单地无限循环:
while(!done);
这只是一个空的while
循环,检查一个布尔变量。当等待结束时,变量将被设置为 false,循环可以中断。虽然这会浪费处理器时间,但如果等待时间不是很长,它可以显著提高性能。.NET Framework 提供了一些特殊的构造,我们将在本章后面讨论,比如SpinWait
和SpinLock
。
让我们尝试通过代码示例了解一些锁定原语。
锁,互斥锁和信号量
锁和互斥锁是锁定构造,只允许一个线程访问受保护的资源。锁是一个使用另一个更高级别的同步类Monitor
的快捷实现。
信号量是一种锁定构造,允许指定数量的线程访问受保护的资源。锁只能在进程内部同步访问,但如果我们需要访问系统级资源或共享内存,我们实际上需要跨多个进程同步访问。互斥锁允许我们通过提供内核级别的锁来跨进程同步访问资源。
以下表格提供了这些构造的功能比较:
正如我们所看到的,Lock和Mutex只允许单线程访问共享资源,而Semaphore和SemaphoreSlim可以用于允许多个线程共享的资源。此外,Lock和SemaphoreSlim只能在进程内部工作,而Mutex和Semaphore具有进程范围的锁。
锁
让我们考虑以下代码,试图将一个数字写入文本文件:
var range = Enumerable.Range(1, 1000);
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < range.Count(); i++)
{
Thread.Sleep(10);
File.AppendAllText("test.txt", i.ToString());
}
watch.Stop();
Console.WriteLine($"Total time to write file is
{watch.ElapsedMilliseconds}");
当我们运行上述代码时,输出如下:
正如你所看到的,任务由 1,000 个工作项组成,每个工作项大约需要 10 毫秒来执行。任务所花费的时间是 1,000 乘以 10,即 10,000 毫秒。我们还需要考虑执行 I/O 所花费的时间,因此总时间为 11,949。
让我们尝试使用AsParallel()
和AsOrdered()
子句并行化这个任务,如下所示:
range.AsParallel().AsOrdered().ForAll(i =>
{
Thread.Sleep(10);
File.AppendAllText ("test.txt", i.ToString());
});
当我们尝试运行这段代码时,我们会得到以下错误信息:System.IO.IOException**:** 'The process cannot access the file …\test.txt' because it is being used by another process.'
。
实际发生的情况是,文件是一个共享资源,具有临界区,因此只允许原子操作。在并行代码中,我们有多个线程实际上尝试写入文件并导致异常的情况。我们需要确保代码尽可能快地并行运行,但在写入文件时也保持原子性。我们需要使用锁语句修改上述代码。
首先,声明一个static
引用类型变量。在我们的例子中,我们使用object
类型的变量。我们需要一个引用类型变量,因为锁只能应用于堆内存:
static object _locker = new object ();
接下来,我们修改ForAll()
方法内的代码,包括一个lock
:
range.AsParallel().AsOrdered().ForAll(i =>
{
lock (_locker)
{
Thread.Sleep(10);
File.WriteAllText("test.txt", i.ToString());
}
});
现在,当我们运行这段代码时,不会出现任何异常,但任务所花费的时间实际上比顺序执行的时间更长:
这里出了什么问题?锁通过确保只有一个线程被允许访问易受攻击的代码来确保原子性,但这会带来阻塞等待锁被释放的线程的开销。我们称之为愚蠢的锁。我们可以稍微修改程序,只锁定关键部分以提高性能,同时保持原子性,如下所示:
range.AsParallel().AsOrdered().ForAll(i =>
{
Thread.Sleep(10);
lock (_locker)
{
File.WriteAllText("test.txt", i.ToString());
}
});
以下是上述代码的输出:
正如你所看到的,通过混合同步和并行化,我们取得了显著的收益。我们可以使用另一个锁原语来实现类似的结果,即Monitor
类。
锁实际上是一种简写语法,用于在try
-catch
块中包装Monitor.Enter()
和Monitor.Exit()
以实现原子性。因此,可以将相同的代码编写如下:
range.AsParallel().AsOrdered().ForAll(i =>
{
Thread.Sleep(10);
Monitor.Enter(_locker);
try
{
File.WriteAllText("test.txt", i.ToString());
}
finally
{
Monitor.Exit(_locker);
}
});
此代码的输出如下:
互斥体
上述代码适用于单个实例应用程序,因为任务在进程内运行,锁实际上锁定了进程内的内存屏障。如果我们运行应用程序的多个实例,两个应用程序将拥有自己的静态数据成员的副本,因此将锁定自己的内存屏障。这将允许每个进程中的一个线程实际进入临界区并尝试写入文件。这将导致以下System.IO.IOException**:** 'The process cannot access the file …\test.txt' because it is being used by another process.'
。
为了能够将锁应用于共享资源,我们可以使用mutex
类在内核级别应用锁。与锁类似,互斥体只允许一个线程访问受保护的资源,但也可以跨进程工作,因此只允许系统中的一个线程访问受保护的资源,而不管执行的进程数量如何。
互斥体可以是命名的或未命名的。未命名的互斥体的工作方式类似于锁,不能跨进程工作。
首先,我们将创建一个未命名的Mutex
:
private static Mutex mutex = new Mutex();
然后,我们将修改前面的并行代码,以便我们可以像使用锁一样使用Mutex
:
range.AsParallel().AsOrdered().ForAll(i =>
{
Thread.Sleep(10);
mutex.WaitOne();
File.AppendAllText("test.txt", i.ToString());
mutex.ReleaseMutex();
});
上述代码的输出如下:
使用Mutex
类,我们可以调用WaitHandle.WaitOne()
方法来锁定临界区,并使用ReleaseMutex()
来解锁临界区。关闭或处理互斥体会自动释放它。
上述程序运行良好,但如果我们尝试在多个实例上运行它,它将抛出一个IOException
。为此,我们可以创建一个namedMutex
,如下所示:
private static Mutex namedMutex = new Mutex(false,"ShaktiSinghTanwar");
在调用WaitOne()
时,我们可以选择指定一个超时,以便在等待一定时间内等待信号,然后解除阻塞。下面是一个示例:
namedMutex.WaitOne(3000);
如果未收到信号,上述互斥体将在三秒后解除阻塞。
锁和互斥体只能从获取它们的线程中释放。
信号量
锁,互斥体和监视器只允许一个线程访问受保护的资源。然而,有时我们需要允许多个线程能够访问共享资源。这些情况包括资源池化场景和限流场景。与锁或互斥体不同,semaphore
是线程不可知的,这意味着任何线程都可以调用semaphore
的释放。就像互斥体一样,它也可以跨进程工作。
典型的semaphore
构造函数如下:
如你所见,它接受两个参数:initialCount
,指定最初允许进入的线程数,以及maximumCount
,指定可以进入的总线程数。
假设我们有一个远程服务,每个客户端只允许三个并发连接,并且需要一秒来处理一个请求,如下所示:
private static void DummyService(int i)
{
Thread.Sleep(1000);
}
我们有一个方法,其中有 1,000 个工作项需要使用参数调用服务。我们需要并行处理一个任务,但也要确保在任何时候最多只有三次对服务的调用。我们可以通过创建一个最大计数为3
的信号量
来实现这一点:
Semaphore semaphore = new Semaphore(3,3);
现在,我们可以编写一些代码,可以模拟并行进行 1,000 次请求,但每次只能进行三次,使用以下信号量
:
range.AsParallel().AsOrdered().ForAll(i =>
{
semaphore.WaitOne();
Console.WriteLine($"Index {i} making service call using
Task {Task.CurrentId}" );
//Simulate Http call
CallService(i);
Console.WriteLine($"Index {i} releasing semaphore using
Task {Task.CurrentId}");
semaphore.Release();
});
这的输出如下:
正如您所看到的,三个线程进入并调用服务,而其他线程则等待锁被释放。一旦一个线程释放锁,另一个线程进入,但只有在任何时候有三个线程在临界区内。
信号量有两种类型:本地和全局。我们将在下面讨论这些。
本地信号量
本地信号量
是在使用的应用程序中本地的。任何没有名称创建的信号量
都将被创建为本地信号量
,如下所示:
Semaphore semaphore = new Semaphore(1,10);
全局信号量
全局信号量
是全局的,因为它应用于内核或系统级别的锁原语。任何使用名称创建的信号量
都将被创建为全局信号量
,如下所示:
Semaphore semaphore = new Semaphore(1,10,”Globalsemaphore”);
如果创建一个只有一个线程的信号量
,它将起到锁的作用。
读写锁
ReaderWriterLock
类定义了一个支持多个读取器和一次写入器的锁。这在共享资源经常被许多线程读取但不经常更新的情况下非常方便。.NET Framework 提供了两个读写锁类:ReaderWriterLock
和ReaderWriterLockSlim
。ReaderWriterLock
现在几乎已经过时,因为它可能会导致潜在的死锁、降低性能、复杂的递归规则以及锁的升级或降级。我们将在本章后面更详细地讨论ReaderWriterLockSlim
。
信号量原语介绍
并行编程的一个重要方面是任务协调。在创建任务时,您可能会遇到生产者/消费者场景,其中一个线程(消费者)正在等待另一个线程(生产者)更新共享资源。由于消费者不知道生产者何时会更新共享资源,它不断轮询共享资源,这可能导致竞争条件。轮询在处理这些情况时效率非常低。最好使用.NET Framework 提供的信号量原语。使用信号量原语,消费者线程暂停,直到它收到来自生产者线程的信号。让我们讨论一些常见的信号量原语,如Thread.Join
,WaitHandles
和EventWaitHandlers
。
线程加入
这是我们可以使一个线程等待另一个线程的信号的最简单方法。Thread.Join
是阻塞的,这意味着调用线程会被阻塞,直到加入的线程完成。可选地,我们可以指定一个超时,一旦超时到达,允许被阻塞的线程退出其阻塞状态。
在下面的代码中,我们将创建一个模拟长时间运行任务的子线程。完成后,它将更新名为result
的本地变量的输出。程序应该在控制台上打印结果10
。让我们尝试运行代码:
int result = 0;
Thread childThread = new Thread(() =>
{
Thread.Sleep(5000);
result = 10;
});
childThread.Start();
Console.WriteLine($"Result is {result}");
前面代码的输出如下:
我们期望的结果是10
,但实际上是0
。这是因为主线程在子线程完成执行之前就已经运行,我们可以通过在子线程上调用Join()
来阻塞主线程,从而实现期望的行为,如下所示:
int result = 0;
Thread childThread = new Thread(() =>
{
Thread.Sleep(5000);
result = 10;
});
childThread.Start();
childThread.Join();
Console.WriteLine($"Result is {result}");
如果现在再次运行代码,我们将在等待五秒钟后看到期望的输出,主线程在此期间被阻塞:
EventWaitHandle
System.Threading.EventWaitHandle
类表示线程的同步事件。它作为AutoResetEvent
和ManualResetEvent
类的基类。我们可以通过调用Set()
或SignalAndWait()
来发出EventWaitHandle
的信号。EventWaitHandle
类没有任何线程关联性,因此可以被任何线程发出信号。让我们更多地了解AutoResetEvent
和ManualResetEvent
。
AutoResetEvent
这是指自动重置的WaitHandle
类。一旦它们被重置,它们允许一个线程通过创建的屏障。一旦线程通过,它们会再次被设置,从而阻塞线程直到下一个信号。
在以下示例中,我们试图以线程安全的方式找出 10 个数字的总和,而不使用锁。
首先,创建一个初始状态为非信号或false
的AutoResetEvent
。这意味着所有线程都应该等待直到收到信号。如果将初始状态设置为信号或true
,第一个线程将通过,而其他线程将等待信号:
AutoResetEvent autoResetEvent = new AutoResetEvent(false);
接下来,创建一个发出信号的任务,使用autoResetEvent.Set()
方法每秒发出 10 次信号:
Task signallingTask = Task.Factory.StartNew(() => {
for (int i = 0; i < 10; i++)
{
Thread.Sleep(1000);
autoResetEvent.Set();
}
});
声明一个变量 sum 并将其初始化为0
:
int sum = 0;
创建一个并行的for
循环,创建 10 个任务。每个任务将立即开始并等待一个信号进入,因此在autoResetEvent.WaitOne()
语句处阻塞。每秒钟,一个信号将被发送,一个线程将进入并更新sum
:
Parallel.For(1, 10, (i) => {
Console.WriteLine($"Task with id {Task.CurrentId} waiting for
signal to enter");
autoResetEvent.WaitOne();
Console.WriteLine($"Task with id {Task.CurrentId} received
signal to enter");
sum += i;
});
输出如下:
如您所见,所有 10 个任务最初都被阻塞,每秒接收到信号后释放一个。
ManualResetEvent
这是指需要手动重置的等待句柄。与AutoResetEvent
不同,它只允许一个线程通过每个信号,ManualResetEvent
允许线程继续通过,直到再次设置。让我们尝试使用一个简单的例子来理解这一点。
在以下示例中,我们需要并行地以每批 5 个的方式进行 15 次服务调用,每批之间延迟 2 秒。在进行服务调用时,我们需要确保系统连接到网络。为了模拟网络状态,我们将创建两个任务:一个信号网络关闭,一个信号网络开启。
首先,我们将创建一个初始状态为关闭的手动重置事件:
ManualResetEvent manualResetEvent = new ManualResetEvent(false);
接下来,我们将创建两个任务,通过每两秒触发一次网络关闭事件(阻塞所有网络调用)和每五秒触发一次网络开启事件(允许所有网络调用通过)来模拟网络的开启和关闭:
Task signalOffTask = Task.Factory.StartNew(() => {
while (true)
{
Thread.Sleep(2000);
Console.WriteLine("Network is down");
manualResetEvent.Reset();
}
});
Task signalOnTask = Task.Factory.StartNew(() => {
while (true)
{
Thread.Sleep(5000);
Console.WriteLine("Network is Up");
manualResetEvent.Set();
}
});
如您从前面的代码中看到的,我们每五秒发出一次手动重置事件的信号,使用manualResetEvent.Set()
。我们每两秒关闭一次它,使用manualResetEvent.Reset()
。以下代码进行实际的服务调用:
for (int i = 0; i < 3; i++)
{
Parallel.For(0, 5, (j) => {
Console.WriteLine($"Task with id {Task.CurrentId} waiting
for network to be up");
manualResetEvent.WaitOne();
Console.WriteLine($"Task with id {Task.CurrentId} making
service call");
DummyServiceCall();
});
Thread.Sleep(2000);
}
如您从前面的代码中看到的,我们创建了一个for
循环,每次迭代创建五个任务,两次迭代之间的休眠间隔为两秒。
在进行服务调用之前,我们通过调用manualResetEvent.WaitOne();
等待网络启动。
如果我们运行上述代码,将收到以下输出:
如您所见,五个任务立即启动并立即阻塞等待网络启动。五秒后,当网络启动时,我们使用Set()
方法发出信号,所有五个线程通过进行服务调用。这将在for
循环的每次迭代中重复。
WaitHandles
System.Threading.WaitHandle
是从MarshalByRefObject
类继承的类,用于同步运行在应用程序中的线程。使用等待句柄来阻塞和发出信号以同步线程。线程可以通过调用WaitHandle
类的任何方法来阻塞。它们根据所选的信号构造的类型而被释放。WaitHandle
类的方法如下:
-
WaitOne
:阻塞调用线程,直到它从等待的等待句柄接收到信号。 -
WaitAll
:阻塞调用线程,直到它从等待的所有等待句柄接收到信号。
以下是一个示例,向我们展示了WaitAll
的工作原理:
public static bool WaitAll (System.Threading.WaitHandle[] waitHandles, TimeSpan timeout, bool exitContext);
以下是一个示例,利用两个线程模拟两个不同的服务调用。两个线程将并行执行,但在打印总和到控制台之前将在WaitHandle.WaitAll(waitHandles)
处等待:
static int _dataFromService1 = 0;
static int _dataFromService2 = 0;
private static void WaitAll()
{
List<WaitHandle> waitHandles = new List<WaitHandle>
{
new AutoResetEvent(false),
new AutoResetEvent(false)
};
ThreadPool.QueueUserWorkItem(new WaitCallback
(FetchDataFromService1), waitHandles.First());
ThreadPool.QueueUserWorkItem(new WaitCallback
(FetchDataFromService2), waitHandles.Last());
//Waits for all the threads (waitHandles) to call the .Set()
//method
//i.e. wait for data to be returned from both service
WaitHandle.WaitAll(waitHandles.ToArray());
Console.WriteLine($"The Sum is
{_dataFromService1 + _dataFromService2}");
}
private static void FetchDataFromService1(object state)
{
Thread.Sleep(1000);
_dataFromService1 = 890;
var autoResetEvent = state as AutoResetEvent;
autoResetEvent.Set();
}
private static void FetchDataFromService2(object state)
{
Thread.Sleep(1000);
_dataFromService2 = 3;
var autoResetEvent = state as AutoResetEvent;
autoResetEvent.Set();
}
上述代码的输出如下:
WaitAny
:阻塞调用线程,直到它从等待的任何等待句柄接收到信号。
以下是WaitAny
方法的签名:
public static int WaitAny (System.Threading.WaitHandle[] waitHandles);
以下是一个示例,利用两个线程执行项目搜索。两个线程将并行执行,并且程序在WaitHandle.WaitAny(waitHandles)
方法中等待任何一个线程完成执行,然后将项目索引打印到控制台。
我们有两种方法,二分搜索和线性搜索,使用二进制和线性算法执行搜索。我们希望尽快从这两种方法中获得结果。我们可以通过使用AutoResetEvent
进行信号传递,并将结果存储在findIndex
和winnerAlgo
全局变量中:
static int findIndex = -1;
static string winnerAlgo = string.Empty;
private static void BinarySearch(object state)
{
dynamic data = state;
int[] x = data.Range;
int valueToFind = data.ItemToFind;
AutoResetEvent autoResetEvent = data.WaitHandle
as AutoResetEvent;
//Search for item using .NET framework built in Binary Search
int foundIndex = Array.BinarySearch(x, valueToFind);
//store the result globally
Interlocked.CompareExchange(ref findIndex, foundIndex, -1);
Interlocked.CompareExchange(ref winnerAlgo, "BinarySearch",
string.Empty);
//Signal event
autoResetEvent.Set();
}
public static void LinearSearch( object state)
{
dynamic data = state;
int[] x = data.Range;
int valueToFind = data.ItemToFind;
AutoResetEvent autoResetEvent = data.WaitHandle as AutoResetEvent;
int foundIndex = -1;
//Search for item linearly using for loop
for (int i = 0; i < x.Length; i++)
{
if (valueToFind == x[i])
{
foundIndex = i;
}
}
//store the result globally
Interlocked.CompareExchange(ref findIndex, foundIndex, -1);
Interlocked.CompareExchange(ref winnerAlgo, "LinearSearch",
string.Empty);
//Signal event
autoResetEvent.Set();
}
以下代码使用ThreadPool
并行调用两种算法:
private static void AlgoSolverWaitAny()
{
WaitHandle[] waitHandles = new WaitHandle[]
{
new AutoResetEvent(false),
new AutoResetEvent(false)
};
var itemToSearch = 15000;
var range = Enumerable.Range(1, 100000).ToArray();
ThreadPool.QueueUserWorkItem(new WaitCallback
(LinearSearch),new {Range = range,ItemToFind =
itemToSearch, WaitHandle= waitHandles[0] });
ThreadPool.QueueUserWorkItem(new WaitCallback(BinarySearch),
new { Range = range, ItemToFind =
itemToSearch, WaitHandle = waitHandles[1] });
WaitHandle.WaitAny(waitHandles);
Console.WriteLine($"Item found at index {findIndex} and faster
algo is {winnerAlgo}" );
}
- SignalAndWait:此方法用于在等待句柄上调用
Set()
并为另一个等待句柄调用WaitOne
。在多线程环境中,此方法可用于释放一个线程,然后重置以等待下一个线程:
public static bool SignalAndWait (System.Threading.WaitHandle toSignal, System.Threading.WaitHandle toWaitOn);
轻量级同步原语
.NET Framework 还提供了轻量级的同步原语,其性能优于其对应物。它们尽可能避免依赖内核对象,如等待句柄,因此只在进程内工作。当线程的等待时间较短时,应使用这些原语。我们可以将它们分为两类,在本节中我们将介绍这两类。
Slim 锁
Slim 锁是传统同步原语的精简实现,可以通过减少开销来提高性能。
以下表格显示了传统同步原语及其精简对应物:
让我们尝试更多地了解 Slim 锁。
ReaderWriterLockSlim
ReaderWriterLockSlim
是ReaderWriterLock
的轻量级实现。它表示一个锁,可用于以允许多个线程共享读取访问的方式管理受保护的资源,同时只允许一个线程写入访问。
以下示例使用ReaderWriterLockSlim
来保护由三个读取线程和一个写入线程共享的列表上的访问:
static ReaderWriterLockSlim _readerWriterLockSlim = new ReaderWriterLockSlim();
static List<int> _list = new List<int>();
private static void ReaderWriteLockSlim()
{
Task writerTask = Task.Factory.StartNew( WriterTask);
for (int i = 0; i < 3; i++)
{
Task readerTask = Task.Factory.StartNew(ReaderTask);
}
}
static void WriterTask()
{
for (int i = 0; i < 4; i++)
{
try
{
_readerWriterLockSlim.EnterWriteLock();
Console.WriteLine($"Entered WriteLock on Task {Task.CurrentId}");
int random = new Random().Next(1, 10);
_list.Add(random);
Console.WriteLine($"Added {random} to list on Task {Task.CurrentId}");
Console.WriteLine($"Exiting WriteLock on Task {Task.CurrentId}");
}
finally
{
_readerWriterLockSlim.ExitWriteLock();
}
Thread.Sleep(1000);
}
}
static void ReaderTask()
{
for (int i = 0; i < 2; i++)
{
_readerWriterLockSlim.EnterReadLock();
Console.WriteLine($"Entered ReadLock on Task {Task.CurrentId}");
Console.WriteLine($"Items: {_list.Select(j=>j.ToString ()).Aggregate((a, b) =>
a + "," + b)} on Task {Task.CurrentId}");
Console.WriteLine($"Exiting ReadLock on Task {Task.CurrentId}");
_readerWriterLockSlim.ExitReadLock();
Thread.Sleep(1000);
}
}
此代码的输出如下:
SemaphoreSlim
SemaphoreSlim
是semaphore
的轻量级实现。它限制对受保护资源的访问,以供多个线程使用。
以下是本章前面展示的semaphore
程序的精简版本:
private static void ThrottlerUsingSemaphoreSlim()
{
var range = Enumerable.Range(1, 12);
SemaphoreSlim semaphore = new SemaphoreSlim(3, 3);
range.AsParallel().AsOrdered().ForAll(i =>
{
try
{
semaphore.Wait();
Console.WriteLine($"Index {i} making service call using Task {Task.CurrentId}");
//Simulate Http call
CallService(i);
Console.WriteLine($"Index {i} releasing semaphore using Task {Task.CurrentId}");
}
finally
{
semaphore.Release();
}
});
}
private static void CallService(int i)
{
Thread.Sleep(1000);
}
我们可以看到这里的区别,除了用SemaphoreSlim
替换Semaphore
类之外,我们现在有了Wait()
方法,而不是WaitOne()
。这样做更有意义,因为我们允许多个线程通过。
另一个重要的区别是SemaphoreSlim
总是作为本地semaphore
创建,而semaphore
可以全局创建。
手动重置事件 Slim
ManualResetEventSlim
是ManualResetEvent
的轻量级实现。它比ManualResetEvent
具有更好的性能和更少的开销。
我们可以按照以下语法创建对象,就像ManualResetEvent
一样:
ManualResetEventSlim manualResetEvent = new ManualResetEventSlim(false);
就像其他 slim 对应物一样,这里的一个主要区别是我们用Wait()
替换了WaitOne()
方法。
您可以尝试运行一些ManualResetEvent
演示代码,通过进行上述更改并查看是否有效。
屏障和倒计时事件
.NET Framework 具有一些内置的信号原语,可以帮助我们同步多个线程,而无需编写大量的同步逻辑。所有同步都由提供的数据结构在内部处理。在本节中,让我们讨论两个非常重要的信号原语:CountDownEvent
和Barrier
:
-
CountDownEvent:
System.Threading.CountDownEvent
类指的是当其计数变为 0 时被触发的事件。 -
屏障:
Barrier
类允许多个线程在没有主线程控制它们的情况下运行。它创建了一个障碍,参与的线程必须在其中等待,直到所有线程都到达。Barrier
非常适用于需要并行和分阶段进行工作的情况。
使用 Barrier 和 CountDownEvent 的案例研究
举个例子,假设我们需要从动态托管的两个服务中获取数据。在从服务一获取数据之前,我们需要托管它。一旦数据被获取,就需要关闭它。只有在服务一关闭后,我们才能启动服务二并从中获取数据。需要尽快获取数据。让我们创建一些代码来满足这种情况的要求。
创建一个有5
个参与者的Barrier
:
static Barrier serviceBarrier = new Barrier(5);
创建两个CountdownEvents
,当六个线程通过它时将触发服务的启动或关闭。五个工作任务将参与其中,还有一个任务将管理服务的启动或关闭:
static CountdownEvent serviceHost1CountdownEvent = new CountdownEvent(6);
static CountdownEvent serviceHost2CountdownEvent = new CountdownEvent(6);
最后,创建另一个计数为5
的CountdownEvent
。这指的是在事件被触发之前可以通过的线程数。当所有工作任务执行完成时,CountdownEvent
将被触发:
static CountdownEvent finishCountdownEvent = new CountdownEvent(5);
这是我们的serviceManagerTask
实现:
Task serviceManager = Task.Factory.StartNew(() =>
{
//Block until service name is set by any of thread
while (string.IsNullOrEmpty(_serviceName))
Thread.Sleep(1000);
string serviceName = _serviceName;
HostService(serviceName);
//Now signal other threads to proceed making calls to service1
serviceHost1CountdownEvent.Signal();
//Wait for worker tasks to finish service1 calls
serviceHost1CountdownEvent.Wait();
//Block until service name is set by any of thread
while (_serviceName != "Service2")
Thread.Sleep(1000);
Console.WriteLine($"All tasks completed for service {serviceName}.");
//Close current service and start the other service
CloseService(serviceName);
HostService(_serviceName);
//Now signal other threads to proceed making calls to service2
serviceHost2CountdownEvent.Signal();
serviceHost2CountdownEvent.Wait();
//Wait for worker tasks to finish service2 calls
finishCountdownEvent.Wait();
CloseService(_serviceName);
Console.WriteLine($"All tasks completed for service {_serviceName}.");
});
这是工作任务执行的方法:
private static void GetDataFromService1And2(int j)
{
_serviceName = "Service1";
serviceHost1CountdownEvent.Signal();
Console.WriteLine($"Task with id {Task.CurrentId} signalled countdown event and waiting for
service to start");
//Waiting for service to start
serviceHost1CountdownEvent.Wait();
Console.WriteLine($"Task with id {Task.CurrentId} fetching data from service ");
serviceBarrier.SignalAndWait();
//change servicename
_serviceName = "Service2";
//Signal Countdown event
serviceHost2CountdownEvent.Signal();
Console.WriteLine($"Task with id {Task.CurrentId} signalled countdown event and waiting for
service to start");
serviceHost2CountdownEvent.Wait();
Console.WriteLine($"Task with id {Task.CurrentId} fetching data from service ");
serviceBarrier.SignalAndWait();
//Signal Countdown event
finishCountdownEvent.Signal();
}
//Finally make worker tasks
for (int i = 0; i < 5; ++i)
{
int j = i;
tasks[j] = Task.Factory.StartNew(() =>
{
GetDataFromService1And2(j);
});
}
Task.WaitAll(tasks);
Console.WriteLine("Fetch completed");
上述代码的输出如下:
在本节中,我们已经看了一些内置的信号原语,这些原语可以帮助我们更轻松地进行代码同步,而无需作为开发人员锁定自己。阻塞仍然会带来性能成本,因为它涉及上下文切换。在下一节中,我们将看一些旋转技术,可以帮助消除上下文切换的开销。
SpinWait
在本章的开头,我们提到对于较小的等待时间,旋转比阻塞更有效。旋转具有较少的与上下文切换和转换相关的内核开销。
我们可以按照以下方式创建SpinWait
对象:
var spin = new SpinWait();
然后,无论我们需要进行spin
,我们都可以调用以下命令:
spin.SpinOnce();
SpinLock
如果获取锁的等待时间非常短,锁和互锁原语可能会显著降低性能。SpinLock
提供了一种轻量级、低级别的替代锁定方法。SpinLock
是一个值类型,因此如果我们想在多个地方使用相同的对象,我们需要通过引用传递它。出于性能原因,即使SpinLock
甚至还没有获取锁,它也会让出线程的时间片,以便垃圾收集器可以有效工作。默认情况下,SpinLock
不支持线程跟踪,这意味着确定哪个线程已经获取了锁。但是,这个特性可以被打开。这只建议用于调试,而不是用于生产,因为它会降低性能。
创建一个SpinLock
对象如下:
static SpinLock _spinLock = new SpinLock();
创建一个将被各个线程调用并更新全局静态列表的方法:
static List<int> _itemsList = new List<int>();
private static void SpinLock(int number)
{
bool lockTaken = false;
try
{
Console.WriteLine($"Task {Task.CurrentId} Waiting for lock");
_spinLock.Enter(ref lockTaken); Console.WriteLine($"Task {Task.CurrentId} Updating list");
_itemsList.Add(number);
}
finally
{
if (lockTaken)
{
Console.WriteLine($"Task {Task.CurrentId} Exiting Update");
_spinLock.Exit(false);
}
}
}
正如你所看到的,锁是使用_spinLock.Enter(ref lockTaken)
获取的,并且通过_spinLock.Exit(false)
释放。在这两个语句之间的所有内容将在所有线程之间同步执行。
让我们在一个并行循环中调用这个方法:
Parallel.For(1, 5, (i) => SpinLock(i));
如果我们使用锁定原语,这里是同步的输出:
作为一个经验法则,如果我们有小任务,可以通过自旋完全避免上下文切换。
摘要
在本章中,我们已经了解了.NET Core 提供的同步原语。如果要编写并行代码并确保其正确性,同步原语是必不可少的,即使多个线程在处理它。同步原语会带来性能开销,建议尽可能使用它们的精简版本。
我们还学习了信号原语,当线程需要处理一些外部事件时,这些原语非常有用。我们还讨论了屏障和倒计时事件,它们帮助我们避免代码同步问题,而无需编写额外的逻辑。最后,我们介绍了一些自旋技术,它们消除了由阻塞代码引起的性能开销,即SpinLock
和SpinWait
。
在下一章中,我们将了解.NET Core 提供的各种数据结构。这些数据结构是自动同步的,同时也是并行的。
问题
-
这些中哪个可以用于跨进程同步?
-
锁
-
Interlocked.Increment
-
Interlocked.MemoryBarrierProcessWide
-
以下哪个不是有效的内存屏障?
-
读取内存屏障
-
半内存屏障
-
完整内存屏障
-
读取和执行内存屏障
-
我们不能从以下哪种状态恢复线程?
-
等待、休眠、加入
-
暂停
-
中止
-
一个无名的
信号量
可以提供同步的地方? -
进程内部
-
跨进程
-
这些结构中哪个支持跟踪线程?
-
SpinWait
-
SpinLock
第六章:使用并发集合
在上一章中,我们看到了一些并行编程的实现,其中需要保护资源免受多个线程的并发访问。同步原语很难实现。通常,共享资源是一个需要多个线程读写的集合。由于集合可以以各种方式访问(例如使用Enumerate
、Read
、Write
、Sort
或Filter
),因此使用原语编写具有受控同步的自定义集合变得棘手。因此,一直存在着对线程安全集合的需求。
在本章中,我们将学习 C#中可用的各种编程构造,这些构造有助于并行开发。以下是本章将涵盖的高级主题:
-
并发集合简介
-
多生产者/消费者场景
技术要求
您应该对 TPL 和 C#有很好的理解。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter06
。
并发集合简介
从.NET Framework 4 开始,.NET 中添加了许多线程安全的集合。还添加了一个新的命名空间System.Threading.Concurrent
。其中包括以下构造:
-
IProducerConsumerCollection<T>
-
BlockingCollection<T>
-
ConcurrentDictionary<TKey,TValue>
在使用上述结构时,不需要任何额外的同步,读取和更新都可以原子地完成。
在集合方面,线程安全并不是一个全新的概念。即使在旧的集合中,如ArrayList
和Hashtable
,也暴露了Synchronized
属性,这使得可以以线程安全的方式访问这些集合。然而,这会带来性能损失,因为为了使集合线程安全,每次读取或更新操作都会将整个集合包装在锁内。
并发集合包装了轻量级、精简的同步原语,如SpinLock
、SpinWait
、SemaphoreSlim
和CountDownEvent
,因此使它们对核心的负担较轻。正如我们已经知道的,对于较短的等待时间,自旋比阻塞更有效。此外,如果等待时间增加,内置算法会将较轻的锁转换为内核锁。
引入 IProducerConsumerCollection
生产者和消费者集合是提供了高效的无锁替代品的集合,例如Stack<T>
和Queue<T>
。任何生产者或消费者集合都必须允许用户添加和删除项目。.NET Framework 提供了IProducerConsumerCollection<T>
接口,表示线程安全的堆栈、队列和包。以下是实现该接口的类:
-
ConcurrentQueue<T>
-
ConcurrentStack<T>
-
ConcurrentBag<T>
接口提供了两个重要的方法:TryAdd
和TryTake
。TryAdd
的语法如下:
bool TryAdd (T item);
TryAdd
方法添加一个项目并返回true
。如果添加项目时出现任何问题,它将返回false
。
TryTake
的语法如下:
bool TryTake (out T item);
TryTake
方法移除一个项目并返回true
。如果移除项目时出现任何问题,它将返回false
。
使用 ConcurrentQueue
并发队列可用于解决应用程序编程中的生产者/消费者场景。在生产者/消费者编程模式中,一个或多个线程生成数据,一个或多个线程消费数据。这会导致线程之间的竞争条件。我们可以通过以下方法解决这个问题:
-
使用队列
-
使用
ConcurrentQueue<T>
根据哪个线程(生产者/消费者)负责添加/消费数据,生产者-消费者模式可以分为以下几种:
-
纯生产者-消费者,一个线程只能生产数据或只能消费数据,但不能两者兼而有之
-
混合生产者-消费者,任何线程都可以同时生产或消费数据
让我们首先尝试使用队列解决生产者-消费者问题。
使用队列解决生产者-消费者问题
在这个例子中,我们将使用System.Collections
命名空间中定义的队列来创建生产者和消费者场景。将有多个任务尝试读取或写入队列,我们需要确保读取和写入是原子的:
- 让我们首先创建
queue
并用一些数据填充它:
Queue<int> queue = new Queue<int>();
for (int i = 0; i < 500; i++)
{
queue.Enqueue(i);
}
- 声明一个变量来保存最终结果:
int sum = 0;
- 接下来,我们将创建一个并行循环,使用多个任务从队列中读取项目,并以线程安全的方式将总和添加到之前声明的 sum 变量中:
Parallel.For(0, 500, (i) =>
{
int localSum = 0;
int localValue;
while (queue.TryDequeue(out localValue))
{
Thread.Sleep(10);
localSum += localValue;
}
Interlocked.Add(ref sum, localSum);
});
Console.WriteLine($"Calculated Sum is {sum} and should be {Enumerable.Range(0, 500).Sum()}");
如果我们运行程序,将得到以下输出。正如你所看到的,由于任务在尝试并发读取时发生了竞争条件,这不是预期的输出:
为了使前面的程序线程安全,我们可以通过修改并行循环代码来锁定关键部分,如下所示:
Parallel.For(0, 500, (i) =>
{
int localSum = 0;
int localValue;
Monitor.Enter(_locker);
while (cq.TryDequeue(out localValue))
{
Thread.Sleep(10);
localSum += localValue;
}
Monitor.Exit(_locker);
Interlocked.Add(ref sum, localSum);
});
同样,在更复杂的情况下,我们需要同步对并行代码中暴露给队列的所有读/写点。如果我们运行前面的代码,将得到以下输出:
正如你所看到的,一切都如预期的那样工作,尽管在频繁读取或写入的情况下,会有额外的同步开销,可能导致死锁。
使用并发队列解决问题
我们可以通过使用System.Collections.Concurrent.ConcurrentQueue
类来解决生产者-消费者问题,这是一个线程安全的队列版本。让我们通过使用并发队列修改前面的代码,如下所示:
private static void ProducerConsumerUsingConcurrentQueues()
{
// Create a Queue.
ConcurrentQueue<int> cq = new ConcurrentQueue<int>();
// Populate the queue.
for (int i = 0; i < 500; i++){
cq.Enqueue(i);
}
int sum = 0;
Parallel.For(0, 500, (i) =>
{
int localSum = 0;
int localValue;
while (cq.TryDequeue(out localValue))
{
Thread.Sleep(10);
localSum += localValue;
}
Interlocked.Add(ref sum, localSum);
});
Console.WriteLine($"outerSum = {sum}, should be {Enumerable.Range(0, 500).Sum()}");
}
正如你所看到的,我们刚刚在我们之前编写的代码中用ConcurrentQueue<int>
替换了Queue<int>
,这带来了同步开销。使用ConcurrentQueue
,我们不必担心其他同步原语。
如果我们运行前面的代码,将得到以下输出:
就像Queue<T>
一样,ConcurrentQueue<T>
也以先进先出(FIFO)模式工作。
性能考虑 - Queue与 ConcurrentQueue
我们应该在以下情况下使用ConcurrentQueue
,在这些情况下它比队列具有轻微或非常大的性能优势:
-
在纯生产者-消费者场景中,每个项目的处理时间非常低
-
在纯生产者-消费者场景中,只有一个专用生产者线程和一个专用消费者线程的情况
-
在纯生产者-消费者场景以及混合生产者-消费者场景中,处理时间为 500 FLOPS(每秒浮点运算次数)或更多
在混合生产者-消费者场景中,每个项目的处理时间较低时,我们应该使用队列而不是并发队列,以获得更好的性能。
使用 ConcurrentStack
ConcurrentStack<T>
是Stack<T>
的并发版本,并实现了IProducerConsumerCollection<T>
接口。我们可以从栈中推送或弹出项目,它以后进先出(LIFO)格式工作。它不涉及内核级锁定,而是依赖于自旋和比较和交换操作来消除任何争用。
以下是ConcurrentStack<T>
类的一些重要方法:
-
Clear
:从集合中移除所有元素 -
Count
:返回集合中的元素数 -
IsEmpty
:如果集合为空,则返回true
-
Push (T item)
:向集合中添加一个元素 -
TryPop (out T result)
:从集合中移除一个元素,并在移除项目时返回true
;否则返回false
-
PushRange (T [] items)
:原子性地向集合中添加一系列项目 -
TryPopRange (T [] items)
:从集合中移除一系列项目
让我们看看如何创建一个并发堆栈实例。
创建一个并发堆栈
我们可以创建一个并发堆栈实例,并按以下方式添加项目:
ConcurrentStack<int> concurrentStack = new ConcurrentStack<int>();
concurrentStack.Push (1);
concurrentStack.PushRange(new[] { 1,2,3,4,5});
我们可以按以下方式从堆栈中获取项目:
int localValue;
concurrentStack.TryPop(out localValue)
concurrentStack.TryPopRange (new[] { 1,2,3,4,5});
以下是创建并发堆栈、添加项目并并行迭代项目的完整代码:
private static void ProducerConsumerUsingConcurrentStack()
{
// Create a Queue.
ConcurrentStack<int> concurrentStack = new ConcurrentStack<int>();
// Populate the queue.
for (int i = 0; i < 500; i++){
concurrentStack.Push(i);
}
concurrentStack.PushRange(new[] { 1,2,3,4,5});
int sum = 0;
Parallel.For(0, 500, (i) =>
{
int localSum = 0;
int localValue;
while (concurrentStack.TryPop(out localValue))
{
Thread.Sleep(10);
localSum += localValue;
}
Interlocked.Add(ref sum, localSum);
});
Console.WriteLine($"outerSum = {sum}, should be 124765");
}
输出如下:
使用 ConcurrentBag
ConcurrentBag<T>
是一个无序集合,不像ConcurrentStack
和ConcurrentQueues
,它在存储和检索项目时会对项目进行排序。ConcurrentBag<T>
针对同一线程既作为生产者又作为消费者的场景进行了优化。ConcurrentBag
支持工作窃取算法,并为每个线程维护一个本地队列。
以下代码创建ConcurrentBag
并向其中添加或获取项目:
ConcurrentBag<int> concurrentBag = new ConcurrentBag<int>();
//Add item to bag
concurrentBag.Add(10);
int item;
//Getting items from Bag
concurrentBag.TryTake(out item)
完整代码如下:
static ConcurrentBag<int> concurrentBag = new ConcurrentBag<int>();
private static void ConcurrentBackDemo()
{
ManualResetEventSlim manualResetEvent = new ManualResetEventSlim(false);
Task producerAndConsumerTask = Task.Factory.StartNew(() =>
{
for (int i = 1; i <= 3; ++i)
{
concurrentBag.Add(i);
}
//Allow second thread to add items
manualResetEvent.Wait();
while (concurrentBag.IsEmpty == false)
{
int item;
if (concurrentBag.TryTake(out item))
{
Console.WriteLine($"Item is {item}");
}
}
});
Task producerTask = Task.Factory.StartNew(() =>
{
for (int i = 4; i <= 6; ++i)
{
concurrentBag.Add(i);
}
manualResetEvent.Set();
});
}
输出如下:
正如您所知,每个线程都有一个线程本地队列。项目 1、2 和 3 被添加到producerAndConsumerTask
的本地队列中,项目 4、5 和 6 被添加到producerTask
的本地队列中。当producerAndConsumerTask
添加了项目后,我们等待producerTask
完成推送其项目。一旦所有项目都被推送,producerAndConsumerTask
开始检索项目。由于它已经推送了 1、2 和 3,这些项目在本地队列中,它将首先处理这些项目,然后再移动到producerTask
的本地队列。
使用 BlockingCollection
BlockingCollection<T>
类是一个线程安全的集合,实现了IProduceConsumerCollection<T>
接口。我们可以同时向集合中添加或移除项目,而不必担心同步问题,因为这些问题会被自动处理。会有两个线程:生产者和消费者。生产者线程将生成数据,我们可以限制生产者线程在进入休眠模式并被阻塞之前可以生产的最大项目数。消费者线程将消耗数据,并在集合为空时被阻塞。当生产者线程解除阻塞并消费者线程从集合中移除一些项目时,消费者线程将被解除阻塞。当生产者线程向集合中添加一些数据时,消费者线程将被解除阻塞。
阻塞集合有两个重要方面:
-
边界:这意味着我们可以将集合限制为最大值,之后不再能添加新对象,生产者线程进入休眠模式。
-
阻塞:这意味着当集合为空时,我们可以阻塞消费者线程。
让我们看看如何创建阻塞集合。
创建 BlockingCollection
以下代码创建一个新的BlockingCollection
,在创建 10 个项目后,它进入阻塞状态,然后由消费者线程消耗项目:
BlockingCollection<int> blockingCollection = new BlockingCollection<int>(10);
可以按以下方式向集合中添加项目:
blockingCollection.Add(1);
blockingCollection.TryAdd(3, TimeSpan.FromSeconds(1))
可以按以下方式从集合中移除项目:
int item = blockingCollection.Take();
blockingCollection.TryTake(out item, TimeSpan.FromSeconds(1))
当没有更多项目可添加时,生产者线程调用CompleteAdding()
方法。这个方法会将集合的IsAddingComplete
属性设置为true
。
当集合为空且IsAddingComplete
也为true
时,消费者线程使用IsCompleted
属性。这表明所有项目都已被处理,生产者将不再添加任何项目。
完整代码如下:
BlockingCollection<int> blockingCollection = new BlockingCollection<int>(10);
Task producerTask = Task.Factory.StartNew(() =>
{
for (int i = 0; i < 5; ++i)
{
blockingCollection.Add(i);
}
blockingCollection.CompleteAdding();
});
Task consumerTask = Task.Factory.StartNew(() =>
{
while (!blockingCollection.IsCompleted)
{
int item = blockingCollection.Take();
Console.WriteLine($"Item retrieved is {item}");
}
});
Task.WaitAll(producerTask, consumerTask);
输出如下:
现在,在介绍了并发集合之后,在下一节中,我们将尝试将生产者-消费者场景推进,并了解如何处理多个生产者/消费者。
多个生产者-消费者场景
在本节中,我们将看到当存在多个生产者和消费者线程时,阻塞集合是如何工作的。为了理解,我们将创建两个生产者和一个消费者。生产者线程将生产项目。一旦所有生产者线程都调用了CompleteAdding
,消费者将开始从集合中读取项目:
- 让我们从创建一个带有多个生产者的阻塞集合开始:
BlockingCollection<int>[] produceCollections = new BlockingCollection<int>[2];
produceCollections[0] = new BlockingCollection<int>(5);
produceCollections[1] = new BlockingCollection<int>(5);
- 接下来,我们将创建两个生产者任务,它们将向生产者添加项目:
Task producerTask1 = Task.Factory.StartNew(() =>
{
for (int i = 1; i <= 5; ++i)
{
produceCollections[0].Add(i);
Thread.Sleep(100);
}
produceCollections[0].CompleteAdding();
});
Task producerTask2 = Task.Factory.StartNew(() =>
{
for (int i = 6; i <= 10; ++i)
{
produceCollections[1].Add(i);
Thread.Sleep(200);
}
produceCollections[1].CompleteAdding();
});
- 最后,我们将编写消费者逻辑,尝试从两个生产者集合中消费项目,一旦项目可用即开始:
while (!produceCollections[0].IsCompleted || !produceCollections[1].IsCompleted)
{
int item;
BlockingCollection<int>.TryTakeFromAny(produceCollections, out item, TimeSpan.FromSeconds(1));
if (item != default(int))
{
Console.WriteLine($"Item fetched is {item}");
}
}
从前面的代码方法中可以看出,TryTakeFromAny
尝试从多个生产者中读取项目,并在项目可用时返回。
输出如下:
在编程中,我们经常遇到需要并发存储数据作为键值对的情况。为此,ConcurrentDictionary
集合非常方便,我们将在下一节介绍它。
使用 ConcurrentDictionary<TKey,TValue>
ConcurrentDictionary<TKey,TValue>
表示线程安全的字典。它用于以线程安全的方式保存可以读取或写入的键值对。
ConcurrentDictionary
可以按以下方式创建:
ConcurrentDictionary<int, int> concurrentDictionary = new ConcurrentDictionary<int, int>();
可以按以下方式向字典中添加项目:
concurrentDictionary.TryAdd(i, i * i);
string value = (i * i).ToString();
// Add item if not exist or else update
concurrentDictionary.AddOrUpdate(i, value,(key, val) => (key * key).ToString());
//Fetches item with key 5 or if not exist than add key 5 with value 25
concurrentDictionary.GetOrAdd(5, "25");
可以按以下方式从字典中移除项目:
string value;
concurrentDictionary.TryRemove(5, out value);
可以按以下方式更新字典中的项目:
//If a key with a value of 25 is found, it will be updated to have a value of 30 concurrentDictionary.TryUpdate(5, "30","25");
在下面的代码中,我们将创建两个生产者线程,它们将向字典中添加项目。生产者将创建一些重复的项目,字典将确保它们以线程安全的方式添加,而不会抛出重复键错误。生产者线程完成后,消费者将使用keys
或values
属性读取所有项目:
ConcurrentDictionary<int, string> concurrentDictionary = new ConcurrentDictionary<int, string>();
Task producerTask1 = Task.Factory.StartNew(() =>
{
for (int i = 0; i < 20; i++)
{
Thread.Sleep(100);
concurrentDictionary.TryAdd(i, (i * i).ToString());
}
});
Task producerTask2 = Task.Factory.StartNew(() =>
{
for (int i = 10; i < 25; i++)
{
concurrentDictionary.TryAdd(i, (i * i).ToString());
}
});
Task producerTask3 = Task.Factory.StartNew(() =>
{
for (int i = 15; i < 20; i++)
{
Thread.Sleep(100);
concurrentDictionary.AddOrUpdate(i, (i * i).ToString(),(key, value)
=> (key * key).ToString());
}
});
Task.WaitAll(producerTask1, producerTask2);
Console.WriteLine("Keys are {0} ", string.Join(",", concurrentDictionary.Keys.Select(c => c.ToString()).ToArray()));
输出如下:
在本节中,我们了解了并发集合在生产者-消费者场景中是非常方便的。使用并发集合,代码可以正确地处理多个任务,而无需自定义同步开销。
摘要
在本章中,我们讨论了.NET Framework 中的线程安全集合。并发集合位于System.Collection.Concurrent
命名空间中,用于编程中的各种用例提供了各种集合。一些常见的用例需要包括字典、列表、包等的集合。
我们还讨论了生产者和消费者场景,其中一些线程生产数据,同时其他线程消费数据。通常,在这些场景中存在竞争条件,但并发集合可以有效地处理它们。
在下一章中,我们将学习通过延迟初始化模式来提高并行代码的性能。
问题
-
以下哪个不是并发集合?
-
ConcurrentQueue<T>
-
ConcurrentBag<T>
-
ConcurrentStack<T>
-
ConcurrentList<T>
-
当一个线程只能生产数据,另一个线程只能消费数据,而不能同时进行时,这种安排是什么?
-
纯生产者-消费者
-
混合生产者-消费者
-
在纯生产者-消费者场景中,如果项目的处理时间较短,队列的性能将最佳。
-
真
-
假
-
哪个不是
ConcurrentStack
的成员? -
Push
-
TryPop
-
TryPopRange
-
TryPush
第七章:使用懒惰初始化提高性能
在上一章中,我们讨论了 C#中线程安全的并发集合。并发集合有助于提高并行代码的性能,而不需要开发人员担心同步开销。
在本章中,我们将讨论一些更多的概念,这些概念有助于改善代码的性能,既可以使用自定义实现,也可以使用内置结构。以下是本章将讨论的主题:
-
懒惰初始化概念介绍
-
介绍
System.Lazy<T>
-
如何处理懒惰模式下的异常
-
使用线程本地存储进行懒惰初始化
-
通过懒惰初始化减少开销
让我们通过引入懒惰初始化模式开始。
技术要求
读者应该对 TPL 和 C#有很好的理解。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/-Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter07
。
介绍懒惰初始化概念
懒加载是应用程序编程中常用的设计模式,其中我们推迟对象的创建,直到在应用程序中实际需要它。正确使用懒加载模式可以显著提高应用程序的性能。
这种模式的常见用法之一可以在缓存旁路模式中看到。我们使用缓存旁路模式来创建对象,这些对象的创建在资源或内存方面都很昂贵。我们不是多次创建它们,而是创建一次并将它们缓存以供将来使用。当对象的初始化从构造函数移动到方法或属性时,这种模式就成为可能。只有在代码首次调用方法或属性时,对象才会被初始化。然后它将被缓存以供后续调用。看一下以下代码示例,它在构造函数中初始化底层数据成员:
class _1Eager
{
//Declare a private variable to hold data
Data _cachedData;
public _1Eager()
{
//Load data as soon as object is created
_cachedData = GetDataFromDatabase();
}
public Data GetOrCreate()
{
return _cachedData;
}
//Create a dummy data object every time this method gets called
private Data GetDataFromDatabase()
{
//Dummy Delay
Thread.Sleep(5000);
return new Data();
}
}
前面的代码问题在于,即使只有通过调用GetOrCreate()
方法才能访问底层对象,但底层数据在对象创建时就被初始化了。在某些情况下,程序甚至可能不会调用该方法,因此会浪费内存。
懒加载可以完全使用自定义代码实现,如下面的代码示例所示:
class _2SimpleLazy
{
//Declare a private variable to hold data
Data _cachedData;
public _2SimpleLazy()
{
//Removed initialization logic from constructor
Console.WriteLine("Constructor called");
}
public Data GetOrCreate()
{
//Check is data is null else create and store for later use
if (_cachedData == null)
{
Console.WriteLine("Initializing object");
_cachedData = GetDataFromDatabase();
}
Console.WriteLine("Data returned from cache");
//Returns cached data
return _cachedData;
}
private Data GetDataFromDatabase()
{
//Dummy Delay
Thread.Sleep(5000);
return new Data();
}
}
从前面的代码中可以看出,我们将初始化逻辑从构造函数移出到GetOrCreate()
方法中,该方法在返回给调用者之前检查项目是否在缓存中。如果缓存中不存在,数据将被初始化。
以下是调用前面方法的代码:
public static void Main(){
_2SimpleLazy lazy = new _2SimpleLazy();
var data = lazy.GetOrCreate();
data = lazy.GetOrCreate();
}
输出如下:
前面的代码虽然懒惰,但可能存在多重加载的问题。这意味着如果多个线程同时调用GetOrCreate()
方法,数据库的调用可能会运行多次。
可以通过引入锁定来改进,如下面的代码示例所示。对于缓存旁路模式,使用另一种模式,双重检查锁定,是有意义的:
class _2ThreadSafeSimpleLazy
{
Data _cachedData;
static object _locker = new object();
public Data GetOrCreate()
{
//Try to Load cached data
var data = _cachedData;
//If data not created yet
if (data == null)
{
//Lock the shared resource
lock (_locker)
{
//Second try to load data from cache as it might have been
//populate by another thread while current thread was
// waiting for lock
data = _cachedData;
//If Data not cached yet
if (data == null)
{
//Load data from database and cache for later use
data = GetDataFromDatabase();
_cachedData = data;
}
}
}
return _cachedData;
}
private Data GetDataFromDatabase()
{
//Dummy Delay
Thread.Sleep(5000);
return new Data();
}
public void ResetCache()
{
_cachedData = null;
}
}
前面的代码是自解释的。我们可以看到从头开始创建懒惰模式是复杂的。幸运的是,.NET Framework 提供了懒惰模式的数据结构。
引入 System.Lazy
.NET Framework 提供了System.Lazy<T>
类,具有懒惰初始化的所有好处,而无需担心同步开销。使用System.Lazy<T>
创建的对象直到首次访问时才被延迟创建。通过前面部分解释的自定义懒惰代码,我们可以看到,我们将初始化部分从构造函数移动到方法/属性以支持懒惰初始化。使用Lazy<T>
,我们不需要修改任何代码。
在 C#中有多种实现延迟初始化模式的方法。其中包括以下内容:
-
封装在构造函数中的构造逻辑
-
将构造逻辑作为委托传递给
Lazy<T>
在接下来的部分,我们将深入了解这些情景。
封装在构造函数中的构造逻辑
让我们首先尝试使用封装构造逻辑的类来实现延迟初始化模式。假设我们有一个Data
类:
class DataWrapper
{
public DataWrapper()
{
CachedData = GetDataFromDatabase();
Console.WriteLine("Object initialized");
}
public Data CachedData { get; set; }
private Data GetDataFromDatabase()
{
//Dummy Delay
Thread.Sleep(5000);
return new Data();
}
}
如您所见,初始化发生在构造函数内部。如果我们正常使用这个类,使用以下代码,对象在创建DataWrapper
对象时被初始化:
DataWrapper dataWrapper = new DataWrapper();
输出如下:
可以使用Lazy<T>
将上述代码转换如下:
Console.WriteLine("Creating Lazy object");
Lazy<DataWrapper> lazyDataWrapper = new Lazy<DataWrapper>();
Console.WriteLine("Lazy Object Created");
Console.WriteLine("Now we want to access data");
var data = lazyDataWrapper.Value.CachedData;
Console.WriteLine("Finishing up");
如您所见,我们将对象包装在延迟类中,而不是直接创建对象。在访问Lazy
对象的Value
属性之前,构造函数不会被调用,如下面的输出所示:
将构造逻辑作为委托传递给 Lazy
对象通常不包含构造逻辑,因为它们只是简单的数据模型。我们需要在首次访问延迟对象时获取数据,同时还要传递获取数据的逻辑。这可以通过System.Lazy<T>
的另一个重载来实现,如下所示:
class _5LazyUsingDelegate
{
public Data CachedData { get; set; }
static Data GetDataFromDatabase()
{
Console.WriteLine("Fetching data");
//Dummy Delay
Thread.Sleep(5000);
return new Data();
}
}
在以下代码中,我们通过传递Func<Data>
委托来创建一个Lazy<Data>
对象:
Console.WriteLine("Creating Lazy object");
Func<Data> dataFetchLogic = new Func<Data>(()=> GetDataFromDatabase());
Lazy<Data> lazyDataWrapper = new Lazy<Data>(dataFetchLogic);
Console.WriteLine("Lazy Object Created");
Console.WriteLine("Now we want to access data");
var data = lazyDataWrapper.Value;
Console.WriteLine("Finishing up");
从上面的代码中可以看出,我们将Func<T>
传递给Lazy<T>
构造函数。逻辑在第一次访问Lazy<T>
实例的Value
属性时被调用,如下面的输出所示:
除了对.NET 中的延迟对象进行构造和使用有一个好的理解之外,我们还需要了解如何处理延迟初始化模式中的异常!让我们看看下一节。
使用延迟初始化模式处理异常
Lazy 对象是不可变的。这意味着它们总是返回与初始化时相同的实例。我们已经看到可以将初始化逻辑传递给Lazy<T>
,并且可以在底层对象的构造函数中有初始化逻辑。如果构造/初始化逻辑有错误并抛出异常会发生什么?在这种情况下,Lazy<T>
的行为取决于LazyThreadSafetyMode
枚举的值和您选择的Lazy<T>
构造函数。在使用延迟模式时,有许多处理异常的方法。其中一些如下:
-
在初始化过程中不会发生异常
-
在异常缓存的情况下进行初始化时发生随机异常
-
不缓存异常
在接下来的部分,我们将深入了解这些情景。
在初始化过程中不会发生异常
初始化逻辑只运行一次,并且对象被缓存以便在后续访问Value
属性时返回。我们在前面的部分已经看到了这种行为,解释了Lazy<T>
。
在异常缓存的情况下进行初始化时发生随机异常
在这种情况下,由于底层对象没有被创建,所以初始化逻辑将在每次调用Value
属性时运行。这在构造逻辑依赖于外部因素(如调用外部服务时的互联网连接)的情况下非常有用。如果互联网暂时中断,那么初始化调用将失败,但后续调用可以返回数据。默认情况下,Lazy<T>
将为所有带参数的构造函数实现缓存异常,但不会为不带参数的构造函数实现缓存异常。
让我们尝试理解当Lazy<T>
初始化逻辑抛出随机异常时会发生什么:
- 首先,我们使用
GetDataFromDatabase()
函数提供的初始化逻辑创建Lazy<Data>
,如下所示:
Func<Data> dataFetchLogic = new Func<Data>(() => GetDataFromDatabase());
Lazy<Data> lazyDataWrapper = new Lazy<Data>(dataFetchLogic);
- 接下来,我们访问
Lazy<Data>
的Value
属性,这将执行初始化逻辑并抛出异常,因为计数器的值为0
:
try
{
data = lazyDataWrapper.Value;
Console.WriteLine("Data Fetched on Attempt 1");
}
catch (Exception)
{
Console.WriteLine("Exception 1");
}
- 接下来,我们将计数器加一,然后再次尝试访问
Value
属性。根据逻辑,这次应该返回Data
对象,但我们看到代码再次抛出异常:
class _6_1_ExceptionsWithLazyWithCaching
{
static int counter = 0;
public Data CachedData { get; set; }
static Data GetDataFromDatabase()
{
if ( counter == 0)
{
Console.WriteLine("Throwing exception");
throw new Exception("Some Error has occurred");
}
else
{
return new Data();
}
}
public static void Main()
{
Console.WriteLine("Creating Lazy object");
Func<Data> dataFetchLogic = new Func<Data>(() =>
GetDataFromDatabase());
Lazy<Data> lazyDataWrapper = new
Lazy<Data>(dataFetchLogic);
Console.WriteLine("Lazy Object Created");
Console.WriteLine("Now we want to access data");
Data data = null;
try
{
data = lazyDataWrapper.Value;
Console.WriteLine("Data Fetched on Attempt 1");
}
catch (Exception)
{
Console.WriteLine("Exception 1");
}
try
{
counter++;
data = lazyDataWrapper.Value;
Console.WriteLine("Data Fetched on Attempt 1");
}
catch (Exception)
{
Console.WriteLine("Exception 2");
// throw;
}
Console.WriteLine("Finishing up");
Console.ReadLine();
}
}
如您所见,即使我们将计数器增加了一次,异常仍然被抛出第二次。这是因为异常值被缓存,并在下次访问Value
属性时返回。输出如下所示:
上述行为与通过将System.Threading.LazyThreadSafetyMode.None
作为第二个参数创建Lazy<T>
相同:
Lazy<Data> lazyDataWrapper = new Lazy<Data>(dataFetchLogic,System.Threading.LazyThreadSafetyMode.None);
不缓存异常
让我们将上述代码中Lazy<Data>
的初始化更改为以下内容:
Lazy<Data> lazyDataWrapper = new Lazy<Data>(dataFetchLogic,System.Threading.LazyThreadSafetyMode.PublicationOnly);
这将允许初始化逻辑在不同线程中多次运行,直到其中一个线程成功运行初始化而没有任何错误。如果在多线程场景中的初始化过程中任何线程抛出错误,则由已完成的线程创建的基础对象的所有实例都将被丢弃,并且异常将传播到Value
属性。在单线程的情况下,当再次访问Value
属性时,初始化逻辑重新运行时会返回异常。异常不会被缓存。
输出如下:
在了解了延迟初始化模式处理异常的方法之后,现在让我们学习一下使用线程本地存储进行延迟初始化。
使用线程本地存储进行延迟初始化
在多线程编程中,我们经常希望创建一个局部于线程的变量,这意味着每个线程都将拥有数据的自己的副本。这对于所有局部变量都成立,但全局变量始终在各个线程之间共享。在旧版本的.NET 中,我们使用ThreadStatic
属性使静态变量表现为线程本地变量。然而,这并不是绝对可靠的,并且在初始化方面效果不佳。如果我们初始化一个ThreadStatic
变量,那么只有第一个线程获得初始化的值,而其余线程获得变量的默认值,在整数的情况下为 0。可以使用以下代码进行演示:
[ThreadStatic]
static int counter = 1;
public static void Main()
{
for (int i = 0; i < 10; i++)
{
Task.Factory.StartNew(() => Console.WriteLine(counter));
}
Console.ReadLine();
}
在上面的代码中,我们使用值为1
的静态counter
变量进行初始化,并将其线程静态化,以便每个线程都可以拥有自己的副本。为了演示目的,我们创建了 10 个任务,打印计数器的值。根据逻辑,所有线程应该打印 1,但如下输出所示,只有一个线程打印 1,其余线程打印 0:
.NET Framework 4 提供了System.Threading.ThreadLocal<T>
作为ThreadStatic
的替代方案,并且更像Lazy<T>
。使用ThreadLocal<T>
,我们可以创建一个可以通过传递初始化函数进行初始化的线程本地变量,如下所示:
static ThreadLocal<int> counter = new ThreadLocal<int>(() => 1);
public static void Main()
{
for (int i = 0; i < 10; i++)
{
Task.Factory.StartNew(() => Console.WriteLine($"Thread with
id {Task.CurrentId} has counter value as {counter.Value}"));
}
Console.ReadLine();
}
输出如预期的那样:
Lazy<T>
和ThreadLocal<T>
之间的区别如下:
-
每个线程都使用自己的私有数据初始化
ThreadLocal
变量,而在Lazy<T>
的情况下,初始化逻辑只运行一次。 -
与
Lazy<T>
不同,ThreadLocal<T>
中的Value
属性是可读/写的。 -
在没有任何初始化逻辑的情况下,默认值
T
将被分配给ThreadLocal
变量。
通过延迟初始化减少开销
Lazy<T>
通过包装底层对象使用了一定程度的间接性。这可能会导致计算和内存问题。为了避免包装对象,我们可以使用Lazy<T>
类的静态变体,即LazyInitializer
类。
我们可以使用LazyInitializer.EnsureInitialized
来初始化通过引用传递的数据成员以及初始化函数,就像我们使用Lazy<T>
一样。
该方法可以通过多个线程调用,但一旦值被初始化,它将作为所有线程的结果使用。为了演示起见,我在初始化逻辑中添加了一行到控制台。虽然循环运行 10 次,但初始化将仅在单线程执行一次:
static Data _data;
public static void Main()
{
for (int i = 0; i < 10; i++)
{
Console.WriteLine($"Iteration {i}");
// Lazily initialize _data
LazyInitializer.EnsureInitialized(ref _data, () =>
{
Console.WriteLine("Initializing data");
// Returns value that will be assigned in the ref parameter.
return new Data();
});
}
Console.ReadLine();
}
以下是输出:
这对于顺序执行是很好的。让我们尝试修改代码并通过多个线程运行它:
static Data _data;
static void Initializer()
{
LazyInitializer.EnsureInitialized(ref _data, () =>
{
Console.WriteLine($"Task with id {Task.CurrentId} is
Initializing data");
// Returns value that will be assigned in the ref parameter.
return new Data();
});
public static void Main()
{
Parallel.For(0, 10, (i) => Initializer());
Console.ReadLine();
}
}
以下是输出:
如您所见,使用多个线程会出现竞争条件,所有线程最终都会初始化数据。我们可以通过修改程序来避免这种竞争条件:
static Data _data;
static bool _initialized;
static object _locker = new object();
static void Initializer()
{
Console.WriteLine("Task with id {0}", Task.CurrentId);
LazyInitializer.EnsureInitialized(ref _data,ref _initialized,
ref _locker, () =>
{
Console.WriteLine($"Task with id {Task.CurrentId} is
Initializing data");
// Returns value that will be assigned in the ref parameter.
return new Data();
});
}
public static void Main()
{
Parallel.For(0, 10, (i) => Initializer());
Console.ReadLine();
}
从上面的代码中可以看出,我们使用了EnsureInitialized
方法的一个重载,并传递了一个布尔变量和一个SyncLock
对象作为参数。这将确保初始化逻辑只能由一个线程执行,如下面的输出所示:
在本节中,我们讨论了如何通过利用另一个内置的静态变体Lazy<T>
,即LazyInitializer
类,来解决与Lazy<T>
相关的开销问题。
总结
在本章中,我们讨论了延迟加载的各个方面,以及.NET Framework 提供的数据结构,使延迟加载更容易实现。
延迟加载可以通过减少内存占用和节省计算资源来显著提高应用程序的性能,因为它可以阻止重复初始化。我们可以选择使用Lazy<T>
从头开始创建延迟加载,也可以使用静态的LazyInitializer
类来避免复杂性。通过最佳的线程存储使用和良好的异常处理逻辑,这些工具对开发人员来说确实是很好的工具。
在下一章中,我们将开始讨论 C#中可用的异步编程方法。
问题
-
延迟初始化总是涉及在构造函数中创建对象。
-
True
-
False
-
在延迟初始化模式中,对象的创建被推迟,直到实际需要它。
-
True
-
False
-
哪个选项可以用来创建不缓存异常的延迟对象?
-
LazyThreadSafetyMode.DoNotCacheException
-
LazyThreadSafetyMode.PublicationOnly
-
哪个属性可以用来创建一个只对线程本地的变量?
-
ThreadLocal
-
ThreadStatic
-
两者
第三部分:使用 C#进行异步编程
在本节中,您将了解到另一个重要的方面,即如何使用异步编程技术制作高性能程序,同时关注早期版本与新的async
和await
构造方式的差异。
本节包括以下章节:
-
第八章,异步编程简介
-
第九章,异步、等待和基于任务的异步编程基础
第八章:异步编程简介
在之前的章节中,我们已经看到并行编程是如何工作的。并行性是关于创建称为工作单元的小任务,可以由一个或多个应用程序线程同时执行。由于线程在应用程序进程内运行,它们在使用委托通知调用线程完成后通知调用线程。
在本章中,我们将首先介绍同步代码和异步代码之间的区别。然后,我们将讨论何时使用异步代码以及何时避免使用它。我们还将讨论异步模式如何随时间演变。最后,我们将看到并行编程中的新特性如何帮助我们解决异步代码的复杂性。
在本章中,我们将涵盖以下主题:
-
同步与异步代码
-
何时使用异步编程
-
何时避免异步编程
-
使用异步代码可以解决的问题
-
C#早期版本中的异步模式
技术要求
要完成本章,您应该对 TPL 和 C#有很好的理解。本章的源代码可在 GitHub 上找到github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter08
。
程序执行的类型
在任何时刻,程序流程可以是同步的,也可以是异步的。同步代码编写和维护更容易,但会带来性能开销和 UI 响应性问题。异步代码可以提高整个应用程序的性能和响应性,但反过来,编写、调试和维护都更加困难。
我们将在以下子章节中详细了解程序执行的同步和异步方式。
理解同步程序执行
在同步执行的情况下,控制永远不会移出调用线程。代码一次执行一行,当调用函数时,调用线程会等待函数执行完成后再执行下一行代码。同步编程是最常用的编程方法,由于过去几年 CPU 性能的提高,它运行良好。随着处理器速度更快,代码完成得更快。
通过并行编程,我们已经看到可以创建多个可以并发运行的线程。我们可以启动许多线程,但也可以通过调用Thread.Join
和Task.Wait
等结构使主程序流程同步。让我们看一个同步代码的例子:
-
我们通过调用
M1()
方法启动应用程序线程。 -
在第 3 行,
M1()
同步调用M3()
。 -
调用
M2()
方法的时刻,控制执行转移到M1()
方法。 -
一旦被调用的方法(
M2
)完成,控制返回到主线程,执行M1()
中的其余代码,即第 4 和第 5 行。 -
在第 5 行对
M2
的调用也是同样的情况。当M2
完成时,第 6 行执行。
以下是同步代码执行的图解表示:
在接下来的部分,我们将尝试更多地了解编写异步代码,这将帮助我们比较两种程序流程。
理解异步程序执行
异步模型允许我们同时执行多个任务。如果我们异步调用一个方法,该方法将在后台执行,而调用的线程立即返回并执行下一行代码。异步方法可能会创建线程,也可能不会,这取决于我们处理的任务类型。当异步方法完成时,它通过回调将结果返回给程序。异步方法可以是 void,这种情况下我们不需要指定回调。
以下是一个图表,显示了一个调用者线程执行M1()
方法,该方法调用了一个名为M2()
的异步方法:
与以前的方法相反,在这里,调用者线程不等待M2()
完成。如果需要利用M2()
的任何输出,需要将其放入其他方法,比如M3()
。这是发生的事情:
-
在执行
M1()
时,调用者线程对M2()
进行异步调用。 -
调用者线程在调用
M2()
时提供回调函数,比如M3()
。 -
调用者线程不等待
M2()
完成,而是完成M1()
中的其余代码(如果有的话)。 -
M2()
将由 CPU 立即在一个单独的线程中执行,或者在以后的某个日期执行。 -
一旦
M2()
完成,将调用M3()
,M3()
接收来自M2()
的输出并对其进行处理。
正如您所看到的,理解同步程序的执行很容易,而异步代码则带有代码分支。我们将学习如何使用async
和await
关键字在第九章中减轻这种复杂性,异步、等待和基于任务的异步编程基础。
何时使用异步编程
有许多情况下会使用直接内存访问(DMA)来访问主机系统或进行 I/O 操作(如文件、数据库或网络访问),这是 CPU 而不是应用程序线程进行处理。在前面的情况下,调用线程调用 I/O API 并等待任务完成,从而进入阻塞状态。当 CPU 完成任务时,线程将解除阻塞并完成方法的其余部分。
使用异步方法,我们可以提高应用程序的性能和响应能力。我们还可以通过不同的线程执行一个方法。
编写异步代码
异步编程对 C#来说并不是什么新鲜事。我们过去在较早版本的 C#中使用Delegate
类的BeginInvoke
方法以及使用IAsyncResult
接口实现来编写异步代码。随着 TPL 的引入,我们开始使用Task
类编写异步代码。从 C# 5.0 开始,开发人员编写异步代码的首选选择是使用async
和await
关键字。
我们可以以以下方式编写异步代码:
-
使用
Delegate.BeginInvoke()
方法 -
使用
Task
类 -
使用
IAsyncResult
接口 -
使用
async
和await
关键字
在接下来的章节中,我们将通过代码示例详细讨论每个内容,除了async
和await
关键字 - 第九章专门讨论它们!
使用 Delegate 类的 BeginInvoke 方法
在.NET Core 中不再支持使用Delegate.BeginInvoke
,但是我们将在这里讨论它,以便与较早版本的.NET 向后兼容。
我们可以使用Delegate.BeginInvoke
方法异步调用任何方法。如果需要将一些任务从 UI 线程移动到后台以提高 UI 的性能,可以这样做。
让我们以Log
方法为例。以下代码以同步方式工作并写入日志。为了演示,日志记录代码已被删除,并替换为一个虚拟的 5 秒延迟,之后Log
方法将在控制台打印一行:
这是一个虚拟的Log
方法,需要 5 秒才能完成:
private static void Log(string message)
{
//Simulate long running method
Thread.Sleep(5000);
//Log to file or database
Console.WriteLine("Logging done");
}
这是从Main
方法调用Log
方法:
static void Main(string[] args)
{
Console.WriteLine("Starting program");
Log("this information need to be logged");
Console.WriteLine("Press any key to exit");
Console.ReadLine();
}
很明显,写日志需要 5 秒的延迟太长了。由于我们不希望从Log
方法中得到任何输出(将控制台输出仅用于演示目的),因此将其异步调用并立即将响应返回给调用者是有意义的。
以下是当前程序的输出:
我们可以在前面的方法中添加一个Log
方法调用。然后,我们可以将Log
方法调用包装在一个委托中,并在委托上调用BeginInvoke
方法,如下所示:
//Log("this information need to be logged");
Action logAction = new Action(()=> Log("this information need to be logged")); logAction.BeginInvoke(null,null);
这次,当我们执行代码时,我们将在较早版本的.NET 中看到异步行为。然而,在.NET Core 中,代码在运行时会出现以下错误消息:
System.PlatformNotSupportedException: 'Operation is not supported on this platform.'
在.NET Core 中,不再支持将同步方法包装成异步委托,原因有两个:
-
异步委托使用基于
IAsyncResult
的异步模式,这在.NET Core 基类库中不受支持。 -
在.NET Core 中,没有
System.Runtime.Remoting
,因此无法使用异步委托。
使用 Task 类
在.NET Core 中实现异步编程的另一种方法是使用System.Threading.Tasks.Task
类,正如我们之前提到的。前面的代码可以改为以下内容:
// Log("this information need to be logged");
Task.Factory.StartNew(()=> Log("this information need to be logged"));
这将为我们提供所需的输出,而不会改变当前代码流的太多内容:
我们在第二章中讨论了Task
,任务并行性。Task
类为我们提供了一种非常强大的实现基于任务的异步模式的方法。
使用 IAsyncResult 接口
IAsyncResult
接口已经被用来在早期版本的 C#中实现异步编程。以下是一些在较早版本的.NET 中运行良好的示例代码:
- 首先,我们创建一个
AsyncCallback
,当异步方法完成时将执行它。
AsyncCallback callback = new AsyncCallback(MyCallback);
- 然后,我们创建一个委托,该委托将使用传递的参数执行
Add
方法。完成后,它将执行由AsyncCallBack
包装的回调方法:
SumDelegate d = new SumDelegate(Add);
d.BeginInvoke(100, 200, callback, state);
- 当调用
MyCallBack
方法时,它会返回IAsyncResult
实例。要获取底层结果、状态和回调,我们需要将IAsyncResult
实例转换为AsyncResult
:
AsyncResult ar = (AsyncResult)result;
- 一旦我们有了
AsyncResult
,我们就可以调用EndInvoke
来获取Add
方法返回的值:
int i = d.EndInvoke(result);
以下是完整的代码:
using System.Runtime.Remoting.Messaging;
public delegate int SumDelegate(int x, int y);
static void Main(string[] args)
{
AsyncCallback callback = new AsyncCallback(MyCallback);
int state = 1000;
SumDelegate d = new SumDelegate(Add);
d.BeginInvoke(100, 200, callback, state);
Console.WriteLine("Press any key to exit");
Console.ReadLine();
}
public static int Add(int a, int b)
{
return a + b;
}
public static void MyCallback(IAsyncResult result)
{
AsyncResult ar = (AsyncResult)result;
SumDelegate d = (SumDelegate)ar.AsyncDelegate;
int state = (int)ar.AsyncState;
int i = d.EndInvoke(result);
Console.WriteLine(i);
Console.WriteLine(state);
Console.ReadLine();
}
不幸的是,.NET Core 不支持System.Runtime.Remoting
,因此前面的代码在.NET Core 中不起作用。我们只能对所有IAsyncResult
场景使用基于任务的异步模式:
FileInfo fi = new FileInfo("test.txt");
byte[] data = new byte[fi.Length];
FileStream fs = new FileStream("test.txt", FileMode.Open, FileAccess.Read, FileShare.Read, data.Length, true);
// We still pass null for the last parameter because
// the state variable is visible to the continuation delegate.
Task<int> task = Task<int>.Factory.FromAsync(
fs.BeginRead, fs.EndRead, data, 0, data.Length, null);
int result = task.Result;
Console.WriteLine(result);
前面的代码使用FileStream
类从文件中读取数据。FileStream
实现了IAsyncResult
,因此支持BeginRead
和EndRead
方法。然后,我们使用Task.Factory.FromAsync
方法来包装IAsyncResult
并返回数据。
何时不使用异步编程
异步编程在创建响应式 UI 和提高应用程序性能方面非常有益。然而,有些情况下应避免使用异步编程,因为它可能降低性能并增加代码的复杂性。在接下来的小节中,我们将讨论一些最好不要使用异步编程的情况。
在单个没有连接池的数据库中
在只有一个没有启用连接池的数据库服务器的情况下,异步编程将没有任何好处。无论是同步还是异步调用,长时间的连接和多个请求都会导致性能瓶颈。
当代码易于阅读和维护很重要时
在使用IAsyncResult
接口时,我们必须将源方法分解为两个方法:BeginMethodName
和EndMethodName
。以这种方式改变逻辑可能需要很多时间和精力,并且会使代码难以阅读、调试和维护。
用于简单和短暂的操作
我们需要考虑代码在同步运行时所花费的时间。如果时间不长,保持代码同步是有意义的,因为将代码改为异步会带来一些性能损失,对于小的收益来说并不划算。
对于有大量共享资源的应用程序
如果您的应用程序使用大量共享资源,例如全局变量或系统文件,保持代码同步是有意义的;否则,我们将减少性能的好处。与共享资源一样,我们需要应用可以减少多线程性能的同步原语。有时,单线程应用程序可能比多线程应用程序更高效。
您可以使用异步代码解决的问题
让我们看看一些情况,异步编程可以帮助改善应用程序的响应性和应用程序和服务器的性能。一些情况如下:
-
日志记录和审计:日志记录和审计是应用程序的横切关注点。如果您自己编写日志记录和审计的代码,那么对服务器的调用会变慢,因为它们需要写回日志。我们可以使日志记录和审计异步化,并且在可能的情况下应该使实现无状态。这将确保回调可以在静态上下文中返回,以便在响应返回到浏览器时调用可以继续执行。
-
服务调用:Web 服务调用和数据库调用可以是异步的,因为一旦我们调用服务/数据库,控制权就离开当前应用程序并转到 CPU,进行网络调用。调用线程进入阻塞状态。一旦服务调用的响应返回,CPU 接收并触发一个事件。调用线程解除阻塞并开始进一步执行。作为一种模式,您可能已经看到所有服务代理都返回异步方法。
-
创建响应式 UI:在程序中可能存在这样的情况,用户点击按钮保存数据。保存数据可能涉及多个小任务:从 UI 读取数据到模型,连接到数据库,并调用数据库更新数据。这可能需要很长时间,如果这些调用在 UI 线程上进行,那么线程将被阻塞直到完成。这意味着用户在调用返回之前无法在 UI 上执行任何操作。通过进行异步调用,我们可以改善用户体验。
-
CPU 密集型应用程序:随着.NET 中新技术和支持的出现,我们现在可以在.NET 中编写机器学习、ETL 处理和加密货币挖掘代码。这些任务对 CPU 要求很高,因此将这些程序设置为异步是有意义的。
C#早期版本中的异步模式 在.NET 的早期版本中,支持了两种模式来执行 I/O 密集型和计算密集型操作:
-
异步编程模型(APM)
-
基于事件的异步模式(EAP)
我们在第二章中详细讨论了这两种方法,任务并行性。我们还学习了如何将这些传统实现转换为基于任务的异步模式。
现在,让我们回顾一下本章涵盖的内容。
总结
在本章中,我们讨论了什么是异步编程,以及为什么编写异步代码是有意义的。我们还讨论了可以实现异步编程的场景以及应该避免的场景。最后,我们介绍了在 TPL 中实现的各种异步模式。
如果正确使用,异步编程可以通过有效利用线程来显著提高服务器端应用程序的性能。它还可以提高桌面/移动应用程序的响应性。
在下一章中,我们将讨论.NET Framework 提供的异步编程原语。
问题
-
________ 代码更容易编写、调试和维护。
-
同步
-
异步
-
在什么场景下应该使用异步编程?
-
文件 I/O
-
带有连接池的数据库
-
网络 I/O
-
没有连接池的数据库
-
哪种方法可以用来编写异步代码?
-
Delegate.BeginInvoke
-
任务
-
IAsyncResult
-
以下哪种不能用于在.NET Core 中编写异步代码?
-
IAsyncResult
-
任务
第九章:异步、等待和基于任务的异步编程基础
在上一章中,我们介绍了 C#中可用的异步编程实践和解决方案,甚至在.NET Core 之前。我们还讨论了异步编程可以派上用场的场景,以及应该避免使用的场景。
在本章中,我们将更深入地探讨异步编程,并介绍两个使编写异步代码变得非常容易的关键字。本章将涵盖以下主题:
-
介绍
async
和await
-
异步委托和 lambda 表达式
-
基于任务的异步模式(TAP)
-
异步代码中的异常处理
-
使用 PLINQ 进行异步
-
测量异步代码性能
-
使用异步代码的指南
让我们从介绍async
和await
关键字开始,这两个关键字首次在 C# 5.0 中引入,并在.NET Core 中也被采用。
技术要求
读者应该对任务并行库(TPL)和 C#有很好的理解。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/-Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter09
。
介绍异步和 await
async
和await
是.NET Core 开发人员中非常流行的两个关键字,用于在调用.NET Framework 提供的新异步 API 时标记代码。在上一章中,我们讨论了将同步方法转换为异步方法的挑战。以前,我们通过将方法分解为两个方法BeginMethodName
和EndMethodName
来实现异步调用。这种方法使代码变得笨拙,难以编写、调试和维护。然而,使用async
和await
关键字,代码可以保持与同步实现相同,只需要进行少量的更改。将方法分解、执行异步方法以及将响应返回给程序的所有困难工作都由编译器完成。
.NET Framework 提供的所有新 I/O API 都支持基于任务的异步性,我们在上一章中已经讨论过。现在让我们尝试理解一些涉及 I/O 操作的场景,我们可以利用async
和await
关键字。假设我们想从返回 JSON 格式数据的公共 API 中下载数据。在较旧版本的 C#中,我们可以使用System.Net
命名空间中提供的WebClient
类编写同步代码,如下所示。
首先,添加对System.Net
程序集的引用:
WebClient client = new WebClient();
string reply = client.DownloadString("http://www.aspnet.com");
Console.WriteLine(reply);
接下来,创建一个WebClient
类的对象,并通过传递要下载的页面的 URL 来调用DownloadString
方法。该方法将同步运行,并且调用线程将被阻塞,直到下载操作完成。这可能会影响服务器的性能(如果在服务器端代码中使用)和应用程序的响应性(如果在 Windows 应用程序代码中使用)。
为了提高性能和响应性,我们可以使用稍后引入的DownloadString
方法的异步版本。
以下是一个创建远程资源http://www.aspnet.com
的下载请求并订阅DownloadStringCompleted
事件的方法,而不是等待下载完成的方法:
private static void DownloadAsynchronously()
{
WebClient client = new WebClient();
client.DownloadStringCompleted += new
DownloadStringCompletedEventHandler(DownloadComplete);
client.DownloadStringAsync(new Uri("http://www.aspnet.com"));
}
以下是DownloadComplete
事件处理程序,当下载完成时触发:
private static void DownloadComplete(object sender, DownloadStringCompletedEventArgs e)
{
if (e.Error != null)
{
Console.WriteLine("Some error has occurred.");
return;
}
Console.WriteLine(e.Result);
Console.ReadLine();
}
在上述代码中,我们使用了基于事件的异步模式(EAP)。正如您所看到的,我们已经订阅了DownloadCompleted
事件,该事件将在WebClient
类完成下载后被触发。然后,我们调用了DownloadStringAsync
方法,该方法将异步调用代码并立即返回,避免了阻塞线程的需要。当后台下载完成时,将调用DownloadComplete
方法,我们可以使用DownloadStringCompletedEventArgs
的e.Error
属性接收错误,或使用e.Result
属性接收数据。
如果我们在 Windows 应用程序中运行上述代码,结果将如预期那样,但响应将始终由工作线程(在后台执行)接收,而不是由主线程接收。作为 Windows 应用程序开发人员,我们需要注意的是,我们不能从DownloadComplete
方法更新 UI 控件,所有这样的调用都需要使用经典 Windows Forms 中的 Invoke 或 WPF 中的 Dispatcher 等技术委托回主 UI 线程。使用 Invoke/Dispatcher 方法的最大好处是主线程永远不会被阻塞,因此整个应用程序更加响应。
在本书附带的代码示例中,我们包括了 Windows Forms 和 WPF 的场景,尽管.NET Core 目前尚不支持 Windows 应用程序或 WPF。预计这种支持将在下一个版本的 Visual Studio,即 VS 2019 中引入。
让我们尝试在.NET Core 控制台应用程序的主线程中运行上述代码,如下所示:
public static void Main()
{
DownloadAsynchronously();
}
我们可以通过在DownloadComplete
方法中添加Console.WriteLine
语句来修改它,如下所示:
private static void DownloadComplete(object sender, DownloadStringCompletedEventArgs e)
{
…
…
…
Console.ReadLine() ;//Added this line
}
根据逻辑,程序应该异步下载页面,打印输出,并在终止之前等待用户输入。当我们运行上述代码时,会发现程序在不打印任何内容且不等待用户输入的情况下终止了。为什么会发生这种情况呢?
正如前面所述,一旦主线程调用DownloadStringAsync
方法,它就会被解除阻塞。主线程不会等待回调函数执行。这是设计上的考虑,异步方法预期以这种方式行为。然而,由于主线程没有其他事情可做,而且已经完成了它预期要做的事情,即调用方法,应用程序终止了。
作为 Web 应用程序开发人员,如果在使用 Web Forms 或 ASP.NET MVC 的服务器端应用程序中使用上述代码,可能会遇到类似的问题。如果您以异步方式调用了该方法,执行您的请求的 IIS 线程将立即返回,而不会等待下载完成。因此,结果将不如预期。我们不希望代码在 Web 应用程序中将输出打印到控制台,当在 Web 应用程序代码中运行时,Console.WriteLine
语句会被简单地忽略。假设您的逻辑是将网页作为响应返回给客户端请求。我们可以使用 ASP.NET MVC 中的WebClient
类同步实现这一点,如下例所示:
public IActionResult Index()
{
WebClient client = new WebClient();
string content = client.DownloadString(new
Uri("http://www.aspnet.com"));
return Content(content,"text/html");
}
这里的问题是,上述代码将阻塞线程,这可能会影响服务器的性能,并导致自我发起的拒绝服务(DoS)攻击,当许多用户同时访问应用程序的某一部分时会发生。随着越来越多的线程被命中并被阻塞,将会有一个点,服务器将没有任何空闲线程来处理客户端请求,并开始排队请求。一旦达到队列限制,服务器将开始抛出 503 错误:服务不可用。
由于一旦调用DownloadStringAsync
方法,线程将立即向客户端返回响应,而不等待DownloadComplete
完成,因此我们无法使用该方法。我们需要一种方法使服务器线程等待而不阻塞它。在这种情况下,async
和await
来拯救我们。除了帮助我们实现我们的目标外,它们还帮助我们编写、调试和维护清晰的代码。
为了演示async
和await
,我们可以使用.NET Core 的另一个重要类HttpClient
,它位于System.Net.Http
命名空间中。应该使用HttpClient
而不是WebClient
,因为它完全支持基于任务的异步操作,具有大大改进的性能,并支持 GET、POST、PUT 和 DELETE 等 HTTP 方法。
以下是使用HttpClient
类和引入async
和await
关键字的前面代码的异步版本:
public async Task<IActionResult> Index()
{
HttpClient client = new HttpClient();
HttpResponseMessage response = await
client.GetAsync("http://www.aspnet.com");
string content = await response.Content.ReadAsStringAsync();
return Content(content,"text/html");
}
首先,我们需要更改方法签名以包含async
关键字。这是对编译器的指示,表明该方法将根据需要异步执行。然后,我们将方法的返回类型包装在Task<T>
中。这很重要,因为.NET Framework 支持基于任务的异步操作,所有异步方法必须返回Task
。
我们需要创建HttpClient
类的一个实例,并调用GetAsync()
方法,传递要下载的资源的 URL。与依赖于回调的 EAP 模式不同,我们只需在调用时写上await
关键字。这确保了以下情况:
-
该方法异步执行。
-
调用线程被解除阻塞,以便它可以返回线程池并处理其他客户端请求,从而使服务器响应。
-
当下载完成时,
ThreadPool
从处理器接收到中断信号,并从ThreadPool
中取出一个空闲线程,可以是正在处理请求的相同线程,也可以是不同的线程。 -
ThreadPool
线程接收到响应并开始执行方法的其余部分。
当下载完成时,我们可以使用另一个异步操作ReadAsStringAsync()
来读取下载的内容。本节已经表明,编写类似于同步方法的异步方法非常容易,使它们的逻辑也很直接。
异步方法的返回类型
在上面的示例中,我们将方法的返回类型从IAsyncResult
更改为Task<IAsyncResult>
。异步方法可以有三种返回类型:
-
void
-
Task
-
Task<T>
所有异步方法必须返回一个Task
以便被等待(使用await
关键字)。这是因为一旦调用它们,它们不会立即返回,而是异步执行一个长时间运行的任务。在这样做的过程中,调用线程也可能在上下文中切换。
void
可以与调用线程不想等待的异步方法一起使用。这些方法可以是后台发生的任何操作,不是返回给用户的响应的一部分。例如,日志记录和审计可以是异步的。这意味着它们可以包装在异步的void
方法中。调用操作时,调用线程将立即返回,日志记录和审计操作将稍后进行。因此,强烈建议从异步方法返回Task
而不是void
。
异步委托和 lambda 表达式
我们也可以使用async
关键字创建异步委托和 lambda 表达式。
以下是返回数字的平方的同步委托:
Func<int, int> square = (x) => {return x * x;};
我们可以通过添加async
关键字使前面的委托异步化,如下所示:
Func<int, Task<int>> square =async (x) => {return x * x;};
类似地,lambda 表达式可以转换如下:
Func<int, Task<int>> square =async (x) => x * x;
异步方法在一个链条中工作。一旦你将任何一个方法变成异步方法,那么调用该方法的所有方法也需要被转换为异步方法,从而创建一个长链的异步方法。
基于任务的异步模式
在第二章中,任务并行性,我们讨论了如何使用Task
类实现 TAP。有两种实现这种模式的方法:
-
编译器方法,使用
async
关键字 -
手动方法
让我们在后续章节中看看这些方法是如何操作的。
编译器方法,使用 async 关键字
当我们使用async
关键字使任何方法成为异步方法时,编译器会进行必要的优化,使用 TAP 在内部异步执行该方法。异步方法必须返回System.Threading.Task
或System.Threading.Task<T>
。编译器负责异步执行方法并将结果或异常返回给调用者。
手动实现 TAP
我们已经展示了如何在 EAP 和异步编程模型(APM)中手动实现 TAP。实现这种模式可以让我们更好地控制方法的整体实现。我们可以创建一个TaskCompletionSource<TResult>
类,然后执行一个异步操作。当异步操作完成时,我们可以通过调用TaskCompletionSource<TResult>
类的SetResult
、SetException
或SetCanceled
方法将结果返回给调用者,如下面的代码所示:
public static Task<int> ReadFromFileTask(this FileStream stream, byte[] buffer, int offset, int count, object state)
{
var taskCompletionSource = new TaskCompletionSource<int>();
stream.BeginRead(buffer, offset, count, ar =>
{
try
{
taskCompletionSource.SetResult(stream.EndRead(ar));
}
catch (Exception exc)
{
taskCompletionSource.SetException(exc);
}
}, state);
return taskCompletionSource.Task;
}
在上面的代码中,我们创建了一个返回Task<int>
的方法,可以作为扩展方法在任何System.IO.FileStream
对象上工作。在方法内部,我们创建了一个TaskCompletionSource<int>
对象,然后调用FileStream
类提供的异步操作将文件读入字节数组。如果读取操作成功完成,我们使用SetResult
方法将结果返回给调用者;否则,我们使用SetException
方法返回异常。最后,该方法将从TaskCompletionSource<int>
对象返回底层任务给调用者。
异步代码的异常处理
在同步代码的情况下,所有异常都会传播到堆栈的顶部,直到它们被 try-catch 块处理或作为未处理的异常抛出。当我们在任何异步方法上等待时,调用堆栈将不会相同,因为线程已经从方法转换到线程池,并且现在正在返回。然而,C#通过改变异步方法的异常行为,使我们更容易进行异常处理。所有异步方法都返回Task
或void
。让我们尝试用例子理解这两种情况,并看看程序的行为。
返回 Task 并抛出异常的方法
假设我们有以下方法,它是void
。作为最佳实践,我们从中返回Task
:
private static Task DoSomethingFaulty()
{
Task.Delay(2000);
throw new Exception("This is custom exception.");
}
该方法在延迟两秒后抛出异常。
我们将尝试使用各种方法调用此方法,以尝试理解异步方法的异常处理行为。本节将讨论以下场景:
-
在 try-catch 块外部调用异步方法,没有使用
await
关键字 -
在 try-catch 块内部调用异步方法,没有使用
await
关键字 -
在 try-catch 块外部使用 await 关键字调用异步方法
-
返回
void
的方法
我们将在后续章节中详细介绍这些方法。
在 try-catch 块外部调用异步方法,没有使用 await 关键字
以下是一个返回Task
的示例异步方法。该方法调用另一个方法DoSomethingFaulty()
,该方法会抛出异常。
这是我们的DoSomethingFaulty()
方法实现:
private static Task DoSomethingFaulty()
{
Task.Delay(2000);
throw new Exception("This is custom exception.");
}
以下是AsyncReturningTaskExample()
方法的代码:
private async static Task AsyncReturningTaskExample()
{
Task<string> task = DoSomethingFaulty();
Console.WriteLine("This should not execute");
try
{
task.ContinueWith((s) =>
{
Console.WriteLine(s);
});
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
}
}
这是从Main()
方法调用的:
public static void Main()
{
Console.WriteLine("Main Method Starts");
var task = AsyncReturningTaskExample();
Console.WriteLine("In Main Method After calling method");
Console.ReadLine();
}
异步主方法是 C# 7.1 版本以后的一个方便的补充。它在 7.2 版本中出现了问题,但在.NET Core 3.0 中得到了修复。
如您所见,程序调用了异步方法——即AsyncReturningTaskExample()
——而没有使用await
关键字。AsyncReturningTaskExample()
方法进一步调用了DoSomethingFaulty()
方法,该方法抛出异常。当我们运行此代码时,将产生以下输出:
在同步编程的情况下,程序会导致未处理的异常,并且会崩溃。但在这里,程序会继续进行,就好像什么都没有发生一样。这是由于框架处理Task
对象的方式。在这种情况下,任务将以故障状态返回给调用者,如下面的截图所示:
更好的代码应该是检查任务状态并在有异常时获取所有异常:
var task = AsyncReturningTaskExample();
if (task.IsFaulted)
Console.WriteLine(task.Exception.Flatten().Message.ToString());
正如我们在第二章中看到的任务并行性,这个任务返回一个AggregateExceptions
的实例。要获取所有抛出的内部异常,我们可以使用Flatten()
方法,就像在前面的截图中演示的那样。
在 try-catch 块内部没有使用 await 关键字的异步方法
让我们将调用异步方法GetSomethingFaulty()
的方法移动到 try-catch 块内,并从Main()
方法调用。
这是Main
方法:
public static void Main()
{
Console.WriteLine("Main Method Started");
var task = Scenario2CallAsyncWithoutAwaitFromInsideTryCatch();
if (task.IsFaulted)
Console.WriteLine(task.Exception.Flatten().Message.ToString());
Console.WriteLine("In Main Method After calling method");
Console.ReadLine();
}
这里是Scenario2CallAsyncWithoutAwaitFromInsideTryCatch()
方法:
private async static Task Scenario2CallAsyncWithoutAwaitFromInsideTryCatch()
{
try
{
var task = DoSomethingFaulty();
Console.WriteLine("This should not execute");
task.ContinueWith((s) =>
{
Console.WriteLine(s);
});
}
catch (Exception ex)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
}
}
这次,我们看到异常将被抛出并被 catch 块接收,之后程序将正常恢复。
值得一看的是Main
方法中Task
对象的值:
如您所见,如果任务创建不在 try-catch 块内进行,异常将不会被观察到。这可能会导致问题,因为逻辑可能不会按预期工作。最佳实践是始终将任务创建包装在 try-catch 块内。
如您所见,由于异常已被处理,执行从异步方法正常返回。返回任务的状态变为RanToCompletion
。
使用 await 关键字从 try-catch 块外部调用异步方法
以下代码块显示了调用有错误的方法DoSomethingFaulty()
并等待方法完成的方法的代码,使用await
关键字:
private async static Task Scenario3CallAsyncWithAwaitFromOutsideTryCatch()
{
await DoSomethingFaulty();
Console.WriteLine("This should not execute");
}
这是从Main
方法调用的:
public static void Main()
{
Console.WriteLine("Main Method Starts");
var task = Scenario3CallAsyncWithAwaitFromOutsideTryCatch();
if (task.IsFaulted)
Console.WriteLine(task.Exception.Flatten().Message.ToString());
Console.WriteLine("In Main Method After calling method");
Console.ReadLine();
}
在这种情况下,程序的行为将与第一个场景相同。
返回 void 的方法
如果方法返回void
而不是Task
,程序将崩溃。您可以尝试运行以下代码。
这是一个返回void
而不是Task
的方法:
private async static void Scenario4CallAsyncWithoutAwaitFromOutsideTryCatch()
{
Task task = DoSomethingFaulty();
Console.WriteLine("This should not execute");
}
这是从Main
方法调用的:
public static void Main()
{
Console.WriteLine("Main Method Started");
Scenario4CallAsyncWithoutAwaitFromOutsideTryCatch();
Console.WriteLine("In Main Method After calling method");
Console.ReadLine();
}
不会有输出,因为程序会崩溃。
虽然从异步方法中返回void
是没有意义的,但错误确实会发生。我们应该编写代码,使其永远不会崩溃,或者在记录异常后优雅地崩溃。
我们可以通过订阅两个全局事件处理程序来全局处理这个问题,如下所示:
AppDomain.CurrentDomain.UnhandledException += (s, e) => Console.WriteLine("Program Crashed", "Unhandled Exception Occurred");
TaskScheduler.UnobservedTaskException += (s, e) => Console.WriteLine("Program Crashed", "Unhandled Exception Occurred");
前面的代码将处理程序中的所有未处理异常,并考虑了异常管理中的良好实践。程序不应该随机崩溃,如果需要崩溃,那么应该记录信息并清理所有资源。
使用 PLINQ 进行异步
PLINQ 是开发人员非常方便的工具,可以通过并行执行一组任务来提高应用程序的性能。创建多个任务可以提高性能,但是,如果任务具有阻塞性质,那么应用程序最终将创建大量阻塞线程,并且在某些时候会变得无响应。特别是如果任务正在执行一些 I/O 操作。以下是一个需要尽快从网络下载 100 页的方法:
public async static void Main()
{
var urls = Enumerable.Repeat("http://www.dummyurl.com", 100);
foreach (var url in urls)
{
HttpClient client = new HttpClient();
HttpResponseMessage response = await
client.GetAsync("http://www.aspnet.com");
string content = await
response.Content.ReadAsStringAsync();
Console.WriteLine();
}
如您所见,上述代码是同步的,具有O(n)的复杂度。如果一个请求需要一秒钟才能完成,那么该方法至少需要 100 秒(n = 100)。
为了加快下载速度(假设我们有一个能够处理此负载的良好服务器配置,乘以应用程序想要支持的用户数量),我们需要并行执行此方法。我们可以使用Parallel.ForEach
来实现:
Parallel.ForEach(urls, url =>
{
HttpClient client = new HttpClient();
HttpResponseMessage response = await
client.GetAsync("http://www.aspnet.com");
string content = await
response.Content.ReadAsStringAsync();
});
突然,代码开始抱怨:
'await'运算符只能在异步 lambda 表达式中使用。考虑使用'async'修饰符标记此 lambda 表达式。
这是因为我们使用了 lambda 表达式,它也需要被标记为 async,如下面的代码所示:
Parallel.ForEach(urls,async url =>
{
HttpClient client = new HttpClient();
HttpResponseMessage response = await
client.GetAsync("http://www.aspnet.com");
string content = await
response.Content.ReadAsStringAsync();
});
现在代码将会编译并按预期工作,性能得到了大幅提升。在下一节中,我们将更深入地讨论异步代码性能的测量方法。
测量异步代码的性能
异步代码可以提高应用程序的性能和响应性,但也存在一些权衡。在基于 GUI 的应用程序(如 Windows Forms 或 WPF)中,如果一个方法花费了很长时间,将其标记为异步是有意义的。然而,对于服务器应用程序,您需要权衡受阻线程所使用的额外内存和使方法异步所需的额外处理器开销之间的权衡。
考虑以下代码,它创建了三个任务。每个任务都是异步运行的,一个接一个地执行。当一个方法完成时,它会继续异步执行另一个任务。使用Stopwatch
可以计算完成方法所需的总时间:
public static void Main(string[] args)
{
MainAsync(args).GetAwaiter().GetResult();
Console.ReadLine();
}
public static async Task MainAsync(string[] args)
{
Stopwatch stopwatch = Stopwatch.StartNew();
var value1 = await Task1();
var value2 = await Task2();
var value3 = await Task3();
stopwatch.Stop();
Console.WriteLine($"Total time taken is
{stopwatch.ElapsedMilliseconds}");
}
public static async Task<int> Task1()
{
await Task.Delay(2000);
return 100;
}
public static async Task<int> Task2()
{
await Task.Delay(2000);
return 200;
}
public static async Task<int> Task3()
{
await Task.Delay(2000);
return 300;
}
上述代码的输出如下:
这与编写同步代码一样好。好处是线程不会被阻塞,但应用程序的整体性能较差,因为所有代码现在都是同步运行的。我们可以改变上述代码以提高性能,如下所示:
Stopwatch stopwatch = Stopwatch.StartNew();
await Task.WhenAll(Task1(), Task2(), Task3());
stopwatch.Stop();
Console.WriteLine($"Total time taken is {stopwatch.ElapsedMilliseconds}");
如您所见,这是更好地使用并行和异步以获得更好的性能:
为了更好地理解异步,我们还需要了解哪个线程运行我们的代码。由于新的异步 API 与Task
类一起工作,所有调用都由ThreadPool
线程执行。当我们进行异步调用时,比如从网络获取数据,控制权会转移到由操作系统管理的 I/O 完成端口线程。通常,这只是一个线程,跨所有网络请求共享。当 I/O 请求完成时,操作系统会触发中断信号,将作业添加到 I/O 完成端口的队列中。在通常以多线程公寓(MTA)模式工作的服务器端应用程序中,任何线程都可以启动异步请求,任何其他线程都可以接收它。
在 Windows 应用程序的情况下(包括 WinForms 和 WPF),它们以单线程公寓(STA)模式工作,因此异步调用返回到启动它的同一线程(通常是 UI 线程)变得很重要。Windows 应用程序中的每个 UI 线程都有一个SynchronizationContext
,它确保代码始终由正确的线程执行。这对于控件所有权很重要。为了避免跨线程问题,只有所有者线程才能更改控件的值。SynchronizationContext
类的最重要方法是Post
,它可以使委托在正确的上下文中运行,从而避免跨线程问题。
每当我们等待一个任务时,当前的SynchronizationContext
都会被捕获。然后,当方法需要恢复时,await
关键字在内部使用Post
方法在捕获的SynchronizationContext
中恢复方法。然而,调用Post
方法非常昂贵,但框架提供了内置的性能优化。如果捕获的SynchronizationContext
与返回线程的当前SynchronizationContext
相同,则不会调用Post
方法。
如果我们正在编写一个类库,并且我们并不真的关心调用将返回到哪个SynchronizationContext
,我们可以完全关闭Post
方法。我们可以通过在返回的任务上调用ConfigureAwait()
方法来实现这一点,如下所示:
HttpClient client = new HttpClient();
HttpResponseMessage response = await client.GetAsync(url).ConfigureAwait(false);
到目前为止,我们已经学习了异步编程的重要方面。现在我们需要了解在编程时使用异步代码的指南!
使用异步代码的指南
在编写异步代码时的一些建议/最佳实践如下:
-
避免使用异步 void。
-
异步链一直延续。
-
在可能的情况下使用
ConfigureAwait
。
我们将在接下来的部分中了解更多。
避免使用异步 void
我们已经看到从异步方法返回void
实际上会影响异常处理。异步方法应该返回Task
或Task<T>
,以便可以观察异常并且不会变成未处理的异常。
异步链一直延续
混合异步和阻塞方法会影响性能。一旦决定将方法设置为异步,从该方法调用的整个方法链也应该设置为异步。不这样做有时会导致死锁,如下面的代码示例所示:
private async Task DelayAsync()
{
await Task.Delay(2000);
}
public void Deadlock()
{
var task = DelayAsync();
task.Wait();
}
如果我们从任何 ASP.NET 或基于 GUI 的应用程序中调用Deadlock()
方法,它将创建死锁,尽管相同的代码在控制台应用程序中可以正常运行。当我们调用DelayAsync()
方法时,它会捕获当前的SynchronizationContext
,或者如果SynchronizationContext
为 null,则捕获当前的TaskScheduler
。当等待的任务完成时,它会尝试使用捕获的上下文执行方法的其余部分。问题在于已经有一个线程在同步等待异步方法完成。在这种情况下,两个线程都将等待另一个线程完成,从而导致死锁。这个问题只会在基于 GUI 或 ASP.NET 的应用程序中出现,因为它们依赖于只能一次执行一块代码的SynchronizationContext
。另一方面,控制台应用程序使用ThreadPool
而不是SynchronizationContext
。当等待完成时,挂起的异步方法部分被安排在ThreadPool
线程上。该方法在单独的线程上完成并将任务返回给调用者,因此不会发生死锁。
永远不要在控制台应用程序中尝试创建示例async
/await
代码,然后将其复制粘贴到 GUI 或 ASP.NET 应用程序中,因为它们有不同的执行异步代码的模型。
在可能的情况下使用 ConfigureAwait
我们可以通过完全跳过使用SynchronizationContext
来避免前面代码示例中的死锁:
private async Task DelayAsync()
{
await Task.Delay(2000);
}
public void Deadlock()
{
var task = DelayAsync().ConfigureAwait(false);
task.Wait();
}
当我们使用ConfigureAwait(false)
时,该方法会被等待。当等待完成时,处理器会尝试在线程池上下文中执行剩余的异步方法。由于没有阻塞上下文,该方法能够顺利完成。该方法完成了其返回的任务,没有死锁。
我们已经到达了本章的结尾。现在让我们看看我们学到了什么!
摘要
在本章中,我们讨论了两个非常重要的构造,使得编写异步代码变得非常容易。当我们使用这些关键字时,所有繁重的工作都是由编译器完成的,代码看起来与其同步对应物非常相似。我们还讨论了当我们使方法异步化时,代码运行在哪个线程上,以及利用SynchronizationContext
会带来的性能损失。最后,我们看了如何完全关闭SynchronizationContext
以提高性能。
在下一章中,我们将介绍使用 Visual Studio 进行并行调试技术。我们还将学习 Visual Studio 中可用的工具,以帮助并行代码调试。
问题
-
在异步方法中,用什么关键字来解除线程阻塞?
-
异步
-
await
-
Thread.Sleep
-
Task
-
以下哪些是异步方法的有效返回类型?
-
无
-
Task
-
Task<T>
-
IAsyncResult
-
TaskCompletionSource<T>
可以用来手动实现基于任务的异步模式。 -
真
-
假
-
我们可以将
Main
方法写成异步的吗? -
是
-
不
-
Task
类的哪个属性可以用来检查异步方法是否抛出了异常? -
IsException
-
IsFaulted
-
我们应该总是将
void
作为异步方法的返回类型使用。 -
真
-
假
第四部分:异步代码的调试、诊断和单元测试
在本节中,我们将解释适用于 Visual Studio 用户的调试技术和工具。主要重点将放在理解 IDE 功能,如并行任务窗口、线程窗口、并行堆栈窗口和并发可视化工具上。我们还将介绍如何为使用 TPL 和异步编程的代码编写单元测试用例,如何为测试用例编写模拟和存根,以及一些技巧和窍门,确保我们为 ORM 编写的测试用例不会失败。
本节包括以下章节:
-
第十章,使用 Visual Studio 调试任务
-
第十一章,编写并行和异步代码的单元测试用例
第十章:使用 Visual Studio 调试任务
并行编程可以提高应用程序的性能和响应能力,但有时结果并不如预期。与并行/异步代码相关的常见问题是性能和正确性。
性能意味着执行结果很慢。正确性意味着结果不如预期(这可能是由于竞争条件)。处理多个并发任务时的另一个重大问题是死锁。调试多线程代码始终是一个挑战,因为在调试时线程会不断切换。在处理基于 GUI 的应用程序时,找出运行我们代码的线程也很重要。
在本章中,我们将解释如何使用 Visual Studio 中可用的工具来调试线程,包括“线程”窗口、“任务”窗口和并发可视化器。
本章将涵盖以下主题:
-
使用 VS 2019 进行调试
-
如何调试线程
-
使用并行任务窗口
-
使用并行堆栈窗口进行调试
-
使用并发可视化器
技术要求
在开始本章之前,需要先了解线程、任务、Visual Studio 和并行编程。
您可以在 GitHub 的以下链接中检查相关源代码:github.com/PacktPublishing/-Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter10
。
使用 VS 2019 进行调试
Visual Studio 提供了许多内置工具,以帮助解决上述的调试和故障排除问题。本章将讨论以下一些工具:
-
线程窗口
-
并行堆栈窗口
-
并行监视窗口
-
调试位置工具栏
-
并发可视化器(截至撰写本文时仅适用于 VS 2017)
-
GPU 线程窗口
在接下来的章节中,我们将尝试深入了解所有这些工具。
如何调试线程
在使用多个线程时,找出在特定时间执行的线程变得很重要。这使我们能够解决跨线程问题以及竞争条件。使用“线程”窗口,我们可以在调试时检查和处理线程。在 Visual Studio IDE 中调试代码时,当您触发断点时,线程窗口提供一个包含有关活动线程信息的表格。
现在,让我们探讨如何使用 Visual Studio 调试线程:
- 在 Visual Studio 中编写以下代码:
for (int i = 0; i < 10; i++)
{
Task task = new TaskFactory().StartNew(() =>
{
Console.WriteLine($"Thread with Id
{Thread.CurrentThread.ManagedThreadId}");
});
}
-
通过在
Console.Writeline
语句上按下F9来创建断点。 -
通过按下F5以调试模式运行应用程序。应用程序将创建线程并开始执行。当触发断点时,我们将从工具栏的调试|窗口|线程窗口中打开线程窗口:
.NET 环境捕获了许多关于线程的信息,这些信息以列的形式显示。黄色箭头标识了当前正在执行的线程。
一些列包括以下内容:
-
标记:如果我们想跟踪特定线程,可以对其进行标记。这可以通过点击旗标图标来完成。
-
ID:显示为每个线程分配的唯一标识号。
-
托管 ID:显示为每个线程分配的托管标识号。
-
类别:每个线程被分配一个唯一的类别,这有助于我们确定它是 GUI 线程(主线程)还是工作线程。
-
名称:显示每个线程的名称,或显示为<无名称>。
-
位置:这有助于确定线程的执行位置。我们可以深入了解完整的调用堆栈。
我们可以通过点击旗标图标来标记我们想要监视的线程。要仅查看已标记的线程,可以在线程窗口中点击“仅显示已标记的线程”选项:
线程窗口的另一个很酷的功能是,我们可以冻结我们认为在调试过程中可能引起问题的线程,以监视应用程序的行为。即使系统有足够的资源可用,冻结后,线程也不会开始执行冻结的线程。冻结后,线程进入暂停状态:
在调试过程中,我们还可以通过右键单击线程窗口中的线程或双击线程来切换执行到另一个线程:
Visual Studio 还支持使用并行堆栈窗口调试任务。我们将在下一节中看看这个。
使用并行堆栈窗口
并行堆栈窗口是调试线程和任务的一个很好的工具,这是在 Visual Studio 的较新版本中引入的。我们可以通过导航到调试|窗口|并行堆栈来在调试时打开并行堆栈窗口。
从前面的截图中可以看出,在并行堆栈窗口上有各种视图,我们可以在这些视图上切换。我们将在下一个主题中学习如何使用并行堆栈窗口和这些视图进行调试。
使用并行堆栈窗口进行调试
并行堆栈窗口有一个下拉菜单,有两个选项。我们可以在这些选项之间切换,以在并行堆栈窗口中获得几个视图。这些视图包括以下内容:
-
线程视图
-
任务视图
让我们在接下来的部分详细检查这些视图。
线程视图
线程视图显示了在调试应用程序时运行的所有线程的调用堆栈:
黄色箭头显示了代码当前执行的位置。悬停在并行堆栈窗口中的任何方法上会打开带有有关当前正在执行的线程信息的线程窗口:
我们可以通过双击它切换到任何其他方法:
我们还可以切换到方法视图以查看完整的调用堆栈:
方法视图非常适用于调试调用堆栈,以查找在任何时间点传递给方法的值。
任务视图
如果我们在代码中使用任务并行库创建System.Threading.Tasks.Task
对象,我们应该使用任务视图:
如下截图所示,当前有 10 个正在执行的任务,每个任务都显示了当前的执行行。
通过悬停在任何方法上,可以看到所有运行任务的状态:
任务窗口帮助我们分析应用程序中由于方法调用缓慢或死锁而引起的性能问题。
使用并行监视窗口进行调试
当我们想要在不同的线程上查看变量的值时,我们可以使用并行监视窗口。考虑以下代码:
for (int i = 0; i < 10; i++)
{
Task task = new Task(() =>
{
for (int j = 0; j < 100; j++)
{
Thread.Sleep(100);
}
Console.WriteLine($"Thread with Id
{Thread.CurrentThread.ManagedThreadId}");
});
task.Start();
}
此代码创建多个任务,每个任务运行 100 次迭代的for
循环。在每次迭代中,线程休眠 100 毫秒。我们允许代码运行一段时间,然后触发断点。我们可以使用并行监视窗口看到所有这些操作。我们可以从调试|窗口|并行监视中打开并行监视窗口。我们可以打开四个这样的窗口,每个窗口一次只能监视一个变量值在不同任务上的值:
从前面的代码中可以看出,我们想要监视 j 的值。因此,我们在第三列的标题中写入 j 并按Enter键。这将 j 添加到此处显示的监视窗口中,我们可以看到所有线程/任务上的 j 的值。
使用并发可视化器
并发可视化器是 Visual Studio 工具集合中非常方便的一个补充。它不会默认随 Visual Studio 一起发布,但可以从 Visual Studio Marketplace 下载:marketplace.visualstudio.com
。
这是一个非常高级的工具,可以用于排除复杂的线程问题,比如性能瓶颈、线程争用问题、检查 CPU 利用率、跨核心线程迁移以及重叠 I/O 的区域。
并发可视化器仅支持 Windows/console 项目,不适用于 Web 项目。让我们考虑在控制台应用程序中的以下代码:
Action computeAction = () =>
{
int i = 0;
while (true)
{
i = 1 * 1;
}
};
Task.Run(() => computeAction());
Task.Run(() => computeAction());
Task.Run(() => computeAction());
Task.Run(() => computeAction());
在上述代码中,我们创建了四个任务,这些任务会无限期地运行计算任务,比如 1*1。然后我们会在while
循环内设置断点并打开并发可视化器。
现在,我们将从 Visual Studio 运行上述代码,并在代码运行时,单击“附加到进程...”,如下面的屏幕截图所示:
您首先需要为您的 Visual Studio 版本安装并发可视化器。Visual Studio 2017 的并发可视化器可以在这里找到:marketplace.visualstudio.com/items?itemName=VisualStudioProductTeam.ConcurrencyVisualizer2017#overview
。
一旦附加,并发可视化器将停止分析。我们将让应用程序运行一段时间,以便它可以收集足够的数据进行审查,然后停止分析器生成视图。
默认情况下,这将打开利用视图,这是并发可视化器中存在的三个视图之一。另外两个是线程和核心视图。我们将在下一节中探索利用视图。
利用视图
利用视图显示了所有处理器上的系统活动。这是并发分析器停止分析时的快照:
正如您在上图中所看到的,有四个核心的 CPU 负载达到了 100%。这由绿色表示。这个视图通常用于获得并发状态的高级概述。
线程视图
线程视图提供了对当前系统状态的非常详细的分析。通过这个视图,我们可以确定线程是在执行还是在因 I/O 和同步等问题而阻塞:
这个视图在识别和修复系统中的性能瓶颈方面非常有帮助。因此,我们可以清楚地识别实际执行所花费的时间以及处理同步问题所花费的时间。
核心视图
核心视图可用于识别线程执行核心切换的次数:
正如您在上图中所看到的,我们的四个线程(ID 为 12112、1604、16928 和 4928)几乎 60%的时间在核心之间进行上下文切换。
掌握了并发可视化器中存在的所有三个视图的理解,我们已经结束了本章。现在,让我们总结一下我们学到的东西。
摘要
在本章中,我们讨论了如何使用线程窗口调试多线程应用程序,以监视.NET 环境捕获的无数信息。我们还学习了如何通过使用标志线程、在线程之间切换、在并行堆栈窗口中打开线程和任务视图、打开多个并行观察窗口以及观察一次多个任务上的单变量值来更好地了解应用程序。
除此之外,我们还探索了并发可视化器,这是一个用于排除仅支持 Windows/console 项目的复杂线程问题的高级工具。
在下一章中,我们将学习如何为并行和异步代码编写单元测试用例,以及与此相关的问题。此外,我们还将了解设置模拟对象涉及的挑战以及如何解决这些问题。
问题
-
在 Visual Studio 中调试线程时,哪个不是有效窗口?
-
并行线程
-
并行堆栈
-
GPU 线程
-
并行监视
-
我们可以通过标记来跟踪调试特定的线程。
-
正确
-
错误
-
并行监视窗口中哪个不是有效视图?
-
任务
-
进程
-
线程
-
我们如何检查线程的调用堆栈?
-
方法视图
-
任务视图
-
以下哪个不是并发可视化器的有效视图?
-
线程视图
-
核心视图
-
进程视图
进一步阅读
您可以在以下链接中阅读有关并行编程和调试技术的信息:
-
www.packtpub.com/application-development/c-multithreaded-and-parallel-programming
-
www.packtpub.com/application-development/net-45-parallel-extensions-cookbook
第十一章:为并行和异步代码编写单元测试用例
在本章中,我们将介绍如何为并行和异步代码编写单元测试用例。编写单元测试用例是编写健壮代码的重要方面,当你与大型团队合作时,这样的代码更易于维护。
有了新的 CI/CD 平台,使运行单元测试用例成为构建过程的一部分变得更容易。这有助于在非常早期发现问题。编写集成测试也是有意义的,这样我们可以评估不同组件是否正确地一起工作。虽然在 Visual Studio 的社区和专业版本中会发现更多功能,但只有 Visual Studio 企业版支持分析单元测试用例的代码覆盖率。
在本章中,我们将涵盖以下主题:
-
了解为异步代码编写单元测试用例的问题
-
为并行和异步代码编写单元测试用例
-
使用 Moq 模拟异步代码的设置
-
使用测试工具
技术要求
学习如何使用 Visual Studio 支持的框架编写单元测试用例需要对单元测试和 C#有基本的了解。本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter11
。
使用.NET Core 进行单元测试
.NET Core 支持三种编写单元测试的框架,即 MSTest、NUnit 和 xUnit,如下截图所示:
最初,编写测试用例的首选框架是 NUnit。然后,MSTest 被添加到 Visual Studio 中,然后 xUnit 被引入到.NET Core 中。与 NUnit 相比,xUnit 是一个非常精简的版本,并帮助用户编写干净的测试并利用新功能。xUnit 的一些好处如下:
-
它很轻量级。
-
它使用了新功能。
-
它改进了测试隔离。
-
xUnit 的创建者也来自微软,是微软内部使用的工具。
-
Setup
和TearDown
属性已被构造函数和System.IDisposable
取代,从而迫使开发人员编写干净的代码。
单元测试用例只是一个简单的返回void
的函数,用于测试函数逻辑并根据预定义的一组输入验证输出。为了使函数被识别为测试用例,必须使用[Fact]
属性进行修饰,如下所示:
[Fact]
public void SomeFunctionWillReturn5AsWeUseResultToLetItFinish()
{
var result = SomeFunction().Result;
Assert.Equal(5, result);
}
要运行此测试用例,我们需要右键单击代码中的函数,然后单击“运行测试”或“调试测试”:
测试用例的执行输出可以在测试资源管理器窗口中看到:
虽然这相当简单,但为并行和异步代码编写单元测试用例是具有挑战性的。我们将在下一节中详细讨论这个问题。
了解为异步代码编写单元测试用例的问题
异步方法返回一个需要等待以获得结果的Task
。如果不等待,方法将立即返回,而不会等待异步任务完成。考虑以下方法,我们将使用它来编写一个使用 xUnit 的单元测试用例:
private async Task<int> SomeFunction()
{
int result =await Task.Run(() =>
{
Thread.Sleep(1000);
return 5;
});
return result;
}
该方法在延迟 1 秒后返回一个常量值 5。由于该方法使用了Task
,我们使用了async
和await
关键字来获得预期的结果。以下是一个非常简单的测试用例,我们可以使用 MSTest 来测试这个方法:
[TestMethod]
public async void SomeFunctionShouldFailAsExpectedValueShouldBe5AndNot3()
{
var result = await SomeFunction();
Assert.AreEqual(3, result);
}
如您所见,该方法应该失败,因为预期的返回值是 3,而方法返回的是 5。然而,当我们运行这个测试时,它通过了:
这里发生的情况是,由于该方法标记为异步,当遇到await
关键字时立即返回。当返回一个任务时,它被视为在将来的某个时间点运行,但由于测试用例没有失败而返回,测试框架将其标记为通过。这是一个重大问题,因为这意味着即使任务抛出异常,测试也会通过。
可以稍微不同地编写前面的测试用例以使其在 MSTest 中运行:
[TestMethod]
public void SomeFunctionWillReturn5AsWeUseResultToLetItFinish()
{
var result = SomeFunction().Result;
Assert.AreEqual(3, result);
}
可以使用 xUnit 编写相同的单元测试用例如下:
[Fact]
public void SomeFunctionWillReturn5AsWeUseResultToLetItFinish()
{
var result = SomeFunction().Result;
Assert.Equal(5, result);
}
当我们运行前面的 xUnit 测试用例时,它会成功运行。但是,这段代码的问题在于它是一个阻塞测试用例,这可能会对我们的测试套件的性能产生重大影响。更好的单元测试用例如下所示:
[Fact]
public async void SomeFunctionWillReturn5AsCallIsAwaited()
{
var result = await SomeFunction();
Assert.Equal(5, result);
}
最初,并非每个单元测试框架都支持异步单元测试用例,正如我们在 MSTest 的情况下所见。但是,它们受到 xUnit 和 NUnit 的支持。前面的测试用例再次返回成功。
可以使用 NUnit 编写上述单元测试用例如下:
[Test]
public async void SomeFunctionWillReturn5AsCallIsAwaited()
{
var result = await SomeFunction();
Assert.AreEqual(3, result);
}
与前面的代码相比,这里有一些区别。[Fact]
属性被[Test]
替换,而Assert.Equal
被Assert.AreEqual
替换。然而,当您尝试在 Visual Studio 中运行前面的测试用例时,您将看到一个错误:"消息:异步测试方法必须具有非 void 返回类型"
。因此,对于 NUnit,方法需要更改如下:
[Test]
public async Task SomeFunctionWillReturn5AsCallIsAwaited()
{
var result = await SomeFunction();
Assert.AreEqual(3, result);
}
唯一的区别是void
被Task
替换。
在本节中,我们已经看到了在使用为单元测试提供的各种框架时可能会遇到的问题。现在,让我们看看如何编写更好的单元测试用例。
编写并行和异步代码的单元测试用例
在上一节中,我们学习了如何为异步代码编写单元测试用例。在本节中,我们将讨论为异常情况编写单元测试用例。考虑以下方法:
private async Task<float> GetDivisionAsync(int number , int divisor)
{
if (divisor == 0)
{
throw new DivideByZeroException();
}
int result = await Task.Run(() =>
{
Thread.Sleep(1000);
return number / divisor;
});
return result;
}
前面的方法以异步方式返回两个数字的除法结果。如果除数为 0,则该方法会抛出DivideByZero
异常。我们需要两种类型的测试用例来覆盖这两种情况:
-
检查成功的结果
-
当除数为 0 时检查异常结果
检查成功的结果
测试用例如下所示:
[Test]
public async Task GetDivisionAsyncShouldReturnSuccessIfDivisorIsNotZero()
{
int number = 20;
int divisor = 4;
var result = await GetDivisionAsync(number, divisor);
Assert.AreEqual(result, 5);
}
如您所见,预期结果是5
。当我们运行测试时,它将在测试资源管理器中显示为成功。
当除数为 0 时检查异常结果
我们可以使用Assert.ThrowsAsync<>
方法为抛出异常的方法编写测试用例:
[Test]
public void GetDivisionAsyncShouldCheckForExceptionIfDivisorIsNotZero()
{
int number = 20;
int divisor = 0;
Assert.ThrowsAsync<DivideByZeroException>(async () =>
await GetDivisionAsync(number, divisor));
}
如您所见,我们在异步调用GetDivisionAsync
方法时使用Assert.ThrowsAsync<DivideByZeroException>
进行断言。由于我们将divisor
传递为0
,该方法将抛出异常,断言将保持为真。
使用 Moq 模拟异步代码的设置
模拟对象是单元测试的一个非常重要的方面。您可能知道,单元测试是关于一次测试一个模块;任何外部依赖都被假定为正常工作。
有许多可用于.NET 的模拟框架,包括以下内容:
-
NSubstitute(在.NET Core 中不受支持)
-
Rhino Mocks(在.NET Core 中不受支持)
-
Moq(在.NET Core 中受支持)
-
NMock3(在.NET Core 中不受支持)
为了演示,我们将使用 Moq 来模拟我们的服务组件。
在本节中,我们将创建一个包含异步方法的简单服务。然后,我们将尝试为调用该服务的方法编写单元测试用例。让我们考虑一个服务接口:
public interface IService
{
Task<string> GetDataAsync();
}
正如我们所见,接口有一个GetDataAsync()
方法,以异步方式获取数据。以下代码片段显示了一个控制器类,该类利用一些依赖注入框架来访问服务实例:
class Controller
{
public Controller (IService service)
{
Service = service;
}
public IService Service { get; }
public async Task DisplayData()
{
var data =await Service.GetDataAsync();
Console.WriteLine(data);
}
}
前面的Controller
类还公开了一个名为DisplayData()
的异步方法,该方法从服务中获取数据并将其写入控制台。当我们尝试为前述方法编写单元测试用例时,我们将遇到的第一个问题是,在没有任何具体实现的情况下,我们无法创建服务实例。即使我们有具体的实现,我们也应该避免调用实际的服务方法,因为这更适合集成测试用例而不是单元测试用例。在这里,Mocking 来拯救我们。
让我们尝试使用 Moq 为前述方法编写一个单元测试用例:
-
我们需要安装
Moq
作为 NuGet 包。 -
添加其命名空间如下:
using Moq;
- 创建一个模拟对象,如下所示:
var serviceMock = new Mock<IService>();
- 设置返回虚拟数据的模拟对象。可以使用
Task.FromResult
方法来实现,如下所示:
serviceMock.Setup(s => s.GetDataAsync()).Returns(
Task.FromResult("Some Dummy Value"));
- 接下来,我们需要通过传递刚刚创建的模拟对象来创建一个控制器对象:
var controller = new Controller(serviceMock.Object);
以下是DisplayData()
方法的一个简单测试用例:
[Test]
public async System.Threading.Tasks.Task DisplayDataTestAsync()
{
var serviceMock = new Mock<IService>();
serviceMock.Setup(s => s.GetDataAsync()).Returns(
Task.FromResult("Some Dummy Value"));
var controller = new Controller(serviceMock.Object);
await controller.DisplayData();
}
上述代码显示了我们如何为模拟对象设置数据。为模拟对象设置数据的另一种方法是通过TaskCompletionSource
类,如下所示:
[Test]
public async Task DisplayDataTestAsyncUsingTaskCompletionSource()
{
// Create a mock service
var serviceMock = new Mock<IService>();
string data = "Some Dummy Value";
//Create task completion source
var tcs = new TaskCompletionSource<string>();
//Setup completion source to return test data
tcs.SetResult(data);
//Setup mock service object to return Task underlined by tcs
//when GetDataAsync method of service is called
serviceMock.Setup(s => s.GetDataAsync()).Returns(tcs.Task);
//Pass mock service instance to Controller
var controller = new Controller(serviceMock.Object);
//Call DisplayData method of controller asynchronously
await controller.DisplayData();
}
由于企业项目中测试用例的数量可能会大幅增长,因此需要能够查找和执行测试用例。在下一节中,我们将讨论一些在 Visual Studio 中可以帮助我们管理测试用例执行过程的常见测试工具。
测试工具
在 Visual Studio 中运行测试或查看测试执行结果的最重要工具之一是 Test Explorer。我们在本章开头简要介绍了 Test Explorer。Test Explorer 的一个关键特性是能够并行运行测试用例。如果您的系统有多个核心,您可以轻松利用并行性来更快地运行测试用例。这可以通过在 Test Explorer 中点击“Run Tests in parallel”工具栏按钮来实现:
根据您的 Visual Studio 版本,Microsoft 还提供了一些额外的支持。一个有用的工具是使用Intellitest自动生成单元测试用例的选项。Intellitest 分析您的源代码并自动生成测试用例、测试数据和测试套件。尽管 Intellitest 尚不支持.NET Core,但它适用于.NET Framework 的其他版本。它很可能会在未来的 Visual Studio 升级中得到支持。
摘要
在本章中,我们学习了为异步方法编写单元测试用例,这有助于实现健壮的代码,支持大型团队,并适应新的 CI/CD 平台,有助于在非常早期发现问题。我们首先介绍了在编写并行和异步代码的单元测试用例时可能遇到的一些问题,以及如何使用正确的编码实践来减轻这些问题。然后,我们继续学习了 Mocking,这是单元测试的一个非常重要的方面。
我们了解到 Moq 支持.NET Core,并且.NET Core 发展非常迅速;很快将支持所有主要的模拟框架。还解释了编写测试用例的所有步骤,包括安装 Moq 作为 NuGet 包和为模拟对象设置数据。最后,我们探讨了一个重要的测试工具 Test Explorer 的功能,我们可以使用它来编写更干净的测试用例,并且如何并行运行单元测试用例以加快执行速度。
在下一章中,我们将介绍 IIS 和 Kestrel 在.NET Core Web 应用程序开发环境中的概念和角色。
问题
-
以下哪个不是 Visual Studio 中支持的单元测试框架?
-
JUnit
-
NUnit
-
xUnit
-
MSTest
-
我们如何检查单元测试用例的输出?
-
通过使用 Task Explorer 窗口
-
通过使用 Test Explorer 窗口
-
当测试框架是 xUnit 时,您可以将哪些属性应用于测试方法?
-
事实
-
TestMethod
-
测试
-
您如何验证抛出异常的测试用例的成功?
-
Assert.AreEqual(ex, typeof(Exception)
-
Assert.IsException
-
Assert.ThrowAsync<T>
-
这些模拟框架中哪些受到.NET Core 的支持?
-
NSubstitute
-
Moq
-
Rhino Mocks
-
NMock
进一步阅读
您可以在以下网页上了解并行编程和单元测试技术:
-
www.packtpub.com/application-development/c-multithreaded-and-parallel-programming
-
www.packtpub.com/application-development/net-45-parallel-extensions-cookbook
第五部分:.NET Core 中并行编程功能的新增内容
在这一部分,您将熟悉.NET Core 中支持并行编程的新突破。
本节包括以下章节:
-
第十二章,ASP.NET Core 中的 IIS 和 Kestrel
-
第十三章,并行编程中的模式
-
第十四章,分布式内存管理
第十二章:ASP.NET Core 中的 IIS 和 Kestrel
在上一章中,我们讨论了为并行和异步代码编写单元测试用例。我们还讨论了在 Visual Studio 中可用的三个单元测试框架:MSUnit、NUnit 和 xUnit。
在本章中,我们将介绍线程模型如何与Internet Information Services(IIS)和 Kestrel 一起工作。我们还将看看我们可以做出哪些各种调整,以充分利用服务器上的资源。我们将介绍 Kestrel 的工作模型,以及在创建微服务时如何利用并行编程技术。
在本章中,我们将涵盖以下主题:
-
IIS 线程模型和内部结构
-
Kestrel 线程模型和内部结构
-
在微服务中线程的最佳实践介绍
-
在 ASP.NET MVC Core 中介绍异步
-
异步流(在.NET Core 3.0 中新增)
让我们开始吧。
技术要求
需要对服务器工作原理有很好的理解,这样你才能理解本章。在开始本章之前,你还应该了解线程模型。本章的源代码可在 GitHub 上找到:github.com/PacktPublishing/-Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter12
。
IIS 线程模型和内部结构
顾名思义,这些是在 Windows 系统上使用的服务,用于通过互联网连接您的 Web 应用程序与其他系统,使用 HTTP、TCP、Web 套接字等一系列协议。
在本节中,我们将讨论IIS 线程模型的工作原理。IIS 的核心是CLR 线程池。要理解 IIS 如何服务用户请求,了解 CLR 线程池如何添加和删除线程是非常重要的。
部署到 IIS 的每个应用程序都被分配一个唯一的工作进程。每个工作进程都有两个线程池:工作线程池和IOCP(即I/O 完成端口)线程池:
-
每当我们使用传统的
ThreadPool.QueueUserWorkItem
或TPL创建新的线程池线程时,ASP.NET 运行时都会利用工作线程进行处理。 -
每当进行任何 I/O 操作,即数据库调用、文件读写或对另一个 Web 服务的网络调用时,ASP.NET 运行时都会利用 IOCP 线程。
默认情况下,每个处理器都有一个工作线程和一个 IOCP 线程。因此,双核 CPU 默认情况下会有两个工作线程和两个 IOCP 线程。ThreadPool
会根据负载和需求不断添加和删除线程。IIS 为每个接收到的请求分配一个线程。这使得每个请求在与服务器同时到达的其他请求的情况下都有不同的上下文。线程的责任是满足请求,并生成并将响应发送回客户端。
如果可用的线程池线程数量少于服务器在任何时间接收到的请求数,那么这些请求将开始排队。稍后,线程池将使用两种重要的算法之一生成线程,这两种算法分别称为爬坡和避免饥饿。线程的创建不是瞬间完成的,通常需要从ThreadPool
知道线程短缺开始到 500 毫秒。让我们试着理解ThreadPool
用来生成线程的这两种算法。
避免饥饿
在这个算法中,ThreadPool
不断监视队列,如果没有进展,它就会不断地将新线程加入队列。
爬坡
在这个算法中,ThreadPool
试图最大限度地利用尽可能少的线程来实现吞吐量。
使用默认设置运行 IIS 将对性能产生重大影响,因为默认情况下,每个处理器只有一个工作线程可用。我们可以通过修改machine.config
文件中的配置元素来增加此设置。
<configuration>
<system.web>
<processModel minWorkerThreads="25" minIoThreads="25" />
</system.web>
</configuration>
如您所见,我们将最小工作线程和 IOCP 线程增加到了 25。随着更多请求的到来,将创建额外的线程。这里需要注意的一点是,由于每个请求都分配了一个唯一的线程,我们应该避免编写阻塞代码。有了阻塞代码,就不会有空闲线程。一旦线程池耗尽,请求将开始排队。IIS 每个应用程序池只能排队最多 1,000 个请求。我们可以通过更改machine.config
文件中的requestQueueLimit
应用程序设置来修改这一点。
要修改所有应用程序池的设置,我们需要添加applicationPool
元素并设置所需的值:
<system.web>
<applicationPool
maxConcurrentRequestPerCPU="5000"
maxConcurrentThreadsPerCPU="0"
requestQueueLimit="5000" />
</system.web>
要修改单个应用程序池的设置,我们需要在 IIS 中导航到特定应用程序池的高级设置。如下截图所示,我们可以更改队列长度属性以修改每个应用程序池可以排队的请求数量:
作为开发人员的良好编码实践,为了减少争用问题并避免服务器上的队列,我们应该尝试对任何阻塞 I/O 代码使用async
/await
关键字。这将减少服务器上的争用问题,因为线程不会被阻塞,并返回到线程池以服务其他请求。
Kestrel 线程模型和内部
IIS 一直是托管.NET 应用程序的最流行服务器,但它与 Windows 操作系统绑定在一起。随着越来越多的云提供商出现和非 Windows 云托管选项变得更加便宜,需要一个跨平台托管服务器。微软推出了 Kestrel 作为托管 ASP.NET Core 应用程序的跨平台 Web 服务器。如果我们创建和运行 ASP.NET Core 应用程序,Kestrel 是默认的 Web 服务器。Kestrel 是开源的,使用基于事件驱动的异步 I/O 服务器。Kestrel 不是一个完整的 Web 服务器,建议在 IIS 和 Nginx 等功能齐全的 Web 服务器后面使用。
当 Kestrel 最初推出时,它是基于libuv
库的,这个库也是开源的。在.NET 中使用libuv
并不是什么新鲜事,可以追溯到 ASP.NET 5。libuv
专门为异步 I/O 操作构建,并使用单线程事件循环模型。该库还支持在 Windows、macOS 和 Linux 上进行跨平台异步套接字操作。您可以在 GitHub 上查看其进展并下载libuv
的源代码以进行自定义实现。
libuv
在 Kestrel 中仅用于支持异步 I/O。除 I/O 操作外,Kestrel 中进行的所有其他工作仍然由.NET 工作线程使用托管代码完成。创建 Kestrel 的核心思想是提高服务器的性能。该堆栈非常强大且可扩展。Kestrel 中的libuv
仅用作传输层,并且由于出色的抽象,它也可以被其他网络实现替换。Kestrel 还支持运行多个事件循环,因此比 Node.js 更可靠。使用的事件循环数量取决于计算机上的逻辑处理器数量,以及一个线程运行一个事件循环。我们还可以在创建主机时通过代码配置此数字。
以下是Program.cs
文件的摘录,该文件存在于所有 ASP.NET Core 项目中:
public class Program
{
public static void Main(string[] args)
{
CreateWebHostBuilder(args).Build().Run();
}
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args).UseStartup<Startup>();
}
正如您将看到的,Kestrel 服务器基于构建器模式,并且可以使用适当的包和扩展方法添加功能。在接下来的部分中,我们将学习如何修改不同版本的.NET Core 的 Kestrel 设置。
ASP.NET Core 1.x
我们可以使用名为UseLibuv
的扩展方法来设置线程计数。我们可以通过设置ThreadCount
属性来实现,如下面的代码所示:
public static IWebHostBuilder CreateWebHostBuilder(string[] args) =>
WebHost.CreateDefaultBuilder(args)
.UseLibuv(opts => opts.ThreadCount = 4)
.UseStartup<Startup>();
WebHost
已在.NET Core 3.0 中被通用主机所取代。以下是 ASP.NET Core 3.0 的代码片段:
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
ASP.NET Core 2.x
从 ASP.NET 2.1 开始,Kestrel 已经替换了libuv
的默认传输方式,改为了托管套接字。因此,如果您将项目从 ASP.NET Core 升级到 ASP.NET 2.x 或 3.x,并且仍然想使用libuv
,则需要添加Microsoft.AspNetCore.Server.Kestrel.Transport.Libuv
NuGet 包以使代码正常工作。
Kestrel 目前支持以下场景:
-
HTTPS
-
不透明升级,用于启用 Web 套接字(
github.com/aspnet/websockets
) -
Nginx 后面的 Unix 套接字用于高性能
-
HTTP/2(目前在 macOS 上不受支持)
由于 Kestrel 是基于套接字构建的,您可以使用Host
上的ConfigureLimits
方法来配置它们的连接限制:
Host.CreateDefaultBuilder(args)
.ConfigureKestrel((context, options) =>
{
options.Limits.MaxConcurrentConnections = 100;
options.Limits.MaxConcurrentUpgradedConnections = 100;
}
如果我们将MaxConcurrentConnections
设置为 null,则默认连接限制是无限的。
引入微服务中线程的最佳实践
微服务是用于创建非常高性能和可扩展的后端服务的最流行的软件设计模式。与为整个应用程序构建一个服务不同,创建了多个松散耦合的服务,每个服务负责一个功能。根据功能的负载,可以单独扩展或缩减每个服务。因此,在设计微服务时,您使用的线程模型的选择变得非常重要。
微服务可以是无状态的或有状态的。无状态和有状态之间的选择对性能有影响。对于无状态服务,请求可以以任何顺序进行处理,而不考虑当前请求之前或之后发生了什么,而对于有状态服务,所有请求都应按特定顺序进行处理,如队列。这可能会对性能产生影响。由于微服务是异步的,我们需要编写一些逻辑来确保请求按正确的顺序和状态进行处理,并且在每个请求之后与下一个消息进行通信。微服务也可以是单线程或多线程的,这种选择与状态结合起来可以真正改善或降低性能,并且在规划服务时应该经过深思熟虑。
微服务设计方法可以分为以下几类:
-
单线程-单进程微服务
-
单线程-多进程微服务
-
多线程-单进程微服务
我们将在接下来的部分中更详细地了解这些设计方法。
单线程-单进程微服务
这是微服务的最基本设计。微服务在单个 CPU 核心的单个线程上运行。对于来自客户端的每个新请求,都会创建一个新线程,从而生成一个新进程。这会带走连接池缓存的好处。在与数据库一起工作时,每个新进程都会创建一个新的连接池。此外,由于一次只能创建一个进程,因此只能为一个客户端提供服务。
单线程-单进程微服务的缺点包括资源浪费以及在负载增加时服务的吞吐量不会增加。
单线程-多进程微服务
微服务在单个线程上运行,但可以生成多个进程,从而提高它们的吞吐量。由于为每个客户端创建了一个新进程,我们无法在连接到数据库时利用连接池。有一些第三方环境,如 Zend、OpCache 和 APC,提供跨进程的操作码缓存。
单线程-多进程微服务方法的优点是在负载上提高了吞吐量,但请注意我们无法利用连接池。
多线程-单进程
微服务在多个线程上运行,有一个长期运行的单个进程。使用相同的数据库,我们可以利用连接池,并在需要时限制连接的数量。单进程的问题在于所有线程将使用共享资源,并可能出现资源争用问题。
多线程-单进程方法的优点是提高了无状态服务的性能,而缺点是在共享资源时可能会出现同步问题。
异步服务
通过解耦微服务之间的通信,我们可以避免与各种应用组件集成时的性能问题。必须通过设计异步创建微服务才能实现这种解耦。
专用线程池
如果应用程序流程要求我们连接到各种微服务,那么为这些任务创建专用线程池更有意义。使用单个线程池,如果一个服务开始出现问题,那么池中的所有线程都可能耗尽。这可能会影响微服务的性能。这种模式也被称为Bulkheads模式。下图显示了两个使用共享连接池的微服务。如您所见,两个微服务都使用了共享连接池:
下图显示了两个使用专用线程池的微服务:
在下一节中,我们将介绍如何在 ASP.NET MVC 核心中使用异步。
在 ASP.NET MVC 核心中引入异步
async
和await
是代码标记,帮助我们使用 TPL 编写异步代码。它们有助于保持代码结构,并使其在后台异步处理代码的同时看起来同步。
我们在第九章中介绍了async
和await
,异步、等待和基于任务的异步编程基础。
现在,让我们使用 ASP.NET Core 3.0 和 VS 2019 预览创建一个异步 Web API。该 API 将从服务器读取文件:
- 打开 Visual Studio 2019,将呈现以下屏幕。在 VS 2019 中创建一个新的 ASP.NET Core Web 应用程序项目,如下图所示:
- 给项目取一个名字,并指定想要创建的位置:
- 选择项目类型,在我们的情况下是 API,然后点击创建:
- 现在,在我们的项目中创建一个名为
Files
的新文件夹,并添加一个名为data.txt
的文件,其中包含以下内容:
- 接下来,我们将修改
ValuesController.cs
中的Get
方法,如下所示:
[HttpGet]
public ActionResult<IEnumerable<string>> Get()
{
var filePath = System.IO.Path.Combine(
HostingEnvironment.ContentRootPath,"Files","data.txt");
var text = System.IO.File.ReadAllText(filePath);
return Content(text);
}
这是一个从服务器读取文件并将内容作为字符串返回给用户的简单方法。这段代码的问题在于,当调用File.ReadAllText
时,调用线程将被阻塞,直到文件完全读取。现在我们知道,我们的服务器响应将是进行异步调用,如下所示:
[HttpGet]
public async Task<ActionResult<IEnumerable<string>>> GetAsync()
{
var filePath = System.IO.Path.Combine(
HostingEnvironment.ContentRootPath, "Files", "data.txt");
var text = await System.IO.File.ReadAllTextAsync(filePath);
return Content(text);
}
ASP.NET Core Web API 支持并行编程的所有新特性,包括异步,正如我们从前面的代码示例中看到的。
异步流
.NET Core 3.0 还引入了异步流支持。IAsyncEnumerable<T>
是IEnumerable<T>
的异步版本。这一新功能允许开发人员在IAsyncEnumerable<T>
上等待foreach
循环以消耗流中的元素,并使用yield
返回流以产生元素。
这在我们想要异步迭代元素并对迭代的元素执行一些计算操作的场景中非常重要。随着现在更加注重大数据(作为流式输出可用),选择支持高数据量的异步流更有意义,同时通过有效地利用线程使服务器响应。
已添加了两个新接口来支持异步流:
public interface IAsyncEnumerable<T>
{
public IAsyncEnumerator<T> GetEnumerator();
}
public interface IAsyncEnumerator<out T>
{
public T Current { get; }
public Task<bool> MoveNextAsync();
}
从IAsyncEnumerator
的定义中可以看出,MoveNext
已经变成了异步的。这有两个好处:
-
很容易在
Task<bool>
上缓存Task<T>
,这样就会减少内存分配 -
现有的集合只需要添加一个额外的方法来支持异步行为
让我们尝试使用一些示例代码来异步枚举奇数索引的数字,以便理解这一点。
这是一个自定义的枚举器:
class OddIndexEnumerator : IAsyncEnumerator<int>
{
List<int> _numbers;
int _currentIndex = 1;
public OddIndexEnumerator(IEnumerable<int> numbers)
{
_numbers = numbers.ToList();
}
public int Current
{
get
{
Task.Delay(2000);
return _numbers[_currentIndex];
}
}
public ValueTask DisposeAsync()
{
return new ValueTask(Task.CompletedTask);
}
public ValueTask<bool> MoveNextAsync()
{
Task.Delay(2000);
if (_currentIndex < _numbers.Count() - 2)
{
_currentIndex += 2;
return new ValueTask<bool>(Task.FromResult<bool>(true));
}
return new ValueTask<bool>(Task.FromResult<bool>(false));
}
}
从我们在前面的代码中定义的MoveNextAsync()
方法中可以看出,这个方法从奇数索引(即索引 1)开始,并持续读取奇数索引的项目。
以下是我们的集合,它使用我们之前创建的自定义枚举逻辑,并实现了IAsyncEnumerable<T>
接口的GetAsyncEnumerator()
方法,以返回我们创建的OddIndexEnumerator
枚举器:
class CustomAsyncIntegerCollection : IAsyncEnumerable<int>
{
List<int> _numbers;
public CustomAsyncIntegerCollection(IEnumerable<int> numbers)
{
_numbers = numbers.ToList();
}
public IAsyncEnumerator<int> GetAsyncEnumerator(
CancellationToken cancellationToken = default)
{
return new OddIndexEnumerator(_numbers);
}
}
这是我们的魔术扩展方法,它将我们的自定义集合转换为AsyncEnumerable
。正如你所看到的,它适用于任何实现IEnumerable<int>
的集合,并使用CustomAsyncIntegerCollection
包装底层集合,而CustomAsyncIntegerCollection
又实现了IAsyncEnumerable<T>
:
public static class CollectionExtensions
{
public static IAsyncEnumerable<int> AsEnumerable(this
IEnumerable<int> source) => new CustomAsyncIntegerCollection(source);
}
一旦所有部分就位,我们就可以创建一个返回异步流的方法。我们可以通过使用yield
关键字来查看项目是如何生成的:
static async IAsyncEnumerable<int> GetBigResultsAsync()
{
var list = Enumerable.Range(1, 20);
await foreach (var item in list.AsEnumerable())
{
yield return item;
}
}
以下代码调用了流。在这里,我们调用了GetBigResultsAsync()
方法,该方法在foreach
循环内返回IAsyncEnumerable<int>
,然后异步迭代它:
async static Task Main(string[] args)
{
await foreach (var dataPoint in GetBigResultsAsync())
{
Console.WriteLine(dataPoint);
}
Console.WriteLine("Hello World!");
}
以下是前面代码的输出。如你所见,它在集合中生成了奇数索引的数字:
在本节中,我们介绍了异步流,这使得我们能够在不阻塞调用线程的情况下并行迭代集合,这是自 TPL 引入以来一直缺少的功能。
现在,让我们看看本章涵盖了什么。
总结
在本章中,我们讨论了 IIS 线程模型,并通过从.NET Core 2.0 使用libuv
到.NET Core 2.1 开始管理套接字来对.NET Core 服务器的实现进行更改。我们还讨论了改进 IIS、Kestrel 以及一些线程池算法(如饥饿避免和爬坡)的方法。我们介绍了微服务的概念以及在微服务中使用的各种线程模式,如单线程-单进程微服务、单线程-多进程微服务和多线程-单进程微服务。
我们还讨论了在 ASP.NET MVC Core 3.0 中使用异步的过程,并介绍了.NET Core 3.0 中异步流的新概念及其用法。异步流在大数据场景中非常方便,因为由于数据的快速涌入,服务器的负载可能会很大。
在下一章中,我们将学习一些常用的并行和异步编程模式。这些模式将增强我们对并行编程的理解。
问题
-
哪一个用于托管 Web 应用程序?
-
IWebHostBuilder
-
IHostBuilder
-
以下哪种
ThreadPool
算法试图最大化吞吐量,同时尽量使用较少的线程? -
爬山
-
饥饿避免
-
哪种不是有效的微服务设计方法?
-
单线程-单进程
-
单线程-多进程
-
多线程-单进程
-
多线程-多进程
-
在新版本的.NET Core 中,我们可以等待
foreach
循环。 -
真
-
假
第十三章:并行编程中的模式
在上一章中,我们介绍了 IIS 和 Kestrel 中的线程模型,以及如何优化它们以提高性能,以及.NET Core 3.0 中一些新的异步特性支持。
在本章中,我们将介绍并行编程模式,并专注于理解并行代码问题场景以及使用并行编程/异步技术解决这些问题。
尽管并行编程技术中使用了许多模式,但我们将限制自己解释最重要的模式。
本章中,我们将涵盖以下主题:
-
MapReduce
-
聚合
-
分支/合并
-
推测处理
-
懒惰
-
共享状态
技术要求
为了理解本章内容,需要具备 C#和并行编程的知识。本章的源代码可以在 GitHub 上找到:github.com/PacktPublishing/-Hands-On-Parallel-Programming-with-C-8-and-.NET-Core-3/tree/master/Chapter13
。
MapReduce 模式
MapReduce
模式是为了处理大数据问题而引入的,例如跨服务器集群的大规模计算需求。该模式也可以在单核机器上使用。
MapReduce
程序由两个任务组成:map和reduce。MapReduce
程序的输入作为一组键值对传递,输出也以此形式接收。
要实现这种模式,我们需要首先编写一个map
函数,该函数以数据(键/值对)作为单个输入值,并将其转换为另一组中间数据(键/值对)。然后用户编写一个reduce
函数,该函数以map
函数的输出(键/值对)作为输入,并将数据与包含任意行数据的较小数据集组合。
让我们看看如何使用 LINQ 实现基本的MapReduce
模式,并将其转换为基于 PLINQ 的实现。
使用 LINQ 实现 MapReduce
以下是MapReduce
模式的典型图形表示。输入经过各种映射函数,每个函数返回一组映射值作为输出。然后,这些值被Reduce()
函数分组和合并以创建最终输出:
按照以下步骤使用 LINQ 实现MapReduce
模式:
-
首先,我们需要编写一个
map
函数,它以单个输入值返回一组映射值。我们可以使用 LINQ 的SelectMany
函数来实现这一点。 -
然后,我们需要根据中间键对数据进行分组。我们可以使用 LINQ 的
GroupBy
方法来实现这一点。 -
最后,我们需要一个
reduce
方法,它将以中间键作为输入。它还将采用相应的值集合并产生输出。我们可以使用SelectMany
来实现这一点。 -
我们的最终
MapReduce
模式现在将如下所示:
public static IEnumerable<TResult> MapReduce<TSource, TMapped, TKey, TResult>(
this IEnumerable<TSource> source,
Func<TSource, IEnumerable<TMapped>> map,
Func<TMapped, TKey> keySelector,
Func<IGrouping<TKey, TMapped>, IEnumerable<TResult>> reduce)
{
return source.SelectMany(map) .GroupBy(keySelector) .SelectMany(reduce); }
- 现在,我们可以改变输入和输出,使其适用于
ParallelQuery<T>
而不是IEnumerable<T>
,如下所示:
public static ParallelQuery<TResult> MapReduce<TSource, TMapped, TKey, TResult>(
this ParallelQuery<TSource> source,
Func<TSource, IEnumerable<TMapped>> map,
Func<TMapped, TKey> keySelector,
Func<IGrouping<TKey, TMapped>, IEnumerable<TResult>> reduce)
{
return source.SelectMany(map)
.GroupBy(keySelector)
.SelectMany(reduce);
}
以下是在.NET Core 中使用自定义实现的MapReduce
的示例。程序在范围内生成一些正数和负数的随机数。然后,它应用一个 map 来过滤掉任何正数,并按数字对它们进行分组。最后,它应用reduce
函数返回一个数字列表,以及它们的计数:
private static void MapReduceTest()
{
//Maps only positive number from list
Func<int, IEnumerable<int>> mapPositiveNumbers = number =>
{
IList<int> positiveNumbers = new List<int>();
if (number > 0)
positiveNumbers.Add( number);
return positiveNumbers;
};
// Group results together
Func<int, int> groupNumbers = value => value;
//Reduce function that counts the occurrence of each number
Func<IGrouping<int, int>,IEnumerable<KeyValuePair<int, int>>>
reduceNumbers = grouping => new[] {
new KeyValuePair<int, int>( grouping.Key, grouping.Count())
};
// Generate a list of random numbers between -10 and 10
IList<int> sourceData = new List<int>();
var rand = new Random();
for (int i = 0; i < 1000; i++)
{
sourceData.Add(rand.Next(-10, 10));
}
// Use MapReduce function
var result = sourceData.AsParallel().MapReduce(mapPositiveNumbers,
groupNumbers,
reduceNumbers);
// process the results
foreach (var item in result)
{
Console.WriteLine($"{item.Key} came {item.Value} times" );
}
}
以下是我们在 Visual Studio 中运行上述程序代码后收到的输出摘录。如您所见,它迭代提供的列表并找到数字出现的次数:
在下一节中,我们将讨论另一个常见且重要的并行设计模式,称为聚合。而MapReduce
模式充当过滤器,聚合只是将输入的所有数据组合在一起,并以另一种格式放置。
聚合
聚合是并行应用程序中常用的设计模式。在并行程序中,数据被分成单元,以便可以通过多个线程在多个核心上处理。在某些时候,需要从所有相关来源组合数据,然后才能呈现给用户。这就是聚合的作用。
现在,让我们探讨聚合的需求以及 PLINQ 提供的内容。
聚合的一个常见用例如下。在这里,我们尝试迭代一组值,执行一些操作,并将结果返回给调用者:
var output = new List<int>();
var input = Enumerable.Range(1, 50);
Func<int,int> action = (i) => i * i;
foreach (var item in input)
{
var result = action(item);
output.Add(result);
}
上述代码的问题是输出不是线程安全的。因此,为了避免跨线程问题,我们需要使用同步原语:
var output = new List<int>();
var input = Enumerable.Range(1, 50);
Func<int, int> action = (i) => i * i;
Parallel.ForEach(input, item =>
{
var result = action(item);
lock (output)
output.Add(result);
});
上面的代码在每个项目的计算量较小时运行良好。然而,随着每个项目的计算量增加,获取和释放锁的成本也会增加。这会导致性能下降。在这里,我们讨论的并发集合在这里发挥了作用。使用并发集合,我们不必担心同步。以下代码片段使用并发集合:
var input = Enumerable.Range(1, 50);
Func<int, int> action = (i) => i * i;
var output = new ConcurrentBag<int>();
Parallel.ForEach(input, item =>
{
var result = action(item);
output.Add(result);
});
PLINQ 还定义了帮助聚合和处理同步的方法。其中一些方法是 ToArray
、ToList
、ToDictionary
和 ToLookup
:
var input = Enumerable.Range(1, 50);
Func<int, int> action = (i) => i * i;
var output = input.AsParallel()
.Select(item => action(item))
.ToList();
在上面的代码中,ToList()
方法负责聚合所有数据,同时处理同步。TPL 中有一些实现模式,并内置在编程语言中。其中之一是 fork/join 模式,我们将在下面讨论。
fork/join 模式
在 fork/join 模式中,工作被 forked(分割)成一组可以异步执行的任务。稍后,分叉的工作按照要求和并行化的范围以相同顺序或不同顺序进行合并。在本书中,当我们讨论愉快的并行循环时,已经看到了一些 fork/join 模式的常见示例。fork/join 的一些实现如下:
-
Parallel.For
-
Parallel.ForEach
-
Parallel.Invoke
-
System.Threading.CountdownEvent
利用这些框架提供的方法有助于更快地开发,而开发人员无需担心同步开销。这些模式导致高吞吐量。为了实现高吞吐量和减少延迟,另一个称为推测处理的模式被广泛使用。
推测处理模式
推测处理模式是另一种并行编程模式,依赖于高吞吐量来减少延迟。这在存在多种执行任务的方式但用户不知道哪种方式会最快返回结果的情况下非常有用。这种方法为每种可能的方法创建一个任务,然后在处理器上执行。首先完成的任务被用作输出,忽略其他任务(它们可能仍然成功完成,但速度较慢)。
以下是典型的 SpeculativeInvoke
表示。它接受 Func<T>
数组作为参数,并并行执行它们,直到其中一个返回:
public static T SpeculativeInvoke<T>(params Func<T>[] functions)
{
return SpeculativeForEach(functions, function => function());
}
以下方法并行执行传递给它的每个操作,并通过调用 ParallelLoopState.Stop()
方法来跳出并行循环,一旦任何被调用的实现成功执行:
public static TResult SpeculativeForEach<TSource, TResult>(
IEnumerable<TSource> source,
Func<TSource, TResult> body)
{
object result = null;
Parallel.ForEach(source, (item, loopState) =>
{
result = body(item);
loopState.Stop();
});
return (TResult)result;
}
以下代码使用两种不同的逻辑来计算 5 的平方。我们将两种方法都传递给 SpeculativeInvoke
方法,并尽快打印 result
:
Func<string> Square = () => {
Console.WriteLine("Square Called");
return $"Result From Square is {5 * 5}";
};
Func<string> Square2 = () =>
{
Console.WriteLine("Square2 Called");
var square = 0;
for (int j = 0; j < 5; j++)
{
square += 5;
}
return $"Result From Square2 is {square}";
};
string result = SpeculativeInvoke(Square, Square2);
Console.WriteLine(result);
以下是上述代码的输出:
正如你将看到的,两种方法都会完成,但只有第一个完成的执行的输出会返回给调用者。创建太多任务可能会对系统内存产生不利影响,因为需要分配和保留更多的变量在内存中。因此,只有在实际需要时分配对象变得非常重要。我们的下一个模式可以帮助我们实现这一点。
懒惰模式
懒惰是应用程序开发人员用来提高应用程序性能的另一种编程模式。懒惰是指延迟计算直到实际需要。在最佳情况下,可能根本不需要计算,这有助于不浪费计算资源,从而提高整个系统的性能。懒惰评估在计算机领域并不新鲜,LINQ 大量使用延迟加载。LINQ 遵循延迟执行模型,在这个模型中,查询直到我们使用一些迭代器函数调用MoveNext()
时才被执行。
以下是一个线程安全的懒惰单例模式的示例,它利用一些繁重的计算操作进行创建,因此是延迟的:
public class LazySingleton<T> where T : class
{
static object _syncObj = new object();
static T _value;
private LazySingleton()
{
}
public static T Value
{
get
{
if (_value == null)
{
lock (_syncObj)
{
if (_value == null)
_value = SomeHeavyCompute();
}
}
return _value;
}
}
private static T SomeHeavyCompute() { return default(T); }
}
通过调用LazySingleton<T>
类的Value
属性来创建一个懒惰对象。懒惰保证对象直到调用Value
属性时才被创建。一旦创建,单例实现确保在后续调用时返回相同的对象。对_value
的空值检查避免在后续调用时创建锁,从而节省一些内存 I/O 操作并提高性能。
我们可以通过使用System.Lazy<T>
来避免编写太多的代码,如下面的代码示例所示:
public class MyLazySingleton<T>
{
//Declare a Lazy<T> instance with initialization
//function (SomeHeavyCompute)
static Lazy<T> _value = new Lazy<T>();
//Value property to return value of Lazy instance when
//actually required by code
public T Value { get { return _value.Value; } }
//Initialization function
private static T SomeHeavyCompute()
{
return default(T);
}
}
在使用异步编程时,我们可以结合Lazy<T>
和 TPL 的力量来取得显著的结果。
以下是使用Lazy<T>
和Task<T>
来实现懒惰和异步行为的示例:
var data = new Lazy<Task<T>>(() => Task<T>.Factory.StartNew(SomeHeavyCompute));
我们可以通过data.Value
属性访问底层的Task
。底层的懒惰实现将确保每次调用data.Value
属性时返回相同的任务实例。这在你不想启动许多线程,只想启动一个可能执行一些异步处理的单个线程的情况下非常有用。
考虑以下代码片段,它从服务中获取数据,并将其保存到 Excel 或 CSV 文件中,使用两种不同的线程实现:
public static string GetDataFromService()
{
Console.WriteLine("Service called");
return "Some Dummy Data";
}
以下是两个示例方法,其中的逻辑可以保存为文本或 CSV 格式:
public static void SaveToText(string data)
{
Console.WriteLine("Save to Text called");
//Save to Text
}
public static void SaveToCsv(string data)
{
Console.WriteLine("Save to CSV called");
//Save to CSV
}
以下代码显示了我们如何将服务调用包装在lazy
中,并确保只有在需要时才进行一次服务调用,而输出可以异步使用。正如你所看到的,我们已经将延迟初始化方法包装为一个任务,使用Task.Factory.StartNew(GetDataFromService)
:
//
Lazy<Task<string>> lazy = new Lazy<Task<string>>(
Task.Factory.StartNew(GetDataFromService));
lazy.Value.ContinueWith((s)=> SaveToText(s.Result));
lazy.Value.ContinueWith((s) => SaveToCsv(s.Result));
以下是前述代码的输出:
正如你所看到的,服务只被调用了一次。每当需要创建对象时,懒惰模式对开发人员来说是一个值得考虑的建议。当我们创建多个任务时,我们面临与资源同步相关的问题。在这些情况下,对共享状态模式的理解非常有用。
共享状态模式
我们在第五章中介绍了这些模式的实现,同步原语。
并行应用程序必须不断处理共享状态问题。应用程序将具有一些数据成员,在多线程环境中访问时需要受到保护。处理共享状态问题有许多方法,例如使用同步
、隔离
和不可变性
。同步可以使用.NET Framework 提供的同步原语来实现,并且还可以对共享数据成员提供互斥。不可变性保证数据成员只有一个状态,永远不会改变。因此,相同的状态可以在线程之间共享而不会出现任何问题。隔离处理每个线程都有自己的数据成员副本。
现在,让我们总结一下本章学到的内容。
总结
在本章中,我们介绍了并行编程的各种模式,并提供了每种模式的示例。虽然不是详尽无遗的列表,但这些模式可以成为并行应用程序编程开发人员的良好起点。
简而言之,我们讨论了MapReduce
模式、推测处理模式、懒惰模式和聚合模式。我们还介绍了一些实现模式,比如分支/合并和共享状态模式,这两种模式都在.NET Framework 库中用于并行编程。
在下一章中,我们将介绍分布式内存管理,并重点了解共享内存模型以及分布式内存模型。我们还将讨论各种类型的通信网络及其具有示例实现的属性。
问题
-
以下哪个不是分支/合并模式的实现?
-
System.Threading.Barrier
-
System.Threading.Countdown
-
Parallel.For
-
Parallel.ForEach
-
以下哪个是 TPL 中懒惰模式的实现?
-
Lazy<T>
-
懒惰单例
-
LazyInitializer
-
哪种模式依赖于实现高吞吐量以减少延迟?
-
懒惰
-
共享状态
-
推测处理
-
如果您需要从列表中过滤数据并返回单个输出,可以使用哪种模式?
-
聚合
-
MapReduce
第十四章:分布式内存管理
在过去的二十年中,行业已经看到了一个向大数据和机器学习架构的转变,这些架构涉及尽可能快地处理 TB / PB 级别的数据。随着计算能力变得更加便宜,需要使用多个处理器来加速处理规模更大的数据。这导致了分布式计算。分布式计算是指通过某种网络/分发中间件连接的计算机系统的安排。所有连接的系统共享资源,并通过中间件协调它们的活动,以便它们以最终用户感知为单个系统的方式工作。由于现代应用程序的巨大容量和吞吐量要求,需要分布式计算。一些典型的示例场景,其中单个系统无法满足计算需求,需要在计算机网格上分布的情况如下:
-
谷歌每年至少进行 1500 亿次搜索。
-
物联网设备向事件中心发送多个 TB 的数据。
-
数据仓库在最短的时间内接收和计算 TB 级别的记录。
在本章中,我们将讨论分布式内存管理和分布式计算的需求。我们还将了解分布式系统中如何通过通信网络传递消息,以及各种类型的通信网络。
本章将涵盖以下主题:
-
分布式系统的优势
-
共享内存模型与分布式内存模型
-
通信网络的类型
-
通信网络的属性
-
探索拓扑结构
-
使用消息传递编程来编程分布式内存机器
-
集合
技术要求
要完成本章,您需要了解在 C 和 C# Windows 平台 API 调用编程中的编程知识。
分布式系统简介
我们已经在本书中讨论了分布式计算的工作原理。在本节中,我们将尝试通过一个在数组上工作的小例子来理解分布式计算。
假设我们有一个包含 1040 个元素的数组,我们想找出所有数字的总和:
a = [1,2,3, 4...., n]
如果将数字相加所需的总时间为 x(假设所有数字都很大),并且我们希望尽快计算它们,我们可以利用分布式计算。我们将数组分成多个数组(假设有四个数组),每个数组包含原始元素数量的 25%,并将每个数组发送到不同的处理器以计算总和,如下所示:
在这种安排中,将所有数字相加所需的总时间减少到(x/4 + d)或(x/处理器数量 + d),其中 d 是从所有处理器收集总和并将它们相加以获得最终结果所需的时间。
分布式系统的一些优势如下:
-
系统可以在没有任何硬件限制的情况下扩展到任何级别
-
没有单点故障,使它们更具容错性
-
高度可用
-
处理大数据问题时非常高效
分布式系统经常与并行系统混淆,但它们之间有微妙的区别。并行系统是一种多处理器的排列,它们大多放置在单个容器中,但有时也放置在多个容器中。分布式系统则由多个处理器组成(每个处理器都有自己的内存和 I/O 设备),它们通过网络连接在一起,实现数据交换。
共享与分布式内存模型
为了实现高性能,多处理器和多计算机架构已经发展。使用多处理器架构,多个处理器共享一个公共内存,并通过读/写共享内存进行通信。使用多计算机,多台不共享单个物理内存的计算机通过传递消息进行通信。分布式共享内存(DSM)处理在物理、非共享(分布式)架构中共享内存。
让我们分别看看它们,并谈论它们的区别。
共享内存模型
在共享内存模型的情况下,多个处理器共享单个公共内存空间。由于多个处理器共享内存空间,需要一些同步措施来避免数据损坏和竞争条件。正如我们在本书中所看到的,同步会带来性能开销。以下是共享内存模型的示例表示。如您所见,排列中有n个处理器,所有这些处理器都可以访问一个共享的内存块:
共享内存模型的特点如下:
- 所有处理器都可以访问整个内存块。内存块可以是由内存模块组成的单个内存块,如下图所示:
-
处理器通过在主内存中创建共享变量来相互通信。
-
并行化的效率在很大程度上取决于服务总线的速度。
-
由于服务总线的速度,系统只能扩展到 n 个处理器。
共享内存模型也被称为对称多处理(SMP)模型,因为所有处理器都可以访问所有可用的内存块。
分布式内存模型
在分布式内存模型的情况下,内存空间不再跨处理器共享。事实上,处理器不共享共同的物理位置;相反,它们可以远程放置。每个处理器都有自己的私有内存空间和 I/O 设备。数据存储在处理器之间而不是单个内存中。每个处理器可以处理自己的本地数据,但要访问存储在其他处理器内存中的数据,它们需要通过通信网络连接。数据通过消息传递在处理器之间传递,使用发送消息和接收消息指令。以下是分布式内存模型的图示表示:
上图描述了每个处理器及其自己的内存空间,并通过 I/O 接口与通信网络进行交互。让我们试着了解分布式系统中可以使用的各种通信网络类型。
通信网络的类型
通信网络是连接典型计算机网络中的两个或多个节点的链路。通信网络分为两类:
-
静态通信网络
-
动态通信网络
让我们来看看两者。
静态通信网络
静态通信网络包含链接,如下图所示:
链接用于连接节点,从而创建一个完整的通信网络,其中任何节点都可以与任何其他节点通信。
动态通信网络
动态通信网络具有链接和交换机,如下图所示:
交换机是具有输入/输出端口的设备,并将输入数据重定向到输出端口。这意味着路径是动态的。如果一个处理器想要向另一个处理器发送数据,就需要通过交换机进行,如前图所示。
通信网络的属性
在设计通信网络时,我们需要考虑以下特性:
-
拓扑
-
路由算法
-
交换策略
-
流量控制
让我们更详细地看看这些特性。
拓扑
拓扑指的是节点(桥接器、交换机和基础设备)的连接方式。一些常见的拓扑包括交叉开关、环形、2D 网格、3D 网格、更高维网格、2D 环、3D 环、更高维环、超立方体、树、蝴蝶、完美洗牌和蜻蜓。
在交叉开关拓扑的情况下,网络中的每个节点都连接到每个其他节点(尽管它们可能不是直接连接的)。因此,消息可以通过多条路由传递,以避免任何冲突。以下是一个典型的交叉开关拓扑:
在网状拓扑或者常被称为网状网络的情况下,节点直接连接到彼此,而不依赖于网络中的其他节点。这样,所有节点都可以独立地中继信息。网状可以是部分连接或完全连接的。以下是一个典型的完全连接的网状:
我们将在本章后面更详细地讨论拓扑,在探索拓扑部分。
路由算法
路由是通过网络发送信息包以使其到达预定节点的过程。路由可以是自适应的,即它通过不断从相邻节点获取信息来响应网络拓扑的变化,也可以是非自适应的,即它们是静态的,并且在网络引导时将路由信息下载到节点。需要选择路由算法以确保没有死锁。例如,在 2D 环中,所有路径都从东到西和从北到南,以避免任何死锁情况。我们将在本章后面更详细地讨论 2D 环。
交换策略
选择适当的交换策略可以提高网络的性能。最突出的两种交换策略如下:
-
电路交换:在电路交换中,整个消息的完整路径被保留,比如电话。在电话网络上开始通话时,需要在呼叫方和被呼叫方之间建立专用电路,并且在整个通话期间电路保持不变。
-
分组交换:在分组交换中,消息被分成单独路由的数据包,比如互联网。在成本效益方面,它比电路交换要好得多,因为链路的成本是由用户共享的。分组交换主要用于异步场景,比如发送电子邮件或文件传输。
流量控制
流量控制是网络确保数据包在发送方和接收方之间高效、无误地传输的过程。在网络拓扑的情况下,发送方和接收方的速度可能不同,这可能导致瓶颈或在某些情况下丢失数据包。通过流量控制,我们可以在网络拥塞时做出决策。一些策略包括临时将数据存储到缓冲区中、将数据重新路由到其他节点、指示源节点暂停传输、丢弃数据等。以下是一些常见的流量控制算法:
-
停止等待:整个消息被分成部分。发送方将一部分发送给接收方,并等待在特定时间段(超时)内收到确认。一旦发送方收到确认,就发送消息的下一部分。
-
滑动窗口:接收方为发送方分配一个传输窗口来发送消息。当窗口已满时,发送方必须停止传输,以便接收方可以处理消息并通知下一个传输窗口。当接收方将数据存储在缓冲区中并且只能接收缓冲区容量时,这种方法效果最好。
探索拓扑
到目前为止,我们已经看过一些完整的通信网络,其中每个处理器都可以直接与其他处理器通信,而不需要任何交换机。当处理器数量较少时,这种排列效果很好,但如果需要增加处理器数量,就会变得非常麻烦。还有其他各种性能拓扑可供使用。在测量拓扑中的图的性能时有两个重要方面:
-
图的直径:节点之间的最长路径。
-
二分带宽:将网络分成两个相等的部分的最小切割的带宽。这对于每个处理器都需要与其他处理器通信的网络非常重要。
以下是一些网络拓扑的示例。
线性和环形拓扑
这些拓扑结构与 1D 数组配合得很好。在线性拓扑的情况下,所有处理器都按线性排列,有一个输入和输出流,而在环形拓扑的情况下,处理器形成一个回路返回到起始处理器。
让我们更详细地看一下它们。
线性数组
所有处理器都按线性排列,如下图所示:
这种排列将具有以下直径和二分带宽的值:
-
直径= n-1,其中 n 是处理器的数量
-
二分带宽= 1
环形或环面
所有处理器都处于环形排列中,信息从一个处理器流向另一个处理器,然后回到起始处理器。然后,这形成一个环,如下图所示:
这种排列将具有以下直径和二分带宽的值:
-
直径= n/2,其中 n 是处理器的数量
-
二分带宽= 2
网格和环形
这些拓扑结构与 2D 和 3D 数组配合得很好。让我们更详细地看一下它们。
2D 网格
在网格的情况下,节点直接连接到彼此,而不依赖于网络中的其他节点。所有节点都处于 2D 网格排列中,如下图所示:
这种排列将具有以下直径和二分带宽的值:
-
直径= 2 * ( sqrt ( n ) – 1 ),其中 n 是处理器的数量
-
二分带宽= sqrt( n )
2D 环面
所有处理器都按 2D 环排列,如下图所示:
这种排列将具有以下直径和二分带宽的值:
-
直径= sqrt( n ),其中 n 是处理器的数量
-
二分带宽= 2 * sqrt(n)
使用消息传递编程分布式内存机器
在本节中,我们将讨论如何使用 Microsoft 的消息传递接口(MPI)编程分布式内存机器。
MPI 是一个标准的、可移植的系统,专为分布式和并行系统开发。它定义了一组基本函数,这些函数由并行硬件供应商用于支持分布式内存通信。在接下来的章节中,我们将讨论使用 MPI 相对于旧的消息传递库的优势,并解释如何安装和运行一个简单的 MPI 程序。
为什么使用 MPI?
MPI 的一个优点是 MPI 例程可以从各种语言中调用,如 C、C++、C#、Java、Python 等。与旧的消息传递库相比,MPI 具有高度的可移植性,MPI 例程针对它们应该运行的每一块硬件进行了速度优化。
在 Windows 上安装 MPI
MPI 可以从www.open-mpi.org/software/ompi/v1.10/
下载并安装为 ZIP 文件。
或者,您可以从github.com/Microsoft/Microsoft-MPI/releases
下载 Microsoft 版本的 MPI。
使用 MPI 的示例程序
以下是一个简单的HelloWorld
程序,我们可以使用 MPI 来运行。该程序在延迟两秒后打印代码正在执行的处理器编号。相同的代码可以在多个处理器上运行(我们可以指定处理器数量)。
让我们在 Visual Studio 中创建一个新的控制台应用程序项目,并在Program.cs
文件中编写以下代码:
[DllImport("Kernel32.dll"), SuppressUnmanagedCodeSecurity]
public static extern int GetCurrentProcessorNumber();
static void Main(string[] args)
{
Thread.Sleep(2000);
Console.WriteLine($"Hello {GetCurrentProcessorNumber()} Id");
}
GetCurrentProcessorNumber()
是一个实用函数,可以给出我们的代码正在执行的处理器编号。正如您从前面的代码中看到的,这并没有什么神奇之处-它作为一个单线程运行,并打印Hello
和当前处理器编号。
我们将从在 Windows 上安装 MPI部分提供的 Microsoft MPI 链接中安装msmpisetup.exe
。安装完成后,我们需要从命令提示符中执行以下命令:
C:\Program Files\Microsoft MPI\Bin>mpiexec.exe -n 5 “path to executable “
在这里,n
表示我们希望程序在其上运行的处理器数量。
以下是前面代码的输出:
正如您所看到的,我们可以使用 MPI 在多个处理器上运行相同的程序。
基本的发送/接收使用
MPI 是一个 C++实现,微软网站上的大部分文档只能用 C++访问。然而,很容易创建一个.NET 编译包装器并在我们的任何项目中使用它。也有一些第三方.NET 实现可用于 MPI,但遗憾的是,目前还没有.NET Core 实现的支持。
以下是MPI_Send
函数的语法,它将一个数据缓冲区发送到另一个处理器:
int MPIAPI MPI_Send(
_In_opt_ void *buf, //pointer to buffer containing Data to send
int count, //Number of elements in buffer
MPI_Datatype datatype,//Datatype of element in buffer
int dest, //rank of destination process
int tag, //tag to distinguish between messages
MPI_Comm comm //Handle to communicator
);
当缓冲区可以安全重用时,该方法将返回。
以下是MPU_Recv
函数的语法,它将从另一个处理器接收一个数据缓冲区:
int MPIAPI MPI_Recv(
_In_opt_ void *buf,
int count,
MPI_Datatype datatype,
int source,
int tag,
MPI_Comm comm,
_Out_ MPI_Status *status //Returns MPI_SUCCESS or the error code.
);
该方法在缓冲区被接收之前不会返回。
以下是使用发送和接收函数的典型示例:
#include “mpi.h”
#include <iostream> int main( int argc, char *argv[]) { int rank, buffer; MPI::Init(argv, argc); rank = MPI::COMM_WORLD.Get_rank(); // Process 0 sends data as buffer and Process 1 receives data as buffer if (rank == 0) { buffer = 999999; MPI::COMM_WORLD.Send( &buffer, 1, MPI::INT, 1, 0 ); } else if (rank == 1) { MPI::COMM_WORLD.Recv( &buffer, 1, MPI::INT, 0, 0 ); std::cout << “Data Received “ << buf << “\n”; } MPI::Finalize(); return 0; }
通过 MPI 运行时,通信器将发送数据,该数据将由另一个处理器中的接收函数接收。
集合
集合,顾名思义,是一种通信方法,其中通信器中的所有处理器都参与其中。集合帮助我们完成这些任务。用于此目的的两种主要使用的集合方法如下:
-
MPI_BCAST
:这个函数将数据从一个(根)进程分发到通信器中的另一个处理器 -
MPI_REDUCE
:这个函数将从通信器中的所有处理器中合并数据,并将其返回给根进程
现在我们了解了集合,我们已经到达了本章的结尾,也是本书的结尾。现在,是时候看看我们学到了什么了!
总结
在本章中,我们讨论了分布式内存管理实现。我们学习了分布式内存管理模型,如共享内存和分布式内存处理器,以及它们的实现。最后,我们讨论了 MPI 是什么以及如何利用它。我们还讨论了通信网络和实现高效网络的各种设计考虑。现在,您应该对网络拓扑、路由算法、交换策略和流量控制有很好的理解。
在本书中,我们已经涵盖了.NET Core 3.1 中可用的各种编程构造,以实现并行编程。如果正确使用,并行编程可以极大地提高应用程序的性能和响应能力。.NET Core 3.1 中可用的新功能和语法确实使编写/调试和维护并行代码变得更加容易。我们还讨论了在 TPL 出现之前我们如何编写多线程代码,以进行比较。
通过新的异步编程构造(async 和 await),我们学习了如何充分利用非阻塞 I/O,同时程序流程是同步的。然后,我们讨论了诸如异步流和异步主方法之类的新功能,这些功能可以帮助我们更轻松地编写异步代码。我们还讨论了 Visual Studio 中的并行工具支持,可以帮助我们更好地调试代码。最后,我们讨论了如何为并行代码编写单元测试用例,以使我们的代码更加健壮。
然后,我们通过介绍分布式编程技术以及如何在.NET Core 中使用它们来结束了这本书。
问题
-
____________ 是将多处理器放置在单个容器中,但有时也放置在彼此紧邻的多个容器中的一种安排。
-
在动态通信网络的情况下,任何节点都可以向任何其他节点发送数据。
-
真
-
假
-
以下哪些是通信网络的特征?
-
拓扑
-
切换策略
-
流量控制
-
共享内存
-
在分布式内存模型的情况下,内存空间在处理器之间共享。
-
真
-
假
-
电路切换可以用于异步场景。
-
真
-
假
第十五章:评估
第一章-并行编程简介
-
2
-
2
-
2
-
2
-
2
第三章-实现数据并行性
-
2
-
1
-
2
-
2
-
2
第四章-使用 PLINQ
-
2
-
1
-
2
-
2
-
1
第五章-同步原语
-
3
-
4
-
3
-
1
-
1
第六章-使用并发集合
-
4
-
1
-
1
-
4
第七章-使用延迟初始化提高性能
-
2
-
1
-
2
-
3
第八章-异步编程简介
-
1
-
1,2,3
-
1,2
-
1
第九章-异步,等待和基于任务的异步编程基础
-
2
-
1,2,3
-
1
-
1
-
1
-
2
第十章-使用 Visual Studio 调试任务
-
3
-
1
-
2
-
2
-
3
第十一章-为并行和异步代码编写单元测试用例
-
1
-
2
-
1
-
3
-
2
第十二章-ASP.NET Core 中的 IIS 和 Kestrel
-
1
-
1
-
4
-
1
第十三章-并行编程中的模式
-
1
-
2
-
3
-
2
第十四章-分布式内存管理
-
并行系统
-
2
-
4
-
2