C#10 编程指南(七)
原文:
zh.annas-archive.org/md5/f6bf98ae10aa686be15d58fe9358e0e2译者:飞龙
第十六章:多线程
多线程使应用程序能够同时执行多个代码片段。这样做的两个常见原因之一是利用计算机的并行处理能力——多核 CPU 现在几乎无处不在,为了充分发挥性能潜力,您需要提供多个工作流以让所有核心有些有用的事情做。编写多线程代码的另一个常见原因是防止在执行缓慢操作(例如从磁盘读取)时进展停滞。
解决第二个问题的方式并不只有多线程——异步技术可能更可取。C# 提供了支持异步工作的特性。异步执行不一定意味着多线程,但实际上两者通常相关,我将在本章节描述一些异步编程模型。然而,本章节侧重于线程的基础知识。我将在第十七章中描述语言级别支持异步代码的特性。
线程
所有能运行 .NET 的操作系统都允许每个进程包含多个线程(尽管如果构建到 Web Assembly 并在浏览器中运行代码,当前特定环境不支持创建新线程)。每个线程都有自己的堆栈,操作系统呈现的假象是线程获得整个 CPU 硬件线程 用于自己。 (见下一个侧边栏,“处理器、核心和硬件线程”)您可以创建比计算机提供的硬件线程数量更多的操作系统线程,因为操作系统虚拟化 CPU,从一个线程切换到另一个线程。我写这篇文章时使用的计算机有 16 个硬件线程,这是一个相当慷慨的数量,但比机器上运行的各种进程当前活动的 8,893 个线程还远远不够。
CLR 在操作系统线程之上提供自己的线程抽象。在 .NET Core 和 .NET 中,将始终存在直接关系——每个Thread对象直接对应于某个特定的底层操作系统线程。在 .NET Framework 中,这种关系并不保证存在——使用 CLR 的非托管托管 API 来自定义 CLR 和其包含进程之间的关系的应用程序理论上可以导致 CLR 线程在不同的操作系统线程之间移动。实际上,这种能力极少被使用,因此即使在 .NET Framework 中,在实践中每个 CLR 线程通常也会对应一个操作系统线程。
我将很快介绍Thread类,但在编写多线程代码之前,您需要了解在使用多个线程时管理状态的基本规则¹。
线程、变量和共享状态
每个 CLR 线程都拥有各自的线程特定资源,比如调用栈(保存方法参数和一些局部变量)。因为每个线程都有自己的栈,最终存储在其中的局部变量将仅属于该线程。每次调用方法时,都会得到一个新的局部变量集合。递归依赖于此特性,但在多线程代码中同样重要,因为对多个线程可访问的数据进行处理需要更多的注意,特别是如果数据发生变化的情况下。协调对共享数据的访问是复杂的。我将在“同步”章节中描述一些技术,但在可能的情况下最好避免这个问题,而栈的线程局部特性能够极大地帮助解决问题。
举例来说,考虑一个基于 Web 的应用程序。繁忙的站点必须同时处理来自多个用户的请求,因此您很可能会遇到这样一种情况:某个特定的代码(例如您站点首页的代码)同时在多个不同的线程上执行—ASP.NET Core 使用多线程能够为多个用户提供相同的逻辑页面。(网站通常不只是简单地提供相同的内容,因为页面通常根据特定用户进行定制,所以如果有 1,000 个用户请求查看主页,它将执行生成该页面的代码 1,000 次。)ASP.NET Core 提供了各种您的代码需要使用的对象,但其中大多数都是特定于特定请求的。因此,如果您的代码能够完全使用这些对象和局部变量,每个线程可以完全独立运行。如果需要共享状态(例如对多个线程可见的对象,可能通过静态字段或属性),生活将变得更加困难,但局部变量通常是比较简单的。
为什么只是“通常”?如果使用 lambda 表达式或匿名函数,情况会变得更加复杂,因为它们允许在包含方法中声明变量,然后在内部方法中使用该变量。现在这个变量对两个或更多方法都是可用的,并且在多线程情况下,这些方法可能会并发执行。(就 CLR 而言,它不再是真正的局部变量,而是编译器生成类中的字段。)在多个方法之间共享局部变量会导致局部性的保证丧失,因此您需要像对待更明显共享的项目(如静态属性和字段)那样谨慎对待这些变量。
在多线程环境中,另一个需要记住的重要点是变量和它所引用的对象之间的区别。(这仅涉及引用类型变量的问题。)尽管局部变量仅在其声明方法内部可访问,但该变量可能并不是唯一引用特定对象的变量。有时候它可能是——如果你在方法内部创建对象并且从未将其存储在任何可以使其对更广泛的受众可访问的地方,那么你就无需担心。示例 16-1 创建的StringBuilder仅在创建它的方法内部使用。
示例 16-1. 对象可见性和方法
public static string FormatDictionary<TKey, TValue>(
IDictionary<TKey, TValue> input)
{
var sb = new StringBuilder();
foreach (var item in input)
{
sb.AppendFormat("{0}: {1}", item.Key, item.Value);
sb.AppendLine();
}
return sb.ToString();
}
此代码无需担心其他线程可能试图修改StringBuilder。这里没有嵌套方法,因此sb变量确实是局部的,而这是唯一包含对StringBuilder的引用的内容。(这依赖于StringBuilder并未在其他线程可能看到的任何地方秘密存储其this引用的事实。)
但是input参数呢?它也是方法的局部变量,但它引用的对象却不是:调用FormatDictionary的代码得决定input引用的是什么。单看示例 16-1,无法确定它所引用的字典对象是否正在被其他线程使用。调用代码可能创建一个字典,并创建两个线程,其中一个修改字典,而另一个调用此FormatDictionary方法。这会造成问题:大多数字典实现不支持在一个线程修改字典的同时另一个线程使用它。即使你正在使用一个设计用于处理并发使用的集合,通常也不允许在枚举其内容的同时修改集合(例如foreach循环)。
你可能会认为任何设计用于同时从多个线程使用的集合(你可以说是线程安全集合)应该允许一个线程在修改其内容的同时另一个线程迭代其内容。如果不允许这样做,那么它如何是线程安全的呢?实际上,在此场景中,线程安全集合与非线程安全集合的主要区别在于可预测性:当它检测到这种情况发生时,线程安全集合可能会抛出异常,而非线程安全集合则不能保证会执行任何特定的操作。它可能会崩溃,或者你可能会从迭代中得到令人困惑的结果,例如单个条目多次出现。它可能会做任何事情,因为你正在不支持的方式中使用它。有时候,线程安全意味着失败以明确定义和可预测的方式发生。
事实上,System.Collection.Concurrent 命名空间中的各种集合确实支持在进行枚举时进行更改而不抛出异常。然而,它们大多数具有与其他集合类不同的 API,专门支持并发,因此它们通常不能直接替换。
没有任何方法可以确保 示例 16-1 在多线程环境中安全地使用其 input 参数,因为它完全取决于其调用者。并发危害需要在更高的级别处理。事实上,“线程安全”这个术语可能是误导性的,因为它暗示了一般情况下不可能的事情。经验不足的开发人员经常陷入这样的陷阱,认为只要确保他们使用的所有对象都是线程安全的,他们就免于思考其代码中的线程问题责任。但这通常行不通,因为虽然单个线程安全对象会维护其自身的完整性,但这并不能保证你的应用程序状态作为一个整体是一致的。
为了说明这一点,示例 16-2 使用了 System.Collections.Concurrent 命名空间中的 ConcurrentDictionary<TKey, TValue> 类。该类定义的每个操作在某种意义上都是线程安全的,因为每个操作都会使对象保持一致的状态,并且会在调用前产生预期的结果。然而,这个例子却构造出了一个非线程安全的使用方式。
示例 16-2. 非线程安全的线程安全集合使用
static string UseDictionary(ConcurrentDictionary<int, string> cd)
{
cd[1] = "One";
return cd[1];
}
看起来这似乎不会失败。(这也似乎毫无意义;这只是为了展示即使是一个非常简单的代码片段也可能出错。)但是如果字典实例被多个线程使用(考虑到我们选择的是专为多线程使用而设计的类型),完全有可能在设置键 1 的值并尝试检索它之间,某些其他线程已经删除了该条目。如果我将这段代码放入一个程序中,该程序在多个线程上重复运行此方法,但也有几个其他线程忙于删除相同的条目,我最终会看到 KeyNotFoundException。
并发系统需要一种自上而下的策略来确保系统范围内的一致性。(这就是为什么数据库管理系统通常使用事务的原因,事务将一组操作组合在一起作为原子工作单元,要么完全成功,要么完全不影响。这种原子分组是事务帮助确保系统范围内状态一致性的关键部分。)查看 示例 16-1,这意味着调用 FormatDictionary 的代码负责确保字典在方法执行期间可以自由使用。
警告
虽然调用代码应确保它传递的任何对象在方法调用期间都是安全使用的,但通常不能假设可以保存对参数的引用以供将来使用。匿名函数和委托使得意外地这样做变得容易——如果嵌套方法引用其包含方法的参数,并且如果该嵌套方法在包含方法返回后运行,则不能再安全地假设您被允许访问参数所引用的对象。如果需要这样做,您需要记录您对何时可以使用对象的假设,并检查调用方法的任何代码以确保这些假设是有效的。
线程本地存储
有时在比单个方法更广泛的范围内维护线程本地状态可能很有用。运行时库的各个部分都在做这件事。例如,System.Transactions 命名空间定义了一个用于与数据库、消息队列和任何支持它们的资源管理器使用事务的 API。它提供了一个隐式模型,您可以在其中启动环境事务,并且任何支持此事务的操作将自动加入其中,而无需传递任何显式的与事务相关的参数。(它还支持显式模型,如果您更喜欢的话。)Transaction 类的静态 Current 属性返回当前线程的环境事务,如果当前线程没有正在进行的环境事务,则返回 null。
为了支持这种每线程状态,.NET 提供了 ThreadLocal<T> 类。示例 16-3 使用它来为委托提供包装,该委托仅允许在任一线程上任一时间只有一个对委托的调用进行中。
使用 ThreadLocal<T> 的示例 16-3
class Notifier
{
private readonly Action _callback;
private readonly ThreadLocal<bool> _isCallbackInProgress = new();
public Notifier(Action callback)
{
_callback = callback;
}
public void Notify()
{
if (_isCallbackInProgress.Value)
{
throw new InvalidOperationException(
"Notification already in progress on this thread");
}
try
{
_isCallbackInProgress.Value = true;
_callback();
}
finally
{
_isCallbackInProgress.Value = false;
}
}
}
如果 Notify 回调的方法尝试再次调用 Notify,这将通过抛出异常来阻止递归尝试。但是,因为它使用 ThreadLocal<bool> 来跟踪是否正在进行调用,这将允许同时调用,只要每次调用发生在不同的线程上。
您可以通过 Value 属性获取和设置 ThreadLocal<T> 为当前线程保存的值。构造函数是重载的,您可以传递一个 Func<T>,每次新线程首次尝试检索值时都会调用它以创建默认初始值。(初始化是惰性的——回调不会在每次新线程启动时都运行。ThreadLocal<T> 仅在新线程首次尝试使用值时调用回调。)您可以创建的 ThreadLocal<T> 对象数量没有固定限制。
ThreadLocal<T>还为跨线程通信提供了一些支持。如果你向接受布尔值的某个构造函数重载传递true参数,该对象将维护一个报告每个线程存储的最新值的集合,可以通过其Values属性获取。仅在构造对象时请求此服务时,它才提供此服务,因为这需要额外的管理工作。此外,如果你使用引用类型作为类型参数,启用跟踪可能意味着对象的存活时间会更长。通常情况下,线程在ThreadLocal<T>中存储的任何引用在线程终止时将不再存在,如果该引用是使对象可达的唯一引用,垃圾回收器将能够回收其内存。但如果启用跟踪,所有这些引用将在ThreadLocal<T>实例本身可达期间保持可达,因为Values即使对于已终止的线程也会报告值。
关于线程本地存储,有一件事需要特别注意。如果你为每个线程创建一个新对象,请注意应用程序可能在其生命周期内创建大量线程,特别是如果你使用线程池(稍后将详细描述)。如果你创建的每个线程对象很昂贵,这可能会引起问题。此外,如果存在任何一次性的线程本地资源,你不一定知道何时线程终止;线程池会定期创建和销毁线程,而无需告知你。
如果你不需要每次新线程首次使用线程本地存储时自动创建对象,你可以简单地使用[ThreadStatic]属性标注静态字段。这由 CLR 处理:这意味着每个访问此字段的线程都会得到自己独立的字段。这可以减少需要分配的对象数量。但要小心:对于这些字段可以定义字段初始化器,但初始化器仅在第一个访问该字段的线程运行时执行。对于使用相同[ThreadStatic]的其他线程,字段最初将包含该字段类型的默认零值。
最后需要注意的一点是:如果你计划使用 第十七章 中描述的异步语言特性,那么要谨慎使用线程本地存储(以及基于它的任何机制),因为这些特性使得单个方法的调用可以在进展过程中使用多个不同的线程。对于这种类型的方法来说使用环境事务或依赖于线程本地状态的任何其他事物将是一个不好的主意。许多.NET 功能可能会使用线程本地存储(例如,ASP.NET Core 框架的静态 HttpContext.Current 属性,它返回与当前线程处理的 HTTP 请求相关的对象),实际上是与称为执行上下文的东西关联的信息。执行上下文更加灵活,因为它可以在需要时跨线程跳转。我稍后会进行描述。
要使我刚刚讨论的问题变得相关,我们需要使用多个线程。有四种主要方法可以使用多线程。一种方法是在你的代表创建多个线程的框架中运行代码,例如 ASP.NET Core。另一种方法是使用某些类型的基于回调的 API。有关此的一些常见模式描述在 “任务” 和 “其他异步模式” 中。但是使用线程的两种最直接的方法是显式创建新线程或使用.NET 线程池。
线程类
正如我之前提到的,Thread 类(定义在 System.Threading 命名空间中)表示一个 CLR 线程。你可以通过 Thread.CurrentThread 属性获得一个代表执行你的代码的线程的 Thread 对象的引用,但是如果你想要引入一些多线程,你可以构造一个新的 Thread 对象。
当一个新线程开始时,它需要知道它应该运行哪些代码,因此你必须提供一个委托,线程将在开始时调用委托引用的方法。线程会运行直到该方法正常返回,或允许异常传播到堆栈顶部(或线程通过任何操作系统机制被强制终止或杀死其包含的进程)。示例 16-4 创建了三个线程同时下载三个网页的内容。
示例 16-4. 创建线程
internal static class Program
{
private static readonly HttpClient http = new();
private static void Main(string[] args)
{
Thread t1 = new(MyThreadEntryPoint);
Thread t2 = new(MyThreadEntryPoint);
Thread t3 = new(MyThreadEntryPoint);
t1.Start("https://endjin.com/");
t2.Start("https://oreilly.com/");
t3.Start("https://dotnet.microsoft.com/");
}
private static void MyThreadEntryPoint(object? arg)
{
string url = (string)arg!;
Console.WriteLine($"Downloading {url}");
var response = http.Send(new HttpRequestMessage(HttpMethod.Get, url));
using StreamReader r = new(response.Content.ReadAsStream());
string page = r.ReadToEnd();
Console.WriteLine($"Downloaded {url}, length {page.Length}");
}
}
Thread构造函数有重载,并接受两种委托类型。ThreadStart委托需要一个不带参数且不返回值的方法,但在示例 16-4 中,MyThreadEntryPoint方法接受一个object参数,这与另一个委托类型ParameterizedThreadStart匹配。这提供了一种方法来向每个线程传递参数,这在多个不同线程调用相同方法时非常有用,就像这个示例所做的那样。线程在调用Start之前不会运行,并且如果使用ParameterizedThreadStart委托类型,则必须调用接受单个object参数的重载。我正在使用这个功能让每个线程从不同的 URL 下载。
Thread构造函数还有两个重载,每个在委托参数之后添加一个int参数。这个int指定线程的堆栈大小。当前的.NET 实现要求堆栈在内存中是连续的,因此需要预分配堆栈的地址空间。如果线程耗尽了这个空间,CLR 会抛出StackOverflowException。(通常只有在错误导致无限递归时才会看到这些异常。)如果没有提供这个参数,CLR 将使用进程的默认堆栈大小。(这取决于操作系统;在 Windows 上通常为 1 MB。您可以通过设置DOTNET_DefaultStackSize环境变量来更改它。请注意,它将该值解释为十六进制数。)通常情况下不需要更改这个设置,但也不是不可能的。如果您有产生非常深堆栈的递归代码,可能需要在具有较大堆栈的线程上运行它。相反,如果您创建大量线程,可能希望减少堆栈大小以节省资源,因为默认的 1 MB 通常远远超过实际需要的量。但是,通常不建议创建如此大量的线程。因此,在大多数情况下,您将仅创建适度数量的线程,并使用使用默认堆栈大小的构造函数。
注意,在示例 16-4 中的Main方法在启动三个线程后立即返回。尽管如此,应用程序会继续运行——直到所有线程都完成为止。CLR 会保持进程处于活动状态,直到没有正在运行的前台线程为止,其中前台线程指的是未明确指定为后台线程的任何线程。如果要阻止特定线程继续运行进程,请将其IsBackground属性设置为true。(这意味着后台线程可能会在执行过程中被终止,因此在这些线程上执行的工作需要小心。)
直接创建线程并非唯一选择。线程池提供了一个常用的替代方案。
线程池
在大多数操作系统中,创建和关闭线程相对昂贵。如果需要执行一段相对较短的工作(例如提供一个网页或类似的简短操作),创建一个线程来完成这项工作,并在完成后将其关闭是一个不好的主意。这个策略有两个严重问题:首先,你可能会在启动和关闭成本上消耗更多资源,而不是在有用的工作上;其次,如果你不断创建新线程以应对更多的工作,系统在负载下可能会变得停滞不前——在重负载情况下,创建越来越多的线程往往会降低吞吐量。这是因为,除了基本的每个线程的开销,如堆栈所需的内存外,操作系统需要定期在可运行的线程之间切换,以使它们都能进展,而这种切换本身也有开销。
为避免这些问题,.NET 提供了一个线程池。你可以提供一个委托,运行时将调用线程池中的一个线程。如果有必要,它将创建一个新线程,但在可能的情况下,它将重用之前创建的线程,如果所有创建的线程都在忙,它可能会将你的工作等待在队列中。在方法运行后,CLR 通常不会终止线程;相反,线程将留在池中,等待其他工作项在多个工作项之间摊销创建线程的成本。如果有必要,它会创建新线程,但它尝试将线程数保持在一个水平,以使可运行线程的数量匹配硬件线程的数量,以最小化切换成本。
警告
线程池始终创建后台线程,因此,如果线程池在你的进程中最后一个前台线程退出时正在执行某些操作,工作将不会完成,因为所有后台线程将在此时终止。如果需要确保线程池上的工作完成,你必须在允许所有前台线程完成之前等待其完成。
使用 Task 启动线程池工作
使用线程池的常用方法是通过 Task 类。这是任务并行库的一部分(在“任务”中有更详细的讨论),但其基本使用非常简单,如示例 16-5 所示。
示例 16-5。使用 Task 在线程池上运行代码
Task.Run(() => MyThreadEntryPoint("https://oreilly.com/"));
这将 lambda 排队等待在线程池上执行(当它运行时,只调用来自示例 16-4 的 MyThreadEntryPoint 方法)。如果有线程可用,它将立即开始运行;否则,它将等待在队列中,直到有线程可用(要么是因为其他正在进行的工作项完成,要么是因为线程池决定向池中添加新线程)。
还有其他使用线程池的方法,其中最明显的是通过ThreadPool类。其QueueUserWorkItem方法的工作方式与Start类似——你传递一个委托,它将方法排队等待执行。这是一个较低级别的 API,它不提供任何直接处理工作完成的方式,也不能链式操作,所以在大多数情况下,Task类更可取。
线程创建启发式方法
运行时根据你提供的工作负载调整线程数量。它使用的启发式方法没有记录并且在.NET 的不同版本中已经改变,因此你不应依赖于我即将描述的确切行为;然而,大致了解可以预期发生的事情仍然很有用。
如果你只给线程池 CPU 绑定的工作,即你要求它执行的每个方法都花费全部时间进行计算,并且从不阻塞等待 I/O 完成,你可能会得到与系统中每个硬件线程相对应的一个线程池线程(尽管如果单个工作项目耗时足够长,线程池可能会决定分配更多线程)。例如,在我写这篇文章时使用的八核双路超线程计算机上,首先排队一堆 CPU 密集型工作项目会导致 CLR 创建 16 个线程池线程,并且只要工作项目大约每秒完成一次,线程数量大部分时候都保持在这个水平(偶尔会超过,因为运行时会尝试不时添加额外的线程以查看其对吞吐量的影响,然后再次降回来)。但是,如果程序处理项目的速度下降,CLR 会逐渐增加线程计数。
如果线程池线程被阻塞(例如,它们正在等待来自磁盘的数据或者从服务器上的网络响应),CLR 会更快地增加线程池线程的数量。同样,它从每个硬件线程开始,但是当慢工作项目几乎不消耗处理器时间时,它可以每秒添加两次线程。
无论哪种情况,CLR 最终会停止添加线程。在 32 位进程中,确切的默认限制因 .NET 版本而异,通常约为 1,000 个线程。在 64 位模式下,默认值似乎是 32,767。您可以更改此限制——ThreadPool 类具有 SetMaxThreads 方法,允许您为进程配置不同的限制。您可能会遇到其他限制,从而导致更低的实际限制。例如,每个线程都有自己的堆栈,必须占用虚拟地址空间的连续范围。默认情况下,每个线程获得 1 MB 的进程地址空间用于其堆栈,因此当您有 1,000 个线程时,仅用于堆栈的地址空间就将达到 1 GB。32 位进程仅有 4 GB 的地址空间,因此您可能没有足够的空间来请求的线程数量。无论如何,1,000 个线程通常比有用的更多,因此如果达到这么高,这可能是您应该调查的一些潜在问题的症状。因此,如果调用 SetMaxThreads,通常会是为了指定一个较低的限制——您可能会发现,在某些工作负载下,通过限制线程数量来减少对系统资源的争用程度,从而提高吞吐量。
ThreadPool 还具有 SetMinThreads 方法。这使您可以确保线程数不会低于某个数字。这对于那些希望在最小数量的线程下能够立即以最大速度运行,而不必等待线程池的启发式算法调整线程计数的应用程序非常有用。
线程亲和性和同步上下文
有些对象要求您只能从特定的线程中使用它们。这在 UI 代码中特别常见——WPF 和 Windows Forms UI 框架要求 UI 对象必须从创建它们的线程上使用。这被称为线程亲和性,虽然它通常是一个 UI 的关注点,但也可能在互操作性场景中出现——一些 COM 对象具有线程亲和性。
如果您想编写多线程代码,线程亲和性可能会让生活变得尴尬。假设您已经精心实现了一个多线程算法,可以利用最终用户计算机上的所有硬件线程,在多核 CPU 上运行时显著提高性能,与单线程算法相比。一旦算法完成,您可能希望向最终用户呈现结果。UI 对象的线程亲和性要求您在特定线程上执行最后一步操作,但您的多线程代码可能会在其他线程上生成最终结果。(事实上,为了确保 UI 在进行工作时保持响应,您可能完全避免使用 UI 线程进行 CPU 密集型工作。)如果您试图从某个随机的工作线程更新 UI,则 UI 框架将抛出异常,指责您违反了其线程亲和性要求。您需要想办法将消息传回 UI 线程,以便它可以显示结果。
运行时库提供了SynchronizationContext类来帮助处理这些情况。其Current静态属性返回一个SynchronizationContext类的实例,表示当前代码运行的上下文。例如,在 WPF 应用程序中,如果在 UI 线程上检索此属性,它将返回与该线程关联的对象。您可以存储Current返回的对象,并在任何时候从任何线程使用它来执行进一步的 UI 线程工作。示例 16-6 正是这样做的,以便它可以在线程池线程上执行一些潜在的缓慢工作,然后在 UI 线程上更新 UI。
示例 16-6. 使用线程池然后SynchronizationContext
private void findButton_Click(object sender, RoutedEventArgs e)
{
`SynchronizationContext` `uiContext` `=` `SynchronizationContext``.``Current``!``;`
Task.Run(() =>
{
string pictures =
Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
var folder = new DirectoryInfo(pictures);
FileInfo[] allFiles =
folder.GetFiles("*.jpg", SearchOption.AllDirectories);
FileInfo? largest =
allFiles.OrderByDescending(f => f.Length).FirstOrDefault();
if (largest is not null)
{
`uiContext``.``Post``(``_` `=``>`
{
long sizeMB = largest.Length / (1024 * 1024);
outputTextBox.Text =
$"Largest file ({sizeMB}MB) is {largest.FullName}";
},
null);
}
});
}
此代码处理按钮的Click事件。(它恰好是一个 WPF 应用程序,但SynchronizationContext在其他桌面 UI 框架(如 Windows Forms)中的工作方式完全相同。)UI 元素在 UI 线程上引发其事件,因此当点击处理程序的第一行检索当前的SynchronizationContext时,它将获取 UI 线程的上下文。然后,代码通过Task类在线程池线程上运行一些工作。该代码查看用户“图片”文件夹中的每张图片,搜索最大的文件,因此可能需要一段时间。在 UI 线程上执行缓慢的工作是一个坏主意——属于该线程的 UI 元素在 UI 线程忙于其他事情时无法响应用户输入。因此将其推入线程池是个好主意。
在这里使用线程池的问题在于,一旦工作完成,我们就在错误的线程上更新 UI。此代码更新文本框的 Text 属性,如果我们尝试从线程池线程中执行此操作,将会抛出异常。因此,当工作完成时,它使用先前检索的 SynchronizationContext 对象,并调用其 Post 方法。该方法接受一个委托,并安排在 UI 线程上调用它。在幕后,它向 Windows 消息队列发布一个自定义消息,当 UI 线程的主消息处理循环接收到该消息时,将调用委托。
提示
Post 方法不等待工作完成。有一个名为 Send 的方法可以等待,但我建议不要使用它。使工作线程在等待 UI 线程执行某些操作时阻塞可能存在风险,因为如果 UI 线程当前正在等待工作线程执行某些操作,应用程序将出现死锁。Post 通过允许工作线程与 UI 线程并发运行来避免此问题。
示例 16-6 在开始线程池工作之前,仍然在 UI 线程上检索 SynchronizationContext.Current。这很重要,因为这个静态属性是上下文敏感的——只有在 UI 线程上时,它才返回 UI 线程的上下文。事实上,在 WPF 中,每个窗口可能都有自己的 UI 线程,因此不可能有一个返回 the UI 线程的 API——可能会有多个。如果你从线程池线程读取此属性,它返回的上下文对象将无法将工作发布到 UI 线程上。
SynchronizationContext 机制是可扩展的,因此如果需要,你可以从中派生自己的类型,并可以调用其静态方法 SetSynchronizationContext 将你的上下文设为当前线程的上下文。在单元测试场景中这非常有用——它使你能够编写测试来验证对象是否正确地与 SynchronizationContext 交互,而无需创建真正的 UI。
执行上下文
SynchronizationContext 类有一个类似的伙伴,叫做 ExecutionContext。它提供了类似的服务,允许你捕获当前的上下文,然后稍后在同一上下文中运行委托,但有两点不同。首先,它捕获不同的内容。其次,它使用不同的方法重新建立上下文。SynchronizationContext 通常会在特定的线程上运行你的工作,而 ExecutionContext 则总是使用你的线程,并确保所有捕获的上下文信息都可在该线程上使用。区分这两者的一种思路是,SynchronizationContext 在现有上下文中完成工作,而 ExecutionContext 则将上下文信息带给你。
警告
有点令人困惑的是,在 .NET Framework 上,ExecutionContext 的实现捕获当前的 SynchonizationContext,因此从某种意义上讲,ExecutionContext 是 SynchronizationContext 的超集。然而,ExecutionContext 在调用委托时不使用捕获的 SynchronizationContext。它所做的只是确保,如果通过 ExecutionContext 执行的代码读取 SynchonizationContext.Current 属性,它将获取在捕获 ExecutionContext 时当前的 SynchronizationContext 属性。这不一定是当前线程正在运行的 SynchonizationContext!这个设计缺陷在 .NET Core 中已经修复。
调用 ExecutionContext.Capture 方法可以检索当前的上下文。执行上下文不会捕获线程局部存储,但会包括当前的逻辑调用上下文中的任何信息。您可以通过 CallContext 类访问这些信息,该类提供 LogicalSetData 和 LogicalGetData 方法来存储和检索名称/值对,或通过更高级的包装器 AsyncLocal<T> 访问。这些信息通常与当前线程相关联,但是如果在捕获的执行上下文中运行代码,即使该代码在完全不同的线程上运行,逻辑上下文中的信息也将可用。
.NET 在一些异步模式中描述的情况下,当长时间运行的工作从一个线程开始,最终在另一个线程上继续时(这在本章的某些异步模式中会发生),会在内部使用 ExecutionContext 类。如果您编写接受稍后将调用的回调函数的任何代码,您可能希望以类似的方式使用执行上下文。为此,您调用 Capture 方法来获取当前的上下文,稍后可以将其传递给 Run 方法以调用委托。示例 16-7 展示了 ExecutionContext 的工作方式。
示例 16-7. 使用 ExecutionContext
public class Defer
{
private readonly Action _callback;
private readonly ExecutionContext? _context;
public Defer(Action callback)
{
_callback = callback;
_context = ExecutionContext.Capture()!;
}
public void Run()
{
if (_context is null) { _callback(); return; }
// When ExecutionContext.Run invokes the lambda we supply as the 2nd
// argument, it passes that lambda the value we supplied as the 3rd
// argument to Run. Here we're passing _callback, so the lambda has
// access to the Action we want to invoke. It would have been simpler
// to write "_ => _callback()", but the lambda would then need to
// capture 'this' to be able to access _callback, and that capture
// would cause an additional allocation.
ExecutionContext.Run(_context, (cb) => ((Action)cb!)(), _callback);
}
}
在 .NET Framework 中,单个捕获的 ExecutionContext 不能同时在多个线程上使用。有时您可能需要在特定上下文中调用多个不同的方法,在多线程环境中,您可能无法保证上一个方法在调用下一个方法之前已经返回。对于这种情况,ExecutionContext 提供了一个 CreateCopy 方法,生成上下文的副本,使您能够通过等效的上下文进行多个并发调用。在 .NET Core 和 .NET 中,ExecutionContext 是不可变的,这意味着不再受此限制,CreateCopy 方法只返回其自身引用。
同步
有时候,您可能需要编写多线程代码,其中多个线程可以访问相同的状态。例如,在第五章中,我建议服务器可以使用 Dictionary<TKey, TValue> 作为缓存的一部分,以避免在接收到多个类似请求时重复工作。虽然这种缓存在某些场景下可以提供显著的性能优势,但在多线程环境中却是一个挑战。(如果您正在处理具有严格性能要求的服务器代码,很可能需要多个线程来处理请求。)Dictionary<TKey, TValue> 类的文档中的线程安全部分指出:
Dictionary<TKey, TValue>可以支持多个读取器同时操作,只要不修改集合。即便如此,枚举集合本质上不是线程安全的过程。在枚举与写访问竞争的罕见情况下,必须在整个枚举过程中锁定集合。要允许集合被多个线程同时读取和写入,必须实现自己的同步。
这比我们所期望的要好——运行库中绝大多数类型根本不支持实例的多线程使用。大多数类型在类级别支持多线程使用,但是每个实例必须一次使用一个线程。 Dictionary<TKey, TValue> 更为宽松:它明确支持多个并发读取器,这对我们的缓存场景听起来很有利。然而,在修改集合时,我们不仅必须确保不会尝试同时从多个线程修改它,还必须确保在此期间没有正在进行的读取操作。
其他通用的集合类也提供类似的保证(不同于库中的大多数其他类)。例如,List<T>、Queue<T>、Stack<T>、SortedDictionary<TKey, TValue>、HashSet<T> 和 SortedSet<T> 都支持并发的只读使用。(同样,如果您修改了这些集合的任何实例,必须确保没有其他线程同时修改或读取同一个实例。)当然,在尝试多线程使用任何类型之前,您应该始终检查文档。² 请注意,通用集合接口类型不提供线程安全保证——尽管 List<T> 支持并发读取,但并非所有 IList<T> 的实现都会如此。 (例如,想象一个包装潜在缓慢内容的实现,比如文件内容。这种包装可能会缓存数据以提高读取操作的速度。从这样的列表中读取项目可能会改变其内部状态,因此如果代码没有采取保护措施,同时从多个线程进行读取可能会导致读取失败。)
如果你可以安排在多线程代码使用数据结构时永远不修改数据结构,则许多集合类提供的并发访问支持可能已经足够。但如果某些线程需要修改共享状态,则需要协调对该状态的访问。为此,.NET 提供了各种同步机制,可以确保在必要时线程轮流访问共享对象。在本节中,我将描述最常用的几种。
监视器和 lock 关键字
用于同步多线程共享状态的首选选项是 Monitor 类。这很受欢迎,因为它高效且提供了直观的模型,而且 C# 提供了直接的语言支持,使用起来非常简单。示例 16-8 展示了一个使用 lock 关键字(其实使用 Monitor 类)的类,每当它读取或修改其内部状态时都会使用它。这确保了只有一个线程会同时访问该状态。
示例 16-8. 使用 lock 保护状态
public class SaleLog
{
private readonly object _sync = new();
private decimal _total;
private readonly List<string> _saleDetails = new();
public decimal Total
{
get
{
lock (_sync)
{
return _total;
}
}
}
public void AddSale(string item, decimal price)
{
string details = $"{item} sold at {price}";
lock (_sync)
{
_total += price;
_saleDetails.Add(details);
}
}
public string[] GetDetails(out decimal total)
{
lock (_sync)
{
total = _total;
return _saleDetails.ToArray();
}
}
}
要使用 lock 关键字,你需要提供一个对象引用和一段代码块。C# 编译器生成的代码将导致 CLR 确保任何时候一个对象的 lock 块内不会有多于一个线程。假设你创建了 SaleLog 类的单个实例,并且在一个线程上调用了 AddSale 方法,而在另一个线程上同时调用了 GetDetails。两个线程都会达到 lock 语句,传入相同的 _sync 字段。无论哪个线程先到达,都将被允许运行 lock 后面的代码块。另一个线程将被阻塞,直到第一个线程离开其 lock 块为止。
SaleLog 类仅在使用 _sync 参数的 lock 块内部使用其字段。这确保所有对字段的访问都是串行化的(在并发意义上——即线程一次只能访问一个字段,而不是同时进入)。当 GetDetails 方法从 _total 和 _saleDetails 字段读取时,可以确信它得到的是一致的视图——总数将与销售详情列表的当前内容保持一致,因为修改这两个数据的代码都在单个 lock 块内执行。这意味着从使用 _sync 的任何其他 lock 块的视角来看,更新将看起来是原子的。
即使是用于返回总数的get访问器,使用lock块可能看起来有些过度。然而,decimal是一个 128 位的值,因此对于这种类型的数据访问并不是固有地原子的——如果没有那个lock,返回的值可能由_total在不同时间点上具有的两个或更多值的混合组成。(例如,底部 64 位可能来自比顶部 64 位更旧的值。)这经常被描述为破碎读。CLR 仅对大小不超过 4 字节的数据类型和引用保证原子读写,即使在引用大于 4 字节的平台上也是如此。(它仅对自然对齐字段保证这一点,但在 C#中,字段总是对齐的,除非你故意为了互操作目的而使它们错位。)
示例 16-8 的一个微妙但重要的细节是,每当它返回关于其内部状态的信息时,它都会返回一个副本。Total属性的类型是decimal,这是一个值类型,值总是作为副本返回。但是当涉及到条目列表时,GetDetails方法调用ToArray,它将构建一个包含列表当前内容副本的新数组。直接返回_saleDetails中的引用将是一个错误,因为这将使得SalesLog类外部的代码能够访问和修改集合而不使用lock。我们需要确保对该集合的所有访问都是同步的,如果我们的类向外部提供对其内部状态的引用,我们将失去这种能力。
提示
如果你编写的代码执行一些多线程工作,最终停止,那么在工作停止后共享对状态的引用是可以的。但是,如果对象正在进行多线程修改,你需要确保对该对象状态的所有使用都受到保护。
lock关键字接受任何对象引用,所以你可能会想知道为什么我专门创建了一个对象——不能直接传递this吗?那确实可以工作,但问题在于你的this引用不是私有的——它是外部代码使用你的对象的同一个引用。使用你对象的公开可见特性来同步访问私有状态是不明智的;其他代码可能会决定将你的对象的引用用作某些完全不相关的lock块的参数。在这种情况下,可能不会造成问题,但在更复杂的代码中,它可能会以一种可能导致性能问题甚至死锁的方式将概念上不相关的并发行为联系在一起。因此,通常最好以防御性编程,并使用只有你的代码可以访问的东西作为lock参数。当然,我可以使用_saleDetails字段,因为它引用了只有我的类可以访问的对象。然而,即使你进行防御性编程,也不应假设其他开发人员会这样做,因此一般来说,最安全的做法是避免使用你没有编写的类的实例作为lock的参数,因为你无法确定它是否在使用它自己的this引用进行自身的锁定目的。
你可以使用任何对象引用这一事实在任何情况下都有些奇怪。大多数.NET 的同步机制使用某种不同类型的实例作为同步的参考点。(例如,如果你想要读者/写者锁定语义,你会使用ReaderWriterLockSlim类的实例,而不仅仅是任意对象。)Monitor类(即lock使用的类)是一个例外,它可以追溯到与 Java 的某种程度兼容性的旧需求。(Java 有类似的锁原语。)这与现代.NET 开发无关,因此这个特性现在只是一个历史上的特殊情况。使用一个专门作为lock参数的独特对象,与锁定的成本相比增加了最小的开销,并且倾向于使同步管理变得更加清晰。
注意
你不能将值类型用作lock的参数——C#会阻止这样做,这是有道理的。编译器会在lock参数上执行隐式转换为object,对于引用类型来说,在运行时不需要 CLR 做任何事情。但是当你将值类型转换为object类型的引用时,需要创建一个装箱。那个装箱将成为lock的参数,这将是一个问题,因为每次将值转换为object引用时,都会得到一个新的装箱。因此,每次运行lock时,它会得到一个不同的对象,这意味着实际上没有同步。这就是为什么编译器阻止你尝试这样做的原因。
如何扩展 lock 关键字
每个 lock 块转换为代码,执行三件事情:首先,调用 Monitor.Enter,传递给 lock 的参数。然后尝试运行块中的代码。最后,一般情况下,一旦块完成,将调用 Monitor.Exit。但由于异常,情况并不完全简单。如果您在块中放置的代码引发异常,代码仍然会调用 Monitor.Exit,但需要处理 Monitor.Enter 本身引发异常的可能性,这意味着线程没有获取锁,因此不应调用 Monitor.Exit。示例 16-9 展示了编译器在 示例 16-8 中的 GetDetails 方法中 lock 块的处理方式。
示例 16-9. lock 块的展开方式
bool lockWasTaken = false;
object temp = _sync;
try
{
Monitor.Enter(temp, ref lockWasTaken);
{
total = _total;
return _saleDetails.ToArray();
}
}
finally
{
if (lockWasTaken)
{
Monitor.Exit(temp);
}
}
Monitor.Enter 是一个 API,它负责发现是否有其他线程已经拥有锁,并在这种情况下使当前线程等待。如果此操作返回,通常意味着获取锁成功。(可能会发生死锁,这种情况下它将永远不会返回。)由于内存耗尽等异常情况的发生,有可能会出现获取失败的小概率情况。虽然这种情况不太常见,但生成的代码仍会考虑这一点——这就是对 lockWasTaken 变量进行稍微绕远的代码的目的。(实际上,编译器会将其作为一个无法访问名称的隐藏变量。顺便说一句,我已经命名它以显示这里发生了什么。)Monitor.Enter 方法确保获取锁与更新指示锁是否被获取的标志是原子性的,这样 finally 块将仅在成功获取锁时尝试调用 Exit。
Monitor.Exit 告诉 CLR 我们不再需要对我们同步访问的任何资源进行独占访问,如果其他任何线程在对象内的 Monitor.Enter 中等待,则允许其中一个线程继续执行。编译器将此放置在 finally 块中,以确保无论您通过运行到末尾、从中间返回还是抛出异常退出块,锁都将被释放。
lock 块在异常发生时调用 Monitor.Exit,这是一把双刃剑。一方面,通过确保在失败时释放锁,它减少了死锁的可能性。另一方面,如果在修改某些共享状态时发生异常,系统可能处于不一致的状态;释放锁将允许其他线程访问该状态,可能导致进一步的问题。在某些情况下,如果异常发生时保持锁定状态可能更好——一个死锁的进程可能比在损坏状态下继续运行造成的危害小。更健壮的策略是编写能够在异常情况下保证一致性的代码,方法可以是如果异常阻止了完整的更新集,则回滚任何已进行的更改;或通过以原子方式改变状态(例如,将新状态放入一个全新对象,并仅在更新对象完全初始化后将其替换为先前的对象)。但这已经超出了编译器能自动处理的范围。
等待和通知
Monitor 类不仅仅用于确保线程轮流执行。它还提供了一种方法,让线程等待来自其他线程的通知。如果一个线程已经获取了特定对象的监视器,它可以调用 Monitor.Wait 并传入该对象。这有两个效果:释放监视器并使线程阻塞。线程将阻塞,直到其他线程为相同的对象调用 Monitor.Pulse 或 PulseAll。调用这些方法时,线程必须持有监视器。(Wait, Pulse 和 PulseAll 在没有持有相关监视器时会抛出异常。)
如果一个线程调用 Pulse,则允许一个等待在 Wait 中的线程唤醒。调用 PulseAll 则允许所有等待在该对象监视器上的线程运行。无论哪种情况,Monitor.Wait 在返回前都会重新获取监视器,因此即使调用 PulseAll,线程也会逐个唤醒——第二个线程在第一个线程释放监视器之前无法从 Wait 返回。事实上,直到调用 Pulse 或 PulseAll 的线程释放锁,没有线程能够从 Wait 返回。
示例 16-10 使用 Wait 和 Pulse 包装了一个 Queue<T>,使得从队列中检索项目的线程在队列为空时等待。(这只是为了说明,如果你需要这种类型的队列,不必自己编写,可以使用内置的 BlockingCollection<T> 或 System.Threading.Channels 中的类型。)
示例 16-10. Wait 和 Pulse
public class MessageQueue<T>
{
private readonly object _sync = new();
private readonly Queue<T> _queue = new();
public void Post(T message)
{
lock (_sync)
{
bool wasEmpty = _queue.Count == 0;
_queue.Enqueue(message);
if (wasEmpty)
{
Monitor.Pulse(_sync);
}
}
}
public T Get()
{
lock (_sync)
{
while (_queue.Count == 0)
{
Monitor.Wait(_sync);
}
return _queue.Dequeue();
}
}
}
本示例以两种方式使用监视器。它通过 lock 关键字确保一次只有一个线程使用保存排队项的 Queue<T>。但它还使用等待和通知使消费项的线程在队列为空时能够有效地阻塞,并使任何添加新项到队列的线程能唤醒被阻塞的读取线程。
超时
无论是等待通知还是尝试获取锁定,都可以指定超时,表示如果操作在指定时间内未成功,则希望放弃。对于锁的获取,使用不同的方法 TryEnter,但在等待通知时,只需使用不同的重载。(没有编译器支持这一点,因此你将无法使用 lock 关键字。)在两种情况下,你可以传递一个表示最大等待时间(以毫秒为单位)的 int 或 TimeSpan 值。两者都返回一个指示操作是否成功的 bool。
你可以使用这个方法来避免进程死锁,但如果你的代码在超时内未能获取锁,那么你就面临着如何处理这个问题的困扰。如果你的应用程序无法获取需要的锁,那么它不能简单地无视原本要做的工作。终止进程可能是唯一现实的选择,因为死锁通常是 bug 的症状,所以如果发生了,你的进程可能已经处于受损状态。尽管如此,一些开发人员对锁的获取可能不那么严格,可能认为死锁是正常的情况。在这种情况下,可能放弃你原本尝试的操作,稍后重试工作,或者只是记录一个失败,放弃这个特定的操作,并继续进行进程的其他工作,可能是一个可行的策略。但这可能是一种风险策略。
自旋锁
SpinLock 提供了与 Monitor 类的 Enter 和 Exit 方法类似的逻辑模型。(它不支持等待和通知。)它是一个值类型,因此在某些情况下,它可以减少需要分配以支持锁定的对象数量——Monitor 需要基于堆的对象。然而,它也更简单:它仅使用一种策略来处理争用,而 Monitor 从相同的策略开始,然后在一段时间后将切换到具有更高初始开销但如果涉及长时间等待则更有效的策略。
当调用 Enter 方法(无论是 Monitor 还是 SpinLock)时,如果锁可用,则会非常快地获取该锁——成本通常是少量的 CPU 指令。如果锁已被另一个线程持有,CLR 将在一个轮询锁的循环中等待(即自旋),直到锁被释放。如果锁仅被持有很短的时间,这可以是一种非常高效的策略,因为它避免了操作系统介入,并且在锁可用的情况下非常快速。即使存在争用,自旋在多核或多 CPU 系统上也可以是最有效的策略,因为如果锁仅被持有很短的时间(例如只需执行加法运算两个 decimal 的时间),线程在锁变得可用之前不必自旋很长时间。
Monitor 和 SpinLock 的区别在于,Monitor 最终会放弃自旋,转而使用操作系统的调度器。这将产生相当于执行许多千甚至百万次 CPU 指令的成本,这就是为什么 Monitor 开始时使用与 SpinLock 类似的方法。然而,如果锁长时间不可用,自旋效率低下——即使只自旋几毫秒,现代 CPU 上会涉及数百万次自旋,在这种情况下,执行成千上万条指令以有效地挂起线程看起来更好一些。(自旋在单核系统上也存在问题,因为自旋依赖于持有锁的线程能够取得进展。^(3)
SpinLock 没有后备策略。与 Monitor 不同,它会自旋,直到成功获取锁或超时(如果指定了超时)。因此,文档建议,如果在持有锁期间执行某些操作(例如等待 I/O 完成或调用可能阻塞的其他代码),不应使用 SpinLock。它还建议不要通过接口、虚方法或委托调用方法,或者分配内存。如果在做任何较为复杂的事情,最好还是使用 Monitor。然而,对于访问 decimal,SpinLock 可能是一种适当的保护方式,正如 Example 16-11 所示。
Example 16-11. 使用 SpinLock 保护 decimal 的访问
public class DecimalTotal
{
private decimal _total;
private SpinLock _lock;
public decimal Total
{
get
{
bool acquiredLock = false;
try
{
_lock.Enter(ref acquiredLock);
return _total;
}
finally
{
if (acquiredLock)
{
_lock.Exit();
}
}
}
}
public void Add(decimal value)
{
bool acquiredLock = false;
try
{
_lock.Enter(ref acquiredLock);
_total += value;
}
finally
{
if (acquiredLock)
{
_lock.Exit();
}
}
}
}
由于缺乏编译器支持,我们必须编写比使用lock更多的代码。也许这样做并不值得——因为Monitor在开始时会自旋,所以性能可能相似,因此这里唯一的好处是我们避免了为执行锁定而分配额外的堆对象。(SpinLock是一个struct,所以它存在于DecimalTotal对象的堆块内。)只有在通过性能分析证明在实际工作负载下它比监视器表现更好时,才应该使用SpinLock。
读者/写者锁
ReaderWriterLockSlim类提供了一种不同的锁定模型,与Monitor和SpinLock呈现的模型不同。使用ReaderWriterLockSlim时,获取锁时需指定自己是读取器还是写入器。该锁允许多个线程同时成为读取器。但是,当一个线程请求以写入器身份获取锁时,该锁会暂时阻止任何试图读取的线程,并等待所有已经在读取的线程释放其锁,然后才授予想要写入的线程访问权限。一旦写入器释放其锁,所有等待读取的线程就可以重新进入。这使得写入线程可以获得独占访问,但这也意味着当没有写入发生时,所有读取者可以并行进行。
警告
还有一个ReaderWriterLock类。不应使用它,因为即使没有锁争用,它也存在性能问题,并且当读取器和写入器线程都在等待获取锁时,它也会做出次优选择。较新的ReaderWriterLockSlim类已经存在很长时间(自.NET 3.5 起),并建议在所有场景中使用它而不是旧类。旧类仅保留用于向后兼容。
这听起来可能适合.NET 内置的许多集合类。正如我之前描述的,它们通常支持多个并发的读取线程,但要求修改必须由一个线程独占完成,并且在进行修改时没有读取器活动。然而,并不是在你偶尔同时有读者和写者的情况下就一定要选择这种锁。
尽管“slim”锁相较于其前身有了性能改进,但是获取该锁的时间仍比进入监视器要长。如果计划仅短时间持有该锁,可能更好直接使用监视器——通过更大的并发性提供的理论改进可能会被获取锁所需的额外工作所抵消。即使持有锁的时间较长,只有在更新偶尔发生时,读者/写者锁才会带来好处。如果有一连串的线程都想修改数据,你不太可能看到任何性能改进。
与所有性能驱动的选择一样,如果你考虑使用 ReaderWriterLockSlim 而不是普通监视器的简单替代方案,请在实际工作负载下用这两种选择来测量性能,看看这种变化是否有任何影响。
事件对象
Windows 的本机 API,Win32,一直提供了称为 事件 的同步原语。从 .NET 的角度来看,这个名称有点不幸,因为它定义了这个术语的完全不同含义,正如 第九章 中讨论的那样。在本节中,当我提到事件时,我指的是同步原语,除非我明确将其作为 .NET 事件进行限定。
ManualResetEvent 类提供了一种机制,其中一个线程可以等待另一个线程的通知。这与 Monitor 类的 Wait 和 Pulse 不同。首先,你不需要拥有监视器或其他锁定来等待或发出事件信号。其次,Monitor 类的脉冲方法只有在至少有一个其他线程在 Monitor.Wait 中阻塞在该对象上时才会起作用——如果没有任何等待,那么脉冲就好像从未发生过一样。但是 ManualResetEvent 记住它的状态——一旦发出信号,除非你通过调用 Reset 手动将其重置(因此得名),它将不会返回到未发出信号的状态。这使其在某些场景中非常有用,例如某个线程 A 无法继续直到另一个线程 B 完成了一些需要不可预测时间的工作。线程 A 可能需要等待,但当 A 检查时,线程 B 可能已经完成了工作。示例 16-12 使用了这种技术来执行一些重叠的工作。
示例 16-12. 使用 ManualResetEvent 等待工作完成
static void LogFailure(string message, string mailServer)
{
var email = new SmtpClient(mailServer);
`using` `(``var` `emailSent` `=` `new` `ManualResetEvent``(``false``)``)`
{
object sync = new();
bool tooLate = false; // Prevent call to Set after a timeout
`email``.``SendCompleted` `+``=` `(``_``,` `_``)` `=``>` `// (Event arguments unused here) ` `{`
`lock``(``sync``)`
`{`
`if` `(``!``tooLate``)` `{` `emailSent``.``Set``(``)``;` `}`
`}`
`}``;`
email.SendAsync("logger@example.com", "sysadmin@example.com",
"Failure Report", "An error occurred: " + message, null);
LogPersistently(message);
`if` `(``!``emailSent``.``WaitOne``(``TimeSpan``.``FromMinutes``(``1``)``)``)`
{
LogPersistently("Timeout sending email for error: " + message);
}
lock (sync)
{
tooLate = true;
}
}
}
此方法使用 System.Net.Mail 命名空间中的 SmtpClient 类通过电子邮件向系统管理员发送错误报告。它还调用一个未在此处显示的内部方法 LogPersistently 将失败记录在本地日志机制中。由于这些都是可能需要一些时间的操作,代码会异步发送电子邮件——SendAsync 方法会立即返回,类会在电子邮件发送完成后引发一个 .NET 事件。这使得代码可以在发送电子邮件的同时继续执行 LogPersistently 方法。
记录了消息后,该方法在返回之前等待电子邮件发送完成,这就是 ManualResetEvent 的用武之地。通过将 false 传递给构造函数,我将事件置于初始未发出信号状态。但在处理电子邮件 SendCompleted .NET 事件的处理程序中,我调用同步事件的 Set 方法,这将使其进入发出信号状态。(在生产代码中,我还会检查 .NET 事件处理程序的参数,看看是否有错误,但这里我省略了,因为它与我要说明的点无关。)
最后,我调用WaitOne,它会阻塞直到事件被标记为已信号。SmtpClient可能完成工作得很快,以至于在我调用LogPersistently返回之前邮件已经发送出去了。但没关系——在这种情况下,WaitOne会立即返回,因为一旦调用Set,ManualResetEvent就会保持信号状态。所以不管哪个工作先完成——持久化日志还是发送邮件,WaitOne都会在邮件发送后让线程继续。关于这个方法奇怪名称的背景,请参见下一个侧边栏,“WaitHandle”。
还有一个AutoResetEvent。一旦单个线程从等待此类事件返回,它会自动恢复到未标记状态。因此,在此事件上调用Set将最多允许一个线程通过。如果在没有线程等待时调用一次Set,事件将保持设置状态,所以不像Monitor.Pulse,通知不会丢失。但该事件不会维护等待设置的数量——如果在没有线程等待事件的情况下调用两次Set,它仍然只允许第一个线程通过,并立即重置。
这两种事件类型只间接地继承自WaitHandle,通过EventWaitHandle基类。你可以直接使用它,并且可以通过构造函数参数指定手动或自动重置。但更有趣的是EventWaitHandle允许你跨进程边界工作(仅限于 Windows)。底层的 Win32 事件对象可以被命名,如果你知道另一个进程创建的事件的名称,你可以在构造EventWaitHandle时传递该名称来打开它。(如果还不存在你指定名称的事件,则你的进程将创建它。)在 Unix 上不存在与命名事件的等效物,因此如果尝试在这些环境中创建一个,将会得到PlatformNotSupportedException异常,尽管支持单进程使用,因此你可以自由使用这些类型,只要不尝试指定名称。
还有一个ManualResetEventSlim类。但与非精简的读取/写入器不同,ManualResetEvent并未被其精简后继者取代,因为只有旧类型支持跨进程使用。ManualResetEventSlim类的主要优点是,如果你的代码只需等待很短的时间,它可能更高效,因为它会像SpinLock一样轮询一段时间。这样可以避免使用相对昂贵的 OS 调度服务。但最终它会放弃并回退到更重的机制。(即使在这种情况下,它也稍微更高效,因为它不需要支持跨进程操作,因此使用更轻量级的机制。)自动事件没有精简版本,因为自动重置事件并不广泛使用。
障碍
在前面的章节中,我展示了如何使用事件来协调并发工作,使得一个线程在继续之前等待某些事件发生。运行时库提供了一个类来处理类似的协调,但语义略有不同。Barrier 类可以处理多个参与者,并且还可以支持多个阶段,这意味着线程可以在工作进行过程中多次等待彼此。Barrier 是对称的——在 示例 16-12 中,事件处理程序调用 Set 而另一个线程调用 WaitOne,而使用 Barrier,所有参与者都调用 SignalAndWait 方法,这实际上将设置和等待组合成一个操作。
当参与者调用 SignalAndWait 时,方法会阻塞,直到所有参与者都调用它为止,此时它们都将解除阻塞并且可以继续。因为你在构造函数参数中传递了计数值,所以 Barrier 知道要期望多少参与者。
多阶段操作只是简单地再来一次。一旦最后一个参与者调用 SignalAndWait 并释放其他线程,如果有任何线程第二次调用 SignalAndWait,它将像以前一样被阻塞,直到所有其他线程第二次调用它。CurrentPhaseNumber 告诉你到目前为止这种情况发生了多少次。
这种对称性使得 Barrier 不如 示例 16-12 中的 ManualResetEvent 适合,因为在后者中,只有一个线程真正需要等待。让 SendComplete 事件处理程序等待持久日志更新完成没有任何好处——只有一个参与者关心工作何时完成。ManualResetEvent 只支持单个参与者,但这并不一定是使用 Barrier 的理由。如果你想要带有多个参与者的事件风格的不对称性,还有另一种方法:倒计时。
CountdownEvent
CountdownEvent 类类似于事件,但它允许你指定在允许等待线程通过之前必须被信号量标记的次数。构造函数接受一个初始计数参数,你可以随时通过调用 AddCount 增加计数。调用 Signal 方法来减少计数;默认情况下,它会减少一个,但有一种重载可以让你减少指定数量。
Wait 方法会阻塞,直到计数器达到零。如果你想查看当前计数以了解还有多少工作要做,可以读取 CurrentCount 属性。
信号量
另一个在并发系统中广泛使用的基于计数的系统被称为信号量。Windows 对此有原生支持,而.NET 的Semaphore类最初设计为其包装器。与事件包装器类似,Semaphore派生自WaitHandle,在非 Windows 平台上会模拟其行为。CountdownEvent在计数达到零后才允许等待线程通过,而Semaphore则在计数为零时开始阻塞线程。如果你希望确保不超过特定数量的线程同时执行某些工作,可以使用它。
因为Semaphore派生自WaitHandle,所以调用WaitOne方法来等待。只有在计数已经为零时才会阻塞。它在返回时将计数减一。通过调用Release来增加计数。您必须在构造函数参数中指定初始计数,并且还必须提供一个最大计数——如果调用Release尝试将计数设置为超过最大值,它将引发异常。
与事件类似,Windows 支持信号量的跨进程使用,因此可以选择将信号量名称作为构造函数参数传递。这将打开现有的信号量,如果尚未存在具有指定名称的信号量,则创建一个新的信号量。
还有一个SemaphoreSlim类。与ManualResetEventSlim类似,在线程通常不必长时间阻塞的场景中提供了性能优势。SemaphoreSlim提供了两种递减计数的方式。其Wait方法与Semaphore类的WaitOne方法类似,但它还提供了WaitAsync,它返回一个Task,一旦计数为非零就完成(并在完成任务时递减计数)。这意味着您无需阻塞线程等待信号量可用。此外,这意味着您可以使用第十七章中描述的await关键字来递减信号量。
互斥体
Windows 定义了一个名为互斥体的同步原语,为此.NET 提供了一个包装类Mutex。名称简称为“互斥”,因为一次只能有一个线程拥有互斥体——如果线程 A 拥有了互斥体,线程 B 就不能拥有,反之亦然。这也正是lock关键字通过Monitor类为我们所做的,但Mutex提供了两个优点。它支持跨进程:与其他跨进程同步原语一样,在构造互斥体时可以传递一个名称。(而且与其他所有类型不同,在 Unix 平台上也支持命名。)使用Mutex还可以在单个操作中等待多个对象。
注意
ThreadPool.RegisterWaitForSingleObject方法不适用于互斥体,因为 Win32 要求互斥体所有权与特定线程相关联,而线程池的内部工作意味着RegisterWaitForSingleObject无法确定哪个线程池线程处理具有互斥体的回调。
通过调用WaitOne获取互斥体,如果在那时某个其他线程拥有互斥体,则WaitOne将阻塞,直到该线程调用ReleaseMutex。一旦WaitOne成功返回,你就拥有了互斥体。你必须在获取互斥体的同一线程上释放互斥体。
Mutex类没有“slim”版本。我们已经有了低开销的等价物,因为所有.NET 对象都具有通过Monitor和lock关键字提供轻量级互斥的天然能力。
Interlocked
Interlocked类与本节到目前为止描述的其他类型有些不同。它支持对共享数据的并发访问,但不是同步原语。相反,它定义了静态方法,提供各种简单操作的原子形式。
例如,它提供了Increment、Decrement和Add方法,支持int和long值的重载。(这些操作类似——增加或减少只是加 1 或-1。)加法涉及从某个存储位置读取值,计算修改后的值,并将其存回同一存储位置,如果使用普通的 C#运算符进行此操作,如果多个线程尝试同时修改同一位置,可能会出现问题。如果值最初为0,某个线程读取该值,然后另一个线程也读取该值,如果两者都加 1 并将结果存回,则它们最终都将写回1——两个线程尝试增加值,但实际上只增加了一个。使用Interlocked形式的这些操作可以防止这种重叠发生。
Interlocked还提供了用于交换值的各种方法。Exchange方法接受两个参数:一个值的引用和一个值。它返回当前在第一个参数引用的位置的值,并用作第二个参数提供的值覆盖该位置,并且将这两个步骤作为单个原子操作执行。它支持int、uint、long、ulong、object、float、double,以及一种称为IntPtr的类型,表示非托管指针。还有一个泛型的Exchange<T>,其中T可以是任何引用类型。
还支持条件交换,使用CompareExchange方法。它接受三个值——与Exchange一样,它接受一个对要修改的某个变量的引用,以及要替换它的值,但还接受第三个参数:您认为已经在存储位置中的值。如果存储位置中的值与预期值不匹配,则此方法不会更改存储位置。(它仍然返回存储位置中的任何值,无论它是否修改了它。)实际上,可以根据这个方法来实现我描述的其他Interlocked操作。示例 16-13 使用它来实现一个交错增量操作。
示例 16-13. 使用CompareExchange
static int InterlockedIncrement(ref int target)
{
int current, newValue;
do
{
current = target;
newValue = current + 1;
}
while (Interlocked.CompareExchange(ref target, newValue, current)
!= current);
return newValue;
}
对于其他操作,模式是相同的:读取当前值,计算要替换它的值,然后仅在该值在此期间似乎未更改时替换它。如果在获取当前值和替换它之间值发生更改,则再次尝试。在这里需要稍微小心——即使CompareExchange成功,其他线程在您读取值和更新值之间可能两次修改该值,第二次更新将事情恢复到第一次更新之前。对于加法和减法,这并不重要,因为它不影响结果,但一般来说,您不应太过于假设成功更新表示什么。如果您有疑问,通常最好坚持使用更重的同步机制之一。
最简单的Interlocked操作是Read方法。它接受一个ref long并原子地读取该值,与通过Interlocked执行的同一变量上的任何其他操作相关。这使您可以安全地读取 64 位值——一般来说,CLR 不保证 64 位读取是原子的(在 64 位进程中,它们通常是,但如果您需要在 32 位架构上保证原子性,则需要使用Interlocked.Read)。没有 32 位值的重载,因为对它们的读写总是原子的。
Interlocked支持的操作对应于大多数 CPU 可以直接支持的原子操作。(一些 CPU 架构本能地支持所有这些操作,而其他一些则仅支持比较和交换,并通过这种方式构建其他所有操作。但无论如何,这些操作最多只是几条指令。)这意味着它们相对高效。与使用普通代码执行等效的非原子操作相比,它们成本要高得多,因为原子 CPU 指令需要在所有 CPU 核心(以及在安装了多个物理上分离的 CPU 的计算机中,所有 CPU 芯片)之间协调以保证原子性。尽管如此,它们的成本远低于lock语句在操作系统级别上阻塞线程时所付出的代价的一小部分。
这类操作有时被描述为无锁操作。这种说法并不完全准确——计算机在硬件的相对低层级上会非常短暂地获取锁。原子读-修改-写操作实际上会在计算机的内存上占用独占锁定,持续两个总线周期。然而,不会获取操作系统的锁,调度程序也不需要介入,而且这些锁持有的时间极短——通常仅仅是一个机器码指令。更重要的是,这里使用的高度专门化和低级别的锁定形式不允许在等待获取另一个锁时保持一个锁的持有状态——代码每次只能锁定一件事情。这意味着这种操作不会发生死锁。然而,排除死锁的简单性也有其两面性。
互锁操作的缺点在于原子性仅适用于极其简单的操作。仅使用Interlocked在多线程环境中构建更复杂的逻辑非常困难。相比之下,使用高级别的同步原语更容易且风险较小,因为这些原语使得保护更复杂的操作变得相对容易,而不仅仅是单个计算。通常情况下,你只会在对性能要求极高的工作中使用Interlocked,即使如此,你也应该仔细测量以验证它是否产生了你期望的效果——例如示例 16-13 中的代码在理论上可以循环任意次数才最终完成,因此它可能比你预期的成本更高。
在使用低级原子操作编写正确代码时的最大挑战之一是,您可能会遇到由 CPU 缓存工作方式引起的问题。一个线程执行的工作可能不会立即对其他线程可见,并且在某些情况下,内存访问可能不会按照代码指定的顺序发生。使用更高级别的同步原语可以通过强制执行某些顺序约束来避免这些问题,但如果您决定使用Interlocked来构建自己的同步机制,您需要理解.NET 为多个线程同时访问同一内存时定义的内存模型,并且通常需要使用Interlocked类定义的MemoryBarrier方法或Volatile类定义的各种方法来确保正确性。这超出了本书的范围,也是编写看起来工作正常但在重载时(即在这可能最为重要的时候)实际上出错的代码的一个很好的方法,因此这类技术很少值得成本。除非您真的别无选择,否则请坚持我在本章讨论过的其他机制。
延迟初始化
当您需要一个对象能够从多个线程访问时,如果该对象可能是不可变的(即,其字段在构造后不会更改),通常可以避免需要同步。多个线程同时从同一位置读取数据始终是安全的——只有在数据需要更改时才会出现问题。然而,这里有一个挑战:何时以及如何初始化共享对象?一种解决方法可能是将对象的引用存储在静态字段中,并从静态构造函数或字段初始化程序初始化该静态字段——CLR 保证对任何类的静态初始化仅运行一次。然而,这可能会导致对象比您想要的更早地被创建。如果在静态初始化中执行了太多工作,则可能会对应用程序启动所需的时间产生不利影响。
在初始化对象之前,您可能希望等到第一次需要该对象。这被称为延迟初始化。这并不特别难实现——您可以检查字段是否为null,如果不是,则初始化它,并使用lock确保只有一个线程可以构造该值。然而,开发人员似乎对展示自己有多聪明有着 remarkable 的食欲,这可能会有一个潜在的不良结果,即显示他们并不像他们认为的那么聪明。
lock 关键字虽然效率相当高,但通过使用 Interlocked 可能会更好。然而,在多处理器系统上的内存访问重排序的微妙之处使得编写代码既快速又聪明,但并非总是有效。为了避免这种反复出现的问题,.NET 提供了两个类来执行延迟初始化,而无需使用 lock 或其他潜在昂贵的同步原语。其中最简单的是 Lazy<T>。
Lazy
Lazy<T> 类提供了一个 Value 属性,类型为 T,并且在首次读取该属性之前不会创建 Value 返回的实例。默认情况下,Lazy<T> 将使用 T 的无参数构造函数,但您可以提供自己的方法来创建该实例。
Lazy<T> 能够为您处理竞态条件。实际上,您可以配置所需的多线程保护级别。由于延迟初始化在单线程环境中也可能很有用,因此您可以通过将 false 或 LazyThreadSafetyMode.None 作为构造函数参数来完全禁用多线程支持。但对于多线程环境,您可以在 LazyThreadSafetyMode 枚举中选择其他两种模式之一。
这些决定了如果多个线程几乎同时尝试首次读取 Value 属性时会发生什么。PublicationOnly 并不尝试确保只有一个线程创建对象 - 它仅在线程完成对象创建时应用任何同步。首个完成构造或初始化的线程将提供对象,其他已启动初始化的线程生成的对象均被丢弃。一旦值可用,所有进一步尝试读取 Value 的操作将直接返回该值。
如果选择 ExecutionAndPublication,则只允许单个线程尝试构造。这可能看起来不太浪费,但 PublicationOnly 提供了一个潜在的优势:因为它在初始化过程中避免了持有任何锁,所以在初始化代码本身尝试获取任何锁时,您不太可能引入死锁 bug。PublicationOnly 还会以不同的方式处理错误。如果第一次初始化尝试引发异常,则其他开始构造尝试的线程将有机会完成,而对于 ExecutionAndPublication,如果唯一的初始化尝试失败,则会保留异常,并且每次读取 Value 时都会抛出异常。
LazyInitializer
支持延迟初始化的另一个类是 LazyInitializer。这是一个静态类,您完全通过其静态泛型方法使用它。与 Lazy<T> 相比稍微复杂一些,但它避免了除所需的惰性分配实例之外的额外对象的分配。示例 16-14 展示了如何使用它。
示例 16-14. 使用 LazyInitializer
public class Cache<T>
{
private static Dictionary<string, T>? _d;
public static IDictionary<string, T> Dictionary =>
LazyInitializer.EnsureInitialized(ref _d);
}
如果字段为空,则EnsureInitialized方法会构造参数类型的一个实例——在本例中为Dictionary<string, T>。否则,它将返回字段中已有的值。还有一些其他重载方式。您可以像对Lazy<T>一样传递回调。您还可以传递一个ref bool参数,它将检查以发现初始化是否已经发生(并在执行初始化时将其设置为true)。
静态字段初始化程序会给我们带来相同的一次性初始化,但可能会在进程的生命周期中运行得更早。在具有多个字段的更复杂类中,静态初始化甚至可能导致不必要的工作,因为它适用于整个类,所以您可能会构造不会被使用的对象。这可能增加应用程序启动所需的时间。LazyInitializer允许您在首次使用时初始化各个字段,确保只做必要的工作。
其他类库并发支持
System.Collections.Concurrent命名空间定义了各种集合,在多线程环境中提供了比通常的集合更慷慨的保证,这意味着您可以在不需要任何其他同步原语的情况下使用它们。但要小心,尽管单个操作在多线程世界中可能具有良好定义的行为,但如果您需要执行的操作涉及多个步骤,这并不一定会帮助您。您可能仍然需要在更广泛的范围内进行协调以确保一致性。但在某些情况下,并发集合可能是您所需要的全部内容。
与非并发集合不同,ConcurrentDictionary、ConcurrentBag、ConcurrentStack和ConcurrentQueue都支持在枚举(例如使用foreach循环)这些内容进行的同时修改它们的内容。字典提供了一个实时枚举器,这意味着如果在枚举过程中添加或删除了值,枚举器可能会显示一些已添加的项,但可能不会显示已删除的项。它不提供明确的保证,主要是因为在多线程代码中,当两个事情发生在两个不同的线程上时,不总是完全清楚哪个事件发生得更早——相对论的法则意味着这可能取决于您的观点。
这意味着,枚举器在从字典中删除该项之后似乎仍返回该项是可能的。袋子、堆栈和队列采取了不同的方法:它们的枚举器都会拍摄快照并在其上进行迭代,因此foreach循环将看到一组内容,这组内容与过去某个时间点集合中的内容一致,即使该集合此后可能已发生变化。
正如我在第五章中已经提到的,并发集合提供的 API 与其非并发对应物相似,但增加了一些成员以支持原子添加和删除项目。例如,ConcurrentDictionary提供了一个GetOrAdd方法,如果已存在条目则返回现有条目,否则添加一个新条目。
运行库的另一部分,可以帮助您处理并发而无需显式使用同步原语,就是 Rx(这是第十一章的主题)。它提供各种运算符,可以将多个异步流组合成单一流。这些操作管理并发问题,记住每个单一的可观察对象都会一次为观察者提供一个项目。
Rx 采取必要的步骤来确保即使是从许多个体流合并输入,这些流都在同时生成项目,它也能遵守这些规则。只要所有源都遵循规则,Rx 就不会要求观察者一次处理多个事物。
System.Threading.Channels NuGet 包提供了支持生产者/消费者模式的类型,其中一个或多个线程生成数据,而其他线程消费这些数据。您可以选择通道是否缓冲,使生产者可以超过消费者,以及超过多少。 (System.Collections.Concurrent中的BlockingCollection<T>也提供这种服务。但是它不太灵活,不支持第十七章中描述的await关键字。)
最后,在多线程场景中,值得考虑的是不可变集合类,我在第五章中有描述。这些集合支持任意数量线程的并发访问,并且因为它们是不可变的,所以从不会出现如何处理并发写访问的问题。显然,不可变性带来了很大的约束,但如果能找到一种方法与这些类型一起工作(记住,内置的string类型是不可变的,因此你已经有了一些使用不可变数据的经验),它们在某些并发场景中非常有用。
任务
在本章的前面部分,我展示了如何使用Task类在线程池中启动工作。这个类不仅仅是线程池的一个包装器。Task及其相关类型构成的任务并行库(TPL)可以处理更广泛的场景。任务特别重要,因为 C#的异步语言特性(这是第十七章的主题)能够直接与其一起工作。运行库中许多 API 都提供基于任务的异步操作。
虽然任务是使用线程池的首选方式,但它们不仅仅是关于多线程的。基本的抽象比那更加灵活。
Task 和 Task<T> 类
TPL 的核心有两个类:Task 和从它派生的类 Task<T>。Task 基类表示可能需要一些时间才能完成的工作。Task<T> 则扩展此功能以表示完成时会产生结果(类型为 T)的工作。(非泛型 Task 不产生任何结果。它是异步版本的 void 返回类型。)注意,这些不一定涉及线程的概念。
大多数 I/O 操作可能需要一段时间才能完成,在大多数情况下,运行时库为它们提供了基于任务的 API。示例 16-15 使用异步方法作为字符串获取网页内容。由于它无法立即返回字符串 —— 可能需要一些时间来下载页面 —— 因此它返回一个任务。
示例 16-15. 基于任务的网络下载
var w = new HttpClient();
string url = "https://endjin.com/";
Task<string> webGetTask = w.GetStringAsync(url);
注意
大多数基于任务的 API 遵循一种命名约定,即它们以 Async 结尾,如果有相应的同步 API,则该 API 的名称不带 Async 后缀。例如,System.IO 中的 Stream 类,提供对字节流的访问,具有 Write 方法用于将字节写入流,该方法是同步的(即它在完成工作之前会等待)。它还提供 WriteAsync 方法。它与 Write 做的事情相同,但因为它是异步的,所以返回而不等待工作完成。它返回一个 Task 来表示工作;这种约定称为 基于任务的异步模式(TAP)。
GetStringAsync 方法不等待下载完成,因此几乎立即返回。要执行下载,计算机必须向相关服务器发送消息,然后必须等待响应。一旦请求启动,CPU 在大部分请求进程中无需执行任何工作,这意味着此操作大部分时间无需涉及线程。因此,此方法不需要在调用 Task.Run 时包装某些基础同步 API 的调用。事实上,HttpClient 甚至没有大多数操作的同步版本。对于同时提供 I/O API 的类,如 Stream,同步版本通常是对基本异步实现的包装:当您调用阻塞 API 执行 I/O 时,它通常会在内部执行异步操作,然后只是阻塞调用线程,直到该工作完成。即使在完全非异步的情况下,例如,FileStream 可以使用非异步操作系统文件 API 实现 Read 和 Write —— OS 内核中的 I/O 通常是异步的。
因此,尽管 Task 和 Task<T> 类很容易生成通过在线程池线程运行方法的任务,它们也能够表示在大部分时间内不需要使用线程的基本异步操作。虽然这不是官方术语的一部分,我将这种操作描述为无线程任务,以区分它们与完全在线程池线程上运行的任务。
ValueTask 和 ValueTask<T>
Task 和 Task<T> 非常灵活,不仅因为它们可以表示基于线程和无线程的操作。正如你将看到的,它们提供了多种机制来发现它们所代表的工作何时完成,包括将多个任务组合为一个任务的能力。多个线程可以同时等待同一个任务。你可以编写缓存机制,重复地分配同一个任务,即使在任务完成之后很长时间仍然如此。这一切都非常方便,但也意味着这些任务类型也具有一些开销。对于更受限的情况,.NET 定义了更少灵活的 ValueTask 和 ValueTask<T> 类型,在某些情况下效率更高。
这些类型与它们的普通对应类型之间最重要的区别在于 ValueTask 和 ValueTask<T> 都是值类型。在性能敏感的代码中,这一点非常重要,因为它可以减少代码分配的对象数量,从而减少应用程序执行垃圾回收工作的时间。你可能会认为,通常涉及并发工作的上下文切换成本可能很高,以至于在处理异步操作时,对象分配的成本将是你最不用担心的问题之一。虽然这通常是正确的,但有一个非常重要的场景,Task<T> 的垃圾回收开销可能会成为一个问题:有时运行缓慢但通常不会的操作。
对于 I/O API 来说,执行缓冲以减少对操作系统的调用是非常常见的。如果你向 Stream 写入少量字节,它通常会将这些字节放入缓冲区,并等待,直到要么你写入足够的数据使其值得将其发送到操作系统,要么你显式调用 Flush。读取时也常常进行缓冲——如果你从文件中读取一个字节,操作系统通常会从驱动器中读取整个扇区(通常至少为 4 KB),并且该数据通常会保存在内存中,因此当你请求第二个字节时,不需要再进行 I/O 操作。实际上,如果你编写一个循环,以相对较小的块(例如一次一行文本)从文件中读取数据,那么大多数读取操作将立即完成,因为要读取的数据已经被提前获取。
在这些情况下,大多数对异步 API 的调用会立即完成,创建任务对象的 GC 开销可能会变得显著。这就是为什么引入了 ValueTask 和 ValueTask<T>。(这些是内置于 .NET Core、.NET 和 .NET Standard 2.1 中的。在 .NET Framework 中,你可以通过 System.Threading.Tasks.Extensions NuGet 包获取它们。)这些类型使得可能的是潜在的异步操作可以在不需要分配任何对象的情况下立即完成。在无法立即完成的情况下,这些类型最终会成为 Task 或 Task<T> 对象的包装器,此时开销会返回,但在只有少数调用需要这样做的情况下,这些类型可以在使用了低分配技术的代码中提供显著的性能提升,尤其是在 第十八章 中描述的代码中。
非泛型的 ValueTask 很少被使用,因为产生无结果的异步操作可以直接返回 Task.CompletedTask 静态属性,它提供了一个可重复使用的任务,已经处于完成状态,避免了任何 GC 开销。但需要生成结果的任务通常不能重用现有任务。(也有一些例外情况:运行时库通常会为 Task<bool> 使用缓存的预完成任务,因为只有两种可能的结果。但对于 Task<int>,没有实际的方法来维护每个可能结果的预完成任务列表。)
这些值任务类型有一些限制。它们是单次使用的:与 Task 和 Task<T> 不同,你不应该将这些类型存储在字典或 Lazy<T> 中以提供缓存的异步值。在完成之前尝试检索 ValueTask<T> 的 Result 是错误的。多次检索 Result 也是错误的。一般来说,你应该使用 ValueTask 或 ValueTask<T> 进行一次 await 操作(如 第十七章 中所述),然后再也不要使用它们了。(或者,如果必要,可以通过调用其 AsTask 方法来获取完整的 Task 或 Task<T>,带有所有对应的开销,此时你不应再对值任务进行任何操作。)
因为值类型任务是在 TPL 出现多年后引入的,类库经常使用 Task<T>,而你可能期望看到的是 ValueTask<T>。例如,Stream 类的 ReadAsync 方法都是主要候选项,但因为大多数这些方法在 ValueTask<T> 存在之前就定义好了,所以它们大多返回 Task<T>。不过,最近添加的重载版本接受 Memory<byte> 而不是 byte[],确实返回 ValueTask<T>,而且更一般地说,在增加对 第十八章 中描述的新内存高效技术支持的 API 中,这些方法通常会返回 ValueTask<T>。如果你处于对任务的 GC 开销非常敏感的环境中,你可能会希望无论如何都使用这些技术。
任务创建选项
你可以通过使用 Task.Factory 或 Task<T>.Factory 的 StartNew 方法创建一个基于线程的任务,而不是使用 Task.Run,这样可以更好地控制新任务的某些方面。 StartNew 的一些重载接受 enum 类型 TaskCreationOptions 的参数,这提供了对 TPL 如何调度任务的一些控制。
PreferFairness 标志请求在已经调度的任务之后运行该任务。默认情况下,线程池通常先运行最近添加的任务(即后进先出,或者 LIFO 策略),因为这样更有效地利用 CPU 缓存。
LongRunning 标志警告 TPL 任务可能会运行很长时间。默认情况下,TPL 的调度器优化相对较短的工作项 —— 即多达几秒钟的任何工作。此标志表明工作可能需要更长时间,这种情况下,TPL 可能会修改其调度。如果有太多长时间运行的任务,它们可能会使用完所有线程,即使某些排队的工作项可能要短得多,它们仍然需要等待在缓慢工作的后面才能开始。但如果 TPL 知道哪些项目可能快速运行,哪些可能较慢,它可以以不同的优先级进行调度,以避免这些问题。
其他 TaskCreationOptions 设置涉及父/子任务关系和调度器,稍后我将进行描述。
任务状态
任务在其生命周期中经历多个状态,你可以使用 Task 类的 Status 属性来发现它所处的位置。这返回 enum 类型 TaskStatus 的值。如果任务成功完成,则该属性将返回枚举的 RanToCompletion 值。如果任务失败,则为 Faulted。如果使用 “Cancellation” 中显示的技术取消任务,则状态将为 Canceled。
在“进行中”主题上有几种变体,其中Running是最明显的——表示某个线程当前正在执行任务。代表 I/O 的任务在进行时通常不需要线程,因此它永远不会进入该状态——它始于WaitingForActivation状态,然后通常直接转换为三种最终状态之一(RanToCompletion、Faulted或Canceled)。基于线程的任务也可以处于WaitingForActivation状态,但只有在某些情况下才会阻止其运行,这通常发生在您设置任务仅在某些其他任务完成时运行时(我稍后将展示如何做到)。基于线程的任务也可能处于WaitingToRun状态,这意味着它在队列中等待线程池线程变得可用。可以在任务之间建立父/子关系,已经完成的父任务创建了一些尚未完成的子任务将处于WaitingForChildrenToComplete状态。
最后,还有Created状态。您很少见到它,因为它表示您已创建但尚未请求运行的基于线程的任务。使用任务工厂的StartNew方法或Task.Run创建的任务中永远不会看到这一点,但如果直接构造新的Task,则会看到这一点。
大多数情况下,TaskStatus属性中的详细级别可能过于复杂,因此Task类定义了各种更简单的bool属性。如果只想知道任务是否没有更多工作要做(并且不关心它成功、失败还是被取消),可以使用IsCompleted属性。如果想检查失败或取消,可以使用IsFaulted或IsCanceled。
检索结果
假设您有一个Task<T>,可以通过提供一个 API 或创建返回值的基于线程的任务获取它。如果任务成功完成,您可能希望检索其结果,您可以从Result属性中获取。因此,由示例 16-15 创建的任务使网页内容在webGetTask.Result中可用。
如果尝试在任务完成之前读取Result属性,则会阻塞您的线程,直到结果可用。(如果有一个普通的Task,它不返回结果,并且您想要等待其完成,可以直接调用Wait。)如果操作失败,则Result会抛出异常(Wait也会如此),尽管这并不像您可能期望的那样直接,我将在“错误处理”中讨论。
警告
你应该避免在未完成的任务上使用Result。在某些情况下,这可能会导致死锁。这在桌面应用程序中特别常见,因为某些工作需要在特定线程上进行,如果通过读取未完成任务的Result来阻塞线程,可能会阻止任务完成。即使不会发生死锁,通过阻塞Result可能会导致性能问题,因为它会占用线程池线程,这些线程本来可以继续进行有用的工作。在未完成的ValueTask<T>中读取Result是不允许的。
在大多数情况下,最好使用 C#的异步语言特性来检索结果。这将是下一章的主题,但作为一个预览,示例 16-16 展示了你如何使用它来获取获取网页的任务结果。(你需要在方法声明前面应用async关键字才能使用await关键字。)
示例 16-16. 使用await获取任务结果
string pageContent = await webGetTask;
这看起来可能并不像是简单地写webGetTask.Result这样的改进,但正如我在第十七章中所展示的,这段代码并不是看上去的那样——C#编译器会将这个语句重组成一个回调驱动的状态机,使你能够在不阻塞调用线程的情况下获取结果。(如果操作尚未完成,线程会返回给调用者,当操作完成时,方法的其余部分稍后运行。)
但是异步语言特性是如何使这个工作的——代码如何发现任务何时完成?Result或Wait让你坐下等待这种情况发生,阻塞线程,但这实际上违背了使用异步 API 的初衷。通常情况下,你希望在任务完成时收到通知,你可以通过继续来实现这一点。
继续
任务提供了名为ContinueWith的方法的各种重载。这将创建一个新的基于线程的任务,在你调用ContinueWith的任务完成时执行(无论是成功完成、失败还是取消)。示例 16-17 在示例 16-15 中创建的任务上使用了这个方法。
示例 16-17. 一个继续
webGetTask.ContinueWith(t =>
{
string webContent = t.Result;
Console.WriteLine("Web page length: " + webContent.Length);
});
一个继续任务始终是一个基于线程的任务(无论其前置任务是基于线程、基于 I/O 还是其他什么)。当你调用ContinueWith时,任务会立即创建,但在其前置任务完成之前不会变为可运行状态。(它最初处于WaitingForActivation状态。)
注意
继续是一个独立的任务——ContinueWith返回一个Task<T>或Task,取决于你提供的委托是否返回结果。如果你想要链接一系列操作,你可以为一个继续设置一个继续。
您为继续任务提供的方法(例如在 示例 16-17 中的 lambda 表达式)将其前置任务作为其参数,并且我已经使用它来检索结果。我也可以使用包含方法中的 webGetTask 变量,因为它引用相同的任务。但是,通过使用参数,示例 16-17 中的 lambda 表达式不使用其包含方法的任何变量,这使得编译器可以生成稍微更高效的代码——它不需要创建对象来保存共享变量,并且它可以重用创建的委托实例,因为它不必为每个调用创建特定于上下文的委托实例。这意味着如果我认为这样做会使代码更易读,我也可以轻松地将其分离为普通的非内联方法。
您可能会认为在 示例 16-17 中存在一个可能的问题:如果下载完成得非常快,以至于 webGetTask 已经在代码管理附加继续任务之前完成了怎么办?实际上,这并不重要——如果您在已经完成的任务上调用 ContinueWith,它仍然会运行继续任务。它只是立即安排它。您可以附加任意数量的继续任务。在任务完成之前附加的所有继续任务将在其完成时安排执行。而在任务完成后附加的继续任务将立即安排执行。
默认情况下,继续任务将像任何其他任务一样在线程池上安排执行。然而,有些事情可以改变它的运行方式。
一些 ContinueWith 的重载接受一个 enum 类型的参数 TaskContinuationOptions,它控制任务如何(以及是否)被安排。这包括与 TaskCreationOptions 可用的所有选项相同的选项,但添加了一些特定于继续任务的选项。
您可以指定继续任务仅在特定情况下运行。例如,OnlyOnRanToCompletion 标志将确保继续任务仅在前置任务成功时运行。还有类似的 OnlyOnFaulted 和 OnlyOnCanceled 标志。或者,您可以指定 NotOnRanToCompletion,这意味着继续任务仅在任务故障或取消时运行。
注意
您可以为单个任务创建多个继续任务。因此,您可以设置一个处理成功情况,另一个处理失败情况。
您还可以指定ExecuteSynchronously。这表示连续性不应作为单独的工作项进行调度。通常,当任务完成时,该任务的任何连续性将被调度执行,并且必须等待直到正常的线程池机制从队列中选择工作项并执行它们。(如果使用默认选项,这不会花费太多时间——除非指定了PreferFairness,线程池用于任务的 LIFO 操作意味着最近调度的项目先运行。)然而,如果您的完成仅需非常少量的工作,将其调度为完全独立的项目的开销可能过大。因此,ExecuteSynchronously 允许您在同一个线程池工作项上挂载完成任务——TPL 将在前驱完成后立即运行这种类型的连续性,然后将线程返回给池。只有在连续性将快速运行时才应使用此选项。
LazyCancellation 选项处理了一种棘手的情况,如果您使任务可取消(如后文所述的“Cancellation”),并且使用了连续性,那么可能会出现问题。如果取消了一个任务,默认情况下任何连续性会立即变为可运行状态。如果被取消的任务本身设置为另一个尚未完成的任务的连续性,并且有自己的连续性,正如示例 16-18 所示,这可能会产生一种轻微令人惊讶的效果。
示例 16-18. 取消和链式连续性
private static void ShowContinuations()
{
Task op = Task.Run(DoSomething);
var cs = new CancellationTokenSource();
Task onDone = op.ContinueWith(
_ => Console.WriteLine("Never runs"),
cs.Token);
Task andAnotherThing = onDone.ContinueWith(
_ => Console.WriteLine("Continuation's continuation"));
cs.Cancel();
}
static void DoSomething()
{
Thread.Sleep(1000);
Console.WriteLine("Initial task finishing");
}
这将创建一个任务,将调用DoSomething,然后是该任务的可取消连续性(onDone中的Task),然后是作为第一个连续性的最终任务(andAnotherThing)。此代码几乎立即被取消,几乎可以肯定会在第一个任务完成之前发生。其效果是最终任务在第一个任务完成之前运行。当onDone完成时,最终的andAnotherThing任务变为可运行状态,即使该完成是由于取消了onDone。由于这里存在一条链——andAnotherThing是onDone的连续性,而onDone是op的连续性——andAnotherThing在op完成之前运行有些奇怪。LazyCancellation 改变了行为,使得第一个连续性不会被视为完成,直到其前驱完成,这意味着最终的连续性只有在第一个任务完成后才会运行。
还有另一种控制任务执行方式的机制:您可以指定调度程序。
调度程序
所有基于线程的任务都由TaskScheduler执行。默认情况下,您将得到 TPL 提供的通过线程池运行工作项的调度程序。然而,还有其他类型的调度程序,甚至可以自己编写。
选择非默认调度程序最常见的原因是处理线程关联性要求。TaskScheduler类的静态FromCurrentSynchronizationContext方法基于调用该方法的当前同步上下文返回调度程序。该调度程序将通过该同步上下文执行所有工作。因此,如果您从 UI 线程调用FromCurrentSynchronizationContext,则生成的调度程序可用于运行可以安全更新 UI 的任务。通常,您会在后续操作中使用此功能——可以运行一些基于任务的异步工作,然后连接一个后续操作,在完成该工作时更新 UI。示例 16-19 展示了在 WPF 应用程序窗口的代码后台文件中使用此技术。
示例 16-19. 在 UI 线程上安排后续操作
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
}
private static readonly HttpClient w = new();
`private` `readonly` `TaskScheduler` `_uiScheduler` `=`
`TaskScheduler``.``FromCurrentSynchronizationContext``(``)``;`
private void FetchButtonClicked(object sender, RoutedEventArgs e)
{
string url = "https://endjin.com/";
Task<string> webGetTask = w.GetStringAsync(url);
webGetTask.ContinueWith(t =>
{
string webContent = t.Result;
outputTextBox.Text = webContent;
},
`_uiScheduler``)``;`
}
}
这使用字段初始化程序获取调度程序——UI 元素的构造函数在 UI 线程上运行,因此这将获取一个用于 UI 线程同步上下文的调度程序。然后,单击处理程序使用HttpClient类的GetStringAsync下载网页。这将异步运行,因此不会阻塞 UI 线程,这意味着在下载进行时应用程序仍然响应。该方法设置了一个使用ContinueWith的重载形式来设置任务的后续操作。这确保了当获取内容的任务完成时,传递给ContinueWith的 lambda 表达式在 UI 线程上运行,因此可以安全地访问 UI 元素。
提示
虽然这样做完全有效,但在下一章中描述的await关键字提供了这个特定问题的更简单的解决方案。
运行时库提供了三种内置调度程序。有一个使用线程池的默认调度程序,还有一个使用同步上下文的调度程序,正如我刚才展示的那样。第三个由名为ConcurrentExclusiveSchedulerPair的类提供,并且正如其名称所示,它提供了两个调度程序,通过属性可用。ConcurrentScheduler属性返回一个类似于默认调度程序的并发运行任务的调度程序。ExclusiveScheduler属性返回一个用于逐个运行任务的调度程序,并在这样做时暂时挂起另一个调度程序(这让我想起了本章前面描述的读者/写者同步语义——它允许在需要时排他性,但其余时间并发运行)。
错误处理
Task对象在其工作失败时通过进入Faulted状态来指示。失败时总会至少有一个异常与之关联,但 TPL 允许复合任务——包含多个子任务的任务。这使得可能发生多个失败,并且根任务将报告它们所有。Task定义了一个Exception属性,其类型为AggregateException。你可能还记得第八章中提到的,除了从基类Exception类型继承的InnerException属性外,AggregateException还定义了一个InnerExceptions属性,返回一个异常集合。在这里你将找到导致任务失败的所有异常的完整集合。(如果任务不是复合任务,则通常只会有一个。)
如果尝试获取Result属性或在故障任务上调用Wait,它将抛出与从Exception属性返回的相同的AggregateException。故障任务会记住你是否使用了这些成员中的至少一个,如果你尚未这样做,它将考虑异常为未观察到。TPL 使用终结来跟踪具有未观察异常的故障任务,如果允许这样的任务变得不可达,TaskScheduler将引发其静态的UnobservedTaskException事件。这给了你最后一次机会来处理异常,之后它将丢失。
自定义无线程任务
许多基于 I/O 的 API 返回无线程任务。如果你希望,你也可以这样做。TaskCompletionSource<T>类提供了一种创建Task<T>的方式,它不具有在线程池上运行的相关方法,而是在你告诉它完成时完成。没有非泛型的TaskCompletionSource,但也不需要。Task<T>派生自Task,因此你可以随意选择任何类型参数。按照惯例,大多数开发人员在不需要提供返回值时使用TaskCompletionSource<object?>。
假设你正在使用一个不提供基于任务的 API 的类,并且希望添加一个基于任务的包装器。我在示例 16-12 中使用的SmtpClient类支持旧的基于事件的异步模式,但不支持基于任务的模式。示例 16-20 使用该 API 与TaskCompletionSource<object?>结合提供了一个基于任务的包装器。(是的,在那里有Canceled/Cancelled的两种拼写。TPL 一致使用Canceled,但旧 API 展示了更多的变化。)
示例 16-20. 使用TaskCompletionSource<T>
public static class SmtpAsyncExtensions
{
public static Task SendTaskAsync(this SmtpClient mailClient, string from,
string recipients, string subject, string body)
{
var tcs = new TaskCompletionSource<object?>();
void CompletionHandler(object s, AsyncCompletedEventArgs e)
{
// Check this is the notification for our SendAsync.
if (!object.ReferenceEquals(e.UserState, tcs)) { return; }
mailClient.SendCompleted -= CompletionHandler;
if (e.Canceled)
{
tcs.SetCanceled();
}
else if (e.Error != null)
{
tcs.SetException(e.Error);
}
else
{
tcs.SetResult(null);
}
};
mailClient.SendCompleted += CompletionHandler;
mailClient.SendAsync(from, recipients, subject, body, tcs);
return tcs.Task;
}
}
SmtpClient通过引发事件来通知我们操作已完成。此事件的处理程序首先检查事件是否对应于我们对SendAsync的调用,而不是可能已经在进行的其他操作。然后,它会分离自身(以防止在后续使用相同SmtpClient进行工作时再次运行)。接着,它检测操作是成功、取消还是失败,并在TaskCompletionSource<object>上分别调用SetResult、SetCanceled或SetException方法。这将导致任务转换为相应状态,并负责运行任何附加到该任务的后续操作。完成源通过其Task属性使其创建的无关线程Task对象可用,并且此方法返回该对象。
父/子关系
如果基于线程的任务方法创建一个新的基于线程的任务,默认情况下,这些任务之间没有特定的关系。然而,TaskCreationOptions标志之一是AttachedToParent,如果设置了这个标志,新创建的任务将作为当前执行任务的子任务。这意味着父任务直到所有子任务完成后才报告完成(当然,其自身的方法也需要完成)。如果任何子任务出现故障,父任务也将失败,并且将所有子任务的异常包含在自己的AggregateException中。
您还可以为继续任务指定AttachedToParent标志。请注意,这并不使其成为其先前任务的子任务。它将成为在调用ContinueWith创建继续任务时正在运行的任何任务的子任务。
注意
线程无关任务(例如,大多数代表 I/O 的任务)通常不能作为其他任务的子任务。如果通过TaskCompletionSource<T>自行创建一个,那么可以做到,因为该类有一个构造函数重载接受TaskCreationOptions。然而,大多数 .NET API 返回的任务没有提供请求将任务设为子任务的方法。
父/子关系并不是创建基于多个其他项目结果的任务的唯一方式。
复合任务
Task类具有静态的WhenAll和WhenAny方法。每个方法都有重载,接受任务集合或Task<T>对象集合作为唯一参数。WhenAll方法返回一个Task或Task<T[]>,仅当提供的所有任务完成时才完成(在后一种情况下,复合任务生成包含每个单独任务结果的数组)。WhenAny方法返回一个Task<Task>或Task<Task<T>>,只要第一个任务完成就完成,并将该任务作为结果返回。
与父任务一样,如果WhenAll生成的任务中的任何任务失败,那么所有失败任务的异常将在组合任务的AggregateException中可用。(WhenAny不报告错误。它在第一个任务完成时就完成了,您必须检查它以发现是否失败。)
您可以将继续任务附加到这些任务上,但还有一个稍微更直接的路线。而不是使用WhenAll或WhenAny创建复合任务,然后在结果上调用ContinueWith,您可以直接调用任务工厂的ContinueWhenAll或ContinueWhenAny方法。同样,这些方法接受一个Task或Task<T>的集合,但它们还接受一个要作为继续调用的方法。
其他异步模式
尽管 TPL 提供了公开异步 API 的首选机制,但在其添加之前,.NET 已经存在了将近十年,因此您可能会遇到较旧的方法。最长建立的形式是异步编程模型(APM)。这是在.NET 1.0 中引入的,因此广泛实现,但现在不鼓励使用。按照这种模式,方法成对出现:一个用于启动工作,另一个用于在完成时收集结果。示例 16-21 展示了System.IO命名空间中Stream类中的这样一对方法,同时显示了相应的同步方法。(今天编写的代码应该使用基于任务的WriteAsync。)
示例 16-21. APM 对及其相应的同步方法
public virtual IAsyncResult BeginWrite(byte[] buffer, int offset, int count,
AsyncCallback callback, object state)...
public virtual void EndWrite(IAsyncResult asyncResult)...
public abstract void Write(byte[] buffer, int offset, int count)...
注意,BeginWrite方法的前三个参数与Write方法的参数相同。在 APM 中,Begin*Xxx*方法接受所有输入(即任何普通参数和任何ref参数,但不是out参数,如果有的话)。End*Xxx*方法提供任何输出,这意味着返回值,任何ref参数(因为这些可以传递信息进入或退出),以及任何out参数。
Begin*Xxx*方法还接受两个额外的参数:类型为AsyncCallback的委托,当操作完成时将调用它,以及类型为object的参数,接受您希望与操作关联的任何对象(或者如果您不需要则为null)。此方法还返回一个IAsyncResult,表示异步操作。
当调用完成回调时,您可以调用End*Xxx*方法,传入与Begin*Xxx*方法返回的相同的IAsyncResult对象,这将提供返回值(如果有的话)。如果操作失败,End*Xxx*方法将引发异常。
你可以用一个Task封装使用 APM 的 API。Task和Task<T>提供的TaskFactory对象提供了FromAsync方法,你可以向其传递一对委托,用于Begin*Xxx*和End*Xxx*方法,并传递Begin*Xxx*方法需要的任何参数。这将返回代表操作的Task或Task<T>。
另一个常见的旧模式是事件驱动的异步模式(EAP)。本章中你已经见过一个示例——SmtpClient使用了这种模式。使用此模式,一个类提供启动操作的方法和操作完成时引发的相应事件。方法和事件通常具有相关的名称,如SendAsync和SendCompleted。此模式的一个重要特点是方法捕获同步上下文并使用它来引发事件,这意味着如果在 UI 代码中使用支持此模式的对象,它有效地呈现了单线程异步模型。这使得它比 APM 更容易使用,因为在异步工作完成时,你无需编写额外的代码以返回到 UI 线程。
没有自动化机制可以将 EAP 包装在任务中,但如我在示例 16-20 中所示,这并不特别难。
异步代码中还有一种常见模式:由 C#异步语言特性(async和await关键字)支持的可等待模式。如我在示例 16-16 中展示的,你可以直接使用这些特性消耗 TPL 任务,但语言不会直接识别Task,而且可以等待的东西不限于任务。你可以用await关键字与实现特定模式的任何东西一起使用。我将在第十七章中展示这一点。
取消
.NET 定义了一种用于取消慢操作的标准机制。可取消操作接受类型为CancellationToken的参数,如果将其设置为取消状态,则操作将尽早停止而不是运行到完成。
CancellationToken类型本身不提供任何方法来启动取消操作——API 设计为你可以告诉操作何时取消,而不给予它们取消与同一CancellationToken相关联的其他操作的权力。取消操作通过单独的对象CancellationTokenSource管理。顾名思义,你可以使用它来获取任意数量的CancellationToken实例。如果调用CancellationTokenSource对象的Cancel方法,它将设置所有相关联的CancellationToken实例为取消状态。
一些我之前描述过的同步机制可以接收CancellationToken。(从WaitHandle派生的那些机制不能,因为底层的 Windows 原语不支持.NET 的取消模型。Monitor也不支持取消,但许多较新的 API 支持。)任务型 API 通常也会接收取消标记,而 TPL 本身也提供了带有取消标记的StartNew和ContinueWith方法的重载版本。如果任务已经开始运行,TPL 无法取消它,但如果在任务开始运行之前取消任务,TPL 会将其从预定任务队列中移除。如果希望在任务开始运行后能取消任务,就需要在任务体内编写代码来检查CancellationToken,并在其IsCancellationRequested属性为true时放弃工作。
取消支持并不普遍,因为并非总是可能取消一些操作。例如,一旦消息已经通过网络发送出去,就无法取消发送。一些操作允许在达到某个不可逆转的点之前取消工作。(例如,如果消息已排队等待发送但实际上尚未发送,则可能取消还为时不晚。)这意味着即使提供了取消功能,它也可能不起作用。因此,在使用取消功能时,需要做好它可能无法正常工作的准备。
并行性
运行时库包括一些类,可以在多个线程上并发地处理数据集合。有三种方法可以做到这一点:Parallel类、并行 LINQ 和 TPL 数据流。
并行类
Parallel类提供了四个静态方法:For、ForEach、ForEachAsync和Invoke。最后一个方法接收一个委托数组并执行它们所有,可能并行执行。(它决定是否使用并行取决于各种因素,如计算机的硬件线程数量、系统的负载情况以及要处理的项数。)For和ForEach方法模仿了同名的 C#循环结构,但它们也可能并行执行迭代。ForEachAsync是.NET 6.0 中新增的,也模仿了foreach,但提供了更好的异步操作支持,包括能够与IAsyncEnumerable<T>(如await foreach)一起工作或让每个迭代执行异步操作(相当于在foreach循环体中使用await)。
示例 16-22 展示了在执行两组样本卷积的代码中使用Parallel.For。这是一种在信号处理中常用的高度重复的操作。(实际上,快速傅里叶变换提供了更有效的执行方式,除非卷积核很小,但那段代码的复杂性将会掩盖这里的主要主题,即Parallel类。)它为每个输入样本产生一个输出样本。每个输出样本是通过计算两个输入的一系列值对的乘积之和来产生的。对于大数据集,这可能会很耗时,因此这是您可能希望通过在多处理器上分布执行来加速的工作类型。每个单独的输出样本值都可以独立计算,因此它是并行化的一个很好的候选对象。
示例 16-22。并行卷积
static float[] ParallelConvolution(float[] input, float[] kernel)
{
float[] output = new float[input.Length];
Parallel.For(0, input.Length, i =>
{
float total = 0;
for (int k = 0; k < Math.Min(kernel.Length, i + 1); ++k)
{
total += input[i - k] * kernel[k];
}
output[i] = total;
});
return output;
}
这段代码的基本结构与一对嵌套的for循环非常相似。我只是用Parallel.For替换了外层的for循环。(我没有尝试并行化内部循环 - 如果每个单独的步骤都很简单,Parallel.For将会在执行代码之外花费更多时间来处理内务工作。)
第一个参数0设置了循环计数器的初始值,第二个参数设置了上限。最后一个参数是一个委托,将为循环计数器的每个值调用一次,并且如果Parallel类的启发式算法告诉它这可能会产生加速效果,则调用将同时发生。在多核机器上使用大数据集运行此方法将导致所有可用的硬件线程充分利用。
可能通过更友好的方式将工作分区以获得更好的性能 - 幼稚的并行化可能会给人以高性能的印象,因为它可以利用所有 CPU 核心,但交付的吞吐量却不够优化。然而,在复杂性和性能之间存在一种权衡,而Parallel类的简单性通常可以在相对较少的工作量下提供可观的收益。
并行 LINQ
并行 LINQ 是一个与内存中的信息一起工作的 LINQ 提供程序,类似于 LINQ 到对象。System.Linq命名空间通过名为AsParallel的扩展方法为任何IEnumerable<T>(由ParallelEnumerable类定义)提供了这一功能。这将返回一个ParallelQuery<T>,支持通常的 LINQ 操作符。
以这种方式构建的任何 LINQ 查询都提供了一个ForAll方法,该方法接受一个委托。当您调用此方法时,它会为查询生成的所有项目并行调用委托,在可能的情况下使用多个线程。
TPL 数据流
TPL Dataflow 是一个运行时库特性,允许您构建一个对象图,这些对象在信息流经它们时执行某种处理。您可以告诉 TPL 哪些节点需要按顺序处理信息,哪些可以同时处理多个数据块。您将数据推入图中,TPL 将管理每个节点处理块的过程,并尝试优化并行级别以匹配计算机上可用的资源。
数据流 API 位于System.Threading.Tasks.Dataflow命名空间中(它内置于 .NET Core 和 .NET 中;在 .NET Framework 中,您需要添加对 NuGet 包的引用,也称为System.Threading.Tasks.Dataflow)。它非常庞大和复杂,可以单独占据一整章。不幸的是,这超出了本书的范围。我提到它是因为对于某些工作来说,了解它是值得的。
摘要
线程提供同时执行多段代码的能力。在具有多个 CPU 执行单元(即多个硬件线程)的计算机上,您可以通过使用多个软件线程利用这种并行潜力。您可以使用Thread类显式创建新的软件线程,或者您可以使用线程池或并行化机制(如Parallel类或 Parallel LINQ)自动确定要使用多少线程来运行应用程序提供的工作。如果多个线程需要使用和修改共享数据结构,则需要使用 .NET 提供的同步机制来确保线程可以正确协调它们的工作。
线程也可以提供一种执行多个并发操作的方式,这些操作不需要整个时间都占用 CPU(例如,等待外部服务的响应),但通常使用异步 API(如果可用的话)执行这类工作更为高效。任务并行库(TPL)提供了适用于这两种并发方式的抽象。它可以管理线程池中的多个工作项,支持组合多个操作和处理可能复杂的错误场景,其Task抽象也可以表示固有的异步操作。下一章将介绍 C# 语言特性,大大简化了与任务的工作。
¹ 在此处广泛使用“状态”一词。我只是指存储在变量和对象中的信息。
² 在撰写本文时,文档并未为HashSet<T>和SortedSet<T>提供只读线程安全性保证。尽管如此,微软已经向我保证这些结构也支持并发读取。
³ 在只有一个硬件线程的机器上,当SpinLock进入其循环时,它告诉操作系统调度程序它希望让出 CPU 的控制权,以便其他线程(希望包括当前持有锁的线程)可以取得进展。即使在多核系统上,SpinLock有时也会这样做,以避免过多的自旋可能导致的一些微妙问题。
第十七章:异步语言特性
C# 提供了语言级别的支持来使用和实现异步方法。使用异步 API 通常是使用某些服务的最有效方式。例如,大多数 I/O 在操作系统内核中是异步处理的,因为大多数外设(如磁盘控制器或网络适配器)能够自主完成大部分工作。它们只需要 CPU 在每个操作的开始和结束时介入。
尽管操作系统提供的许多服务本质上是异步的,开发者通常选择通过同步 API 使用它们(即在工作完成前不返回)。这可能会浪费资源,因为它们会阻塞线程直到 I/O 完成。线程会带来额外开销,如果你的目标是在高并发应用程序(例如为大量用户提供服务的 Web 应用)中获得最佳性能,通常最好只使用相对较少的 OS 线程。理想情况下,你的应用程序 OS 线程数量不应该超过硬件线程数量,但只有在确保线程只在没有未完成工作时阻塞时才是最佳的。《第十六章》描述了操作系统线程和硬件线程之间的区别。在性能敏感的代码中,异步 API 很有用,因为它们不会浪费资源,不会强制线程等待 I/O 完成,而是可以在此期间启动其他有用的工作。
异步 API 的问题在于,它们使用起来可能比同步 API 复杂得多,特别是如果你需要协调多个相关操作并处理错误的话。这也是为什么在主流编程语言提供内置支持之前,开发者通常选择效率较低的同步替代方案的原因。2012 年,C# 和 Visual Basic 将这些特性从研究实验室带到了实际应用中,此后许多其他流行的语言也添加了类似的特性(尤其是 JavaScript,在 2016 年也采用了非常相似的语法)。C# 中的异步特性使得我们可以编写使用高效异步 API 的代码,同时保留了使用简单同步 API 时的大部分简洁性。
这些语言特性在一些场景中也非常有用,其中最大化吞吐量并非主要性能目标。使用客户端代码时,避免阻塞 UI 线程以保持响应性是很重要的,而异步 API 提供了一种实现方式。语言对异步代码的支持可以处理线程亲和性问题,大大简化了编写高度响应式 UI 代码的工作。
异步关键字:async 和 await
C# 通过两个关键字来支持异步代码:async 和 await。前者不能单独使用。你需要将 async 关键字放在方法的声明中,这告诉编译器你打算在方法中使用异步特性。如果没有这个关键字,你就不能使用 await 关键字。
这可能会显得多余——如果试图在不使用 async 的情况下使用 await,编译器会产生错误。它知道方法体何时尝试使用异步特性,为什么我们还需要显式告诉它呢?有两个原因。首先,正如你将看到的,这些特性显著改变了编译器生成的代码行为,因此对于阅读代码的人来说,清楚地指示方法异步行为是有用的。其次,await 在 C# 中并不总是关键字,因此开发人员曾可以自由将其用作标识符。或许微软可以设计 await 的语法,使其仅在非常特定的上下文中充当关键字,从而使你能够继续在所有其他情况下将其用作标识符,但是 C# 团队决定采取稍微粗粒度的方法:你不能在 async 方法内部将 await 用作标识符,但在其他任何地方它都是有效的标识符。
注意
async 关键字不会改变方法的签名。它决定了方法如何编译,而不是如何使用。
程序的入口点是一个有趣的情况。通常,Main 方法要么返回 void,要么返回 int,但你也可以返回 Task 或 Task<int>。.NET 运行时不支持异步入口点,因此如果你使用这些任务返回类型之一,C# 编译器会生成一个隐藏方法作为真正的入口点,该方法调用你的异步 Main 方法,然后阻塞直到返回的任务完成。这使得可以将 C# 程序的 Main 方法设为 async(即使在使用这些返回类型时,编译器也会生成包装器,即使你没有将方法设为 async)。如果你使用 C# 10.0 的顶级语句来避免显式声明 Main,那么就没有地方放置 async 关键字或返回类型,因此这是唯一一种情况,编译器根据你是否使用 await 推断方法是否异步。它基于程序入口点的返回类型来确定你是否返回任何内容。
因此,async关键字只是声明你打算使用await关键字。尽管不能在不使用async的情况下使用await,但将async关键字应用于不使用await的方法不会报错。然而,这样做没有任何意义,所以如果你这样做,编译器会生成警告。示例 17-1 显示了一个相当典型的例子。它使用HttpClient类仅请求特定资源的头部(使用 HTTP 协议为此目的定义的标准HEAD动词)。然后将结果显示在 UI 控件中——这种方法是 UI 的代码后端的一部分,其中包括一个名为headerListTextBox的TextBox。
示例 17-1. 使用async和await来获取 HTTP 头
// Note: as you'll see later, async methods usually should not be void private async void FetchAndShowHeaders(string url, IHttpClientFactory cf)
{
using (HttpClient w = cf.CreateClient())
{
var req = new HttpRequestMessage(HttpMethod.Head, url);
HttpResponseMessage response =
`await` `w``.``SendAsync``(``req``,` `HttpCompletionOption``.``ResponseHeadersRead``)``;`
headerListTextBox.Text = response.Headers.ToString();
}
}
此代码包含一个粗体显示的单个await表达式。你可以在可能需要一些时间来产生结果的表达式中使用await关键字,它表示在该操作完成之前,方法的其余部分不应执行。这听起来很像阻塞同步 API 所做的事情,但不同之处在于await表达式不会阻塞线程——这段代码并不完全是看上去的样子。
HttpClient类的SendAsync方法返回一个Task<HttpResponseMessage>,你可能会想为什么我们不直接使用其Result属性。正如你在第十六章中看到的,如果任务未完成,此属性将阻塞线程,直到结果可用(或任务失败,这种情况下它会抛出异常)。然而,在 UI 应用程序中这样做是很危险的:如果你尝试读取不完整任务的Result来阻塞 UI 线程,那么将阻止任何需要在该线程上运行的操作的进展。由于 UI 应用程序需要在 UI 线程上执行大量工作,以这种方式阻塞该线程几乎可以保证迟早会导致死锁,从而导致应用程序冻结。所以不要这样做!
尽管示例 17-1 中的await表达式在逻辑上类似于读取Result,但其工作方式截然不同。如果任务的结果不立即可用,await关键字并不会使线程等待,尽管其名称暗示了这一点。相反,它会导致包含的方法立即返回。你可以使用调试器来验证FetchAndShowHeaders立即返回。例如,如果我从示例 17-2 中显示的按钮点击事件处理程序中调用该方法,我可以在该处理程序中的Debug.WriteLine调用上设置断点,并在示例 17-1 中更新headerListTextBox.Text属性的代码处设置另一个断点。
示例 17-2. 调用异步方法
private void fetchHeadersButton_Click(object sender, RoutedEventArgs e)
{
FetchAndShowHeaders("https://endjin.com/", this.clientFactory);
Debug.WriteLine("Method returned");
}
在调试器中运行时,我发现代码在示例 17-2 的最后语句上的断点命中,然后才命中示例 17-1 最终语句上的断点。换句话说,示例 17-1 中跟随await表达式的部分在方法已返回给其调用者后运行。显然,编译器以某种方式安排方法的剩余部分通过回调运行,一旦异步操作完成。
注意
Visual Studio 的调试器在调试异步方法时会进行一些技巧,使您能够像调试普通方法一样逐步执行它们。这通常很有帮助,但有时会掩盖执行的真实本质。我刚描述的调试步骤是人为设计的,旨在打败 Visual Studio 的聪明尝试,而是揭示实际发生的情况。
注意,示例 17-1 中的代码期望在 UI 线程上运行,因为它朝着结束修改了文本框的Text属性。异步 API 不一定保证在您启动工作的同一线程上通知您完成,事实上,大多数情况下都不会。尽管如此,示例 17-1 按预期工作,因此除了将方法的一半转换为回调外,await关键字还为我们处理了线程关联性问题。
显然,每次使用await关键字时,C#编译器都会对您的代码进行一些重大的修改。在较早的 C#版本中,如果您想使用此异步 API 然后更新 UI,您需要编写类似于示例 17-3 的内容。这使用了我在第十六章中展示的技术:它为SendAsync返回的任务设置了一个继续项,使用TaskScheduler确保继续项的主体在 UI 线程上运行。
示例 17-3. 手动异步编码
private void OldSchoolFetchHeaders(string url, IHttpClientFactory cf)
{
HttpClient w = cf.CreateClient();
var req = new HttpRequestMessage(HttpMethod.Head, url);
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
w.SendAsync(req, HttpCompletionOption.ResponseHeadersRead)
.ContinueWith(sendTask =>
{
try
{
HttpResponseMessage response = sendTask.Result;
headerListTextBox.Text = response.Headers.ToString();
}
finally
{
w.Dispose();
}
},
uiScheduler);
}
这是直接使用 TPL 的一个合理方式,并且与示例 17-1 有类似的效果,尽管它不完全代表 C#编译器如何转换代码。正如我稍后将展示的,await使用的模式是由Task或Task<T>支持的,但不是必需的。它还生成处理早期完成(即任务在您准备等待它之前已经完成)的代码,比示例 17-3 要高效得多。但在展示编译器的具体操作之前,我想说明它为您解决的一些问题,最好的方法是展示在此语言功能出现之前可能编写的代码类型。
我当前的示例相当简单,因为它仅涉及一个异步操作,但除了我已经讨论过的两个步骤——设置某种完成回调并确保它在正确的线程上运行之外——我还不得不处理位于示例 17-1 中的using语句。示例 17-3 不能使用using关键字,因为我们希望在完成后才处理HttpClient对象的释放。¹ 在外部方法返回之前不久调用Dispose是行不通的,因为我们需要在继续运行时使用该对象,而这通常会晚一些。因此,我需要在一个方法(外部方法)中创建对象,然后在另一个方法(嵌套方法)中处理释放。而且因为我手动调用Dispose,所以现在需要处理异常,因此我不得不用try块包装我移入回调的所有代码,并在finally块中调用Dispose。(事实上,我甚至没有做完整的工作——如果HttpRequestMessage构造函数或检索任务调度程序的调用抛出异常,HttpClient将不会被处理。我只处理了 HTTP 操作本身失败的情况。)
示例 17-3 已经使用了任务调度程序来安排继续通过启动时的SynchronizationContext运行。这确保了回调在正确的线程上发生以更新 UI。await关键字可以替我们处理这些。
执行和同步上下文
当程序的执行达到一个await表达式时,如果这个操作不会立即完成,为该await生成的代码将确保当前的执行上下文已被捕获。(如果这不是在该方法中第一个阻塞的await,并且如果自上次捕获以来上下文未更改,则已经捕获。)当异步操作完成时,方法的剩余部分将通过执行上下文执行。²
正如我在第十六章中描述的那样,执行上下文处理某些需要在一个方法调用另一个方法时流动的上下文信息(即使间接调用也是如此)。但在写 UI 代码时,还有另一种上下文可能会引起我们的兴趣:同步上下文(也在第十六章中有描述)。
尽管所有的 await 表达式都会捕获执行上下文,但是否流动同步上下文取决于被等待的类型。如果你等待一个 Task,同步上下文默认也会被捕获。任务并不是你可以 await 的唯一东西,我将在 “等待模式” 小节中描述类型如何支持 await。
有时候,你可能希望避免涉及同步上下文。如果你希望从 UI 线程开始执行异步工作,但又没有特别需要保持在该线程上,通过同步上下文调度每一个继续操作就是不必要的开销。如果异步操作是一个 Task 或 Task<T>(或者等效的值类型,如 ValueTask 或 ValueTask<T>),你可以通过调用 ConfigureAwait 方法并传递 false 来声明不需要这样做。这会返回异步操作的另一种表示,如果你 await 这个新的表示而不是原始任务,它将忽略当前的 SynchronizationContext(如果有的话)。(没有相应的机制来选择退出执行上下文。)示例 17-4 展示了如何使用它。
示例 17-4. ConfigureAwait
private async void OnFetchButtonClick(object sender, RoutedEventArgs e)
{
using (HttpClient w = this.clientFactory.CreateClient())
using (Stream f = File.Create(fileTextBox.Text))
{
Task<Stream> getStreamTask = w.GetStreamAsync(urlTextBox.Text);
`Stream` `getStream` `=` `await` `getStreamTask``.``ConfigureAwait``(``false``)``;`
Task copyTask = getStream.CopyToAsync(f);
`await` `copyTask``.``ConfigureAwait``(``false``)``;`
}
}
这段代码是按钮的点击处理程序,因此最初在 UI 线程上运行。它从两个文本框中检索 Text 属性。然后启动一些异步工作——获取 URL 的内容并将数据复制到文件中。在获取这两个 Text 属性之后,它不再使用任何 UI 元素,因此在每个 await 之后不必要地涉及 UI 线程无关紧要。通过向 ConfigureAwait 传递 false 并等待它返回的值,我们告诉 TPL 可以使用任何方便的线程来通知我们完成,这在本例中很可能是线程池线程。这将使工作完成更有效率和更快速,因为它避免了在每个 await 之后不必要地涉及 UI 线程。
并非所有的异步 API 都返回 Task 或 Task<T>。例如,作为 UWP 的一部分引入到 Windows 的各种异步 API 返回的是 IAsyncOperation<T> 而不是 Task<T>。这是因为 UWP 不是特定于 .NET 的,它有自己独立于运行时的异步操作表示,可以从 C++ 和 JavaScript 中使用。这个接口在概念上类似于 TPL 任务,并支持 await 模式,这意味着你可以在这些 API 上使用 await。然而,它不提供 ConfigureAwait。如果你想对其中一个这些 API 做类似于 示例 17-4 的事情,可以使用 AsTask 扩展方法,将 IAsyncOperation<T> 包装为 Task<T>,然后在该任务上调用 ConfigureAwait。
提示
如果你正在编写库,那么在使用await的任何地方都应该调用ConfigureAwait(false)。这是因为通过同步上下文继续可能会很昂贵,并且在某些情况下可能会引入死锁的可能性。唯一的例外是当你做一些积极需要保留同步上下文的操作,或者你确信你的库只会在不设置同步上下文的应用程序框架中使用时。 (例如,ASP.NET Core 应用程序不使用同步上下文,因此通常无论是否在这些应用程序中调用ConfigureAwait(false)都无关紧要。)
示例 17-1 中仅包含一个await表达式,即使这个表达式在经典的 TPL 编程中也相当复杂。示例 17-4 包含两个await表达式,如果没有await关键字的帮助,要实现相同的行为就需要更多的代码,因为异常可能会在第一个await之前、第二个await之后或两者之间发生,我们需要在这些情况下调用HttpClient和Stream的Dispose方法(以及在没有抛出异常的情况下)。然而,一旦涉及流控制,事情可能会变得更加复杂。
多个操作和循环
假设我想处理响应体中的数据,而不是仅仅获取标头或将 HTTP 响应体复制到文件中。如果响应体很大,检索它可能是一个需要多个缓慢步骤的操作。示例 17-5 逐步获取一个网页。
示例 17-5. 多个异步操作
private async void FetchAndShowBody(string url, IHttpClientFactory cf)
{
using (HttpClient w = cf.CreateClient())
{
`Stream` `body` `=` `await` `w``.``GetStreamAsync``(``url``)``;`
using (var bodyTextReader = new StreamReader(body))
{
while (!bodyTextReader.EndOfStream)
{
`string?` `line` `=` `await` `bodyTextReader``.``ReadLineAsync``(``)``;`
bodyTextBox.AppendText(line);
bodyTextBox.AppendText(Environment.NewLine);
`await` `Task``.``Delay``(``TimeSpan``.``FromMilliseconds``(``10``)``)``;`
}
}
}
}
现在这段代码包含了三个await表达式。第一个发起了一个 HTTP GET 请求,该操作将在收到响应的第一部分时完成,但响应可能尚未完全接收——可能还有几兆字节的内容未到达。此代码假定内容将是文本,因此将返回的Stream对象封装在StreamReader中,该对象将字节流展示为文本[³]。然后它使用该封装对象的异步ReadLineAsync方法逐行读取响应中的文本。因为数据通常是分块到达的,读取第一行可能需要一些时间,但接下来几次调用该方法可能会立即完成,因为每个网络数据包通常包含多行。但是,如果代码读取速度快于网络数据到达速度,最终它将消耗完首个数据包中的所有行,然后在下一行可用之前将需要一些时间。因此,ReadLineAsync的调用将返回一些慢和一些立即完成的任务。第三个异步操作是对Task.Delay的调用。我加入了这个操作来减慢速度,以便逐步看到数据在 UI 中到达。Task.Delay返回一个在指定延迟后完成的Task,因此它提供了Thread.Sleep的异步等效方式(Thread.Sleep会阻塞调用线程,而await Task.Delay引入延迟但不会阻塞线程)。
注意
我已经将每个await表达式放在单独的语句中,但这不是必需的。写成(await t1) + (await t2)这种形式的表达式是完全合法的(如果你愿意,可以省略括号,因为await比加法运算符具有更高的优先级;但我个人喜欢在这里使用括号来提供视觉上的强调)。
我不打算展示 Example 17-5 的完整的非async等效版本,因为它将非常庞大,但我会描述一些问题。首先,我们有一个循环,其主体包含两个await块。要使用Task和回调构建等效的内容意味着需要构建自己的循环结构,因为循环的代码最终会分散在三个方法中:开始运行循环的那个方法(这将是作为GetStreamAsync的连续回调的嵌套方法)以及处理ReadLineAsync和Task.Delay完成的两个回调方法。可以通过创建一个在两个地方都调用的本地方法来解决这个问题:你希望开始循环的地方以及在Task.Delay的继续中再次启动下一个迭代。Example 17-6 展示了这种技术,但它只说明了我们希望编译器为我们完成的某些方面;它并非 Example 17-5 的完整替代方案。
示例 17-6. 一个不完整的手动异步循环
private void IncompleteOldSchoolFetchAndShowBody(
string url, IHttpClientFactory cf)
{
HttpClient w = cf.CreateClient();
var uiScheduler = TaskScheduler.FromCurrentSynchronizationContext();
w.GetStreamAsync(url).ContinueWith(getStreamTask =>
{
Stream body = getStreamTask.Result;
var bodyTextReader = new StreamReader(body);
StartNextIteration();
void StartNextIteration()
{
if (!bodyTextReader.EndOfStream)
{
bodyTextReader.ReadLineAsync().ContinueWith(readLineTask =>
{
string? line = readLineTask.Result;
bodyTextBox.AppendText(line);
bodyTextBox.AppendText(Environment.NewLine);
Task.Delay(TimeSpan.FromMilliseconds(10))
.ContinueWith(
_ => StartNextIteration(), uiScheduler);
},
uiScheduler);
}
};
},
uiScheduler);
}
这段代码勉强能够运行,但它甚至没有尝试释放它所使用的任何资源。存在多处可能出现故障的地方,因此我们不能只是简单地放置一个 using 块或 try/finally 对来清理事物。即使没有额外的复杂性,这段代码也几乎无法被识别出来 —— 它并不明显地表明这是试图执行与 示例 17-5 相同基本操作的代码。在实际应用中,也许完全采用不同的方法会更容易,比如编写一个实现状态机的类来跟踪工作进度。这可能会使得编写正确操作的代码更容易,但对于阅读你的代码的人来说,理解他们看到的内容实际上只是一个循环,这并不会变得更加容易。
众所周知,许多开发人员过去更喜欢同步 API。但是,C# 允许我们编写几乎与同步等效的异步代码结构,从而在不带来痛苦的情况下获得所有异步代码的性能和响应优势。这就是 async 和 await 的主要好处。
消费和生成异步序列
示例 17-5 展示了一个 while 循环,正如你所期望的那样,在 async 方法中你可以自由使用其他类型的循环,比如 for 和 foreach。然而,foreach 可能会引入一个微妙的问题:如果你遍历的集合需要执行缓慢的操作会怎么样?对于像数组或 HashSet<T> 这样的集合类型,所有集合项都已经在内存中,这个问题并不会出现,但是对于 File.ReadLines 返回的 IEnumerable<string> 呢?显然,这是一个适合异步操作的明显候选,但在实践中,每次需要等待更多数据从存储中到达时,它却会阻塞您的线程。这是因为 foreach 期望的模式不支持异步操作。问题的核心在于 foreach 将调用移动到下一项的方法 —— 它期望枚举器(通常,但并非总是 IEnumerator<T> 的实现之一)提供一个类似于 示例 17-7 中所示的 MoveNext 方法的签名。
示例 17-7. 不友好的非异步 IEnumerator.MoveNext
bool MoveNext();
如果有更多的项即将到来但尚未可用,集合将别无选择,只能阻塞线程,直到数据到达为止。幸运的是,C#识别了这种模式的变体。运行时库定义了一对类型,⁴在示例 17-8(首次引入于第五章)中展示,体现了这种新模式。与同步的IEnumerable<T>一样,foreach并不严格要求这些确切的类型。任何提供相同签名成员的东西都可以工作。
示例 17-8. IAsyncEnumerable<T> 和 IAsyncEnumerator<T>
public interface IAsyncEnumerable<out T>
{
IAsyncEnumerator<T> GetAsyncEnumerator(
CancellationToken cancellationToken = default);
}
public interface IAsyncEnumerator<out T> : IAsyncDisposable
{
T Current { get; }
ValueTask<bool> MoveNextAsync();
}
从概念上讲,这与同步模式完全相同:异步的foreach会向集合对象请求一个枚举器,并将重复要求其前进到下一项,每次执行循环体时使用Current返回的值,直到枚举器指示没有更多项为止。主要区别在于同步的MoveNext已被MoveNextAsync取代,后者返回一个可等待的ValueTask<T>。(IAsyncEnumerable<T>接口还支持传递取消令牌。异步的foreach不会直接使用,但可以通过IAsyncEnumerable<T>的WithCancellation扩展方法间接使用。)
要消费实现此模式的可枚举源,您必须在foreach前面加上await关键字。C#还可以帮助您实现此模式:第五章展示了如何在迭代器方法中使用yield关键字来实现IEnumerable<T>,但也可以返回一个IAsyncEnumerable<T>。示例 17-9 展示了IAsyncEnumerable<T>的实现和消费示例。
示例 17-9. 消费和生成异步枚举
await foreach (string line in ReadLinesAsync(args[0]))
{
Console.WriteLine(line);
}
static async IAsyncEnumerable<string> ReadLinesAsync(string path)
{
using (var bodyTextReader = new StreamReader(path))
{
while (!bodyTextReader.EndOfStream)
{
string? line = await bodyTextReader.ReadLineAsync();
if (line is not null) { yield return line; }
}
}
}
由于这种语言支持使得创建和使用IAsyncEnumerable<T>非常类似于使用IEnumerable<T>,您可能会想知道是否存在异步版本的描述在第十章中描述的各种 LINQ 运算符。与 LINQ to Objects 不同,IAsyncEnumerable<T>的实现不在构建到.NET 或.NET Standard 的运行库的部分中,但 Microsoft 提供了一个合适的 NuGet 包。如果您引用了System.Linq.Async包,通常的using System.Linq;声明将使得所有 LINQ 运算符在IAsyncEnumerable<T>表达式上可用。
当我们查看广泛实现的类型的异步等效时,我们应该看看IAsyncDisposable。
异步处理
正如第七章所述,IDisposable接口由需要立即执行某种清理操作的类型实现,例如关闭打开的句柄,并且语言支持使用using语句。但是,如果清理涉及潜在的缓慢工作,例如刷新数据到磁盘?.NET Core 3.1、.NET 和.NET Standard 2.1 为这种情况提供了IAsyncDisposable接口。正如示例 17-10 所示,您可以在using语句前面放置await关键字以使用异步可释放资源。(您也可以在using声明前面放置await关键字。)
示例 17-10. 使用和实现IAsyncDisposable
await using (DiagnosticWriter w = new(@"c:\temp\log.txt"))
{
await w.LogAsync("Test");
}
class DiagnosticWriter : IAsyncDisposable
{
private StreamWriter? _sw;
public DiagnosticWriter(string path)
{
_sw = new StreamWriter(path);
}
public Task LogAsync(string message)
{
if (_sw is null)
{ throw new ObjectDisposedException(nameof(DiagnosticWriter)); }
return _sw.WriteLineAsync(message);
}
public async ValueTask DisposeAsync()
{
if (_sw != null)
{
await LogAsync("Done");
await _sw.DisposeAsync();
_sw = null;
}
}
}
注意
尽管await关键字出现在using语句前面,但它等待的潜在缓慢操作发生在执行离开using语句块时。这是不可避免的,因为using语句和声明有效地隐藏了对Dispose的调用。
示例 17-10 还展示了如何实现IAsyncDisposable接口。与同步的IDisposable接口定义了单一的Dispose方法不同,它的异步版本定义了一个返回ValueTask的DisposeAsync方法。这使我们能够在方法上标记为async。使用await using语句将确保DisposeAsync返回的任务在其块的末尾完成后才继续执行。您可能已经注意到,我们对async方法使用了几种不同的返回类型。迭代器在同步代码中也是特例,但是对于这些返回不同任务类型的方法,又有什么不同呢?
返回一个 Task
任何使用await的方法本身可能需要一定的运行时间,因此除了能够调用异步 API 之外,通常还希望呈现一个异步的公共界面。C#编译器允许用async关键字标记的方法返回表示异步工作进展的对象。您可以返回Task,也可以返回Task<T>,其中T是任何类型。这使调用者可以发现您的方法执行的工作状态,附加连续操作,以及如果使用Task<T>,获取结果的方法。或者,您可以返回值类型的等价物,ValueTask和ValueTask<T>。返回任何这些类型意味着,如果您的方法从另一个async方法调用,它可以使用await等待您的方法完成,并在适用时收集其结果。
当使用 async 时,几乎总是比 void 返回类型更可取的是返回一个任务,因为对于 void 返回类型,调用者无法知道你的方法何时真正完成,或者发生异常时如何处理。(异步方法可以在返回后继续运行—事实上,这正是其全部意义—因此在抛出异常时,原始调用者可能已经不在堆栈上。)通过返回任务对象,你为编译器提供了一种使异常可用,并在适用时提供结果的方法。
返回任务非常简单,几乎没有理由不这样做。要修改示例 17-5 中的方法以返回任务,我只需要进行一个简单的更改。我将返回类型改为 Task 而不是 void,如示例 17-11 所示,其余代码完全可以保持不变。
示例 17-11. 返回 Task
private async Task FetchAndShowBody(string url, IHttpClientFactory cf)
// ...as before
编译器会自动生成所需代码来生成 Task 对象(或 ValueTask,如果你将其用作返回类型),并在方法返回或抛出异常时将其设置为已完成或故障状态。Task 类型的返回类型是异步版本的 void,因为当其完成时 Task 不产生任何结果(这就是为什么我们不需要在此方法中添加 return 语句,即使它现在的返回类型是 Task)。如果你想要从任务中返回结果,这也很容易。将返回类型设为 Task<T> 或 ValueTask<T>,其中 T 是你的结果类型,然后你可以使用 return 关键字,就像你的方法是正常的非异步方法一样,如示例 17-12 所示。
示例 17-12. 返回 Task<T>
public static async Task<string?> GetServerHeader(
string url, IHttpClientFactory cf)
{
using (HttpClient w = cf.CreateClient())
{
var request = new HttpRequestMessage(HttpMethod.Head, url);
HttpResponseMessage response = await w.SendAsync(
request, HttpCompletionOption.ResponseHeadersRead);
string? result = null;
IEnumerable<string>? values;
if (response.Headers.TryGetValues("Server", out values))
{
result = values.FirstOrDefault();
}
`return` `result``;`
}
}
此方法异步获取 HTTP 头部,方式与示例 17-1 相同,但不显示结果,而是挑选第一个 Server: 头部的值,并将其作为此方法返回的 Task<string?> 的结果。(需要是可空字符串,因为可能不存在该头部。)正如你所见,return 语句只是返回一个 string?,即使方法的返回类型是 Task<string?>。编译器生成的代码完成任务,并安排该字符串成为结果。无论是 Task 还是 Task<T> 返回类型,生成的代码都生成类似于使用 TaskCompletionSource<T> 所得到的任务,如第十六章中所述。
注意
就像await关键字可以使用符合特定模式的任何异步方法一样(稍后描述),C#在实现异步方法时也提供了同样的灵活性。你不仅限于Task、Task<T>、ValueTask和ValueTask<T>。你可以返回任何符合两个条件的类型:它必须标注有AsyncMethodBuilder属性,标识编译器用于管理任务进度和完成的类,并且它还必须提供一个GetAwaiter方法,返回一个实现了ICriticalNotifyCompletion接口的类型。
返回内置任务类型几乎没有什么坏处。调用者不必对其做任何事情,因此你的方法使用起来与void方法一样简单,但增加了一个优势,即想要的调用者可以获得一个任务。唯一的返回void的理由可能是某些外部约束强制你的方法具有特定的签名。例如,大多数事件处理程序要求返回类型为void,这就是我之前一些示例这样做的原因。但除非你被迫使用它,否则不推荐将void作为异步方法的返回类型。
将异步应用于嵌套方法
到目前为止展示的示例中,我已经将async关键字应用于普通方法。你也可以将它用于匿名函数(匿名方法或 Lambda 表达式)和局部函数。例如,如果你正在编写一个以编程方式创建 UI 元素的程序,你可能会发现将写成 Lambda 的事件处理程序作为异步的会很方便,就像示例 17-13 那样。
示例 17-13. 一个异步 Lambda
okButton.Click += async (s, e) =>
{
using (HttpClient w = this.clientFactory.CreateClient())
{
infoTextBlock.Text = await w.GetStringAsync(uriTextBox.Text);
}
};
注意
这与异步委托调用无关,这是我在第九章中提到的现在已经弃用的使用线程池的技术,在匿名方法和 TPL 提供更好替代之前曾经流行过。
等待模式
大多数支持await关键字的异步 API 将返回某种 TPL 任务。然而,C#并不绝对要求这样做。它会await任何实现特定模式的东西。此外,虽然Task支持此模式,但它的工作方式使得编译器使用任务与直接使用 TPL 时略有不同——这也是我之前说过展示基于任务的异步等价于await基础代码并不完全代表编译器所做的原因之一。在本节中,我将展示编译器如何使用任务和支持await的其他类型,以更好地说明它的实际工作方式。
我将创建一个await模式的自定义实现来展示 C#编译器的期望。示例 17-14 展示了一个异步方法UseCustomAsync,它使用了这个自定义实现。它将await表达式的结果赋值给一个string,因此明显期待异步操作以string形式输出。它调用了一个方法CustomAsync,该方法返回我们模式的实现(稍后将在示例 17-15 中展示)。如您所见,这不是一个Task<string>。
示例 17-14. 调用自定义等待实现
static async Task UseCustomAsync()
{
string result = await CustomAsync();
Console.WriteLine(result);
}
public static MyAwaitableType CustomAsync()
{
return new MyAwaitableType();
}
编译器期望await关键字的操作数是提供名为GetAwaiter方法的类型。这可以是普通的实例成员或者扩展方法。(因此,可以通过定义合适的扩展方法使不本能支持await的类型也能够使用它。)这个方法必须返回一个对象或值,称为等待器,它需要执行三件事情。
首先,等待器必须提供一个名为IsCompleted的bool属性。编译器生成的await代码使用它来判断操作是否已经完成。在不需要执行耗时操作的情况下(例如,在Stream上调用ReadAsync时可以立即使用流中已有的缓冲区数据),设置回调将是一种浪费。因此,如果IsCompleted属性返回true,await将避免创建不必要的委托,直接继续执行方法的剩余部分。
编译器还需要一种在工作完成后获取结果的方式,因此等待器必须有一个GetResult方法。其返回类型定义了操作的结果类型——它将是await表达式的类型。(如果没有结果,返回类型是void。GetResult仍然需要存在,因为它负责在操作失败时抛出异常。)由于示例 17-14 将await的结果赋值给了类型为string的变量,因此MyAwaitableType类的GetAwaiter返回的等待器的GetResult方法必须是string类型(或者某种隐式转换为string的类型)。
最后,编译器需要能够提供回调。如果IsCompleted返回false,表示操作尚未完成,await表达式生成的代码将创建一个委托,该委托将运行方法的其余部分。它需要能够将该委托传递给等待器。(这类似于将委托传递给任务的ContinueWith方法。)为此,编译器不仅需要一个方法,还需要一个接口。您需要实现INotifyCompletion,并且建议您在可能的情况下也实现一个可选接口,称为ICriticalNotifyCompletion。这两者功能类似:每个定义一个接受单个Action委托的方法(分别是OnCompleted和UnsafeOnCompleted),等待器在操作完成后必须调用此委托。这两个接口及其相应方法的区别在于,前者要求等待器将当前执行上下文流到目标方法,而后者则不需要。C#编译器用于构建异步方法的.NET 运行时库总是为您流动执行上下文,因此生成的代码通常在可能时调用UnsafeOnCompleted,以避免重复流动。 (如果编译器使用OnCompleted,等待器将再次流动上下文。)然而,在.NET Framework 上,您会发现安全约束可能会阻止使用UnsafeOnCompleted。(.NET Framework 有一个不受信任代码的概念。来自可能不可信来源的代码—例如从互联网下载的代码—将受到各种约束。这个概念在.NET Core 中被放弃,但各种遗留物仍然存在,例如这种异步操作的设计细节。)因为UnsafeOnCompleted不流动执行上下文,不受信任的代码不应允许调用它,因为这将提供一种绕过某些安全机制的方式。各种任务类型的.NET Framework 实现中提供的UnsafeOnCompleted标有SecurityCriticalAttribute,这意味着只有完全信任的代码才能调用它。我们需要OnCompleted,以便部分受信任的代码能够使用等待器。
示例 17-15 展示了等待器模式的最小可行实现。这个示例过于简化,因为它总是同步完成,所以它的OnCompleted方法什么也不做。如果你在MyAwaitableType的实例上使用await关键字,由 C#编译器生成的代码永远不会调用OnCompleted。await模式要求只有在IsCompleted返回false时才调用OnCompleted,而在这个例子中,IsCompleted始终返回true。这就是为什么我让OnCompleted抛出异常。然而,尽管这个例子过于简单,它将说明await的作用。
Example 17-15. 过于简单的 await 模式实现
public class MyAwaitableType
{
public MinimalAwaiter GetAwaiter()
{
return new MinimalAwaiter();
}
public class MinimalAwaiter : INotifyCompletion
{
public bool IsCompleted => true;
public string GetResult() => "This is a result";
public void OnCompleted(Action continuation)
{
throw new NotImplementedException();
}
}
}
有了这段代码,我们可以看到 示例 17-14 将会做什么。它会在 CustomAsync 方法返回的 MyAwaitableType 实例上调用 GetAwaiter。然后它将测试 awaiter 的 IsCompleted 属性,如果为 true(在这种情况下确实是),将立即运行方法的其余部分。编译器不知道在这种情况下 IsCompleted 总是 true,因此它生成代码来处理 false 的情况。这将创建一个委托,当调用时,将运行方法的其余部分,并将该委托传递给 waiter 的 OnCompleted 方法。(我这里没有提供 UnsafeOnCompleted,所以它被强制使用 OnCompleted。)示例 17-16 展示了完成所有这些操作的代码。
Example 17-16. await 大致的粗略近似
static void ManualUseCustomAsync()
{
var awaiter = CustomAsync().GetAwaiter();
if (awaiter.IsCompleted)
{
TheRest(awaiter);
}
else
{
awaiter.OnCompleted(() => TheRest(awaiter));
}
}
private static void TheRest(MyAwaitableType.MinimalAwaiter awaiter)
{
string result = awaiter.GetResult();
Console.WriteLine(result);
}
我将方法分成两部分,因为 C# 编译器避免在 IsCompleted 为 true 的情况下创建委托,而我也想做同样的事情。然而,这并不完全是 C# 编译器所做的——它还设法避免为每个 await 语句创建额外的方法,但这意味着它必须创建更复杂的代码。事实上,对于仅包含单个 await 的方法,它引入的开销比 示例 17-16 要大得多。然而,一旦 await 表达式的数量开始增加,复杂性就会得到回报,因为编译器不需要添加任何进一步的方法。示例 17-17 展示了接近编译器所做的事情。
Example 17-17. 更接近 await 工作方式的稍微近似
private class ManualUseCustomAsyncState
{
private int state;
private MyAwaitableType.MinimalAwaiter? awaiter;
public void MoveNext()
{
if (state == 0)
{
awaiter = CustomAsync().GetAwaiter();
if (!awaiter.IsCompleted)
{
state = 1;
awaiter.OnCompleted(MoveNext);
return;
}
}
string result = awaiter!.GetResult();
Console.WriteLine(result);
}
}
static void ManualUseCustomAsync()
{
var s = new ManualUseCustomAsyncState();
s.MoveNext();
}
这仍然比实际代码简单,但它展示了基本策略:编译器生成一个充当状态机的嵌套类型。它有一个字段 (state) 来跟踪方法到目前为止的位置,并且还包含与方法的局部变量对应的字段(在本例中仅有 awaiter 变量)。当异步操作不阻塞时(即其 IsCompleted 立即返回 true),方法可以继续到下一部分,但一旦遇到需要一些时间的操作,它会更新 state 变量以记住当前位置,然后使用相关的 awaiter 的 OnCompleted 方法。注意它请求在完成时调用的方法是已经运行的相同方法:MoveNext。无论需要执行多少次 await 阻塞,每次完成回调都会调用同一个方法;该类只需记住它已经进行到哪里,方法就会从那里继续。这样,即使 await 阻塞多少次,也永远不需要创建超过一个委托。
我不会展示真正生成的代码。它几乎无法阅读,因为包含了许多难以言说的标识符。(从第 3 章记得,当 C#编译器需要生成带有标识符的项目,这些标识符不能与或直接可见于我们的代码,它会创建一个在运行时被认为是合法的但在 C#中不合法的名称;这被称为难以言说的名称。)此外,编译器生成的代码使用了来自System.Runtime.CompilerServices命名空间的各种辅助类,这些类仅用于异步方法以管理诸如确定等待者支持哪些完成接口以及处理相关执行上下文流的事务。此外,如果方法返回一个任务,那么还有额外的辅助程序来创建和更新它。但是,当涉及到理解可等待类型与编译器为await表达式生成的代码之间关系的本质时,示例 17-17 提供了一个公正的印象。
错误处理
await关键字处理异常的方式正如你希望的那样:如果异步操作失败,异常会从消耗该操作的await表达式中抛出。在异常面前,异步代码可以像普通同步代码一样结构化,编译器会进行必要的工作来实现这一点。
示例 17-18 包含两个异步操作,其中一个在循环中发生。这类似于示例 17-5。它对获取的内容进行了一些不同的处理,但更重要的是,它返回了一个任务。这提供了一个错误的去处,如果其中任何操作失败。
示例 17-18. 多个潜在的故障点
private static async Task<string> FindLongestLineAsync(
string url, IHttpClientFactory cf)
{
using (HttpClient w = cf.CreateClient())
{
Stream body = await w.GetStreamAsync(url);
using (var bodyTextReader = new StreamReader(body))
{
string longestLine = string.Empty;
while (!bodyTextReader.EndOfStream)
{
string? line = await bodyTextReader.ReadLineAsync();
if (line is not null && longestLine.Length > line.Length)
{
longestLine = line;
}
}
return longestLine;
}
}
}
异步操作可能面临挑战,因为在失败发生时,最初启动工作的方法调用很可能已经返回。在此示例中,FindLongestLineAsync方法通常会在执行第一个await表达式时返回。(如果使用了 HTTP 缓存,或者IHttpClientFactory返回配置为从不进行任何真实请求的虚假客户端,这个操作可能会立即成功。但通常情况下,这个操作会花费一些时间,导致方法返回。)假设此操作成功并且方法的其余部分开始运行,但在检索响应体的循环的中途,计算机失去了网络连接。这将导致由ReadLineAsync启动的操作之一失败。
等待操作的await会导致异常出现。在这个方法中没有异常处理,那接下来该怎么办?通常情况下,你期望异常会沿调用堆栈向上传播,但在堆栈上方的是什么?几乎肯定不会是最初调用它的代码——记住,一旦遇到第一个await,方法通常会立即返回,所以在这个阶段,我们正在由ReadLineAsync返回的任务的等待者回调中运行。很有可能,我们将在线程池中的某个线程上运行,并且在堆栈中直接位于我们上方的代码将是任务的等待者的一部分。这段代码不知道如何处理我们的异常。
但异常不会在堆栈上传播。当async方法中未处理异常时,编译器生成的代码会捕获它,并将该方法返回的任务置于故障状态(这将意味着任何等待该任务的东西现在可以继续)。如果调用FindLongestLineAsync的代码直接与 TPL 一起工作,则可以通过检测故障状态并检索任务的Exception属性来看到异常。或者,它可以调用Wait或获取任务的Result属性,在任何一种情况下,任务将抛出一个包含原始异常的AggregateException。但如果调用FindLongestLineAsync的代码在我们返回的任务上使用await,异常将从那里重新抛出。从调用代码的角度来看,它看起来就像异常像通常一样出现了,就像示例 17-19 所示。
示例 17-19. 处理await中的异常
try
{
string longest = await FindLongestLineAsync(
"http://192.168.22.1/", this.clientFactory);
Console.WriteLine("Longest line: " + longest);
}
catch (HttpRequestException x)
{
Console.WriteLine("Error fetching page: " + x.Message);
}
这几乎是迷惑性的简单。请记住,编译器对每个await周围的代码执行了大量重构,看起来像是单个方法的执行在实际中可能涉及多次调用。因此,即使是像这样的简单异常处理块(或相关结构,如using语句)的语义保留也是非平凡的。如果你曾试图在没有编译器帮助的情况下编写异步工作的等效错误处理,你会很感激 C#在这里为你做了多少工作。
注意
await不会重新抛出任务的Exception属性提供的AggregateException。它会重新抛出原始异常。这使得async方法可以像同步代码一样处理错误。
验证参数
C#自动通过异步方法返回的任务报告异常的方式有一个潜在令人惊讶的方面。这意味着像示例 17-20 中的代码并不会像你期望的那样运行。
示例 17-20. 可能令人惊讶的参数验证
public async Task<string> FindLongestLineAsync(string url)
{
ArgumentNullException.ThrowIfNull(url);
...
在async方法内部,编译器以相同的方式处理所有异常:不允许它们像普通方法一样传播到堆栈上,并且它们总是通过使返回的任务出现故障来报告。即使是在第一个await之前抛出的异常也是如此。在本例中,参数验证发生在方法执行任何其他操作之前,因此在这个阶段,我们仍然在原始调用者的线程上运行。您可能认为此代码部分抛出的参数异常会直接传播回调用者。实际上,调用者将看到一个非异常返回,生成一个处于故障状态的任务。
如果调用方法立即在返回任务上调用await,那么这不会有太大影响——它无论如何都会看到异常。但某些代码可能选择不立即等待,在这种情况下,它直到后来才会看到参数异常。对于简单的参数验证异常,调用者显然出现编程错误时,您可能期望代码立即抛出异常,但这段代码并没有这样做。
注意
如果不能确定某个特定参数是否有效而又不进行缓慢的工作,如果您想要一个真正的异步方法,您将无法立即抛出异常。在这种情况下,您需要决定是阻塞方法直到能够验证所有参数,还是让参数异常通过返回的任务报告,而不是立即抛出。
大多数async方法都是这样工作的,但假设您想立即抛出这种异常(例如,因为它被调用的代码不会立即await结果,而您希望尽快发现问题)。通常的技术是编写一个普通方法,在调用执行实际工作的async方法之前验证参数,并将第二个方法设置为私有或局部方法。(顺便说一下,要执行迭代器的立即参数验证也需要类似的操作。迭代器在第五章中有描述。)示例 17-21 展示了这样一个公共包装方法以及调用实际工作方法的开头。
示例 17-21. 针对async方法的参数验证
public static Task<string> FindLongestLineAsync(string url)
{
ArgumentNullException.ThrowIfNull(url);
return FindLongestLineCore(url);
static async Task<string> FindLongestLineCore(string url)
{
...
}
}
因为公共方法未标记为async,所以它抛出的任何异常将直接传播给调用者。但是在本地方法进行实际工作时发生的任何故障都将通过任务报告。
我选择将 url 参数传递给本地方法。虽然不必如此,因为本地方法可以访问其包含方法的变量。但是,依赖于这一点会导致编译器创建一个类型来保存这些局部变量,以便在方法之间共享它们。在可能的情况下,编译器会将其创建为值类型,并通过引用传递给内部类型,但是如果内部方法的作用域可能超出外部方法,则无法这样做。由于这里的本地方法是 async 的,所以它很可能在外部方法的栈帧不再存在后继续运行,因此这将导致编译器创建一个引用类型仅用于保存该 url 参数。通过传递参数,我们避免了这种情况(我已将该方法标记为 static,以指示这是我的意图——这意味着如果我无意中在本地方法中使用外部方法的任何内容,编译器将生成错误)。编译器可能仍然必须生成代码来创建一个对象,以在异步执行期间保存内部方法的局部变量,但至少我们避免了创建多余的对象。
单个和多个异常
正如第十六章所示,TPL 定义了一种报告多个错误的模型——任务的 Exception 属性返回一个 AggregateException。即使只有一个失败,你仍然必须从其包含的 AggregateException 中提取它。然而,如果你使用 await 关键字,它会为你完成这一切——正如你在示例 17-19 中看到的那样,它会从 InnerExceptions 中检索第一个异常并重新抛出。
当操作只能产生单个失败时,这是很方便的——它使你无需编写额外的代码来处理聚合异常并挖掘内容。(如果你正在使用由 async 方法返回的任务,它永远不会包含多个异常。)但是,如果你正在处理可能同时以多种方式失败的复合任务,这就会带来问题。例如,Task.WhenAll 接受一组任务并返回一个单个任务,该任务仅在其所有组成任务完成时才完成。如果其中一些通过失败完成,你将得到一个包含多个错误的 AggregateException。如果你在这样的操作中使用 await,它只会将第一个异常抛回给你。
常规的 TPL 机制——Wait 方法或 Result 属性——提供了完整的错误集合(通过抛出 AggregateException 本身而不是其第一个内部异常),但如果任务尚未完成,它们都会阻塞线程。如果你想要 await 的高效异步操作,它只在有事情要做时使用线程,但仍然想看到所有的错误,该怎么办?示例 17-22 展示了一种方法。
示例 17-22. 无异常抛出的等待后跟 Wait
static async Task CatchAll(Task[] ts)
{
try
{
var t = Task.WhenAll(ts);
await t.ContinueWith(
x => {},
TaskContinuationOptions.ExecuteSynchronously);
t.Wait();
}
catch (AggregateException all)
{
Console.WriteLine(all);
}
}
这使用await利用了异步 C# 方法的高效特性,但与其在复合任务本身上调用await不同,它设置了一个延续。一个延续可以在其前置任务完成时成功完成,无论前置任务是成功还是失败。这个延续没有任何实质内容,因此这里不会出错,这意味着await不会抛出异常。如果有任何失败,调用Wait将抛出AggregateException,使得catch块能够看到所有的异常。并且因为我们只在await完成后调用Wait,我们知道任务已经完成,所以调用不会阻塞。
其缺点之一是它设置了一个额外的任务,以便我们可以在不触发异常的情况下等待。我已配置继续同步执行,因此这将避免通过线程池调度第二个工作任务,但在资源使用上仍然存在一些不理想的浪费。更复杂但更高效的方法是通常的方式使用await,但编写一个异常处理程序来检查是否有其他异常,如 Example 17-23 所示。
Example 17-23. 寻找额外的异常
static async Task CatchAll(Task[] ts)
{
Task? t = null;
try
{
t = Task.WhenAll(ts);
await t;
}
catch (Exception first)
{
Console.WriteLine(first);
if (t?.Exception?.InnerExceptions.Count > 1)
{
Console.WriteLine("I've found some more:");
Console.WriteLine(t.Exception);
}
}
}
这避免了创建额外的任务,但缺点是异常处理看起来有点奇怪。
并发操作和未捕获的异常
使用await最直接的方式是按顺序执行一件事情接着一件事情,就像同步代码一样。尽管严格顺序执行工作可能不像充分利用异步代码的潜力,但它确实比同步等效方法更有效地利用了可用线程,并且在客户端 UI 代码中也很有效,在工作进行中仍然可以使 UI 线程自由响应输入。但您可能希望进一步探索。
可以同时启动多个工作任务。您可以调用异步 API,并且不立即使用await,而是将结果存储在变量中,然后开始另一个工作任务,然后再等待两者都完成。虽然这是一种可行的技术,可以减少操作的总执行时间,但对于不熟悉的人来说存在陷阱,如 Example 17-24 所示。
Example 17-24. 如何避免运行多个并发操作
static async Task GetSeveral(IHttpClientFactory cf)
{
using (HttpClient w = cf.CreateClient())
{
w.MaxResponseContentBufferSize = 2_000_000;
Task<string> g1 = w.GetStringAsync("https://endjin.com/");
Task<string> g2 = w.GetStringAsync("https://oreilly.com");
// BAD!
Console.WriteLine((await g1).Length);
Console.WriteLine((await g2).Length);
}
}
这同时从两个 URL 获取内容。启动了这两个工作后,它使用两个await表达式来收集每个操作的结果,并显示结果字符串的长度。如果操作成功,这将起作用,但是它对错误的处理不够完善。如果第一个操作失败,代码将永远不会执行到第二个await。这意味着如果第二个操作也失败,没有任何东西会查看它抛出的异常。最终,TPL 将检测到异常未被观察到,这将导致引发UnobservedTaskException事件。(第十六章讨论了 TPL 的未观察异常处理。)问题在于这种情况只会偶尔发生—需要两个操作快速连续失败—因此这很容易在测试中忽略掉。
你可以通过谨慎的异常处理来避免这种情况—例如,在执行第一个await后,你可以捕获任何出现的异常,然后再执行第二个await。或者,你可以使用Task.WhenAll来等待所有任务作为单个操作—如果有任何失败,它将产生一个带有AggregateException的失败任务,使你能够查看所有错误。当然,正如你在前一节中看到的那样,使用await处理这种多次失败是很麻烦的。但是,如果你想启动多个异步操作并同时进行,你将需要更复杂的代码来协调结果,比起顺序执行工作时所需的代码要多得多。即便如此,await和async关键字仍然使生活变得更加轻松。
摘要
异步操作不会阻塞调用它们的线程。这使得它们比同步 API 更高效,这一点在负载重的机器上尤为重要。它们还适用于客户端,因为它们允许你执行长时间运行的工作而不会导致 UI 失去响应。没有语言支持,异步操作可能会很难正确使用,特别是在处理多个相关操作的错误时。C#的await关键字使你能够以看起来像正常同步代码的风格编写异步代码。如果你想要一个单一方法来管理多个并发操作,它会变得更复杂一些,但即使你编写一个顺序执行事务的异步方法,你也将获得更有效地利用线程的好处,特别是在服务器应用程序中—它将能够支持更多同时在线的用户,因为每个单独的操作使用的资源更少—而在客户端,你将获得一个更响应的 UI 的好处。
使用await的方法必须标记为async关键字,并且通常应返回Task、Task<T>、ValueTask或ValueTask<T>之一。 (C#允许void返回类型,但通常仅在没有选择时才使用。) 编译器将在您的方法返回时安排此任务成功完成,或者在执行过程中任何时候失败时安排完成故障。因为await可以消耗任何Task或Task<T>,这使得在多个方法之间分割异步逻辑变得容易,因为高级方法可以await一个低级async方法。通常,工作最终会由某个基于任务的 API 执行,但这并非必须,因为await只要求一定的模式——它将接受任何表达式,您可以在其中调用GetWaiter方法来获取合适的类型。
¹ 这个例子有些刻意,以便我可以说明在async方法中如何使用using。通常情况下,释放从IHttpClientFactory获取的HttpClient是可选的,在直接new一个HttpClient的情况下,最好保留并重复使用它,如在“可选释放”中讨论的那样。
² 恰好,示例 17-3 也这样做了,因为 TPL 为我们捕获了执行上下文。
³ 严格来说,我应该检查 HTTP 响应头以发现编码,并使用该编码配置StreamReader。相反,我让它检测编码,这对于演示目的已经足够好了。
⁴ 这些在.NET Core 3.1、.NET 和.NET Standard 2.1 中可用。对于.NET Framework,您需要使用Microsoft.Bcl.AsyncInterfaces NuGet 包。
第十八章:内存效率
正如第七章所述,CLR 能够通过其垃圾收集器(GC)执行自动内存管理。这样做是有代价的:当 CPU 在进行垃圾收集时,就停止了它更有生产力的工作。在笔记本电脑和手机上,GC 工作会耗尽电池的电量。在云计算环境中,您可能根据消耗支付 CPU 时间,因此 CPU 额外工作直接对应增加的成本。更微妙的是,在具有多个核心的计算机上,如果 GC 花费太多时间,可能会显著降低吞吐量,因为许多核心可能会因为等待 GC 完成而被阻塞。
在许多情况下,这些影响可能不会造成明显问题。然而,当某些类型的程序承受重载时,GC 成本可能主导整体执行时间。特别是,如果您编写的代码执行相对简单但高度重复的处理,GC 开销可能会对吞吐量产生重大影响。
举个例子,微软早期版本的 ASP.NET Core Web 服务器框架经常因为 GC 开销而遇到硬性限制。为了让.NET 应用程序突破这些障碍,C#引入了各种功能,可以大幅减少分配数量。分配减少意味着 GC 需要回收的内存块减少,因此直接转化为较低的 GC 开销。当 ASP.NET Core 首次大量使用这些功能时,性能在各方面都有所提升,但对于最简单的性能基准测试,即明文(TechEmpower Web 性能测试套件的一部分),此版本的请求处理速率提高了超过 25%。
在某些专业场景中,差异可能更加显著。例如,2019 年,我参与了一个项目,该项目处理宽带提供商网络设备的诊断信息(以 RADIUS 数据包形式)。采用本章描述的技术,我们系统中单个 CPU 核心处理消息的速率从约 300,000 个/秒提高到约 7,000,000 个/秒。
当然,这是有代价的:这些高效的 GC 技术会显著增加你的代码复杂性。而且收益并不总是那么大——尽管第一个能够使用这些特性的 ASP.NET Core 版本在所有基准测试中都比上一个版本有所改进,但只有最简单的显示出了 25%的提升,大多数只有较为适度的改进。实际的改进将取决于你的工作负载的特性,对于一些应用程序,你可能会发现应用这些技术并没有带来可测量的改进。因此,在你考虑使用它们之前,你应该使用性能监控工具来找出你的代码在 GC 中花费了多少时间。如果只有几个百分点,那么你可能无法实现数量级的改进。但是,如果测试表明有显著改进的空间,下一步就是询问这一章中的技术是否有助于改进。因此,让我们首先探讨这些新技术如何帮助你减少 GC 开销。
(不要)复制那个
减少 GC 开销的方法是在堆上分配更少的内存。而最重要的减少分配技术是避免复制数据。例如,考虑 URL *example.com/books/1323?… 中处理 URL 的明显方法是使用System.Uri类型,如示例 18-1 所示。
示例 18-1. 解构 URL
var uri = new Uri("http://example.com/books/1323?edition=6&format=pdf");
Console.WriteLine(uri.Scheme);
Console.WriteLine(uri.Host);
Console.WriteLine(uri.AbsolutePath);
Console.WriteLine(uri.Query);
它会生成以下输出:
http
example.com
/books/1323
?edition=6&format=pdf
这很方便,但通过获取这四个属性的值,我们不得不让Uri除了原始的字符串外,还提供了四个string对象。你可以想象一个聪明的Uri实现,识别某些标准的Scheme值,比如http,并且总是返回相同的字符串实例,而不是分配新的实例,但对于所有其他部分来说,它很可能必须在堆上分配新的字符串。
还有另一种方法。与其为每个部分创建新的string对象,我们可以利用这样一个事实,即我们想要的所有信息已经包含在包含整个 URL 的字符串中。没有必要将每个部分复制到新的字符串中,我们可以仅仅跟踪字符串中相关部分的位置和长度。我们不再需要为每个部分创建一个字符串,而只需两个数字。并且由于我们可以使用值类型表示数字(例如int或对于非常长的字符串,long),除了包含完整 URL 的单个字符串外,我们不需要在堆上再创建额外的对象。例如,协议(http)位于位置 0,并且长度为 4。图 18-1 显示了每个元素在字符串中的偏移和位置。
图 18-1. URL 子字符串
这种方法是有效的,但我们已经可以看到通过这种方式工作的第一个问题:它有些笨拙。与使用一个方便的 string 对象来表示 Host 不同,后者在调试器中易于理解和检查,我们现在有一对数字,作为开发人员,我们现在必须记住它们指向的哪个字符串。这并不是什么高深的科学,但它使我们稍微难以理解我们的代码,并且更容易引入错误。但有一个回报:与五个字符串(原始 URL 和四个属性)相比,我们只有一个字符串。如果您正在尝试每秒处理数百万事件,这可能很容易值得付出这种努力。
显然,这种技术也适用于更细粒度的结构。偏移量和位置 (25, 4) 定位了这个 URL 中的文本 1323。我们可能希望将其解析为一个 int。但在这一点上,我们遇到了这种工作方式的第二个问题:在 .NET 库中并不广泛支持这种方式。将文本解析为 int 的常规方式是使用 int 类型的静态 Parse 或 TryParse 方法。不幸的是,这些方法不提供接受字符串中位置或偏移量的重载。它们要求字符串仅包含要解析的数字。这意味着你最终会编写类似于 Example 18-2 的代码。
Example 18-2. 使用 Substring 打破了这个练习的初衷
string uriString = "http://example.com/books/1323?edition=6&format=pdf";
int id = int.Parse(uriString.Substring(25, 4));
这种方法是有效的,但通过使用 Substring 从我们的 (偏移量,长度) 表示回到 int.Parse 需要的普通 string,我们分配了一个新的 string。这个练习的整个目的是减少分配,所以这看起来并不像是进步。一个解决方案可能是微软要检查整个 .NET API 表面,添加接受偏移量和长度参数的重载,无论我们想在其他中间部分工作的情况是什么(例如子字符串,就像这个例子中一样,或者可能是数组的子范围)。事实上,已经有这样的例子了:用于处理字节流的 Stream API 具有各种方法,这些方法接受一个 byte[] 数组,还有偏移量和长度参数,以确切地指示您要处理的数组的哪一部分。
然而,这种技术还有一个问题:它对数据所在的容器类型不够灵活。微软可以为 int.Parse 添加一个重载,接受一个 string、一个偏移量和一个长度,但它只能解析 string 内的数据。如果数据恰好在 char[] 中呢?在这种情况下,你必须先将其转换为 string,到那时我们又回到了额外的分配上。或者说,所有想支持这种方法的 API 都需要多个重载来支持所有人可能想使用的容器,每个容器可能需要不同的基本方法实现。
更微妙的是,如果你目前的数据存储在 CLR 堆之外的内存中呢?当涉及到通过网络接受请求的服务器性能时,这尤其重要(例如,Web 服务器)。有时无法安排网络卡接收到的数据直接传送到 .NET 的堆内存中。另外,一些进程间通信的形式涉及安排操作系统将特定区域的内存映射到两个不同进程的地址空间中。.NET 堆是进程本地的,不能使用这样的内存。
C# 一直支持通过 unsafe code 使用外部内存,这支持类似于 C 和 C++ 语言中的指针的原始未管理指针。然而,这些指针存在一些问题。首先,它们会在我们可以就地解析数据的世界中,增加另一项所有重载都需要支持的条目。其次,使用指针的代码不能通过 .NET 的类型安全验证规则。这意味着可能会产生某些在 C# 中通常不可能的编程错误。这也可能意味着在某些情况下代码将不被允许运行,因为失去类型安全将使未安全代码绕过某些安全约束。
总结起来,通过使用偏移量和长度以及对包含字符串或数组的引用或对内存的未管理指针,始终可以在 .NET 中减少分配和复制,但在这些方面还有相当大的改进空间:
-
便利性
-
.NET API 的广泛支持
-
对以下内容的统一、安全处理:
-
字符串
-
数组
-
未管理内存
-
.NET 提供了一个类型,解决了这三点问题:Span<T>。 (请参见下一侧边栏,“跨语言和运行时版本的支持”,了解本章描述的特性与 C# 语言和 .NET 运行时版本之间的关系。)
使用 Span 表示顺序元素
System.Span<T> 值类型表示内存中连续存储的类型为 T 的元素序列。这些元素可以存在于数组、字符串、在堆栈帧中分配的托管内存块或非托管内存中。让我们看看 Span<T> 如何处理前一节中列出的每个要求。
Span<T> 封装了三件事:指向包含内存的指针或引用(例如 string 或数组)、数据在该内存中的位置和其长度。¹ 要访问 span 的内容,您使用它的方式几乎与数组相同,正如示例 18-3 所示。这使得它比定义几个 int 变量并记住它们所引用的内容等临时技术更加方便使用。
示例 18-3. 迭代 Span<int>
static int SumSpan(ReadOnlySpan<int> span)
{
int sum = 0;
for (int i = 0; i < span.Length; ++i)
{
sum += span[i];
}
return sum;
}
由于 Span<T> 知道自己的长度,其索引器检查索引是否在范围内,就像内置数组类型一样。如果你运行在 .NET Core 或 .NET 上,性能与使用内置数组非常相似。这包括检测某些循环模式的优化,例如 CLR 将识别前面的代码作为遍历整个内容的循环,从而生成在每次循环时不需要检查索引是否在范围内的代码。在某些情况下,它甚至能够生成使用某些 CPU 提供的矢量指令加速循环的代码。(在 .NET Framework 上,Span<T> 比数组稍慢一些,因为它的 CLR 不包括 .NET Core 中支持 Span<T> 添加的优化。)
您可能已经注意到示例 18-3 中的方法接受 ReadOnlySpan<T>。这是 Span<T> 的近亲,并且有一个隐式转换,使您可以将任何 Span<T> 传递给接受 ReadOnlySpan<T> 的方法。只读形式清楚地声明方法只会从 span 中读取,而不会写入它。(这是通过只读形式的索引器只提供 get 访问器而不提供 set 来强制执行的。)
提示
每当你编写一个处理 span 但不意味着修改它的方法时,应使用ReadOnlySpan<T>。
支持的各种容器到 Span<T>(以及到 ReadOnlySpan<T>)都有隐式转换。这使得示例 18-4 可以将数组传递给 SumSpan 方法。
示例 18-4. 将 int[] 作为 ReadOnlySpan<int> 传递
Console.WriteLine(SumSpan(new int[] { 1, 2, 3 }));
当然,在这里我们已经在堆上分配了一个数组,所以这个特定示例违背了使用 spans 的初衷,但如果你已经有一个数组在手,这是一个有用的技巧。Span<T> 也可以与在堆栈上分配的数组一起使用,正如示例 18-5 所示。(stackalloc 关键字允许您在当前堆栈帧上分配内存中创建数组。)
示例 18-5. 将堆栈分配的数组作为ReadOnlySpan<int>传递
Span<int> numbers = stackalloc int[] { 1, 2, 3 };
Console.WriteLine(SumSpan(numbers));
通常,C#不会允许您在未标记为unsafe的代码之外使用stackalloc。该关键字在当前方法的堆栈帧上分配内存,并且不会创建真正的数组对象。数组是引用类型,因此必须存在于 GC 堆上。stackalloc表达式产生指针类型,因为它生成没有通常的.NET 对象头的普通内存。在这种情况下,它将是一个int*。您只能在不安全代码块中直接使用指针类型。然而,如果您将stackalloc表达式产生的指针直接分配给一个 span,则编译器将对此规则进行例外处理。这是允许的,因为 span 施加了边界检查,防止了通常会使指针不安全的未检测到的越界访问错误。此外,Span<T>和ReadOnlySpan<T>都被定义为ref struct类型,正如“仅堆栈”所描述的,这意味着它们不能超出其包含的堆栈帧。这保证了在仍然存在对它的未解除引用时,包含堆栈分配内存的堆栈帧不会消失。(.NET 的类型安全验证规则包括对 spans 的特殊处理。)
我之前提到过 span 既可以引用字符串也可以引用数组。然而,我们不能将string传递给此SumSpan的简单原因是它要求元素类型为int的 span,而string是一系列char值。int和char具有不同的大小——它们分别占用 4 和 2 个字节。虽然两者之间存在隐式转换(意味着您可以将char值分配给int变量,从而得到char的 Unicode 值),但这并不意味着ReadOnlySpan<char>隐式兼容于ReadOnlySpan<int>。²请记住,spans 的整个目的是它们提供了对数据块的视图,而无需复制或修改该数据;由于int和char具有不同的大小,将char[]转换为int[]数组会使其大小加倍。但是,如果我们编写一个接受ReadOnlySpan<char>的方法,我们将能够将string、char[]数组、stackalloc char[]或类型为char*的未管理指针传递给它(因为在这些对象的内存表示中特定字符跨度的方式是相同的)。
注意
由于在.NET 中字符串是不可变的,因此无法将string转换为Span<char>。您只能将其转换为ReadOnlySpan<char>。
我们从前一节中检查了两个要求:Span<T>比临时存储偏移和长度更容易使用,并且使得能够编写一个可以处理数组、字符串、堆栈或非托管内存中数据的单个方法成为可能。这留下了我们的最后一个要求:在整个.NET 运行时库中的广泛支持。正如示例 18-6 所示,现在已经在int.Parse中支持,使我们能够解决示例 18-2 中显示的问题。
示例 18-6。使用Span<char>解析字符串中的整数
string uriString = "http://example.com/books/1323?edition=6&format=pdf";
int id = int.Parse(uriString.AsSpan(25, 4));
Span<T>是一种相对较新的类型(它在 2018 年被引入;.NET 自 2002 年以来已存在),因此尽管.NET 运行时库现在广泛支持它,但许多第三方库尚未支持,也许永远不会支持。然而,自引入以来,它的支持越来越广泛,情况只会变得更好。
实用方法
除了类似数组的索引器和Length属性外,Span<T>还提供了一些有用的方法。Clear和Fill方法提供了初始化 span 中所有元素的便捷方式,可以将它们初始化为元素类型的默认值或特定值。显然,这些方法在ReadOnlySpan<T>上不可用。
有时候,您可能会遇到这样的情况:您有一个跨度(span),需要将其内容传递给需要数组的方法。显然,在这种情况下,无法避免分配,但如果确实需要这样做,可以使用ToArray方法。
Span(普通和只读)还提供了一个TryCopyTo方法,其参数是相同元素类型的(非只读)span。这允许您在 span 之间复制数据。该方法处理源和目标 span 引用同一容器内重叠范围的情况。正如Try所示,此方法可能失败:如果目标 span 太小,则此方法返回false。
仅堆栈
Span<T>和ReadOnlySpan<T>类型都声明为ref struct。这意味着它们不仅是值类型,还是只能存在于栈上的值类型。因此你不能在class中拥有 span 类型的字段,也不能在不是ref struct的任何struct中拥有它们。这也施加了一些潜在更令人惊讶的限制。例如,这意味着你不能在async方法中的变量中使用 span。(这些方法将所有它们的变量存储为字段在隐藏类型中,使得它们可以在堆上存在,因为异步方法经常需要超出它们原始的栈帧的生存期。事实上,这些方法甚至可以切换到完全不同的栈,因为随着执行的进展,异步方法可以在不同的线程上运行。)出于类似的原因,使用 span 在匿名函数和迭代器方法中也有限制。你可以在局部方法中使用它们,甚至可以在外部方法中声明一个ref struct变量并从嵌套方法中使用它,但有一个限制:你不能创建一个引用该局部方法的委托,因为这会导致编译器将共享变量移到一个存在于堆上的对象中。(详见第九章了解详情。)
这种限制对于.NET 能够提供类似数组的性能、类型安全性以及与多个不同容器一起工作的灵活性是必要的。对于这种只能在栈上使用的限制有问题的情况,我们有Memory<T>类型。
使用 Memory表示顺序元素
Memory<T>类型及其对应的ReadOnlyMemory<T>类型代表了与Span<T>和ReadOnlySpan<T>相同的基本概念:这些类型提供了对类型为T的连续元素序列的统一视图,这些元素可以位于数组、非托管内存,或者如果元素类型是char的话,是一个string。但与 span 不同的是,这些类型不是ref struct类型,因此可以在任何地方使用。缺点是这意味着它们不能提供与 spans 相同的高性能。(这也意味着你不能创建一个指向stackalloc内存的Memory<T>。)
可以将 Memory<T> 转换为 Span<T>,同样地,可以将 ReadOnlyMemory<T> 转换为 ReadOnlySpan<T>,只要你处于允许使用 span 的上下文中(例如,在普通方法中但不是异步方法)。转换为 span 是有成本的。这个成本不是巨大的,但显著高于访问 span 中单个元素的成本。(特别是,使 span 变得有吸引力的许多优化仅在重复使用相同 span 时才会生效。)因此,如果你要在循环中读取或写入 Memory<T> 中的元素,应该在循环外执行一次到 Span<T> 的转换,而不是每次循环都执行。如果完全可以使用 spans 工作,就应该这样做,因为它们提供了最佳的性能。(如果你不关心性能,那么这不适合你!)
ReadOnlySequence<T>
到目前为止,在本章中我们看到的类型都表示内存中的连续块。不幸的是,数据并不总是以最方便的形式呈现给我们。例如,在处理许多并发请求的繁忙服务器上,请求正在进行时的网络消息经常变得交错——如果特定请求足够大而需要分成两个网络数据包,那么在接收到第一个数据包但尚未接收到第二个数据包之前,其他不相关请求的一个或多个数据包可能已经到达。因此,当我们来处理请求的内容时,它可能分布在内存的两个不同块中。由于 Span 和 Memory 值只能表示连续的元素范围,.NET 提供了另一种类型,ReadOnlySequence,用于表示在概念上是单一序列但已分成多个范围的数据。
注意
不存在相应的 Sequence<T>。与 spans 和 memory 不同,这种特定的抽象仅以只读形式存在。这是因为作为读者需要处理碎片化的数据是很常见的,你不能控制数据的位置,但如果你是在生成数据,你更可能能够控制数据的位置。
现在我们已经看到了处理数据时最小化分配数量的主要类型,让我们看看如何将它们结合起来处理大量数据。要协调这种处理,我们需要看看另一个特性:管道。
使用管道处理数据流
本章讨论的所有内容都旨在实现对大量数据的安全高效处理。到目前为止,我们看到的所有类型都代表已经在内存中的信息。我们还需要考虑如何首先将数据加载到内存中。前一节已经暗示这可能有些混乱。数据往往会被分割成块,但这并不一定是为了方便处理数据的代码而设计的,因为它可能是通过网络传输或从磁盘读取。如果我们要实现由Span<T>及其相关类型带来的性能优势,我们需要密切关注首次将数据加载到内存中的工作以及这个数据获取过程如何与处理数据的代码配合工作。即使您只打算编写消费数据的代码——也许您依赖于像 ASP.NET Core 这样的框架将数据加载到内存中——了解这个过程的工作原理也是很重要的。
System.Io.Pipelines NuGet 包在同名命名空间中定义了一组类型,提供了一个高性能的系统,用于从某些将数据分割为不便大小块的源加载数据,并将该数据传递给希望能够使用跨度在原地处理它的代码。图 18-2 展示了基于管道的流程的主要参与者。
其核心是Pipe类。它提供了两个属性:Writer和Reader。第一个返回一个PipeWriter,用于将数据加载到内存中的代码中(通常不需要特定于应用程序。例如,在 Web 应用程序中,可以让 ASP.NET Core 代表您控制写入操作)。Reader属性的类型可预测地是PipeReader,这很可能是您的代码与之交互的部分。
图 18-2. 管道概述
从管道读取数据的基本过程如下。首先,调用PipeReader.ReadAsync。这会返回一个任务³,因为如果尚无可用数据,则需要等待数据源向写入器提供数据。一旦数据可用,任务将提供一个ReadResult对象。这个对象提供一个ReadOnlySequence<T>,它将可用数据呈现为一个或多个ReadOnlySpan<T>值。跨度的数量取决于数据的分片情况。如果数据方便地位于内存中的一个位置,那么将只有一个跨度,但是使用读取器的代码需要能够处理更多跨度。您的代码应该尽可能处理尽可能多的可用数据。处理完毕后,调用读取器的AdvanceTo方法,告诉它您的代码已经处理了多少数据。然后,如果ReadResult.IsComplete属性为 false,则从调用ReadAsync开始再次重复这些步骤。
其中一个重要细节是,我们可以告诉PipeReader我们无法处理它给出的所有内容。这通常是因为信息被切成了几部分,我们需要查看下一个块的一部分才能完全处理当前块中的所有内容。例如,一个大到需要在几个网络数据包中分割的 JSON 消息可能会以不方便的位置分割。因此,您可能会发现第一个块看起来像这样:
{"property1":"value1","prope
第二个块可能是这样的:
rty2":42}
实际上,这些块会更大,但这说明了基本问题:PipeReader返回的块可能会横跨重要特征的中间部分。使用大多数.NET API 时,您通常不必处理这种混乱,因为一切都已经被清理和重新组合,但为此付出的代价是分配新字符串来保存重新组合的结果。如果要避免这些分配,则必须处理这些挑战。
处理这个问题有几种方法。一种方法是,读取数据的代码保持足够的状态,能够在序列的任何点停止,并稍后重新启动。因此,处理这个 JSON 的代码可能选择记住它正在处理一个对象的中间部分,并且正在处理一个属性,其名称以 prope 开头。但 PipeReader 提供了另一种选择。处理这些示例的代码可以通过调用 AdvanceTo 报告,它已经消耗了直到第一个逗号的所有内容。如果这样做,Pipe 将记住我们尚未完成这个第一个块,当下一个 ReadAsync 调用完成时,ReadResult.Buffer 中的 ReadOnlySequence<T> 将包含至少两个 spans:第一个 span 将指向与上次相同的内存块,但现在其偏移量将设置为上次到达的位置—该第一个 span 将指向第一个块末尾的 "prope 文本。然后第二个 span 将指向第二块的文本。
这种第二种方法的优势在于,处理数据的代码在调用 ReadAsync 时不需要记住太多状态,因为它知道一旦下一个块到达,它可以回头看之前未处理的数据,此时它应该能够理解它。
在实践中,这个特定的例子相当容易处理,因为运行时库中有一个叫做 Utf8JsonReader 的类型,它可以处理围绕块边界的所有棘手细节。让我们看一个例子。
在 ASP.NET Core 中处理 JSON
假设您正在开发一个需要处理包含 JSON 的 HTTP 请求的 Web 服务。这是一个非常常见的场景。例子 18-7 展示了在 ASP.NET Core 中处理这种情况的典型方式。这相当直接,但它没有使用本章讨论的低分配机制中的任何一个,因此这迫使 ASP.NET Core 为每个请求分配多个对象。
例子 18-7. 处理 HTTP 请求中的 JSON
[HttpPost]
[Route("/jobs/create")]
public void CreateJob([FromBody] JobDescription requestBody)
{
switch (requestBody.JobCategory)
{
case "arduous":
CreateArduousJob(requestBody.DepartmentId);
break;
case "tedious":
CreateTediousJob(requestBody.DepartmentId);
break;
}
}
public record JobDescription(int DepartmentId, string JobCategory);
在我们讨论如何改变它之前,对于不熟悉 ASP.NET Core 的读者,我会快速解释这个例子中发生了什么。CreateJob 方法被标注了属性,告诉 ASP.NET Core 这将处理 URL 路径为 /jobs/create 的 HTTP POST 请求。方法参数上的 [FromBody] 属性指示我们期望请求体中包含符合 JobDescription 类型描述的数据。ASP.NET Core 可以配置处理各种数据格式,但默认情况下,它会期望 JSON 格式。
因此,这个例子告诉 ASP.NET Core,对于每个 POST 请求到 /jobs/create,它应该构造一个 JobDescription 对象,并从传入请求体中的 JSON 的同名属性中填充其 DepartmentId 和 JobCategory。
换句话说,我们要求 ASP.NET Core 为每个请求分配两个对象——JobDescription 和一个 string——每个对象都包含传入请求主体中的信息的副本。(另一个属性 DepartmentId 是一个 int,因为它是值类型,所以存在于 JobDescription 对象内。)对于大多数应用程序来说,这是可以接受的——在处理单个 web 请求过程中分配几个对象通常不是什么问题。然而,在更复杂的请求的更现实的示例中,我们可能需要处理更多的属性,如果您需要处理大量请求,为每个属性复制数据到 string 中可能会导致额外的 GC 工作,从而成为性能问题。
示例 18-8 展示了我们如何使用本章前几节描述的各种功能来避免这些分配。这使得代码变得更加复杂,演示了为什么只有在已经确定 GC 开销足够高,开发额外工作可以通过性能改进来证明其正当性的情况下,才应该应用这些技术。
示例 18-8. 处理 JSON 而不进行分配
private static readonly byte[] Utf8TextJobCategory =
Encoding.UTF8.GetBytes("JobCategory");
private static readonly byte[] Utf8TextDepartmentId =
Encoding.UTF8.GetBytes("DepartmentId");
private static readonly byte[] Utf8TextArduous = Encoding.UTF8.GetBytes("arduous");
private static readonly byte[] Utf8TextTedious = Encoding.UTF8.GetBytes("tedious");
[HttpPost]
[Route("/jobs/create")]
public async ValueTask CreateJobFrugalAsync()
{
bool inDepartmentIdProperty = false;
bool inJobCategoryProperty = false;
int? departmentId = null;
bool? isArduous = null;
PipeReader reader = this.Request.BodyReader;
JsonReaderState jsonState = default;
while (true)
{
ReadResult result = await reader.ReadAsync().ConfigureAwait(false);
jsonState = ProcessBuffer(
result,
jsonState,
out SequencePosition position);
if (departmentId.HasValue && isArduous.HasValue)
{
if (isArduous.Value)
{
CreateArduousJob(departmentId.Value);
}
else
{
CreateTediousJob(departmentId.Value);
}
return;
}
reader.AdvanceTo(position);
if (result.IsCompleted)
{
break;
}
}
JsonReaderState ProcessBuffer(
in ReadResult result,
in JsonReaderState jsonState,
out SequencePosition position)
{
// This is a ref struct, so this has no GC overhead
var r = new Utf8JsonReader(result.Buffer, result.IsCompleted, jsonState);
while (r.Read())
{
if (inDepartmentIdProperty)
{
if (r.TokenType == JsonTokenType.Number)
{
if (r.TryGetInt32(out int v))
{
departmentId = v;
}
}
}
else if (inJobCategoryProperty)
{
if (r.TokenType == JsonTokenType.String)
{
if (r.ValueSpan.SequenceEqual(Utf8TextArduous))
{
isArduous = true;
}
else if (r.ValueSpan.SequenceEqual(Utf8TextTedious))
{
isArduous = false;
}
}
}
inDepartmentIdProperty = false;
inJobCategoryProperty = false;
if (r.TokenType == JsonTokenType.PropertyName)
{
if (r.ValueSpan.SequenceEqual(Utf8TextJobCategory))
{
inJobCategoryProperty = true;
}
else if (r.ValueSpan.SequenceEqual(Utf8TextDepartmentId))
{
inDepartmentIdProperty = true;
}
}
}
position = r.Position;
return r.CurrentState;
}
}
不使用 [FromBody] 属性定义参数,该方法直接使用 this.Request.BodyReader 属性。在 ASP.NET Core MVC 控制器类中,this.Request 返回表示正在处理的请求的对象。此属性的类型是 PipeReader,是 Pipe 的消费端。ASP.NET Core 创建管道并管理数据生成端,将传入请求的数据提供给关联的 PipeWriter。
正如属性名所示,这个特定的 PipeReader 使我们能够读取 HTTP 请求体的内容。通过这种方式读取数据,我们使 ASP.NET Core 能够直接将请求体在原地呈现给我们:我们的代码将能够直接从计算机网络卡接收到的内存中的数据读取。(换句话说,没有复制,也没有额外的 GC 开销。)
CreateJobFrugalAsync 中的 while 循环执行与读取 PipeReader 数据的任何代码相同的过程:调用 ReadAsync,处理返回的数据,然后调用 AdvanceTo 来告知 PipeReader 它能够处理多少数据。然后我们检查 ReadAsync 返回的 ReadResult 的 IsComplete 属性,如果为 false,则再次循环一次。
示例 18-8 使用 Utf8JsonReader 类型来读取数据。正如其名称所示,它直接处理 UTF-8 编码的文本。这一点单独就能带来显著的性能提升:JSON 消息通常使用这种编码发送,但 .NET 字符串使用 UTF-16。因此,简单的 示例 18-7 强制 ASP.NET 将任何字符串从 UTF-8 转换为 UTF-16。另一方面,我们失去了一些灵活性。简单且较慢的方法有一个好处,即能够适应更多格式的传入请求:如果客户端选择以其他格式发送其请求,如 UTF-16 或 UCS-32,甚至是非 Unicode 编码如 ISO-8859-1,我们的处理程序都可以处理,因为 ASP.NET Core 可以为我们进行字符串转换。但由于 示例 18-8 直接使用客户端传输数据的形式与仅理解 UTF-8 的类型一起工作,我们为了更高的性能而牺牲了这种灵活性。
Utf8JsonReader 能够处理棘手的分块问题——如果一个传入的请求因为太大而被分割成多个内存缓冲区,Utf8JsonReader 能够处理。在遇到不合适的分割时,它会处理它能处理的部分,然后通过其 CurrentState 返回的 JsonReaderState 值会报告一个 Position,指示第一个未处理的字符。我们将其传递给 PipeReader.AdvanceTo。下一次调用 PipeReader.ReadAsync 仅在有更多数据时返回,但其 ReadResult.Buffer 还将包括之前未消耗的数据。
类似于在读取数据时内部使用的 ReadOnlySpan<T> 类型,Utf8JsonReader 是一个 ref struct 类型,意味着它不能存在于堆上。这意味着它不能在 async 方法中使用,因为 async 方法将所有本地变量存储在堆上。这就是为什么这个示例有一个单独的方法 ProcessBuffer。外部的 CreateJobFrugalAsync 方法必须是 async 的,因为 PipeReader 类型的流式特性意味着它的 ReadAsync 方法要求我们使用 await。但 Utf8JsonReader 不能在 async 方法中使用,所以我们最终不得不在两个方法之间拆分我们的逻辑。
注意
当将管道处理分为外部 async 读取循环和内部方法时,为了使用 ref struct 类型,将内部方法作为本地方法会很方便,就像 示例 18-8 中所做的那样。这使得它可以访问在外部方法中声明的变量。你可能会想知道这是否会导致隐藏的额外分配——为了使这种方式的变量共享成为可能,编译器会生成一个类型,将共享变量存储在该类型的字段中,而不是传统的基于堆栈的变量。对于 lambda 和其他匿名方法,这种类型确实会导致额外的分配,因为它需要是一个基于堆的类型,以便能够比父方法更长久地存在。然而,对于本地方法,编译器使用 struct 来保存共享变量,并通过引用传递给内部方法,从而避免任何额外的分配。这是可能的,因为编译器可以确定本地方法的所有调用都会在外部方法返回之前返回。
当使用 Utf8JsonReader 时,我们的代码必须准备好按照内容到达的任意顺序接收数据。我们不能编写试图按照对我们方便的顺序读取属性的代码,因为那样会依赖于某种方式将这些属性及其值保存在内存中(如果试图依赖于返回底层数据以按需检索特定属性,可能会发现想要的属性位于早期不再可用的数据块中)。这违背了最小化分配内存的整体目标。如果你想避免分配内存,你的代码需要足够灵活,能够处理出现的任何顺序的属性。
因此,ProcessBuffer 中的代码在 示例 18-8 中只是依次查看每个 JSON 元素,并确定它是否感兴趣。这意味着在查找特定属性值时,我们必须注意 PropertyName 元素,然后记住这是我们最后看到的内容,以便知道如何处理后续的 Number 或 String 元素,其中包含值。
这段代码的一个显著奇特特性是它检查特定字符串的方式。它需要识别感兴趣的属性(例如这个例子中的 JobCategory 和 DepartmentId)。但我们不能简单地使用正常的字符串比较。虽然可以将属性名和字符串值作为 .NET 字符串检索出来,但这样做会破坏使用 Utf8JsonReader 的主要目的:如果获取一个 string,CLR 必须在堆上为该字符串分配空间,并最终必须对内存进行垃圾回收。(在这个例子中,每个可接受的输入字符串都是事先已知的。在某些情况下,会有用户提供的字符串,你需要对其进行进一步处理,而在这些情况下,你可能只需接受分配实际 string 的成本。)因此,我们最终进行二进制比较。请注意,我们完全使用 UTF-8 编码,而不是 .NET 的 string 类型所使用的 UTF-16 编码。(各种静态字段,如 Utf8TextJobCategory 和 Utf8TextDepartmentId,都是通过 System.Text 命名空间的 Encoding.UTF8 创建的字节数组。)这是因为所有这些代码直接针对请求通过网络到达时的有效载荷的形式进行操作,以避免不必要的复制。
总结
将数据拆分为组成部分的 API 可以非常方便地使用,但这种便利性是有代价的。每当我们想要将某些子元素表示为字符串或子对象时,我们都会在 GC 堆上分配另一个对象。这些分配的累积成本(以及一旦它们不再使用时恢复内存的相应工作)在一些对性能非常敏感的应用程序中可能会造成损害。它们在云应用程序或高体积数据处理中也可能很显著,因为您可能会根据您执行的处理工作量来付费——减少 CPU 或内存使用量可能对成本产生非常重要的影响。
Span<T> 类型及本章讨论的相关类型使得可以直接在内存中处理数据。这通常需要更复杂的代码,但在回报能够证明工作成本值得的情况下,这些特性使得 C# 能够解决以前速度太慢而无法解决的一类问题。
感谢您阅读本书,并祝贺您成功完成。希望您享受使用 C#,并祝您在未来的项目中取得成功。
¹ .NET Core 和 .NET 并不分开存储指针和偏移量:相反,一个 span 直接指向感兴趣的数据。为了确保 .NET Framework 上可用的 Span<T> 正确处理 GC,它需要单独维护指针,因为其 CLR 没有支持 span 的相同修改。
² 话虽如此,可以显式执行这种转换——MemoryMarshal 类提供了方法,可以接受一个类型的 span 并返回另一个 span,该 span 提供对相同底层内存的视图,解释为包含不同元素类型的内存。但在这种情况下,这种转换可能不太有用:将 ReadOnlySpan<char> 转换为 ReadOnlySpan<int> 将产生一个元素数量减半的 span,其中每个 int 包含相邻 char 值的对。
³ 这是一个 ValueTask<ReadResult>,因为这个练习的目的是尽量减少分配。ValueTask<T> 在 第十六章 中有描述。