原文:
zh.annas-archive.org/md5/BE48315910DEF416E754F7470D0341EA译者:飞龙
第三部分:使用 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
660



被折叠的 条评论
为什么被折叠?



