C#9 和 .NET5 高级教程(八)
十四、进程、应用域和加载上下文
在这一章中,你将深入探究运行时如何承载程序集的细节,并开始理解进程、应用域和对象上下文之间的关系。
简而言之,应用域(或简称为 AppDomains )是给定进程中的逻辑子部分,包含一组相关的。NET 核心程序集。正如您将看到的,AppDomain 被进一步细分为上下文边界,用于分组志同道合的人。NET 核心对象。使用上下文的概念,运行时可以确保具有特殊要求的对象得到适当的处理。
虽然您的许多日常编程任务可能不涉及直接使用进程、AppDomains 或对象上下文,但在使用大量。NET 核心 API,包括多线程、并行处理和对象序列化。
Windows 进程的角色
“进程”的概念早在。NET/。NET 核心平台。简单来说,进程就是一个正在运行的程序。然而,从形式上来说,进程是一个操作系统级的概念,用于描述一组资源(如外部代码库和主线程)以及正在运行的应用所使用的必要内存分配。对于每一个。NET 核心应用加载到内存中时,操作系统会创建一个单独且隔离的进程供其在生命周期中使用。
使用这种应用隔离方法,结果是一个更加健壮和稳定的运行时环境,假设一个进程的失败不会影响另一个进程的运行。此外,一个进程中的数据不能被另一个进程直接访问,除非您使用特定的工具,如System.IO.Pipes
或MemoryMappedFile
类。考虑到这几点,您可以将该进程视为正在运行的应用的一个固定的、安全的边界。
每个 Windows 进程都分配有一个唯一的进程标识符(PID ),并且可以根据需要由操作系统独立加载和卸载(也可以通过编程方式)。如您所知,Windows 任务管理器实用程序的“进程”选项卡(在 Windows 上通过 Ctrl+Shift+Esc 组合键激活)允许您查看有关给定计算机上运行的进程的各种统计信息。详细信息选项卡允许您查看分配的 PID 和图像名称(参见图 14-1 )。
图 14-1。
Windows 任务管理器
线程的作用
每个 Windows 进程都包含一个初始“线程”,作为应用的入口点。第十五章研究了在。NET 核心平台;然而,为了方便这里介绍的主题,您需要一些工作定义。首先,线程是一个进程中的执行路径。从形式上讲,进程入口点创建的第一个线程被称为主线程。任何。NET 核心程序(控制台应用、Windows 服务、WPF 应用等。)用Main()
方法或包含顶级语句的文件标记它的入口点。调用这段代码时,会自动创建主线程。
包含单个主执行线程的进程本质上是线程安全的,因为在给定时间只有一个线程可以访问应用中的数据。但是,如果单线程执行复杂的操作(例如打印一个很长的文本文件,执行数学密集型计算,或者试图连接到数千英里之外的远程服务器),单线程进程(尤其是基于 GUI 的进程)对用户来说通常会显得有点无响应。
鉴于单线程应用的这一潜在缺点,支持的操作系统。NET Core(以及。NET Core platform)使得主线程可以使用一些 API 函数(如CreateThread
)来产生额外的辅助线程(也称为工作线程)。每个线程(主线程或次线程)都成为进程中唯一的执行路径,并且可以同时访问进程中所有共享的数据点。
您可能已经猜到,开发人员通常会创建额外的线程来帮助提高程序的整体响应能力。多线程进程提供了大量活动同时发生的假象。例如,一个应用可能会产生一个工作线程来执行一个劳动密集型的工作单元(比如打印一个大的文本文件)。当这个辅助线程运行时,主线程仍然响应用户输入,这使得整个进程有可能提供更好的性能。然而,实际情况可能并非如此:在单个进程中使用太多线程实际上会降低性能,因为 CPU 必须在进程中的活动线程之间切换(这需要时间)。
在某些机器上,多线程通常是操作系统提供的假象。承载单个(非超线程)CPU 的机器不具备同时处理多个线程的能力。相反,单个 CPU 将部分基于线程的优先级在单位时间(称为时间片)内执行一个线程。当一个线程的时间片结束时,现有的线程被挂起,以允许另一个线程执行其业务。为了让一个线程记住在它被踢出之前发生了什么,每个线程都被赋予了写入线程本地存储(TLS)的能力,并被提供了一个单独的调用堆栈,如图 14-2 所示。
图 14-2。
Windows 进程/线程关系
如果线程的主题对你来说是新的,不要为细节伤脑筋。此时,请记住线程是 Windows 进程中唯一的执行路径。每个进程都有一个主线程(通过可执行文件的入口点创建),并且可能包含以编程方式创建的其他线程。
使用与进程交互。净核心
虽然进程和线程并不新鲜,但是您在。NET 核心平台发生了相当大的变化(变得更好)。为了给理解多线程程序集的构建铺平道路(参见第十五章),让我们先看看如何使用?NET 核心基本类库。
System.Diagnostics
名称空间定义了几种类型,允许您以编程方式与进程和各种与诊断相关的类型(如系统事件日志和性能计数器)进行交互。在本章中,你只关心表 14-1 中定义的以过程为中心的类型。
表 14-1。
选择系统的成员。诊断名称空间
|以流程为中心的系统类型。诊断名称空间
|
生命的意义
|
| --- | --- |
| Process
| Process
类提供对本地和远程进程的访问,并允许您以编程方式启动和停止进程。 |
| ProcessModule
| 这个类型表示一个模块(*.dll
或*.exe
)被加载到一个进程中。要知道,ProcessModule
类型可以代表任何基于 COM 的模块。基于. NET 或传统的基于 C 的二进制文件。 |
| ProcessModuleCollection
| 这提供了一个强类型的ProcessModule
对象集合。 |
| ProcessStartInfo
| 这指定了通过Process.Start()
方法启动流程时使用的一组值。 |
| ProcessThread
| 此类型表示给定进程中的线程。请注意,ProcessThread
是一种用于诊断进程线程集的类型,而不是用于在一个进程中产生新的执行线程。 |
| ProcessThreadCollection
| 这提供了一个强类型的ProcessThread
对象集合。 |
System.Diagnostics.Process
类允许您分析在给定机器(本地或远程)上运行的进程。Process
类还提供了一些成员,允许您以编程方式启动和终止进程,查看(或修改)进程的优先级,以及获取给定进程中活动线程和/或加载模块的列表。表 14-2 列出了System.Diagnostics.Process
的一些关键属性。
表 14-2。
选择流程类型的属性
|财产
|
生命的意义
|
| --- | --- |
| ExitTime
| 该属性获取与已经终止的进程相关联的时间戳(用一个DateTime
类型表示)。 |
| Handle
| 该属性返回操作系统与进程关联的句柄(由一个IntPtr
表示)。这在构建时会很有用。需要与非托管代码通信的. NET 应用。 |
| Id
| 此属性获取关联进程的 PID。 |
| MachineName
| 此属性获取运行关联进程的计算机的名称。 |
| MainWindowTitle
| MainWindowTitle
获取进程主窗口的标题(如果进程没有主窗口,您会收到一个空的string
)。 |
| Modules
| 该属性提供对强类型ProcessModuleCollection
类型的访问,该类型表示当前进程中加载的模块集(*.dll
或*.exe
)。 |
| ProcessName
| 该属性获取进程的名称(如您所想,这是应用本身的名称)。 |
| Responding
| 此属性获取一个值,该值指示进程的用户界面是否正在响应用户输入(或者当前是否“挂起”)。 |
| StartTime
| 该属性获取相关进程开始的时间(通过一个DateTime
类型)。 |
| Threads
| 该属性获取在相关进程中运行的一组线程(通过一组ProcessThread
对象表示)。 |
除了刚刚检查的属性,System.Diagnostics.Process
还定义了一些有用的方法(见表 14-3 )。
表 14-3。
选择流程类型的方法
|方法
|
生命的意义
|
| --- | --- |
| CloseMainWindow()
| 此方法通过向主窗口发送关闭消息来关闭具有用户界面的进程。 |
| GetCurrentProcess()
| 这个静态方法返回一个新的代表当前活动进程的Process
对象。 |
| GetProcesses()
| 这个静态方法返回在给定机器上运行的新的Process
对象的数组。 |
| Kill()
| 此方法会立即停止关联的进程。 |
| Start()
| 此方法启动一个进程。 |
枚举正在运行的进程
为了演示操作Process
对象的过程(原谅冗余),创建一个名为ProcessManipulator
的 C# 控制台应用项目,该项目在Program.cs
类中定义了以下静态帮助器方法(确保在代码文件中导入了System.Diagnostics
和System.Linq
名称空间):
static void ListAllRunningProcesses()
{
// Get all the processes on the local machine, ordered by
// PID.
var runningProcs =
from proc
in Process.GetProcesses(".")
orderby proc.Id
select proc;
// Print out PID and name of each process.
foreach(var p in runningProcs)
{
string info = $"-> PID: {p.Id}\tName: {p.ProcessName}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
静态的Process.GetProcesses()
方法返回一组Process
对象,这些对象代表目标机器上正在运行的进程(这里显示的点符号代表本地计算机)。在你获得了Process
对象的数组后,你可以调用表 14-2 和 14-3 中列出的任何成员。这里,您只是显示 PID 和每个进程的名称,按 PID 排序。按如下方式更新顶级语句:
using System;
using System.Diagnostics;
using System.Linq;
Console.WriteLine("***** Fun with Processes *****\n");
ListAllRunningProcesses();
Console.ReadLine();
当您运行该应用时,您将看到本地计算机上所有进程的名称和 PID。以下是我当前机器的部分输出(您的输出很可能会不同):
***** Fun with Processes *****
-> PID: 0 Name: Idle
-> PID: 4 Name: System
-> PID: 104 Name: Secure System
-> PID: 176 Name: Registry
-> PID: 908 Name: svchost
-> PID: 920 Name: smss
-> PID: 1016 Name: csrss
-> PID: 1020 Name: NVDisplay.Container
-> PID: 1104 Name: wininit
-> PID: 1112 Name: csrss
************************************
调查特定流程
除了获得给定机器上所有正在运行的进程的完整列表,静态Process.GetProcessById()
方法还允许您通过相关的 PID 获得单个Process
对象。如果您请求访问一个不存在的 PID,就会抛出一个ArgumentException
异常。例如,如果您有兴趣获得一个代表 PID 为 30592 的进程的Process
对象,您可以编写以下代码:
// If there is no process with the PID of 30592, a runtime exception will be thrown.
static void GetSpecificProcess()
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(30592);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
}
}
至此,您已经学会了如何通过 PID 查找获得所有进程的列表,以及机器上的特定进程。虽然发现 PID 和进程名有些用处,但是Process
类还允许您发现给定进程中使用的一组当前线程和库。让我们看看如何做到这一点。
调查进程的线程集
线程集由强类型的ProcessThreadCollection
集合表示,它包含一些单独的ProcessThread
对象。举例来说,将以下额外的静态 helper 函数添加到您当前的应用中:
static void EnumThreadsForPid(int pID)
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
// List out stats for each thread in the specified process.
Console.WriteLine(
"Here are the threads used by: {0}", theProc.ProcessName);
ProcessThreadCollection theThreads = theProc.Threads;
foreach(ProcessThread pt in theThreads)
{
string info =
$"-> Thread ID: {pt.Id}\tStart Time: {pt.StartTime.ToShortTimeString()}\tPriority: {pt.PriorityLevel}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
如您所见,System.Diagnostics.Process
类型的Threads
属性提供了对ProcessThreadCollection
类的访问。这里,您将打印客户机指定的进程中每个线程的分配线程 ID、开始时间和优先级。现在,更新程序的顶级语句,提示用户输入要调查的 PID,如下所示:
...
// Prompt user for a PID and print out the set of active threads.
Console.WriteLine("***** Enter PID of process to investigate *****");
Console.Write("PID: ");
string pID = Console.ReadLine();
int theProcID = int.Parse(pID);
EnumThreadsForPid(theProcID);
Console.ReadLine();
当您运行程序时,您现在可以输入机器上任何进程的 PID,并查看该进程中使用的线程。以下输出显示了我的计算机上 PID 3804 使用的线程的部分列表,该计算机恰好托管 Edge:
***** Enter PID of process to investigate *****
PID: 3804
Here are the threads used by: msedge
-> Thread ID: 3464 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 19420 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 17780 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 22380 Start Time: 01:20 PM Priority: Normal
-> Thread ID: 27580 Start Time: 01:20 PM Priority: -4
…
************************************
除了Id
、StartTime
和PriorityLevel
之外,ProcessThread
类型还有其他感兴趣的成员。表 14-4 记录了一些感兴趣的成员。
表 14-4。
选择进程线程类型的成员
|成员
|
生命的意义
|
| --- | --- |
| CurrentPriority
| 获取线程的当前优先级 |
| Id
| 获取线程的唯一标识符 |
| IdealProcessor
| 设置此线程运行的首选处理器 |
| PriorityLevel
| 获取或设置线程的优先级 |
| ProcessorAffinity
| 设置相关线程可以运行的处理器 |
| StartAddress
| 获取操作系统调用的启动此线程的函数的内存地址 |
| StartTime
| 获取操作系统启动线程的时间 |
| ThreadState
| 获取该线程的当前状态 |
| TotalProcessorTime
| 获取该线程使用处理器的总时间 |
| WaitReason
| 获取线程等待的原因 |
在您进一步阅读之前,请注意ProcessThread
类型是而不是,它是用于在。NET 核心平台。更确切地说,ProcessThread
是一种工具,用于获取正在运行的进程中的活动 Windows 线程的诊断信息。同样,您将在第十五章中研究如何使用System.Threading
名称空间构建多线程应用。
调查进程的模块集
接下来,让我们看看如何迭代给定进程中托管的已加载模块的数量。当谈到进程时,模块是一个通用术语,用于描述由特定进程托管的给定*.dll
(或*.exe
本身)。当您通过Process.Modules
属性访问ProcessModuleCollection
时,您可以通过枚举托管在一个进程中的所有模块。基于. NET Core、基于 COM 或传统的基于 C 的库。思考下面的附加帮助函数,它将基于 PID 枚举特定进程中的模块:
static void EnumModsForPid(int pID)
{
Process theProc = null;
try
{
theProc = Process.GetProcessById(pID);
}
catch(ArgumentException ex)
{
Console.WriteLine(ex.Message);
return;
}
Console.WriteLine("Here are the loaded modules for: {0}",
theProc.ProcessName);
ProcessModuleCollection theMods = theProc.Modules;
foreach(ProcessModule pm in theMods)
{
string info = $"-> Mod Name: {pm.ModuleName}";
Console.WriteLine(info);
}
Console.WriteLine("************************************\n");
}
为了查看一些可能的输出,让我们检查托管当前示例程序(ProcessManipulator
)的进程的已加载模块。为此,运行应用,识别分配给ProcessManipulator.exe
的 PID(通过任务管理器),并将该值传递给EnumModsForPid()
方法。一旦你这样做了,你可能会惊讶地看到一个简单的控制台应用项目使用的*.dll
列表(GDI32.dll
、USER32.dll
、ole32.dll
等)。).以下输出是加载的模块的部分列表(为简洁起见进行了编辑):
Here are (some of) the loaded modules for: ProcessManipulator
Here are the loaded modules for: ProcessManipulator
-> Mod Name: ProcessManipulator.exe
-> Mod Name: ntdll.dll
-> Mod Name: KERNEL32.DLL
-> Mod Name: KERNELBASE.dll
-> Mod Name: USER32.dll
-> Mod Name: win32u.dll
-> Mod Name: GDI32.dll
-> Mod Name: gdi32full.dll
-> Mod Name: msvcp_win.dll
-> Mod Name: ucrtbase.dll
-> Mod Name: SHELL32.dll
-> Mod Name: ADVAPI32.dll
-> Mod Name: msvcrt.dll
-> Mod Name: sechost.dll
-> Mod Name: RPCRT4.dll
-> Mod Name: IMM32.DLL
-> Mod Name: hostfxr.dll
-> Mod Name: hostpolicy.dll
-> Mod Name: coreclr.dll
-> Mod Name: ole32.dll
-> Mod Name: combase.dll
-> Mod Name: OLEAUT32.dll
-> Mod Name: bcryptPrimitives.dll
-> Mod Name: System.Private.CoreLib.dll
…
************************************
以编程方式启动和停止进程
这里考察的System.Diagnostics.Process
类的最后一个方面是Start()
和Kill()
方法。正如您可以通过它们的名称收集的那样,这些成员分别提供了一种以编程方式启动和终止进程的方法。例如,考虑下面的静态StartAndKillProcess()
helper 方法。
Note
根据操作系统的安全设置,您可能需要以管理员权限运行才能启动新进程。
static void StartAndKillProcess()
{
Process proc = null;
// Launch Edge, and go to Facebook!
try
{
proc = Process.Start(@"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe", "www.facebook.com");
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
Console.Write("--> Hit enter to kill {0}...",
proc.ProcessName);
Console.ReadLine();
// Kill all of the msedge.exe processes.
try
{
foreach (var p in Process.GetProcessesByName("MsEdge"))
{
p.Kill(true);
}
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
}
静态的Process.Start()
方法被重载了几次。至少,您需要指定想要启动的进程的路径和文件名。这个例子使用了一个Start()
方法的变体,它允许您指定任何额外的参数来传递到程序的入口点,在这个例子中是要加载的 web 页面。
在您调用了Start()
方法之后,您将返回一个对新激活的进程的引用。当您想要终止流程时,只需调用实例级的Kill()
方法。在本例中,由于 Microsoft Edge 启动了许多进程,因此您将循环终止所有已启动的进程。您还将对Start()
和Kill()
的调用包装在try
/ catch
块中,以处理任何InvalidOperationException
错误。这在调用Kill()
方法时尤其重要,因为如果在调用Kill()
之前进程已经终止,就会出现这个错误。
Note
使用时。NET Framework(之前的版本。NET Core),Process.Start()
方法允许启动进程的完整路径和文件名或操作系统快捷方式(例如,msedge
)。和。NET 核心和跨平台支持,您必须指定完整的路径和文件名。可以使用ProcessStartInfo
来利用操作系统关联,这将在接下来的两节中介绍。
使用 ProcessStartInfo 类控制进程启动
Process.Start()
方法还允许您传入一个System.Diagnostics.ProcessStartInfo
类型来指定关于给定流程应该如何运行的附加信息。下面是ProcessStartInfo
的部分定义(参见文档了解全部细节):
public sealed class ProcessStartInfo : object
{
public ProcessStartInfo();
public ProcessStartInfo(string fileName);
public ProcessStartInfo(string fileName, string arguments);
public string Arguments { get; set; }
public bool CreateNoWindow { get; set; }
public StringDictionary EnvironmentVariables { get; }
public bool ErrorDialog { get; set; }
public IntPtr ErrorDialogParentHandle { get; set; }
public string FileName { get; set; }
public bool LoadUserProfile { get; set; }
public SecureString Password { get; set; }
public bool RedirectStandardError { get; set; }
public bool RedirectStandardInput { get; set; }
public bool RedirectStandardOutput { get; set; }
public Encoding StandardErrorEncoding { get; set; }
public Encoding StandardOutputEncoding { get; set; }
public bool UseShellExecute { get; set; }
public string Verb { get; set; }
public string[] Verbs { get; }
public ProcessWindowStyle WindowStyle { get; set; }
public string WorkingDirectory { get; set; }
}
为了说明如何微调您的流程启动,下面是一个修改后的StartAndKillProcess()
版本,它将加载 Microsoft Edge 并导航到 www.facebook.com
,使用 windows 关联MsEdge
:
static void StartAndKillProcess()
{
Process proc = null;
// Launch Microsoft Edge, and go to Facebook, with maximized window.
try
{
ProcessStartInfo startInfo = new
ProcessStartInfo("MsEdge", "www.facebook.com");
startInfo.UseShellExecute = true;
proc = Process.Start(startInfo);
}
catch (InvalidOperationException ex)
{
Console.WriteLine(ex.Message);
}
...
}
英寸 NET Core 中,UseShellExecute
属性默认为 false,而在以前版本的。NET 中,UseShellExecute
属性默认为 true。这就是上一版本流程的原因。如果不使用ProcessStartInfo
并将UseShellExecute
属性设置为 true,这里显示的 Start()将不再工作:
Process.Start("msedge")
通过 ProcessStartInfo 利用操作系统动词
除了使用操作系统快捷方式启动应用,您还可以利用与ProcessStartInfo
的文件关联。在 Windows 上,如果右键单击 Word 文档,会出现编辑或打印文档的选项。让我们使用ProcessStartInfo
来确定可用的动词,然后使用它们来操纵流程。
使用以下代码创建一个新方法:
static void UseApplicationVerbs()
{
int i = 0;
//adjust this path and name to a document on your machine
ProcessStartInfo si =
new ProcessStartInfo(@"..\TestPage.docx");
foreach (var verb in si.Verbs)
{
Console.WriteLine($" {i++}. {verb}");
}
si.WindowStyle = ProcessWindowStyle.Maximized;
si.Verb = "Edit";
si.UseShellExecute = true;
Process.Start(si);
}
运行此代码时,第一部分打印出 Word 文档的所有可用动词,如下所示:
***** Fun with Processes *****
0\. Edit
1\. OnenotePrintto
2\. Open
3\. OpenAsReadOnly
4\. Print
5\. Printto
6\. ViewProtected
将WindowStyle
设置为最大化后,动词被设置为Edit
,这将在编辑模式下打开文档。如果将动词设置为Print
,文档将被直接发送到打印机。
现在,您已经了解了 Windows 进程的作用以及如何从 C# 代码中与它们进行交互,您已经准备好研究. NET 应用域的概念了。
Note
应用运行的目录取决于您如何运行示例应用。如果使用 CLI 命令dotnet run
,当前目录与项目文件所在的目录相同。如果使用的是 Visual Studio,当前目录将是编译后的程序集的目录,也就是.\bin\debug\net5.0
。您需要相应地调整 Word 文档的路径。
理解。NET 应用域
在下面。NET 和。NET 核心平台中,可执行文件不像传统的非托管应用那样直接驻留在 Windows 进程中。更确切地说。NET 和。NET 核心可执行文件由一个名为应用域的进程中的一个逻辑分区托管。传统 Windows 进程的这种划分提供了几个好处,其中一些如下:
-
AppDomains 是。NET 核心平台,因为这种逻辑划分抽象出了底层操作系统如何表示加载的可执行文件的差异。
-
就处理能力和内存而言,AppDomains 远比成熟的进程便宜。因此,CoreCLR 可以比正式进程更快地加载和卸载应用域,并且可以极大地提高服务器应用的可伸缩性。
appdomain 与进程中的其他 appdomain 完全隔离。鉴于这一事实,请注意,在一个 AppDomain 中运行的应用无法在另一个 AppDomain 中获得任何类型的数据(全局变量或静态字段),除非它们使用分布式编程协议。
Note
对 AppDomains 的支持在中有所更改。NET 核心。英寸 NET Core,正好有一个 AppDomain。不再支持创建新的 AppDomains,因为它们需要运行时支持,并且创建起来通常很昂贵。ApplicationLoadContext
(本章稍后介绍)在中提供组件隔离。NET 核心。
系统。AppDomain 类
AppDomain
类在很大程度上被弃用。NET 核心。而剩下的大部分支持都是为了从。NET 4.x 到。NET Core 更容易,剩下的特性仍然可以提供价值,这将在接下来的两节中介绍。
与默认应用域交互
您的应用可以使用静态的AppDomain.CurrentDomain
属性访问默认的应用域。有了这个访问点之后,您可以使用AppDomain
的方法和属性来执行一些运行时诊断。
要了解如何与默认应用域交互,首先创建一个名为DefaultAppDomainApp
的新控制台应用项目。现在,用下面的逻辑更新您的Program.cs
类,这将使用AppDomain
类的一些成员简单地显示关于默认应用域的一些细节:
using System;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
Console.WriteLine("***** Fun with the default AppDomain *****\n");
DisplayDADStats();
Console.ReadLine();
static void DisplayDADStats()
{
// Get access to the AppDomain for the current thread.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Print out various stats about this domain.
Console.WriteLine("Name of this domain: {0}",
defaultAD.FriendlyName);
Console.WriteLine("ID of domain in this process: {0}",
defaultAD.Id);
Console.WriteLine("Is this the default domain?: {0}",
defaultAD.IsDefaultAppDomain());
Console.WriteLine("Base directory of this domain: {0}",
defaultAD.BaseDirectory);
Console.WriteLine("Setup Information for this domain:");
Console.WriteLine("\t Application Base: {0}",
defaultAD.SetupInformation.ApplicationBase);
Console.WriteLine("\t Target Framework: {0}",
defaultAD.SetupInformation.TargetFrameworkName);
}
此示例的输出如下所示:
***** Fun with the default AppDomain *****
Name of this domain: DefaultAppDomainApp
ID of domain in this process: 1
Is this the default domain?: True
Base directory of this domain: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net5.0\
Setup Information for this domain:
Application Base: C:\GitHub\Books\csharp8-wf\Code\Chapter_14\DefaultAppDomainApp\DefaultAppDomainApp\bin\Debug\net5.0\
Target Framework: .NETCoreApp,Version=v5.0
请注意,默认应用域的名称将与其中包含的可执行文件的名称相同(在本例中为DefaultAppDomainApp.exe
)。另请注意,将用于探测外部所需私有程序集的基目录值映射到已部署的可执行文件的当前位置。
枚举加载的程序集
也有可能发现所有加载的。NET 核心程序集在给定的应用域中使用实例级的GetAssemblies()
方法。这个方法将返回给你一个Assembly
对象的数组(在第十七章中涉及)。为此,您必须将System.Reflection
名称空间添加到您的代码文件中(正如您在本节前面所做的)。
举例来说,在Program
类中定义一个名为ListAllAssembliesInAppDomain()
的新方法。这个帮助器方法将获取所有加载的程序集,并打印每个程序集的友好名称和版本。
static void ListAllAssembliesInAppDomain()
{
// Get access to the AppDomain for the current thread.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Now get all loaded assemblies in the default AppDomain.
Assembly[] loadedAssemblies = defaultAD.GetAssemblies();
Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n",
defaultAD.FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}
假设您已经更新了顶级语句来调用这个新成员,您将会看到承载您的可执行文件的应用域当前正在使用。NET 核心库:
***** Here are the assemblies loaded in DefaultAppDomainApp *****
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Threading:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0
现在要明白,当您编写新的 C# 代码时,加载的程序集列表可能会随时改变。例如,假设您已经更新了您的ListAllAssembliesInAppDomain()
方法以利用 LINQ 查询,该查询将按名称对加载的程序集进行排序,如下所示:
using System.Linq;
static void ListAllAssembliesInAppDomain()
{
// Get access to the AppDomain for the current thread.
AppDomain defaultAD = AppDomain.CurrentDomain;
// Now get all loaded assemblies in the default AppDomain.
var loadedAssemblies =
defaultAD.GetAssemblies().OrderBy(x=>x.GetName().Name);
Console.WriteLine("***** Here are the assemblies loaded in {0} *****\n", defaultAD.FriendlyName);
foreach(Assembly a in loadedAssemblies)
{
Console.WriteLine($"-> Name, Version: {a.GetName().Name}:{a.GetName().Version}" );
}
}
如果您再次运行该程序,您将会看到System.Linq.dll
也被加载到内存中。
** Here are the assemblies loaded in DefaultAppDomainApp **
-> Name, Version: DefaultAppDomainApp:1.0.0.0
-> Name, Version: System.Console:5.0.0.0
-> Name, Version: System.Linq:5.0.0.0
-> Name, Version: System.Private.CoreLib:5.0.0.0
-> Name, Version: System.Runtime:5.0.0.0
-> Name, Version: System.Text.Encoding.Extensions:5.0.0.0
-> Name, Version: System.Threading:5.0.0
具有应用加载上下文的程序集隔离
正如您刚才看到的,AppDomains 是用于托管的逻辑分区。NET 核心程序集。此外,应用域可以进一步细分为多个加载上下文边界。从概念上讲,加载上下文创建了加载、解析和可能卸载一组程序集的范围。简而言之,. NET 核心加载上下文为单个 AppDomain 提供了一种为给定对象建立“特定主目录”的方式。
Note
虽然理解过程和应用域非常重要,但是大多数。NET 核心应用永远不会要求您使用对象上下文。我已经包括了这个概述材料,只是为了描绘一个更完整的画面。
AssemblyLoadContext
类提供了将附加程序集加载到它们自己的上下文中的能力。为了进行演示,首先添加一个名为ClassLibary1
的类库项目,并将其添加到您当前的解决方案中。使用。NET Core CLI,在包含当前解决方案的目录中执行以下命令:
dotnet new classlib -lang c# -n ClassLibrary1 -o .\ClassLibrary1 -f net5.0
dotnet sln .\Chapter14_AllProjects.sln add .\ClassLibrary1
接下来,通过执行以下 CLI 命令,从DefaultAppDomainApp
添加对 ClassLibrary1 项目的引用:
dotnet add DefaultAppDomainApp reference ClassLibrary1
如果使用的是 Visual Studio,请在解决方案资源管理器中右击解决方案节点,选择“添加➤新项目”,然后添加一个名为 ClassLibrary1 的. NET 核心类库。这将创建项目并将其添加到您的解决方案中。接下来,通过右键单击 DefaultAppDomainApp 项目并选择“添加➤引用”来添加对此新项目的引用。选择左边栏中的项目➤解决方案选项,并选中类库 1 复选框,如图 14-3 所示。
图 14-3。
在 Visual Studio 中添加项目引用
在这个新类库中,添加一个Car
类,如下所示:
namespace ClassLibrary1
{
public class Car
{
public string PetName { get; set; }
public string Make { get; set; }
public int Speed { get; set; }
}
}
有了这个新的程序集,添加下面的using
语句:
using System.IO;
using System.Runtime.Loader;
您将添加的下一个方法需要已经在Program.cs
中添加的System.IO
和System.Runtime.Loader using
语句。该方法如下所示:
static void LoadAdditionalAssembliesDifferentContexts()
{
var path =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll");
AssemblyLoadContext lc1 =
new AssemblyLoadContext("NewContext1",false);
var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car");
AssemblyLoadContext lc2 =
new AssemblyLoadContext("NewContext2",false);
var cl2 = lc2.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine("*** Loading Additional Assemblies in Different Contexts ***");
Console.WriteLine($"Assembly1 Equals(Assembly2) {cl1.Equals(cl2)}");
Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}
第一行使用静态的Path.Combine
方法为ClassLibrary1
组件构建目录。
Note
您可能想知道为什么要为动态加载的程序集创建引用。这是为了确保当项目构建时,ClassLibrary1
程序集也构建,并且与DefaultAppDomainApp
在同一目录中。这仅仅是为了这个例子的方便。不需要引用将动态加载的程序集。
接下来,代码创建一个名为NewContext1
(方法的第一个参数)的新AssemblyLoadContext
,并且不支持卸载(第二个参数)。这个LoadContext
用于加载ClassLibrary1
程序集,然后创建一个Car
类的实例。如果其中一些代码对你来说是新的,我们会在第十九章对其进行更全面的解释。使用新的AssemblyLoadContext
重复该过程,然后比较程序集和类的相等性。当您运行这个新方法时,您将看到以下输出:
*** Loading Additional Assemblies in Different Contexts ***
Assembly1 Equals(Assembly2) False
Assembly1 == Assembly2 False
Class1.Equals(Class2) False
Class1 == Class2 False
这表明同一个程序集已被加载到应用域中两次。正如所料,类也是不同的。
接下来,添加一个新方法,它将从同一个AssemblyLoadContext
加载程序集。
static void LoadAdditionalAssembliesSameContext()
{
var path =
Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
"ClassLibrary1.dll");
AssemblyLoadContext lc1 =
new AssemblyLoadContext(null,false);
var cl1 = lc1.LoadFromAssemblyPath(path);
var c1 = cl1.CreateInstance("ClassLibrary1.Car");
var cl2 = lc1.LoadFromAssemblyPath(path);
var c2 = cl2.CreateInstance("ClassLibrary1.Car");
Console.WriteLine("*** Loading Additional Assemblies in Same Context ***");
Console.WriteLine($"Assembly1.Equals(Assembly2) {cl1.Equals(cl2)}");
Console.WriteLine($"Assembly1 == Assembly2 {cl1 == cl2}");
Console.WriteLine($"Class1.Equals(Class2) {c1.Equals(c2)}");
Console.WriteLine($"Class1 == Class2 {c1 == c2}");
}
这段代码的主要区别是只创建了一个AssemblyLoadContext
。现在,当ClassLibrary1
程序集被加载两次时,第二个程序集只是一个指向第一个程序集实例的指针。运行代码会产生以下输出:
*** Loading Additional Assemblies in Same Context ***
Assembly1.Equals(Assembly2) True
Assembly1 == Assembly2 True
Class1.Equals(Class2) False
Class1 == Class2 False
汇总流程、AppDomains 和加载上下文
至此,您应该对运行库如何承载. NET 核心程序集有了更好的了解。如果前几页对你来说有点太低级了,不用担心。在很大程度上。NET Core 代表您自动处理进程、应用域和负载上下文的细节。不过,好消息是这些信息为理解。NET 核心平台。
摘要
本章的重点是研究. NET 核心应用是如何由?NET 核心平台。正如您所看到的,长期存在的 Windows 进程的概念已经被改变,以适应 CoreCLR 的需要。单个流程(可以通过System.Diagnostics.Process
类型以编程方式操作)现在由一个应用域组成,它代表流程中隔离和独立的边界。
应用域能够承载和执行任意数量的相关程序集。此外,单个应用域可以包含任意数量的加载上下文,用于进一步的程序集隔离。使用这种额外级别的类型隔离,CoreCLR 可以确保特殊需要的对象得到正确处理。
十五、多线程、并行和异步编程
没有人喜欢使用在执行过程中反应迟钝的应用。此外,没有人喜欢在一个应用中启动一个任务(可能是通过点击一个工具栏项启动的),这个任务会阻止程序的其他部分尽可能地响应。在发布之前。网(和。NET Core),构建能够执行多项任务的应用通常需要编写使用 Windows 线程 API 的复杂 C++代码。谢天谢地。NET/。NET Core platform 为您提供了多种方法来构建软件,这些软件可以在独特的执行路径上执行复杂的操作,而棘手的问题却少得多。
本章首先定义了“多线程应用”的整体性质接下来,将向您介绍从开始提供的原始线程名称空间。NET 1.0,具体是System.Threading
。在这里,你将考察众多类型(Thread
、ThreadStart
等)。)允许您显式地创建额外的执行线程并同步您的共享资源,这有助于确保多个线程能够以非易失的方式共享数据。
本章的其余部分将研究三种最新的技术。NET 核心开发人员可以使用来构建多线程软件,特别是任务并行库(TPL)、并行 LINQ (PLINQ)以及相对较新的(从 C# 6 开始)C# 固有异步关键字(async
和await
)。正如您将看到的,这些特性可以极大地简化您构建响应性多线程软件应用的方式。
进程/AppDomain/上下文/线程关系
在第十四章中,线程被定义为可执行应用中的执行路径。虽然很多人。NET 核心应用可以过着快乐而高效的单线程生活,程序集的主线程(当应用的入口点执行时由运行时产生)可以随时创建执行的辅助线程来执行额外的工作单元。通过创建额外的线程,您可以构建响应速度更快(但不一定在单核机器上执行得更快)的应用。
名称空间System.Threading
是随。NET 1.0 并提供了一种构建多线程应用的方法。Thread
类可能是核心类型,因为它代表一个给定的线程。如果您想以编程方式获取对当前执行给定成员的线程的引用,只需调用静态的Thread.CurrentThread
属性,如下所示:
static void ExtractExecutingThread()
{
// Get the thread currently
// executing this method.
Thread currThread = Thread.CurrentThread;
}
回想一下。NET 核心,只有一个 AppDomain。即使不能创建额外的 AppDomain,一个应用的 AppDomain 在任何给定时间都可以有许多线程在其中执行。要获取对托管应用的 AppDomain 的引用,请调用静态的Thread.GetDomain()
方法,如下所示:
static void ExtractAppDomainHostingThread()
{
// Obtain the AppDomain hosting the current thread.
AppDomain ad = Thread.GetDomain();
}
单个线程也可以在任何给定的时间被移动到一个执行上下文中,并且可以在。NET 核心运行时。当您想要获得一个线程正在其中执行的当前执行上下文时,使用静态的Thread.CurrentThread.ExecutionContext
属性,如下所示:
static void ExtractCurrentThreadExecutionContext()
{
// Obtain the execution context under which the
// current thread is operating.
ExecutionContext ctx =
Thread.CurrentThread.ExecutionContext;
}
再说一遍。NET Core Runtime 监督将线程移入(和移出)执行上下文。作为一名. NET 核心开发人员,您通常可以幸福地保持不知道给定线程的结束位置。然而,您应该知道获得底层原语的各种方法。
并发的问题
多线程编程的众多“乐趣”(也就是痛苦的方面)之一是,您几乎无法控制底层操作系统或运行时如何使用它的线程。例如,如果您创建了一个新的执行线程的代码块,您不能保证该线程立即执行。相反,这些代码只指示操作系统/运行时尽快执行线程(通常是在线程调度程序开始执行时)。
此外,鉴于线程可以根据运行时的需要在应用和上下文边界之间移动,您必须注意应用的哪些方面是线程易变的(例如,受多线程访问的影响),哪些操作是原子的(线程易变的操作是危险的!).
为了说明这个问题,假设一个线程正在调用一个特定对象的方法。现在假设线程调度器指示该线程暂停其活动,以允许另一个线程访问同一对象的同一方法。
如果原始线程没有完成其操作,则第二个传入线程可能正在查看处于部分修改状态的对象。在这一点上,第二个线程基本上是在读取假数据,这肯定会让位于极其奇怪(并且难以发现)的错误,这些错误甚至更难复制和调试。
另一方面,原子操作在多线程环境中总是安全的。可悲的是,在美国几乎没有什么行动。NET 核心基本类库,保证是原子的。甚至给成员变量赋值的行为也不是原子的!除非。NET 核心文档明确指出操作是原子性的,您必须假设它是线程易变的,并采取预防措施。
线程同步的作用
在这一点上,应该清楚多线程程序本身是非常不稳定的,因为许多线程可以同时(或多或少)在共享资源上操作。为了保护应用的资源免受可能的损坏。NET 核心开发人员必须使用任意数量的线程原语(比如锁、监视器和[Synchronization]
属性或语言关键字支持)来控制执行线程之间的访问。
虽然。NET Core 平台并不能使构建健壮的多线程应用的困难完全消失,这个过程已经大大简化了。使用在System.Threading
名称空间、任务并行库以及 C# async
和await
语言关键字中定义的类型,您可以以最少的麻烦和麻烦处理多线程。
在深入到System.Threading
名称空间、TPL、C# async
和await
关键字之前,您将首先检查。NET 核心委托类型可用于以异步方式调用方法。虽然可以肯定的是。NET 4.6 新的 C# async
和await
关键字为异步委托提供了一个更简单的替代方法,知道如何使用这种方法与代码交互仍然很重要(相信我,生产中有大量代码使用异步委托)。
系统。线程命名空间
在下面。NET 和。NET 核心平台中,System.Threading
命名空间提供了支持直接构建多线程应用的类型。除了提供允许您与. NET 核心运行时线程交互的类型之外,此命名空间还定义了允许访问。NET Core 运行时维护的线程池,一个简单的(非基于 GUI 的)Timer
类,以及许多用于提供对共享资源的同步访问的类型。表 15-1 列出了这个名称空间的一些重要成员。(请务必查阅。NET Core SDK 文档以获得完整的详细信息。)
表 15-1。
系统的核心类型。线程命名空间
|类型
|
生命的意义
|
| --- | --- |
| Interlocked
| 这种类型为由多个线程共享的变量提供原子操作。 |
| Monitor
| 这种类型使用锁和等待/信号来提供线程对象的同步。C# lock
关键字使用了一个Monitor
对象。 |
| Mutex
| 这个同步原语可用于应用域边界之间的同步。 |
| ParameterizedThreadStart
| 此委托允许线程调用接受任意数量参数的方法。 |
| Semaphore
| 这种类型允许您限制可以并发访问资源的线程数量。 |
| Thread
| 此类型表示在中执行的线程。NET 核心运行时。使用这种类型,您可以在原始 AppDomain 中生成额外的线程。 |
| ThreadPool
| 此类型允许您与。NET 核心运行时——给定进程中维护的线程池。 |
| ThreadPriority
| 该枚举表示线程的优先级(Highest
、Normal
等)。). |
| ThreadStart
| 此委托用于指定给定线程要调用的方法。与ParameterizedThreadStart
委托不同,ThreadStart
的目标必须总是有相同的原型。 |
| ThreadState
| 该枚举指定了线程可能采用的有效状态(Running
、Aborted
等)。). |
| Timer
| 这种类型提供了一种以指定间隔执行方法的机制。 |
| TimerCallback
| 该委托类型与Timer
类型一起使用。 |
系统。线程.线程类
在System.Threading
名称空间的所有类型中,最原始的是Thread
。此类表示 AppDomain 中给定执行路径周围的面向对象包装。此类型还定义了几个方法(静态和实例级),允许您在当前 AppDomain 中创建新线程,以及挂起、停止和销毁线程。考虑表 15-2 中的关键静态成员列表。
表 15-2。
线程类型的关键静态成员
|静态构件
|
生命的意义
|
| --- | --- |
| ExecutionContext
| 此只读属性返回与执行的逻辑线程相关的信息,包括安全性、调用、同步、本地化和事务上下文。 |
| CurrentThread
| 此只读属性返回对当前运行线程的引用。 |
| Sleep()
| 此方法将当前线程挂起一段指定的时间。 |
Thread
类还支持几个实例级成员,其中一些如表 15-3 所示。
表 15-3。
选择线程类型的实例级成员
|实例级成员
|
生命的意义
|
| --- | --- |
| IsAlive
| 返回一个 Boolean 值,指示该线程是否已启动(尚未终止或中止)。 |
| IsBackground
| 获取或设置一个值,该值指示此线程是否为“后台线程”(稍后将提供更多详细信息)。 |
| Name
| 允许您建立线程的友好文本名称。 |
| Priority
| 获取或设置线程的优先级,可以从ThreadPriority
枚举中为其赋值。 |
| ThreadState
| 获取该线程的状态,可以从ThreadState
枚举中为其赋值。 |
| Abort()
| 指示。NET Core 运行时尽快终止线程。 |
| Interrupt()
| 从合适的等待周期中断(例如唤醒)当前线程。 |
| Join()
| 阻塞调用线程,直到指定的线程(调用Join()
的线程)退出。 |
| Resume()
| 恢复先前挂起的线程。 |
| Start()
| 指示。NET 核心运行时尽快执行线程。 |
| Suspend()
| 挂起线程。如果线程已经被挂起,调用Suspend()
没有任何效果。 |
Note
中止或挂起一个活动线程通常被认为是一个坏主意。当您这样做时,线程在受到干扰或终止时有可能会“泄漏”其工作负载(尽管可能性很小)。
获取当前执行线程的统计信息
回想一下,可执行程序集的入口点(即顶级语句或Main()
方法)运行在执行的主线程上。为了说明Thread
类型的基本用法,假设您有一个名为 ThreadStats 的新控制台应用项目。如您所知,静态的Thread.CurrentThread
属性检索一个代表当前执行线程的Thread
对象。一旦获得了当前线程,就可以打印出各种统计数据,如下所示:
// Be sure to import the System.Threading namespace.
using System;
using System.Threading;
Console.WriteLine("***** Primary Thread stats *****\n");
// Obtain and name the current thread.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "ThePrimaryThread";
// Print out some stats about this thread.
Console.WriteLine("ID of current thread: {0}",
primaryThread.ManagedThreadId);
Console.WriteLine("Thread Name: {0}",
primaryThread.Name);
Console.WriteLine("Has thread started?: {0}",
primaryThread.IsAlive);
Console.WriteLine("Priority Level: {0}",
primaryThread.Priority);
Console.WriteLine("Thread State: {0}",
primaryThread.ThreadState);
Console.ReadLine();
以下是当前输出:
***** Primary Thread stats *****
ID of current thread: 1
Thread Name: ThePrimaryThread
Has thread started?: True
Priority Level: Normal
Thread State: Running
名称属性
注意,Thread
类支持一个名为Name
的属性。如果不设置这个值,Name
将返回一个空的string
。然而,一旦你给一个给定的Thread
对象分配了一个友好的字符串名字,你就可以大大简化你的调试工作。如果您使用的是 Visual Studio,则可以在调试会话期间访问“线程”窗口(在程序运行时选择“调试➤ Windows ➤线程”)。从图 15-1 可以看出,可以快速识别出想要诊断的线程。
图 15-1。
使用 Visual Studio 调试线程
优先属性
接下来,请注意,Thread
类型定义了一个名为Priority
的属性。默认情况下,所有线程的优先级都是Normal
。然而,你可以在线程生命周期的任何时候使用Priority
属性和相关的System.Threading.ThreadPriority
枚举来改变它,就像这样:
public enum ThreadPriority
{
Lowest,
BelowNormal,
Normal, // Default value.
AboveNormal,
Highest
}
如果你要给一个线程的优先级指定一个不同于默认值(ThreadPriority.Normal
)的值,要知道你不能直接控制线程调度器何时在线程间切换。线程的优先级为。NET 核心运行时关于线程活动的重要性。因此,具有值ThreadPriority.Highest
的线程不一定保证被给予最高优先级。
同样,如果线程调度器全神贯注于给定的任务(例如,同步对象、切换线程或移动线程),则优先级很可能会相应地改变。然而,所有的事情都是平等的。NET Core Runtime 将读取这些值,并指示线程调度程序如何最好地分配时间片。具有相同线程优先级的每个线程应该获得相同的时间来执行它们的工作。
在大多数情况下,您很少(如果有的话)需要直接改变线程的优先级。理论上,可以提高一组线程的优先级,从而阻止优先级较低的线程在它们需要的级别上执行(所以要小心)。
手动创建辅助线程
当您想要以编程方式创建额外的线程来执行某个工作单元时,请在使用System.Threading
名称空间的类型时遵循这个可预测的过程:
-
创建一个方法作为新线程的入口点。
-
创建一个新的
ParameterizedThreadStart
(或ThreadStart
)委托,将步骤 1 中定义的方法的地址传递给构造函数。 -
创建一个
Thread
对象,将ParameterizedThreadStart/ThreadStart
委托作为构造函数参数传递。 -
建立任何初始线程特征(名称、优先级等。).
-
调用
Thread.Start()
方法。这将尽快在步骤 2 中创建的委托所引用的方法处启动线程。
如步骤 2 所述,您可以使用两种不同的委托类型来“指向”辅助线程将执行的方法。委托可以指向任何不带参数且不返回任何内容的方法。当方法被设计为只在后台运行而不需要进一步交互时,此委托会很有帮助。
ThreadStart
的限制是不能传入参数进行处理。然而,ParameterizedThreadStart
委托类型允许类型为System.Object
的单个参数。鉴于任何东西都可以表示为一个System.Object
,您可以通过一个定制的类或结构传入任意数量的参数。但是,请注意,ThreadStart
和ParameterizedThreadStart
委托只能指向返回void
的方法。
使用 ThreadStart 委托
为了说明构建多线程应用的过程(以及演示这样做的有用性),假设您有一个名为 SimpleMultiThreadApp 的控制台应用项目,该项目允许最终用户选择应用是使用单个主线程来执行其任务,还是使用两个单独的执行线程来分担其工作负载。
假设您已经导入了System.Threading
名称空间,您的第一步是定义一个方法来执行(可能的)辅助线程的工作。为了将重点放在构建多线程程序的机制上,该方法将简单地在控制台窗口中打印出一系列数字,每次大约暂停两秒钟。下面是Printer
类的完整定义:
using System;
using System.Threading;
namespace SimpleMultiThreadApp
{
public class Printer
{
public void PrintNumbers()
{
// Display Thread info.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Print out numbers.
Console.Write("Your numbers: ");
for(int i = 0; i < 10; i++)
{
Console.Write("{0}, ", i);
Thread.Sleep(2000);
}
Console.WriteLine();
}
}
}
现在,在Program.cs
中,您将添加顶级语句,首先提示用户确定是使用一个还是两个线程来执行应用的工作。如果用户请求单个线程,您只需在主线程中调用PrintNumbers()
方法。但是,如果用户指定了两个线程,您将创建一个指向PrintNumbers()
的ThreadStart
委托,将这个委托对象传递给一个新的Thread
对象的构造函数,并调用Start()
来通知。此线程已准备好进行处理。下面是完整的实现:
using System;
using System.Threading;
using SimpleMultiThreadApp;
Console.WriteLine("***** The Amazing Thread App *****\n");
Console.Write("Do you want [1] or [2] threads? ");
string threadCount = Console.ReadLine();
// Name the current thread.
Thread primaryThread = Thread.CurrentThread;
primaryThread.Name = "Primary";
// Display Thread info.
Console.WriteLine("-> {0} is executing Main()",
Thread.CurrentThread.Name);
// Make worker class.
Printer p = new Printer();
switch(threadCount)
{
case "2":
// Now make the thread.
Thread backgroundThread =
new Thread(new ThreadStart(p.PrintNumbers));
backgroundThread.Name = "Secondary";
backgroundThread.Start();
break;
case "1":
p.PrintNumbers();
break;
default:
Console.WriteLine("I don't know what you want...you get 1 thread.");
goto case "1";
}
// Do some additional work.
Console.WriteLine("This is on the main thread, and we are finished.");
Console.ReadLine();
现在,如果您用单线程运行这个程序,您会发现最终的消息框不会显示消息,直到整个数字序列打印到控制台。由于在打印每个数字后,您会明显地暂停大约两秒钟,这将导致不太好的最终用户体验。但是,如果您选择两个线程,消息框会立即显示,因为有一个唯一的Thread
对象负责将数字打印到控制台。
使用 ParameterizedThreadStart 委托
回想一下,ThreadStart
委托只能指向返回void
且不带参数的方法。虽然在某些情况下这可能符合要求,但是如果您想将数据传递给在辅助线程上执行的方法,您将需要使用ParameterizedThreadStart
委托类型。举例来说,创建一个名为 AddWithThreads 的新控制台应用项目,并导入System.Threading
名称空间。现在,假设ParameterizedThreadStart
可以指向任何带System.Object
参数的方法,您将创建一个包含要添加的数字的自定义类型,如下所示:
namespace AddWithThreads
{
class AddParams
{
public int a, b;
public AddParams(int numb1, int numb2)
{
a = numb1;
b = numb2;
}
}
}
接下来,在Program
类中创建一个方法,该方法将接受一个AddParams
参数并打印所涉及的两个数字的和,如下所示:
void Add(object data)
{
if (data is AddParams ap)
{
Console.WriteLine("ID of thread in Add(): {0}",
Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("{0} + {1} is {2}",
ap.a, ap.b, ap.a + ap.b);
}
}
Program.cs
中的代码很简单。简单地使用ParameterizedThreadStart
而不是ThreadStart
,就像这样:
using System;
using System.Threading;
using AddWithThreads;
Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
Thread.CurrentThread.ManagedThreadId);
// Make an AddParams object to pass to the secondary thread.
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);
// Force a wait to let other thread finish.
Thread.Sleep(5);
Console.ReadLine();
AutoResetEvent 类
在前面的几个例子中,没有一种明确的方法来知道辅助线程何时完成了它的工作。在最后一个例子中,Sleep
在任意时间被调用,以让另一个线程完成。一种简单且线程安全的强制线程等待另一个线程完成的方法是使用AutoResetEvent
类。在需要等待的线程中,创建这个类的一个实例,并将false
传递给构造函数,以表示您还没有得到通知。然后,在你愿意等待的点上,调用WaitOne()
方法。下面是对Program.cs
类的更新,它将使用静态级别的AutoResetEvent
成员变量来做这件事:
AutoResetEvent _waitHandle = new AutoResetEvent(false);
Console.WriteLine("***** Adding with Thread objects *****");
Console.WriteLine("ID of thread in Main(): {0}",
Thread.CurrentThread.ManagedThreadId);
AddParams ap = new AddParams(10, 10);
Thread t = new Thread(new ParameterizedThreadStart(Add));
t.Start(ap);
// Wait here until you are notified!
_waitHandle.WaitOne();
Console.WriteLine("Other thread is done!");
Console.ReadLine();
...
当另一个线程完成其工作负载时,它将在同一个AutoResetEvent
类型的实例上调用Set()
方法。
void Add(object data)
{
if (data is AddParams ap)
{
Console.WriteLine("ID of thread in Add(): {0}",
Thread.CurrentThread.ManagedThreadId);
Console.WriteLine("{0} + {1} is {2}",
ap.a, ap.b, ap.a + ap.b);
// Tell other thread we are done.
_waitHandle.Set();
}
}
前台线程和后台线程
既然您已经看到了如何使用System.Threading
名称空间以编程方式创建新的执行线程,那么让我们来正式区分前台线程和后台线程:
-
前台线程可以阻止当前应用终止。那个。在所有前台线程结束之前,NET Core Runtime 不会关闭应用(也就是说,卸载宿主 AppDomain)。
-
后台线程(有时称为后台线程)被。NET 核心运行时作为可消耗的执行路径,可以在任何时间点被忽略(即使它们当前正在某个工作单元上工作)。因此,如果所有前台线程都已终止,当应用域卸载时,所有后台线程都会自动终止。
值得注意的是,前台和后台线程与主线程和工作线程是不同的。默认情况下,通过Thread.Start()
方法创建的每个线程都自动成为前台线程。同样,这意味着 AppDomain 不会卸载,直到所有执行线程都完成了它们的工作单元。在大多数情况下,这正是您需要的行为。
然而,为了便于讨论,假设您想要在一个应该作为后台线程的辅助线程上调用Printer.PrintNumbers()
。同样,这意味着由Thread
类型(通过ThreadStart
或ParameterizedThreadStart
委托)指向的方法应该能够在所有前台线程完成它们的工作后安全地暂停。配置这样一个线程就像将IsBackground
属性设置为true
一样简单,就像这样:
Console.WriteLine("***** Background Threads *****\n");
Printer p = new Printer();
Thread bgroundThread =
new Thread(new ThreadStart(p.PrintNumbers));
// This is now a background thread.
bgroundThread.IsBackground = true;
bgroundThread.Start();
注意这个code
是而不是调用Console.ReadLine()
来强制控制台保持可见直到你按下回车键。因此,当您运行应用时,它会立即关闭,因为Thread
对象已经被配置为后台线程。假设进入应用的入口点(这里显示的顶级语句或Main()
方法)触发主前台线程的创建,一旦入口点中的逻辑完成,AppDomain 就会在辅助线程完成其工作之前卸载。
但是,如果您注释掉设置IsBackground
属性的行,您会发现每个数字都会打印到控制台,因为所有前台线程都必须在 AppDomain 从宿主进程中卸载之前完成它们的工作。
在很大程度上,当相关的工作线程正在执行程序的主任务完成后不再需要的非关键任务时,配置线程作为后台类型运行会很有帮助。例如,您可以构建一个应用,每隔几分钟就向电子邮件服务器发送一次新邮件,更新当前天气状况,或者执行一些其他非关键任务。
并发性的问题
当您构建多线程应用时,您的程序需要确保任何共享数据都受到保护,以防大量线程更改其值。假设 AppDomain 中的所有线程都可以并发访问应用的共享数据,想象一下如果多个线程访问同一点数据会发生什么。由于线程调度器会强制线程随机暂停它们的工作,如果线程 A 在完全完成工作之前就被踢出去了呢?线程 B 现在正在读取不稳定的数据。
为了说明并发性问题,让我们构建另一个名为 MultiThreadedPrinting 的控制台应用项目。这个应用将再次使用之前创建的Printer
类,但是这次PrintNumbers()
方法将强制当前线程暂停一段随机生成的时间。
using System;
using System.Threading;
namespace MultiThreadedPrinting
{
public class Printer
{
public void PrintNumbers()
{
// Display Thread info.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Print out numbers.
for (int i = 0; i < 10; i++)
{
// Put thread to sleep for a random amount of time.
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
}
}
调用代码负责创建一个由十个(唯一命名的)Thread
对象组成的数组,每个对象调用Printer
对象的同一个实例,如下所示:
using System;
using System.Threading;
using MultiThreadedPrinting;
Console.WriteLine("*****Synchronizing Threads *****\n");
Printer p = new Printer();
// Make 10 threads that are all pointing to the same
// method on the same object.
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++)
{
threads[i] = new Thread(new ThreadStart(p.PrintNumbers))
{
Name = $"Worker thread #{i}"
};
}
// Now start each one.
foreach (Thread t in threads)
{
t.Start();
}
Console.ReadLine();
在查看一些测试运行之前,让我们回顾一下这个问题。这个 AppDomain 中的主线程通过产生十个辅助工作线程而开始存在。每个工作线程被告知在同一个 Printer
实例上调用PrintNumbers()
方法。假设您没有采取任何预防措施来锁定该对象的共享资源(控制台),那么在PrintNumbers()
方法能够打印完整的结果之前,当前线程很有可能会被踢出去。因为您不知道这种情况何时(或是否)会发生,所以您肯定会得到不可预测的结果。例如,您可能会发现如下所示的输出:
*****Synchronizing Threads *****
-> Worker thread #3 is executing PrintNumbers()
-> Worker thread #0 is executing PrintNumbers()
-> Worker thread #1 is executing PrintNumbers()
-> Worker thread #2 is executing PrintNumbers()
-> Worker thread #4 is executing PrintNumbers()
-> Worker thread #5 is executing PrintNumbers()
-> Worker thread #6 is executing PrintNumbers()
-> Worker thread #7 is executing PrintNumbers()
-> Worker thread #8 is executing PrintNumbers()
-> Worker thread #9 is executing PrintNumbers()
0, 0, 0, 0, 1, 0, 1, 0, 1, 0, 0, 0, 1, 0, 1, 1, 2, 3, 1, 2, 2, 2, 1, 2, 1, 1, 2, 2, 3, 3, 4, 3, 3, 2, 2, 3, 4, 3, 4, 5, 4, 5, 4, 4, 3, 6, 7, 2, 3, 4, 4, 4, 5, 6, 5, 3, 5, 8, 9,
6, 7, 4, 5, 6, 6, 5, 5, 5, 8, 5, 6, 7, 8, 7, 7, 6, 6, 6, 8, 9,
8, 7, 7, 7, 7, 9,
6, 8, 9,
8, 9,
9, 9,
8, 8, 7, 8, 9,
9,
9,
现在再运行几次应用并检查输出。很可能每次都不一样。
Note
如果您无法生成不可预测的输出,那么将线程数量从 10 增加到 100(例如),或者在您的程序中引入另一个对Thread.Sleep()
的调用。最终,您会遇到并发问题。
这里显然存在一些问题。当每个线程告诉Printer
打印数字数据时,线程调度器很高兴地在后台交换线程。结果是输出不一致。您需要的是一种以编程方式强制同步访问共享资源的方法。正如您所猜测的,System.Threading
名称空间提供了几种以同步为中心的类型。C# 编程语言还为多线程应用中同步共享数据的任务提供了一个关键字。
使用 C# lock 关键字进行同步
第一个可以用来同步访问共享资源的技术是 C# lock
关键字。该关键字允许您定义必须在线程间同步的语句范围。通过这样做,传入线程不能中断当前线程,从而阻止它完成工作。lock
关键字要求您指定一个令牌(一个对象引用),线程必须获得这个令牌才能进入锁范围。当您试图锁定一个私有实例级方法时,您可以简单地传入一个对当前类型的引用,如下所示:
private void SomePrivateMethod()
{
// Use the current object as the thread token.
lock(this)
{
// All code within this scope is thread safe.
}
}
然而,如果您要锁定一个公共成员中的一段代码,那么声明一个私有object
成员变量作为锁标记会更安全(也是最佳实践),如下所示:
public class Printer
{
// Lock token.
private object threadLock = new object();
public void PrintNumbers()
{
// Use the lock token.
lock (threadLock)
{
...
}
}
}
在任何情况下,如果您检查PrintNumbers()
方法,您可以看到线程竞争访问的共享资源是控制台窗口。在锁定范围内确定所有与Console
类型的交互的范围,如下所示:
public void PrintNumbers()
{
// Use the private object lock token.
lock (threadLock)
{
// Display Thread info.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Print out numbers.
Console.Write("Your numbers: ");
for (int i = 0; i < 10; i++)
{
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
}
当这样做时,您已经有效地设计了一个方法,该方法将允许当前线程完成其任务。一旦线程进入锁范围,其他线程就无法访问锁令牌(在这种情况下,是对当前对象的引用),直到在锁范围退出后释放锁。因此,如果线程 A 已经获得锁令牌,其他线程就不能进入任何使用相同锁令牌的范围,直到线程 A 放弃锁令牌。
Note
如果您试图锁定静态方法中的代码,只需声明一个私有静态对象成员变量作为锁标记。
如果您现在运行应用,您可以看到每个线程都有足够的机会来完成它的任务。
*****Synchronizing Threads *****
-> Worker thread #0 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #1 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #3 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #2 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #4 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #5 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #7 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #6 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #8 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
-> Worker thread #9 is executing PrintNumbers()
Your numbers: 0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
使用系统进行同步。线程。监视器类型
C# lock
语句是使用System.Threading.Monitor
类的简写符号。一旦被 C# 编译器处理,锁的作用域就解析为如下内容(可以使用ildasm.exe
来验证):
public void PrintNumbers()
{
Monitor.Enter(threadLock);
try
{
// Display Thread info.
Console.WriteLine("-> {0} is executing PrintNumbers()",
Thread.CurrentThread.Name);
// Print out numbers.
Console.Write("Your numbers: ");
for (int i = 0; i < 10; i++)
{
Random r = new Random();
Thread.Sleep(1000 * r.Next(5));
Console.Write("{0}, ", i);
}
Console.WriteLine();
}
finally
{
Monitor.Exit(threadLock);
}
}
首先,请注意,Monitor.Enter()
方法是您指定为lock
关键字参数的线程令牌的最终接收者。接下来,锁范围内的所有代码都被包装在一个try
块中。相应的finally
块确保线程令牌被释放(通过Monitor.Exit()
方法),不管任何可能的运行时异常。如果您要修改多线程打印程序以直接使用Monitor
类型(如上所示),您会发现输出是相同的。
现在,鉴于lock
关键字似乎比显式使用System.Threading.Monitor
类型需要更少的代码,您可能想知道直接使用Monitor
类型的好处。简单的答案是控制。如果使用Monitor
类型,可以指示活动线程等待一段时间(通过静态Monitor.Wait()
方法),在当前线程完成时通知等待线程(通过静态Monitor.Pulse()
和Monitor.PulseAll()
方法),等等。
如您所料,在大多数情况下,C# lock
关键字将符合要求。但是,如果您对检查Monitor
类的其他成员感兴趣,请参考。NET 核心文档。
使用系统进行同步。螺纹.互锁型
尽管这总是令人难以置信,但直到你看到底层的 CIL 代码,赋值和简单的算术运算才是原子的 ??。出于这个原因,System.Threading
名称空间提供了一种类型,允许您以比Monitor
类型更少的开销原子地操作单点数据。Interlocked
类定义了表 15-4 中所示的关键静态成员。
表 15-4。
选择系统的静态成员。穿线.互锁型
|成员
|
生命的意义
|
| --- | --- |
| CompareExchange()
| 安全地测试两个值是否相等,如果相等,将其中一个值与第三个值交换 |
| Decrement()
| 安全地将值递减 1 |
| Exchange()
| 安全地交换两个值 |
| Increment()
| 安全地将值递增 1 |
虽然从一开始看起来不像,但是在多线程环境中原子地改变单个值的过程是很常见的。假设您有代码来增加一个名为intVal
的整数成员变量。而不是编写如下的同步代码:
int intVal = 5;
object myLockToken = new();
lock(myLockToken)
{
intVal++;
}
你可以通过静态的Interlocked.Increment()
方法来简化你的代码。只需通过引用传递要递增的变量。请注意,Increment()
方法不仅调整传入参数的值,还返回新值。
intVal = Interlocked.Increment(ref intVal);
除了Increment()
和Decrement()
之外,Interlocked
类型允许你原子地分配数字和对象数据。例如,如果您想将成员变量的值赋给值83
,您可以避免使用显式的lock
语句(或显式的Monitor
逻辑),而使用Interlocked.Exchange()
方法,如下所示:
Interlocked.Exchange(ref myInt, 83);
最后,如果您想测试两个值是否相等,并以线程安全的方式改变比较点,您可以如下利用Interlocked.CompareExchange()
方法:
public void CompareAndExchange()
{
// If the value of i is currently 83, change i to 99.
Interlocked.CompareExchange(ref i, 99, 83);
}
用定时器回调编程
许多应用需要在固定的时间间隔内调用特定的方法。例如,您可能有一个应用需要通过给定的助手函数在状态栏上显示当前时间。作为另一个例子,您可能希望让您的应用偶尔调用一个 helper 函数来执行非关键的后台任务,比如检查新的电子邮件。对于这样的情况,您可以将System.Threading.Timer
类型与名为TimerCallback
的相关委托结合使用。
举例来说,假设您有一个控制台应用项目(TimerApp ),它将每秒打印一次当前时间,直到用户按下一个键来终止应用。第一个明显的步骤是编写将由Timer
类型调用的方法(确保将System.Threading
导入到您的代码文件中)。
using System;
using System.Threading;
Console.WriteLine("***** Working with Timer type *****\n");
Console.ReadLine();
static void PrintTime(object state)
{
Console.WriteLine("Time is: {0}",
DateTime.Now.ToLongTimeString());
}
注意,PrintTime()
方法有一个类型为System.Object
的单一参数,并返回void
。这不是可选的,因为TimerCallback
委托只能调用匹配这个签名的方法。传递到您的TimerCallback
委托的目标中的值可以是任何类型的对象(在电子邮件示例中,该参数可能表示在该过程中要与之交互的 Microsoft Exchange 服务器的名称)。还要注意,假设这个参数确实是一个System.Object
,那么您可以使用一个System.Array
或者定制的类/结构来传递多个参数。
下一步是配置一个TimerCallback
委托的实例,并将其传递给Timer
对象。除了配置一个TimerCallback
委托之外,Timer
构造函数还允许您指定传递给委托目标的可选参数信息(定义为一个System.Object
)、轮询方法的时间间隔以及在进行第一次调用之前等待的时间(以毫秒为单位)。这里有一个例子:
Console.WriteLine("***** Working with Timer type *****\n");
// Create the delegate for the Timer type.
TimerCallback timeCB = new TimerCallback(PrintTime);
// Establish timer settings.
Timer t = new Timer(
timeCB, // The TimerCallback delegate object.
null, // Any info to pass into the called method (null for no info).
0, // Amount of time to wait before starting (in milliseconds).
1000); // Interval of time between calls (in milliseconds).
Console.WriteLine("Hit Enter key to terminate...");
Console.ReadLine();
在这种情况下,PrintTime()
方法将大约每秒被调用一次,并且不会向该方法传递任何附加信息。以下是输出:
***** Working with Timer type *****
Hit key to terminate...
Time is: 6:51:48 PM
Time is: 6:51:49 PM
Time is: 6:51:50 PM
Time is: 6:51:51 PM
Time is: 6:51:52 PM
Press any key to continue . . .
如果您确实想发送一些信息供委托目标使用,只需用适当的信息替换第二个构造函数参数的null
值,如下所示:
// Establish timer settings.
Timer t = new Timer(timeCB, "Hello From C# 9.0", 0, 1000);
然后,您可以按如下方式获取传入数据:
static void PrintTime(object state)
{
Console.WriteLine("Time is: {0}, Param is: {1}",
DateTime.Now.ToLongTimeString(), state.ToString());
}
使用独立丢弃(新 7.0)
在前面的示例中,Timer
变量没有在任何执行路径中使用,因此可以用 discard 替换它,如下所示:
var _ = new Timer(
timeCB, // The TimerCallback delegate object.
null, // Any info to pass into the called method
// (null for no info).
0, // Amount of time to wait before starting
//(in milliseconds).
1000); // Interval of time between calls
//(in milliseconds).
了解线程池
你将在本章研究的下一个以线程为中心的主题是运行时线程池的角色。启动一个新线程是有成本的,所以为了提高效率,线程池会保留已创建的(但不活动的)线程,直到需要为止。为了允许您与这个等待线程池进行交互,System.Threading
名称空间提供了ThreadPool
类类型。
如果您想让一个方法调用在池中排队由一个工作线程处理,您可以使用ThreadPool.QueueUserWorkItem()
方法。这个方法已经被重载,允许你为自定义状态数据指定一个可选的System.Object
以及一个WaitCallback
委托的实例。
public static class ThreadPool
{
...
public static bool QueueUserWorkItem(WaitCallback callBack);
public static bool QueueUserWorkItem(WaitCallback callBack,
object state);
}
WaitCallback
委托可以指向任何将System.Object
作为其唯一参数(代表可选的状态数据)并且不返回任何内容的方法。请注意,如果在调用QueueUserWorkItem()
时没有提供System.Object
。NET Core 运行时自动传递空值。来说明供。NET 核心运行时线程池,思考下面的程序(在一个名为 ThreadPoolApp 的控制台应用中),它再次使用了Printer
类型。然而,在这种情况下,您不是手动创建一个Thread
对象的数组;相反,您是在将池中的成员分配给PrintNumbers()
方法。
using System;
using System.Threading;
using ThreadPoolApp;
Console.WriteLine("***** Fun with the .NET Core Runtime Thread Pool *****\n");
Console.WriteLine("Main thread started. ThreadID = {0}",
Thread.CurrentThread.ManagedThreadId);
Printer p = new Printer();
WaitCallback workItem = new WaitCallback(PrintTheNumbers);
// Queue the method ten times.
for (int i = 0; i < 10; i++)
{
ThreadPool.QueueUserWorkItem(workItem, p);
}
Console.WriteLine("All tasks queued");
Console.ReadLine();
static void PrintTheNumbers(object state)
{
Printer task = (Printer)state;
task.PrintNumbers();
}
此时,您可能想知道使用。NET 核心运行时维护线程池,而不是显式创建Thread
对象。考虑利用线程池的这些好处:
-
线程池通过最小化必须创建、启动和停止的线程数量来有效地管理线程。
-
通过使用线程池,您可以专注于您的业务问题,而不是应用的线程基础设施。
但是,在某些情况下,最好使用手动线程管理。这里有一个例子:
-
如果需要前台线程或者必须设置线程优先级。池线程是总是具有默认优先级的后台线程(
ThreadPriority.Normal
)。 -
如果你需要一个有固定身份的线程来中止它,挂起它,或者通过名字发现它。
这就完成了对System.Threading
名称空间的研究。可以肯定的是,在创建多线程应用时,理解本章到目前为止介绍的主题(尤其是在您研究并发性问题的过程中)是非常有价值的。有了这个基础,现在将注意力转向中引入的几个新的以线程为中心的主题。NET 4.0 并延续到。NET 核心。首先,您将研究另一个线程模型任务并行库的作用。
使用任务并行库的并行编程
在本章的这一点上,您已经检查了允许您构建多线程软件的System.Threading
名称空间对象。从发布。在. NET 4.0 中,微软引入了一种新的多线程应用开发方法,该方法使用一个名为任务并行库 (TPL)的并行编程库。使用System.Threading.Tasks
的类型,您可以构建细粒度的、可伸缩的并行代码,而不必直接使用线程或线程池。
然而,这并不是说当你使用 TPL 时,你不会使用System.Threading
的类型。这两个线程工具包可以非常自然地一起工作。尤其是因为System.Threading
名称空间仍然提供了您之前检查过的大多数同步原语(Monitor
、Interlocked
等)。).然而,你很可能会发现你更喜欢使用 TPL 而不是原来的System.Threading
名称空间,因为同样的任务可以用更直接的方式来执行。
系统。线程.任务命名空间
统称起来,System.Threading.Tasks
的类型被称为任务并行库。TPL 将使用运行时线程池,在可用的 CPU 之间动态地自动分配应用的工作负载。TPL 处理工作的划分、线程调度、状态管理和其他底层细节。结果是,您可以最大限度地发挥。NET 核心应用,同时避免了许多直接使用线程的复杂性。
并行类的作用
第三方物流的一个关键类别是System.Threading.Tasks.Parallel
。这个类包含的方法允许你以并行的方式迭代一组数据(特别是一个实现了IEnumerable<T>
的对象),主要是通过两个主要的静态方法Parallel.For()
和Parallel.ForEach()
,每个方法都定义了许多重载版本。
这些方法允许您创作将以并行方式处理的代码语句体。从概念上讲,这些语句与您在普通循环结构中编写的逻辑是相同的(通过for
或foreach
C# 关键字)。好处是Parallel
类将代表您从线程池中提取线程(并管理并发性)。
这两种方法都要求您指定一个兼容IEnumerable
或IEnumerable<T>
的容器,该容器保存您需要以并行方式处理的数据。容器可以是一个简单的数组、一个非泛型集合(比如ArrayList
)、一个泛型集合(比如List<T>
)或者一个 LINQ 查询的结果。
此外,您将需要使用System.Func<T>
和System.Action<T>
委托来指定将被调用来处理数据的目标方法。你已经遇到了第十三章中的Func<T>
代表,在你调查 LINQ 地对象期间。回想一下,Func<T>
表示一个可以有给定返回值和不同数量参数的方法。Action<T>
委托类似于Func<T>
,因为它允许你指向一个带一些参数的方法。但是,Action<T>
指定了一个只能返回void
的方法。
虽然您可以调用Parallel.For()
和Parallel.ForEach()
方法并传递强类型的Func<T>
或Action<T>
委托对象,但是您可以通过使用合适的 C# 匿名方法或 lambda 表达式来简化您的编程。
并行类的数据并行性
使用 TPL 的第一种方法是执行数据并行。简单地说,这个术语指的是使用Parallel.For()
或Parallel.ForEach()
方法以并行方式迭代数组或集合的任务。假设您需要执行一些劳动密集型的文件 I/O 操作。具体来说,你需要将大量的*.jpg
文件加载到内存中,翻转过来,将修改后的图像数据保存到新的位置。
在这个示例中,您将看到如何使用图形用户界面执行相同的整体任务,因此您可以检查“匿名委托”的使用,以允许辅助线程更新主用户界面线程(也称为 UI 线程)。
Note
当您构建多线程图形用户界面(GUI)应用时,辅助线程永远不能直接访问用户界面控件。原因是控件(按钮、文本框、标签、进度条等。)与创建它们的线程有线程关联。在下面的例子中,我将说明一种允许辅助线程以线程安全的方式访问 UI 项的方法。当您检查 C# async
和await
关键字时,您会看到一个更简化的方法。
举例来说,创建一个新的 WPF 应用(模板缩写为 WPF 应用。NET Core))命名为 DataParallelismWithForEach。要使用 CLI 创建项目并将其添加到本章的解决方案中,请输入以下命令:
dotnet new wpf -lang c# -n DataParallelismWithForEach -o .\DataParallelismWithForEach -f net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\DataParallelismWithForEach
Note
Windows Presentation Foundation(WPF)仅适用于此版本的 Windows。NET 核心,将在第 24—28 章节中详细介绍。如果你没有和 WPF 一起工作过,这里列出了你在这个例子中需要的所有东西。如果您更愿意跟随一个完整的解决方案,您可以在Chapter
15
文件夹中找到 DataParallelismWithForEach。WPF 开发使用 Visual Studio 代码,尽管没有设计器支持。为了获得更丰富的开发体验,我建议您使用 Visual Studio 2019 来获得本章中的 WPF 示例。
在解决方案资源管理器中双击MainWindow.xaml
文件,并用以下内容替换 XAML:
<Window x:Class="DataParallelismWithForEach.MainWindow"
xmlns:="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:DataParallelismWithForEach"
mc:Ignorable="d"
Title="Fun with TPL" Height="400" Width="800">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Label Grid.Row="0" Grid.Column="0">
Feel free to type here while the images are processed...
</Label>
<TextBox Grid.Row="1" Grid.Column="0" Margin="10,10,10,10"/>
<Grid Grid.Row="2" Grid.Column="0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Button Name="cmdCancel" Grid.Row="0" Grid.Column="0" Margin="10,10,0,10" Click="cmdCancel_Click">
Cancel
</Button>
<Button Name="cmdProcess" Grid.Row="0" Grid.Column="2" Margin="0,10,10,10"
Click="cmdProcess_Click">
Click to Flip Your Images!
</Button>
</Grid>
</Grid>
</Window>
同样,不要担心标记意味着什么或者它是如何工作的;你很快就会有很多时间和 WPF 在一起。应用的 GUI 由多行TextBox
和单个Button
(名为cmdProcess
)组成。文本区域的目的是允许您在后台执行工作时输入数据,从而说明并行任务的非阻塞性质。
对于这个例子,需要一个额外的 NuGet 包(System.Drawing.Common
)。若要将它添加到项目中,请在 Visual Studio 的命令行(与解决方案文件位于同一目录中)或包管理器控制台中输入以下行(全部在一行中):
dotnet add DataParallelismWithForEach package System.Drawing.Common
打开MainWindow.xaml.cs
文件(在 Visual Studio 中双击它——您可能需要通过MainWindow.xaml
展开节点),并将以下using
语句添加到文件的顶部:
// Be sure you have these namespaces! (System.Threading.Tasks should already be there from the default template)
using System;
using System.Drawing;
using System.Threading.Tasks;
using System.Threading;
using System.Windows;
using System.IO;
Note
您应该更新传递到下面的Directory.GetFiles()
方法调用中的字符串,以指向您的计算机上有一些图像文件的路径(比如家庭照片的个人文件夹)。为了方便起见,我在Solution
目录中包含了一些示例图像(Windows 操作系统附带的)。
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private void cmdCancel_Click(object sender, EventArgs e)
{
// This will be updated shortly
}
private void cmdProcess_Click(object sender, EventArgs e)
{
ProcessFiles();
this.Title = "Processing Complete";
}
private void ProcessFiles()
{
// Load up all *.jpg files, and make a new folder for the
// modified data.
//Get the directory path where the file is executing
//For VS 2019 debugging, the current directory will be <projectdirectory>\bin\debug\net5.0-windows
//For VS Code or “dotnet run”, the current directory will be <projectdirectory>
var basePath = Directory.GetCurrentDirectory();
var pictureDirectory =
Path.Combine(basePath, "TestPictures");
var outputDirectory =
Path.Combine(basePath, "ModifiedPictures");
//Clear out any existing files
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, true);
}
Directory.CreateDirectory(outputDirectory);
string[] files = Directory.GetFiles(pictureDirectory,
"*.jpg", SearchOption.AllDirectories);
// Process the image data in a blocking manner.
foreach (string currentFile in files)
{
string filename =
System.IO.Path.GetFileName(currentFile);
// Print out the ID of the thread processing the current image.
this.Title = $"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(System.IO.Path.Combine(
outputDirectory, filename));
}
}
}
}
Note
如果您收到一条错误消息,指出Path
是System.IO.Path
和System.Windows.Shapes.Path
之间的不明确引用,请删除System.Windows.Shapes
的using
,或者将System.IO
添加到Path
: System.IO.Path.Combine(...)
。
请注意,ProcessFiles()
方法将旋转指定目录下的每个*.jpg
文件。目前,所有的工作都发生在可执行文件的主线程上。因此,如果单击该按钮,程序将显示为挂起。此外,窗口的标题还将报告同一个主线程正在处理文件,因为我们只有一个执行线程。
为了在尽可能多的 CPU 上处理文件,您可以重写当前的foreach
循环来使用Parallel.ForEach()
。回想一下,这个方法已经被重载了无数次;然而,在最简单的形式中,您必须指定包含要处理的项目的与IEnumerable<T>
兼容的对象(那将是files
字符串数组)和一个指向将执行工作的方法的Action<T>
委托。
下面是相关的更新,使用 C# lambda 操作符代替文字Action<T>
委托对象。请注意,您目前正在注释掉显示执行当前图像文件的线程 ID 的代码行。见下一节找出原因。
// Process the image data in a parallel manner!
Parallel.ForEach(files, currentFile =>
{
string filename = Path.GetFileName(currentFile);
// This code statement is now a problem! See next section.
// this.Title = $"Processing {filename} on thread
// {Thread.CurrentThread.ManagedThreadId}"
// Thread.CurrentThread.ManagedThreadId);
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
}
);
访问辅助线程上的 UI 元素
您会注意到,我已经注释掉了用当前执行线程的 ID 更新主窗口标题的前一行代码。如前所述,GUI 控件与创建它们的线程有“线程亲缘关系”。如果辅助线程试图访问它们没有直接创建的控件,那么在调试软件时,您肯定会遇到运行时错误。另一方面,如果您要运行应用(通过 Ctrl+F5),您可能永远不会发现原始代码有任何问题。
Note
让我重申前面的观点:当您调试多线程应用时,您有时可以捕捉到当辅助线程“接触”在主线程上创建的控件时出现的错误。然而,通常当您运行应用时,应用可能看起来运行正常(或者可能立即出错)。除非您采取预防措施(接下来将讨论),否则在这种情况下,您的应用有可能引发运行时错误。
允许这些辅助线程以线程安全的方式访问控件的一种方法是另一种以委托为中心的技术,特别是一种匿名委托。WPF 中的Control
父类定义了一个Dispatcher
对象,它管理一个线程的工作项。这个对象有一个名为Invoke()
的方法,它接受一个System.Delegate
作为输入。当您处于涉及辅助线程的编码上下文中时,可以调用此方法,以提供线程安全的方式来更新给定控件的 UI。现在,虽然您可以直接编写所有需要的委托代码,但大多数开发人员使用表达式语法作为简单的替代方法。以下是对先前注释掉的代码语句内容的相关更新:
// Eek! This will not work anymore!
//this.Title = $"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";
// Invoke on the Form object, to allow secondary threads to access controls
// in a thread-safe manner.
Dispatcher?.Invoke(() =>
{
this.Title = $"Processing {filename}";
});
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
现在,如果您运行这个程序,TPL 将会使用尽可能多的 CPU 将工作负载分配给线程池中的多个线程。然而,由于Title
总是从主线程更新,所以Title
更新代码不再显示当前线程,并且如果您在文本框中键入内容,直到所有图像都被处理完,您将看不到任何内容!原因是主 UI 线程仍然被阻塞,等待所有其他线程完成它们的任务。
任务类
Task
类允许您轻松地调用辅助线程上的方法,并且可以作为使用异步委托的简单替代方法。更新Button
控件的Click
处理程序,如下所示:
private void cmdProcess_Click(object sender, EventArgs e)
{
// Start a new "task" to process the files.
Task.Factory.StartNew(() => ProcessFiles());
//Can also be written this way
//Task.Factory.StartNew(ProcessFiles);
}
Task
的Factory
属性返回一个TaskFactory
对象。当您调用它的StartNew()
方法时,您传入一个Action<T>
委托(这里,用一个合适的 lambda 表达式隐藏起来),该委托指向要以异步方式调用的方法。通过这个小小的更新,您会发现窗口的标题将显示线程池中的哪个线程正在处理给定的文件,更好的是,文本区域能够接收输入,因为 UI 线程不再被阻塞。
处理取消请求
您可以对当前示例进行的一个改进是,通过第二个(恰当命名的)Cancel 按钮,为用户提供一种停止处理图像数据的方法。幸运的是,Parallel.For()
和Parallel.ForEach()
方法都支持使用取消令牌进行取消。当您调用Parallel
上的方法时,您可以传入一个ParallelOptions
对象,该对象又包含一个CancellationTokenSource
对象。
首先,在名为_cancelToken
的CancellationTokenSource
类型的Form
派生类中定义以下新的私有成员变量:
public partial class MainWindow :Window
{
// New Window-level variable.
private CancellationTokenSource _cancelToken = new CancellationTokenSource();
...
}
将取消按钮Click
事件更新为以下代码:
private void cmdCancel_Click(object sender, EventArgs e)
{
// This will be used to tell all the worker threads to stop!
_cancelToken.Cancel();
}
现在,真正的修改需要发生在ProcessFiles()
方法中。考虑最终的实现:
private void ProcessFiles()
{
// Use ParallelOptions instance to store the CancellationToken.
ParallelOptions parOpts = new ParallelOptions();
parOpts.CancellationToken = _cancelToken.Token;
parOpts.MaxDegreeOfParallelism = System.Environment.ProcessorCount;
// Load up all *.jpg files, and make a new folder for the modified data.
string[] files = Directory.GetFiles(@".\TestPictures", "*.jpg", SearchOption.AllDirectories);
string outputDirectory = @".\ModifiedPictures";
Directory.CreateDirectory(outputDirectory);
try
{
// Process the image data in a parallel manner!
Parallel.ForEach(files, parOpts, currentFile =>
{
parOpts
.CancellationToken.ThrowIfCancellationRequested();
string filename = Path.GetFileName(currentFile);
Dispatcher?.Invoke(() =>
{
this.Title =
$"Processing {filename} on thread {Thread.CurrentThread.ManagedThreadId}";
});
using (Bitmap bitmap = new Bitmap(currentFile))
{
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
});
Dispatcher?.Invoke(()=>this.Title = "Done!");
}
catch (OperationCanceledException ex)
{
Dispatcher?.Invoke(()=>this.Title = ex.Message);
}
}
注意,这个方法是通过配置一个ParallelOptions
对象开始的,设置CancellationToken
属性来使用CancellationTokenSource
令牌。还要注意,当您调用Parallel.ForEach()
方法时,您将把ParallelOptions
对象作为第二个参数传入。
在循环逻辑的范围内,您调用令牌上的ThrowIfCancellationRequested()
,这将确保如果用户单击 Cancel 按钮,所有线程都将停止,并且您将通过运行时异常得到通知。当您捕捉到OperationCanceledException
错误时,您将把主窗口的文本设置为错误消息。
使用并行类的任务并行性
除了数据并行性之外,TPL 还可以使用Parallel.Invoke()
方法轻松地启动任意数量的异步任务。这种方法比使用来自System.Threading
的成员更简单;然而,如果您需要对任务的执行方式有更多的控制,您可以放弃使用Parallel.Invoke()
而直接使用Task
类,就像您在前面的例子中所做的那样。
为了说明任务并行性,创建一个名为 MyEBookReader 的新控制台应用,并确保在Program.cs
的顶部导入了System.Threading
、System.Text
、System.Threading.Tasks
、System.Linq
和System.Net
名称空间(这个示例是对。NET 核心文档)。在这里,您将从 Project Gutenberg ( www.gutenberg.org
)获取一个公开可用的电子书,然后并行执行一组冗长的任务。
这本书是用GetBook()
方法下载的,如下所示:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Net;
using System.Text;
string _theEBook = "";
GetBook();
Console.WriteLine("Downloading book...");
Console.ReadLine();
void GetBook()
{
WebClient wc = new WebClient();
wc.DownloadStringCompleted += (s, eArgs) =>
{
_theEBook = eArgs.Result;
Console.WriteLine("Download complete.");
GetStats();
};
// The Project Gutenberg EBook of A Tale of Two Cities, by Charles Dickens
// You might have to run it twice if you’ve never visited the site before, since the first
// time you visit there is a message box that pops up, and breaks this code.
wc.DownloadStringAsync(new Uri("http://www.gutenberg.org/files/98/98-8.txt"));
}
WebClient
类是System.Net
的成员。此类提供向 URI 标识的资源发送数据和从该资源接收数据的方法。事实证明,这些方法中有很多都有异步版本,比如DownloadStringAsync()
。此方法将从。NET 核心运行时自动线程池。当WebClient
完成获取数据时,它将触发DownloadStringCompleted
事件,这里使用 C# lambda 表达式处理该事件。如果您要调用该方法的同步版本(DownloadString()
),那么在下载完成之前,不会显示“正在下载”的消息。
接下来,实现GetStats()
方法来提取包含在theEBook
变量中的单个单词,然后将字符串数组传递给几个辅助函数进行处理,如下所示:
void GetStats()
{
// Get the words from the ebook.
string[] words = _theEBook.Split(new char[]
{ ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
StringSplitOptions.RemoveEmptyEntries);
// Now, find the ten most common words.
string[] tenMostCommon = FindTenMostCommon(words);
// Get the longest word.
string longestWord = FindLongestWord(words);
// Now that all tasks are complete, build a string to show all stats.
StringBuilder bookStats = new StringBuilder("Ten Most Common Words are:\n");
foreach (string s in tenMostCommon)
{
bookStats.AppendLine(s);
}
bookStats.AppendFormat("Longest word is: {0}", longestWord);
bookStats.AppendLine();
Console.WriteLine(bookStats.ToString(), "Book info");
}
FindTenMostCommon()
方法使用 LINQ 查询来获得在string
数组中最常出现的string
对象的列表,而FindLongestWord()
则定位最长的单词。
string[] FindTenMostCommon(string[] words)
{
var frequencyOrder = from word in words
where word.Length > 6
group word by word into g
orderby g.Count() descending
select g.Key;
string[] commonWords = (frequencyOrder.Take(10)).ToArray();
return commonWords;
}
string FindLongestWord(string[] words)
{
return (from w in words orderby w.Length descending select w).FirstOrDefault();
}
如果您要运行这个项目,根据您的机器的 CPU 数量和整体处理器速度,执行所有任务可能会花费大量的时间。最终,您应该会看到如下所示的输出:
Downloading book...
Download complete.
Ten Most Common Words are:
Defarge
himself
Manette
through
nothing
business
another
looking
prisoner
Cruncher
Longest word is: undistinguishable
通过并行调用FindTenMostCommon()
和FindLongestWord()
方法,可以帮助确保您的应用使用主机上所有可用的 CPU。为此,将您的GetStats()
方法修改如下:
void GetStats()
{
// Get the words from the ebook.
string[] words = _theEBook.Split(
new char[] { ' ', '\u000A', ',', '.', ';', ':', '-', '?', '/' },
StringSplitOptions.RemoveEmptyEntries);
string[] tenMostCommon = null;
string longestWord = string.Empty;
Parallel.Invoke(
() =>
{
// Now, find the ten most common words.
tenMostCommon = FindTenMostCommon(words);
},
() =>
{
// Get the longest word.
longestWord = FindLongestWord(words);
});
// Now that all tasks are complete, build a string to show all stats.
...
}
Parallel.Invoke()
方法需要一个Action<>
委托的参数数组,这是您使用 lambda 表达式间接提供的。同样,虽然输出是相同的,但好处是 TPL 现在将使用机器上所有可能的处理器来尽可能并行地调用每个方法。
并行 LINQ 查询(PLINQ)
总结一下您对 TPL 的看法,要知道还有另一种方法可以将并行任务合并到您的。NET 核心应用。如果您愿意,可以使用一组扩展方法来构造一个并行执行其工作负载的 LINQ 查询(如果可能的话)。相应地,设计为并行运行的 LINQ 查询被称为 PLINQ 查询。
像使用Parallel
类创作的并行代码一样,如果需要,PLINQ 可以选择忽略您并行处理集合的请求。PLINQ 框架已经在许多方面进行了优化,包括确定一个查询实际上是否会以同步方式执行得更快。
在运行时,PLINQ 分析查询的整体结构,如果查询可能受益于并行化,它将并发运行。但是,如果并行化查询会损害性能,PLINQ 只会按顺序运行查询。如果 PLINQ 可以在潜在昂贵的并行算法或便宜的顺序算法之间进行选择,默认情况下它会选择顺序算法。
必要的扩展方法可以在名称空间System.Linq
的ParallelEnumerable
类中找到。表 15-5 记录了一些有用的 PLINQ 扩展。
表 15-5。
选择 ParallelEnumerable 类的成员
|成员
|
生命的意义
|
| --- | --- |
| AsParallel()
| 指定查询的其余部分应该并行化(如果可能) |
| WithCancellation()
| 指定 PLINQ 应定期监视所提供的取消令牌的状态,并在收到请求时取消执行 |
| WithDegreeOfParallelism()
| 指定 PLINQ 用于并行查询的最大处理器数量 |
| ForAll()
| 支持并行处理结果,而无需先合并回消费者线程,这是使用foreach
关键字枚举 LINQ 结果时的情况 |
要查看 PLINQ 的运行情况,创建一个名为 plinqdataprocessingwithcassignation 的控制台应用,并导入System.Linq
、System.Threading
和System.Threading.Tasks
名称空间(如果还没有的话)。当处理开始时,程序将触发一个新的Task
,它执行一个 LINQ 查询,该查询调查一个大的整数数组,只查找x % 3 == 0
为true
的项目。下面是一个不平行的版本的查询:
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine("Start any key to start processing");
Console.ReadKey();
Console.WriteLine("Processing");
Task.Factory.StartNew(ProcessIntData);
Console.ReadLine();
void ProcessIntData()
{
// Get a very large array of integers.
int[] source = Enumerable.Range(1, 10_000_000).ToArray();
// Find the numbers where num % 3 == 0 is true, returned
// in descending order.
int[] modThreeIsZero = (
from num in source
where num % 3 == 0
orderby num descending
select num).ToArray();
Console.WriteLine($"Found { modThreeIsZero.Count()} numbers that match query!");
}
选择加入 PLINQ 查询
如果您想要通知 TPL 并行执行这个查询(如果可能的话),您将想要使用AsParallel()
扩展方法,如下所示:
int[] modThreeIsZero = (
from num in source.AsParallel()
where num % 3 == 0
orderby num descending select num).ToArray();
请注意,LINQ 查询的整体格式与您在前面章节中看到的完全相同。然而,通过包含对AsParallel()
的调用,TPL 将试图将工作负载传递给任何可用的 CPU。
取消 PLINQ 查询
也可以使用CancellationTokenSource
对象通知 PLINQ 查询在正确的条件下停止处理(通常是因为用户干预)。声明一个名为_cancelToken
的类级CancellationTokenSource
对象,并更新顶级语句方法以接受用户输入。以下是相关的代码更新:
CancellationTokenSource _cancelToken =
new CancellationTokenSource();
do
{
Console.WriteLine("Start any key to start processing");
Console.ReadKey();
Console.WriteLine("Processing");
Task.Factory.StartNew(ProcessIntData);
Console.Write("Enter Q to quit: ");
string answer = Console.ReadLine();
// Does user want to quit?
if (answer.Equals("Q",
StringComparison.OrdinalIgnoreCase))
{
_cancelToken.Cancel();
break;
}
}
while (true);
Console.ReadLine();
现在,通过链接WithCancellation()
扩展方法并传入令牌,通知 PLINQ 查询它应该注意一个传入的取消请求。此外,您将希望将这个 PLINQ 查询包装在一个适当的try
/ catch
范围内,并处理可能的异常。下面是ProcessIntData()
方法的最终版本:
void ProcessIntData()
{
// Get a very large array of integers.
int[] source = Enumerable.Range(1, 10_000_000).ToArray();
// Find the numbers where num % 3 == 0 is true, returned
// in descending order.
int[] modThreeIsZero = null;
try
{
modThreeIsZero = (from num in source.AsParallel().WithCancellation(_cancelToken.Token)
where num % 3 == 0
orderby num descending
select num).ToArray();
Console.WriteLine();
Console.WriteLine($"Found {modThreeIsZero.Count()} numbers that match query!");
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex.Message);
}
}
当运行这个程序时,您会想要点击 Q 并快速输入以查看来自取消令牌的消息。在我的开发机器上,在它自己完成之前,我有大约一秒钟的时间退出。
用 async/await 进行异步调用
在这一章(相当长)中,我已经介绍了很多材料。可以肯定的是,构建、调试和理解复杂的多线程应用在任何框架中都具有挑战性。虽然 TPL、PLINQ 和 delegate 类型可以在某种程度上简化事情(特别是与其他平台和语言相比),但开发人员仍然需要了解各种高级技术的来龙去脉。
自从发布以来。NET 4.5 中,C# 编程语言已经更新了两个新的关键字,进一步简化了创作异步代码的过程。与本章中的所有例子相比,当你使用新的async
和await
关键字时,编译器将使用System.Threading
和System.Threading.Tasks
名称空间的众多成员为你生成大量线程代码。
首先看看 C# async 和 await 关键字(更新 7.1,9.0)
C# 的关键字async
用于限定方法、lambda 表达式或匿名方法应该以异步方式自动调用。是的,这是真的。只需用async
修饰符标记一个方法。NET 核心运行时将创建一个新的执行线程来处理手头的任务。此外,当您调用一个async
方法时,await
关键字将自动暂停当前线程的任何进一步活动,直到任务完成,让调用线程自由地继续。
举例来说,创建一个名为 FunWithCSharpAsync 的控制台应用,并将System.Threading
、System.Threading.Tasks
和System.Collections.Generic
名称空间导入到Program.cs
中。添加一个名为DoWork()
的方法,强制调用线程等待五秒钟。到目前为止,故事是这样的:
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
Console.WriteLine(" Fun With Async ===>");
Console.WriteLine(DoWork());
Console.WriteLine("Completed");
Console.ReadLine();
static string DoWork()
{
Thread.Sleep(5_000);
return "Done with work!";
}
现在,考虑到你在这一章中的工作,你知道如果你要运行这个程序,你需要等待五秒钟,然后其他事情才会发生。如果这是一个图形应用,整个屏幕将被锁定,直到工作完成。
如果你要使用本章中介绍的任何一种技术来提高程序的响应能力,你将有大量的工作要做。然而自从。NET 4.5,您可以编写以下 C# 代码库:
...
string message = await DoWorkAsync();
Console.WriteLine(message);
...
static string DoWork()
{
Thread.Sleep(5_000);
return "Done with work!";
}
static async Task<string> DoWorkAsync()
{
return await Task.Run(() =>
{
Thread.Sleep(5_000);
return "Done with work!";
});
}
如果您使用一个Main()
方法作为入口点(而不是顶级语句),您需要将该方法标记为async
,这是在 C# 7.1 中引入的。
static async Task Main(string[] args)
{
...
string message = await DoWorkAsync();
Console.WriteLine(message);
...
}
Note
从 C# 7.1 开始,可以用async
来修饰Main()
方法。在 C# 9.0 中,顶层语句是隐式的async
。
注意在命名将以异步方式调用的方法之前的await
关键字*。这一点很重要:如果你用async
关键字修饰一个方法,但是没有至少一个内部的await
为中心的方法调用,你实际上已经构建了一个同步方法调用(事实上,你将得到一个关于这个效果的编译器警告)。*
现在,请注意,您需要使用来自System.Threading.Tasks
名称空间的Task
类来重构您的Main()
(如果您正在使用Main()
)和DoWork()
方法(后者被添加为DoWorkAsync()
)。基本上,不是直接返回一个特定的返回值(在当前的例子中是一个string
对象),而是返回一个Task<T>
对象,其中泛型类型参数T
是底层的实际返回值(到目前为止?).如果方法没有返回值(就像在Main()
方法中一样),那么就用 Task
代替任务。
DoWorkAsync()
的实现现在直接返回一个Task<T>
对象,这个对象就是Task.Run()
的返回值。Run()
方法接受一个Func<>
或Action<>
委托,正如您在本文中所知,您可以通过使用 lambda 表达式来简化您的生活。基本上你的新版DoWorkAsync()
本质上是在说下面的话:
当你调用我的时候,我会运行一个新的任务。这个任务将导致调用线程睡眠五秒钟,当它完成时,它给我一个字符串返回值。我将把这个字符串放到一个新的 Task < string >对象中,并将其返回给调用者。
将DoWorkAsync()
的这个新实现翻译成更自然(诗意)的语言后,您对await
标记的真正作用有了一些了解。这个关键字将总是修改返回一个Task
对象的方法。当逻辑流到达await
标记时,调用线程在这个方法中被挂起,直到调用完成。如果您要运行这个版本的应用,您会发现Completed
消息显示在Done with work!
消息之前。如果这是一个图形应用,当DoWorkAsync()
方法执行时,用户可以继续使用 UI。
同步上下文和异步/等待
SynchronizationContext
的官方定义是一个基类,提供无同步的自由线程上下文。虽然最初的定义不是很有描述性,但官方文档继续说:
由该类实现的同步模型的目的是允许公共语言运行库的内部异步/同步操作在不同的同步模型下正常运行。
这一陈述,以及您对多线程的了解,阐明了这个问题。回想一下,GUI 应用(WinForms,WPF)不允许辅助线程直接访问控件,但必须委托该访问。我们已经看到了 WPF 例子中的Dispatcher
对象。对于不使用 WPF 的控制台应用,没有这种限制。这些是提到的不同的同步模型。记住这一点,让我们更深入地了解一下SynchronizationContext
。
SynchonizationContext
是一种提供虚拟 post 方法的类型,它接受一个要异步执行的委托。这为框架提供了适当处理异步请求的模式(为 WPF/WinForms 分派,为非 GUI 应用直接执行,等等)。).它提供了一种方法来将一个工作单元排队到一个上下文中,并对未完成的async
操作进行计数。
正如我们前面讨论的,当一个委托被排队异步运行时,它被安排在一个单独的线程上运行。这个细节由。NET 核心运行时。这通常是使用。NET 核心运行时托管线程池,但可以用自定义实现重写。
虽然这种管道工作可以通过代码手动管理,但是async
/ await
模式完成了大部分繁重的工作。当等待一个async
方法时,它利用目标框架的SynchronizationContext
和TaskScheduler
实现。例如,如果您在一个 WPF 应用中使用async
/ await
,WPF 框架会管理委托的分派,并在等待的任务完成时回调状态机,以便安全地更新控件。
ConfigureAwait 的作用
现在您对SynchronizationContext
有了更好的理解,是时候介绍一下ConfigureAwait()
方法的作用了。默认情况下,等待Task
将导致同步上下文被利用。当开发 GUI 应用(WinForms,WPF)时,这是您想要的行为。但是,如果您正在编写非 GUI 应用代码,在不需要时对原始上下文进行排队的开销可能会导致应用中的性能问题。
要了解这一点,请将您的顶级语句更新为以下内容:
Console.WriteLine(" Fun With Async ===>");
//Console.WriteLine(DoWork());
string message = await DoWorkAsync();
Console.WriteLine(message);
string message1 = await DoWorkAsync().ConfigureAwait(false);
Console.WriteLine(message1);
原始代码块使用框架提供的SynchronizationContext
(在本例中,是。NET 核心运行时)。相当于调用ConfigureAwait(true)
。第二个例子忽略了当前的上下文和调度程序。
的指导。NET 核心团队建议在开发应用代码时(WinForms、WPF 等。)保留默认行为。如果你正在编写非应用代码(如库代码),那么使用ConfigureAwait(false)
。一个例外是 ASP.NET 核心(在第九部分中讨论)。ASP.NET 核心不创建自定义SynchronizationContext
;因此,ConfigureAwait(false)
在使用其他框架时不提供这种好处。
异步方法的命名约定
当然,你注意到了从DoWork()
到DoWorkAsync()
的名称变化,但是为什么会发生变化呢?假设新版本的方法仍被命名为DoWork()
;但是,调用代码是这样实现的:
//Oops! No await keyword here!
string message = DoWork();
注意你确实用async
关键字标记了方法,但是你忽略了在DoWork()
方法调用之前使用await
关键字作为修饰。此时,您将遇到编译器错误,因为DoWork()
的返回值是一个Task
对象,您试图将它直接赋给一个字符串变量。记住,await
标记提取包含在Task
对象中的内部返回值。因为您没有使用这个标记,所以您有一个类型不匹配。
Note
一个“可适应的”方法只是一个返回Task
或Task<T>
的方法。
鉴于返回Task
对象的方法现在可以通过async
和await
标记以非阻塞的方式调用,微软建议(作为最佳实践)任何返回Task
的方法都用Async
后缀标记。通过这种方式,知道命名约定的开发人员会收到一个视觉提示,如果他们打算在异步上下文中调用该方法,则需要使用await
关键字。
Note
GUI 控件的事件处理程序(如按钮Click
处理程序)以及 MVC 风格应用中使用async
/ await
关键字的动作方法不遵循这种命名约定(按照约定,请原谅冗余!).
Void 异步方法
目前,您的DoWorkAsync()
方法正在返回一个Task
,它包含调用者的“真实数据”,这些数据将通过await
关键字透明地获得。但是,如果要构建一个返回 void 的异步方法呢?如何实现这一点取决于该方法是否需要等待(就像在“一劳永逸”的场景中一样)。
适用的 Void 异步方法
如果你的async
方法需要是可适应的,你使用非泛型Task
类并省略任何return
语句,就像这样:
static async Task MethodReturningTaskOfVoidAsync()
{
await Task.Run(() => { /* Do some work here... */
Thread.Sleep(4_000);
});
Console.WriteLine("Void method completed");
}
这个方法的调用者将使用关键字await
,如下所示:
await MethodReturningVoidAsync();
Console.WriteLine("Void method complete");
“一劳永逸”的 Void 异步方法
如果你的方法需要是async
但不需要是可实现的,而是用于“一劳永逸”的情况,添加带有void
的async
关键字,而不是Task
返回类型。这通常用于日志记录之类的情况,在这种情况下,您不希望日志记录工作延迟其余的代码。
static async void MethodReturningVoidAsync()
{
await Task.Run(() => { /* Do some work here... */
Thread.Sleep(4_000);
});
Console.WriteLine("Fire and forget void method completed");
}
这个方法的调用者将而不是这样使用await
关键字:
MethodReturningVoidAsync();
Console.WriteLine("Void method complete");
具有多个等待的异步方法
一个async
方法在其实现中拥有多个 await 上下文是完全允许的。下面是完全可以接受的代码:
static async Task MultipleAwaits()
{
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with first task!");
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with second task!");
await Task.Run(() => { Thread.Sleep(2_000); });
Console.WriteLine("Done with third task!");
}
同样,这里的每个任务都只是暂停当前线程一段时间;然而,任何工作单元都可以由这些任务来表示(调用 web 服务、读取数据库等。).
另一种选择是不等待每个任务,而是一起等待它们。这是一个更可能的场景,其中有三件事(检查邮件、更新服务器、下载文件)必须成批完成,但可以并行完成。下面是使用Task.WhenAll()
方法更新的代码:
static async Task MultipleAwaits()
{
var task1 = Task.Run(() =>
{
Thread.Sleep(2_000);
Console.WriteLine("Done with first task!");
});
var task2=Task.Run(() =>
{
Thread.Sleep(1_000);
Console.WriteLine("Done with second task!");
});
var task3 = Task.Run(() =>
{
Thread.Sleep(1_000);
Console.WriteLine("Done with third task!");
});
await Task.WhenAll(task1,task2,task3);
}
当您现在运行程序时,您会看到这三个任务按照最短的Sleep
时间的顺序启动。
Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Done with third task!
Done with first task!
Completed
还有一个WhenAny()
,它返回完成的任务。为了演示WhenAny()
,将MultipleAwaits
的最后一行改为:
await Task.WhenAny(task1,task2,task3);
当您这样做时,输出更改为:
Fun With Async ===>
Done with work!
Void method completed
Done with second task!
Completed
Done with third task!
Done with first task!
从非异步方法调用异步方法
前面的每个例子都使用了async
关键字在async
方法执行时将线程返回给调用代码。在 review 中,您只能在标记为async
的方法中使用await
关键字。如果您不能(或者不想)标记一个方法async
该怎么办?
幸运的是,还有其他方法可以调用异步方法。如果您只是不使用await
关键字,那么该方法中的代码会继续通过async
方法,而不会返回给调用者。如果您需要等待您的async
方法完成(当您使用await
关键字时就会发生这种情况),有两种方法。
第一种是简单地使用Task<T>
上的Result
属性或者Task
/ Task<T>
方法上的Wait
。(记住异步时返回值的方法必须返回Task<T>
,无返回值的方法在async
时返回Task
)。如果该方法失败,则返回一个AggregateException
。
您也可以调用GetAwaiter().GetResult()
,它完成与async
方法中的await
关键字相同的事情,并以与aync
/ await
相同的方式传播异常。然而,这些方法在文档中被标记为“不供外部使用”,这意味着它们可能会在将来的某个时候改变或消失。GetAwaiter().GetResult()
方法对有返回值的方法和没有返回值的方法都有效。
Note
在Task<T>
上使用Result
还是GetAwaiter().GetResult()
取决于你自己,大多数开发者基于异常处理来决定。如果你的方法返回Task
,你必须使用GetAwaiter().GetResult()
或者Wait()
。
例如,您可以像这样调用DoWorkAsync()
方法:
Console.WriteLine(DoWorkAsync().Result);
Console.WriteLine(DoWorkAsync().GetAwaiter().GetResult());
要暂停执行,直到一个async
方法返回一个void
返回类型,只需在Task
上调用Wait()
,就像这样:
MethodReturningVoidAsync().Wait();
等待捕获并最终阻塞
C# 6 引入了在catch
和finally
块中放置 await 调用的能力。方法本身必须是async
才能做到这一点。下面的代码示例演示了该功能:
static async Task<string> MethodWithTryCatch()
{
try
{
//Do some work
return "Hello";
}
catch (Exception ex)
{
await LogTheErrors();
throw;
}
finally
{
await DoMagicCleanUp();
}
}
通用异步返回类型(新 7.0)
在 C# 7 之前,async
方法的唯一返回选项是Task
、Task<T>
和void
。C# 7 支持额外的返回类型,如果它们遵循async
模式的话。一个具体的例子就是ValueTask
。要了解这一点,请创建如下代码:
static async ValueTask<int> ReturnAnInt()
{
await Task.Delay(1_000);
return 5;
}
同样的规则也适用于ValueTask
和Task
,因为ValueTask
只是值类型的一个Task
,而不是强制在堆上分配一个对象。
本地函数(新 7.0)
局部函数在第四章中介绍,在第八章中使用迭代器。它们也有利于async
方法。为了证明好处,你需要首先看到问题。添加一个名为MethodWithProblems()
的新方法,并添加以下代码:
static async Task MethodWithProblems(int firstParam, int secondParam)
{
Console.WriteLine("Enter");
await Task.Run(() =>
{
//Call long running method
Thread.Sleep(4_000);
Console.WriteLine("First Complete");
//Call another long running method that fails because
//the second parameter is out of range
Console.WriteLine("Something bad happened");
});
}
场景是第二个长时间运行的任务由于无效的输入数据而失败。您可以(也应该)将检查添加到方法的开头,但是由于整个方法是异步的,因此无法保证检查将在何时执行。在调用代码继续运行之前,最好立即进行检查。在下面的更新中,检查以同步方式完成,然后私有函数异步执行:
static async Task MethodWithProblemsFixed(int firstParam, int secondParam)
{
Console.WriteLine("Enter");
if (secondParam < 0)
{
Console.WriteLine("Bad data");
return;
}
await actualImplementation();
async Task actualImplementation()
{
await Task.Run(() =>
{
//Call long running method
Thread.Sleep(4_000);
Console.WriteLine("First Complete");
//Call another long running method that fails because
//the second parameter is out of range
Console.WriteLine("Something bad happened");
});
}
}
取消异步/等待操作
使用async
/ await
模式也可以取消,比使用Parallel.ForEach
模式简单得多。为了演示,我们将使用本章前面的同一个 WPF 项目。您可以重用该项目或添加一个新的 WPF 应用(。NET Core)添加到解决方案中,并通过执行以下 CLI 命令将System.Drawing.Common
包添加到项目中:
dotnet new wpf -lang c# -n PictureHandlerWithAsyncAwait -o .\PictureHandlerWithAsyncAwait -f net5.0
dotnet sln .\Chapter15_AllProjects.sln add .\PictureHandlerWithAsyncAwait
dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common
如果您使用的是 Visual Studio,可以在解决方案资源管理器中右键单击解决方案名称,选择“添加➤项目”,并将其命名为PictureHandlerWithAsyncAwait
。确保通过右键单击新项目名称并选择 Set as StartUp Project 将新项目设置为启动项目。添加System.Drawing.Common
NuGet 包。
dotnet add PictureHandlerWithAsyncAwait package System.Drawing.Common
替换 XAML 以匹配之前的 WPF 项目,除了将标题更改为Picture Handler with Async/Await
。
在MainWindow.xaml.cs
文件中,确保以下using
语句到位:
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using System.Drawing;
接下来,为CancellationToken
添加一个类级变量,并添加 Cancel 按钮事件处理程序:
private CancellationTokenSource _cancelToken = null;
private void cmdCancel_Click(object sender, EventArgs e)
{
_cancelToken.Cancel();
}
过程和前面的例子一样:获取图片目录,创建输出目录,获取图片文件,旋转,保存到新目录。代替使用Parallel.ForEach()
,这个新版本将使用async
方法来完成工作,并且方法签名接受一个CancellationToken
作为参数。输入以下代码:
private async void cmdProcess_Click(object sender, EventArgs e)
{
_cancelToken = new CancellationTokenSource();
var basePath = Directory.GetCurrentDirectory();
var pictureDirectory =
Path.Combine(basePath, "TestPictures");
var outputDirectory =
Path.Combine(basePath, "ModifiedPictures");
//Clear out any existing files
if (Directory.Exists(outputDirectory))
{
Directory.Delete(outputDirectory, true);
}
Directory.CreateDirectory(outputDirectory);
string[] files = Directory.GetFiles(
pictureDirectory, "*.jpg", SearchOption.AllDirectories);
try
{
foreach(string file in files)
{
try
{
await ProcessFile(
file, outputDirectory,_cancelToken.Token);
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
}
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
catch (Exception ex)
{
Console.WriteLine(ex);
throw;
}
_cancelToken = null;
this.Title = "Processing complete";
}
在初始设置之后,代码遍历文件,并为每个文件异步调用ProcessFile()
。对ProcessFile()
的调用被包装在一个try
/ catch
块中,CancellationToken
被传递给ProcessFile()
方法。如果在CancellationTokenSource
上执行Cancel()
(比如当用户点击取消按钮时),就会抛出OperationCanceledException
。
Note
try
/ catch
代码可以在调用链中的任何地方(您很快就会看到)。是将它放在第一次调用中还是放在异步方法本身中,这纯粹是偏好和应用需求的问题。
要添加的最后一个方法是ProcessFile()
方法。
private async Task ProcessFile(string currentFile,
string outputDirectory, CancellationToken token)
{
string filename = Path.GetFileName(currentFile);
using (Bitmap bitmap = new Bitmap(currentFile))
{
try
{
await Task.Run(() =>
{
Dispatcher?.Invoke(() =>
{
this.Title = $"Processing {filename}";
});
bitmap.RotateFlip(RotateFlipType.Rotate180FlipNone);
bitmap.Save(Path.Combine(outputDirectory, filename));
}
,token);
}
catch (OperationCanceledException ex)
{
Console.WriteLine(ex);
throw;
}
}
}
这个方法使用了Task.Run
命令的另一个重载,将CancellationToken
作为一个参数。这个Task.Run
命令被封装在一个try
/ catch
块中(就像调用代码一样),以防用户点击取消按钮。
异步流(新 8.0)
C# 8.0 中的新特性,流(在第二十章中讨论)可以异步创建和使用。返回异步流的方法
-
是用
async
修饰符声明的 -
返回一个
IAsyncEnumerable<T>
-
包含用于返回异步流中连续元素的
yield return
语句(在第八章中介绍)
举以下例子:
public static async IAsyncEnumerable<int> GenerateSequence()
{
for (int i = 0; i < 20; i++)
{
await Task.Delay(100);
yield return i;
}
}
该方法被声明为async
,返回一个IAsyncEnumerable<int>
,并使用yield return
从序列中返回整数。若要调用此方法,请将以下内容添加到调用代码中:
await foreach (var number in GenerateSequence())
{
Console.WriteLine(number);
}
异步包装并等待
这一节包含了很多例子;这一部分的要点如下:
-
方法(以及 lambda 表达式或匿名方法)可以用
async
关键字标记,以使方法能够以非阻塞方式工作。 -
标有
async
关键字的方法(以及 lambda 表达式或匿名方法)将同步运行,直到遇到await
关键字。 -
一个
async
方法可以有多个await
上下文。 -
当遇到
await
表达式时,调用线程被挂起,直到等待的任务完成。同时,控制权将返回给方法的调用方。 -
关键字
await
将从视图中隐藏返回的Task
对象,看起来像是直接返回底层返回值。没有返回值的方法简单地返回void
。 -
参数检查和其他错误处理应该在方法的主要部分完成,实际的
async
部分被移到私有函数中。 -
对于堆栈变量,
ValueTask
比Task
对象更有效,这可能会导致装箱和取消装箱。 -
作为命名约定,异步调用的方法应该用后缀
Async
标记。
摘要
本章从检查System.Threading
名称空间的角色开始。正如您所了解的,当应用创建额外的执行线程时,结果是相关程序可以同时(看起来)执行许多任务。您还研究了几种保护线程敏感的代码块的方式,以确保共享资源不会变成不可用的伪数据单元。
然后,本章研究了一些新的模型,用于处理。NET 4.0,特别是任务并行库和 PLINQ。我总结了一下async
和await
关键字的作用。正如你所看到的,这些关键字在后台使用了许多类型的 TPL 框架;然而,编译器为您完成了创建复杂线程和同步代码的大部分工作。