代码改变世界

[资料收集]通过 C# 简化 APM

2007-11-08 11:25  水随风  阅读(302)  评论(0编辑  收藏  举报


目录


曾一直赞扬异步编程模型 (APM) 的优点,强调异步执行 I/O 密集型操作是生产高响应和可伸缩应用程序及组件的关键。这些目标是可以达成的,因为 APM 可让您使用极少量的线程来执行大量的工作,而无需阻止任何线程。遗憾的是,使用 APM 构建典型的应用程序或组件有些难度,因此许多程序员不愿意去做。

 

有几个因素使得 APM 难以实现。首先,您需要避免状态数据驻留于线程的堆栈上,因为线程堆栈上的数据不能迁移到其他线程。避免基于堆栈的数据意味着必须避免方法参数和局部变量。多年以来,开发人员已喜欢上参数和局部变量,因为它们使编程变得简单多了。

其次,您需要将代码拆分为几个回调方法,有时称为“续”。例如,如果在一个方法中开始异步读取或写入操作,之后必须实现要调用的另一个方法(可能通过不同的线程),以处理 I/O 操作的结果。但程序员就是不习惯考虑以这种方法进行数据处理。在方法和线程之间迁移状态意味着状态必须进行打包,导致实现过程复杂化。

再次,众多有用的编程构造 — 如 try/catch/finally 语句、lock 语句、using 语句,甚至是循环(for、while 和 foreach 语句)— 不能在多个方法之间进行拆分。避免这些构造也增加了实现过程的复杂性。

最后,尝试提供多种功能,如协调多个重叠操作的结果、取消、超时,以及将 UI 控件修改封送到 Windows® 窗体或 Windows Presentation Foundation (WPF) 应用程序中的 GUI 线程,这都为使用 APM 增加了更多的复杂性。

在本期的专栏中,我将演示 C# 编程语言的一些最新添加内容,它们大大简化了异步编程模型的使用。之后我会介绍我自己的一个类,称为 AsyncEnumerator,它建立在这些 C# 语言功能的基础上,用来解决我刚提到的问题。我的 AsyncEnumerator 类能够让您在代码中使用 APM 变得简单而有趣。通过此类,您的代码会变得可伸缩且高响应,因此没有理由不使用异步编程。请注意,AsyncEnumerator 类是 Power Threading 库的一部分,并且依赖于同样是此库一部分的其他代码;读者可从 Wintellect.com 下载该库。


匿名方法和 lambda 表达式

图 1 中,SynchronousPattern 方法显示了如何同步打开并读取文件。该方法简单明了;它会构造一个 FileStream 对象,分配 Byte[],调用 Read,然后处理返回的数据。C# using 语句可确保完成数据处理后会关闭该 FileStream 对象。

ApmPatternWithMultipleMethods 方法显示了如何使用公共语言运行时 (CLR) 的 APM,来执行与 SynchronousPattern 方法相同的操作。您会立即看到实现过程要复杂得多。请注意,ApmPatternWithMultipleMethods 方法会启动异步 I/O 操作,操作完成时会调用 ReadCompleted 方法。同时请注意,两个方法之间的数据传递是通过将共享数据封装到 ApmData 类的实例来完成的,为此我必须专门进行定义,以便启用这两个方法之间的数据传递。还应注意,不能使用 C# using 语句,因为 FileStream 是在一个方法中打开,然后在另一个方法中关闭的。为弥补这个问题,我编写了代码,用于在 ReadCompleted 方法返回之前显式调用 FileStream 的 Close 方法。

ApmPatternWithAnonymousMethod 方法展示了如何使用 C# 2.0 称为匿名方法的新功能重新编写此代码,通过此功能您可以将代码作为参数传递到方法。它能有效地让您将一个方法的代码嵌入到另一个方法的代码中。(我在所著书籍“CLR via C#”(CLR 编程之 C# 篇)(Microsoft Press, 2006) 中详细说明了匿名方法。)请注意,ApmPatternWithAnonymousMethod 方法要简短得多,也更易于理解 — 在习惯使用匿名方法后就可以体会到这一点。

首先,请注意该代码较简单,因为它完全包含在一个方法内。在此代码中,我将调用 BeginRead 方法启动异步 I/O 操作。所有 BeginXxx 方法会将其第二个至最后一个参数视为一个引用方法的委托,即 AsyncCallback,该方法在操作完成时由线程池线程进行调用。通常,使用 APM 时,您必须编写单独的方法,为该方法命名,并通过 BeginXxx 方法的最后一个参数将额外数据传递到该方法。但是,匿名方法功能允许只编写单独的内嵌方法,这样启动请求和处理结果的所有代码便会和环境协调。实际上,该代码看上去与 SynchronousPattern 方法有些类似。

其次,请注意 ApmData 类不再是必需的;您不需要定义该类、构造其实例以及使用它的任何字段!这是如何实现的?其实,匿名方法的作用不仅仅限于将一个方法的代码嵌入另一个方法的代码中。当 C# 编译器检测到外部方法中声明的任何参数或局部变量也用于内部方法时,该编译器实际上会自动定义一个类,并且两个方法之间共享的每个变量会成为此编译器定义的类中的字段。然后,在 ApmPatternWithAnonymousMethod 方法内,编译器会生成代码以构造此类的实例,且引用变量的任何代码都会编译成访问编译器所定义类的字段的代码。编译器还使得内部方法成为新类上的实例方法,允许其代码轻松地访问字段,现在两个方法可以共享数据。

这是匿名方法的出色功能,它可让您像使用方法参数和局部变量一样编写代码,但实际上编译器会重新编写您的代码,从堆栈中取出这些变量,并将它们作为字段嵌入对象。对象可在方法之间轻松传递,并且可以从一个线程轻松迁移到另一个线程,这对于使用 APM 而言是十分完美的。由于编译器会自动执行所有的工作,您可以很轻松地将最后一个参数的空值传递到 BeginRead 方法,因为现在没有要在方法和线程之间显式传递的数据。但是,我仍然无法使用 C# using 语句,因为此处有两个不同的方法,尽管看上去似乎只有一个方法。

以下内容显示了执行图 1 中摘录的代码后的输出。

Primary ThreadId=1
ThreadId=1: 4D-5A-90-00-03 (SynchronousPattern)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithMultipleMethods)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithAnonymousMethod)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithLambdaExpression)
ThreadId=3: 4D-5A-90-00-03 (ApmPatternWithIterator)

 

我让 Main 方法显示应用程序主线程的托管线程 ID。然后我让 ProcessData 方法显示执行该方法的线程的托管线程 ID。如您所见,输出显示了所有异步模式让主线程之外的其他线程执行结果,而同步模式则让应用程序的主线程执行所有工作。

还应指出,C# 3.0 引入了一个新功能,称为 lambda 表达式。在执行同一操作时,lambda 表达式功能的语法比 C# 匿名方法功能更简洁。实际上,这对于 C# 团队来说是一个麻烦,因为现在它必须记录和支持产生相同结果的两个不同语法。为了使用 C# 3.0 lambda 表达式功能,ApmPatternWithAnonymousMethod 方法经修改后成为图 1 中所示的 ApmPatternWithLambdaExpression 方法。在此处可以看到语法略为简化,因为编译器能够自动推断出结果参数的类型为 IAsyncResult,而且“=>”要键入的内容比“delegate”少。

Back to top

foreach 语句

C# 2.0 为 C# 编程语言引入了另一种功能:迭代器。迭代器功能的最初目的是让开发人员能够轻松地编写代码,遍历集合的内部数据结构。要了解迭代器,必须首先好好看一下 C# foreach 语句。编写如下代码时,编译器会将它转化为如图 2 所示的内容:

foreach (String s in collectionObject)
DoSomething(s);

 

可以看到,foreach 语句提供了遍历集合类中所有项目的简便方法。但是,有很多不同种类的集合类 — 数组、列表、词典、树、链接列表等等 — 每个均使用其内部数据结构来表示项目集合。使用 foreach 语句就是代表要遍历集合中的所有项目,而并不关注集合类内部用来维护其各个项目的数据结构。

foreach 语句使得编译器生成对集合类的 GetEnumerator 方法的调用。此方法创建了一个实现 IEnumerator<T> 接口的对象。此对象知道如何遍历集合的内部数据结构。while 循环的每次迭代都会调用 IEnumerator<T> 对象的 MoveNext 方法。此方法告诉对象前进到集合中的下一个项目,如果成功,则返回 true,如果所有项目均已枚举,则返回 false。在内部,MoveNext 可能只会递增索引,也可能会前进到链接列表的下一个节点,或者它可能会向上向下遍历树。整个 MoveNext 方法的要点是,从执行 foreach 循环的代码中抽象出集合的内部数据结构。

如果 MoveNext 返回 true,则调用 Current 属性的 get 访问器方法会返回 MoveNext 方法抵达项的值,以便 foreach 语句(try 块内部的代码)的主体能处理该项目。当 MoveNext 确定集合的所有项目均已得到处理时,会返回 false。

此时,while 循环退出,并进入 finally 块。在 finally 块中,通过检查来确定 IEnumerator<T> 对象是否实现 IDisposable 接口,如果是,则调用其 Dispose 方法。对记录而言,IEnumerator<T> 接口由 IDisposable 派生而来,因此需要所有 IEnumerator<T> 对象来实现 Dispose 方法。一些枚举器对象在迭代过程中需要附加资源。对象可能会返回文本文件的文本行。foreach 循环退出后(可能会在遍历集合中的所有项目之前发生),finally 块会调用 Dispose,允许 IEnumerator<T> 对象释放这些附加资源 — 例如,关闭文本文件。

Back to top

迭代器

现在您已了解 foreach 语句产生的代码,下面让我们看一下对象如何与其 MoveNext 和 Current 成员实现 IEnumerable<T> 接口。我们先讨论数组,它们可能是所有集合数据结构中最简单的结构。System.Array 类是所有数组的基类,实现 IEnumerable<T> 接口。它的 GetEnumerator 方法会返回一个对象,其类型实现 IEnumerator<T> 接口。因此,您可以轻松编写代码,以遍历数组的所有元素:

Int32[] numbers = new Int32[] { 1, 2, 3 };
foreach (Int32 num in numbers)
Console.WriteLine(num);

 

另外,如果 C# 编译器检测到与 foreach 一同使用的集合是一个数组,则编译器会产生优化代码,具有从 0 到数组长度减一的简单索引。但是在本次讨论中,让我们忽略编译器对此进行的优化,假设编译器像对待其他任何集合类一样对待数组。实际上,您可以强制编译器像对待其他任何集合类一样对待数组,只需将其转换为 IEnumerable<T> 即可,如下所示:

foreach (Int32 num in (IEnumerable<Int32>) numbers)
Console.WriteLine(num);

 

我的 ApmPatterns 类包括一个 ArrayEnumerator<T> 类,该类知道如何以相反的顺序返回数组中的每一个项目(请参见图 3)。此类可实现 IEnumerator<T> 接口,这要求该类同时实现非泛型 IEnumerable 和 IDisposable 接口。AsyncEnumerator<T> 类提供了公共的构造函数,可将传递的数组保存在私有成员中。还有另一个私有成员 m_index,指示您当前要迭代至数组中的哪个项目;此字段初始化为数组的长度(即数组中最后一个元素之后的元素)。您可以在图 3 中看到。

所有 IEnumerator<T> 对象实质上都是状态机,可跟踪集合中要枚举的项目。foreach 循环调用 MoveNext 时,MoveNext 方法会将状态机推进到下一个项目。在我的 ArrayEnumerator 类中,如果所有项目均已被枚举(m_index 已经是 0,即最后一项),则 MoveNext 返回 false,否则,它会递减 m_index 字段并返回 true。Current 属性只会返回状态机当前位置所反映的项目。

正如您所看到的,实现代码枯燥乏味,因此我将一些接口方法留空以简化编码。我还省略了一些错误检查代码。同时,数组是一个相当简单的数据结构,因此为它编写枚举器简直轻而易举。其他数据结构(如链接列表或树)则会大大增加代码的复杂性。

还应指出的一点是,您不能通过 foreach 语句直接使用我的 ArrayEnumerator<T> 类。换句话说,这将无法编译:

foreach (Int32 num in new ArrayEnumerator<Int32>(numbers))
Console.WriteLine(num);

 

foreach 语句等待 in 关键字后面出现一个集合(其类型实现 IEnumerable<T> 的对象),而不是 IEnumerator<T>。因此,为了使用我的 ArrayEnumerator<T>,我还必须定义实现 IEnumerable<T> 的类,我的 ArrayEnumerator<T> 类也显示在图 3 中。现在,已经定义了 ArrayEnumerable<T> 类之后,我便能够编写可顺利编译和执行的以下代码:

foreach (Int32 num in new ArrayEnumerable<Int32>(numbers))
Console.WriteLine(num);

 

哇!为了遍历项目集合,需要编写相当多的代码。要是有一些技术使得代码的编写工作变简单该有多好。幸运的是,确实有 — 那就是 C# 2.0 迭代器功能。迭代器允许您编写可返回有序值序列的单个成员。通过简单语法来表达应如何返回值,以及 C# 编译器如何将代码块转换为实现 IEnumerable<T>、IEnumerable、IEnumerator<T>、IEnumerator 和 IDisposable 接口的完备类,便可实现此编写目的。

使用 C# 迭代器功能,我将 AsyncEnumerable<T> 类和 AsyncEnumerator<T> 类替换为以下一个成员:

private static IEnumerable<T> ArrayIterator<T>(T[] array) {
for (Int32 index = array.Length-1; index >= 0; index--)
yield return array[index];
}

 

而且还可以使用 foreach 语句调用此成员,如下所示:

foreach (Int32 item in ArrayIterator(numbers))
Console.WriteLine(item);

 

还有一个额外的好处,由于这是泛型方法(相对于泛型类),C# 类型推断也会介入,因此我不需要编写 ArrayIterator<Int32>,从而也令 foreach 代码更加简单。

请注意,由于我的迭代器成员会返回 IEnumerable<T>,因此编译器会生成可实现 IEnumerable<T>、IEnumerable、IEnumerator<T>、IEnumerator 和 IDisposable 的代码。但是,您可以编写返回 IEnumerator<T> 的迭代器成员,在这种情况下,编译器生成只实现 IEnumerator<T>、IEnumerator 和 IDisposable 接口成员的类定义。

还要指出的一点是,您可以定义返回类型为非泛型 IEnumerable 或 IEnumerator 接口的迭代器成员,然后编译器会定义 IEnumerable<Object>/IEnumerable(如果将 IEnumerable 指定为返回类型)、IEnumerator<Object>/IEnumerator 和 IDisposable。

在迭代器中,yield return 语句会有效指示编译器从方法返回何值(集合项目)。然而,此时迭代器实际上并不会结束执行过程;而是挂起该执行。下次调用 MoveNext 时,该迭代器会在紧跟着 yield return 语句后的语句处重新开始其执行。除 yield return 之外,迭代器还包括 yield break 语句,在执行时可使 MoveNext 方法返回 false,强制终止 foreach 循环。从迭代器成员退出也会导致 MoveNext 方法返回 false,强制终止 foreach 循环。

Back to top

异步编程

对异步编程而言,迭代器带来了诸多切实的好处。首先,在迭代器内,您可以编写访问参数和局部变量的代码。但是,编译器实际上会将这些变量封装到类字段(就像为匿名方法和 lambda 表达式所做的那样)。

其次,编译器使您能够编写序列代码,但也可以中途挂起方法,并在稍后继续该执行(可能使用不同的线程)。

再次,您可以在迭代器内使用各种编程构造,如 try/catch/finally 语句、lock 语句、using 语句,甚至是循环(for、while 和 foreach 语句)。编译器会自动重新编写代码,以便这些构造能维护其语义。

对记录而言,有一些与迭代器相关的限制:

  • 迭代器成员的签名不能包含任何 out 或 ref 参数。
  • 迭代器不能包含 return 语句(yield return 是可以的)。
  • 迭代器不能包含任何不安全代码。
  • finally 块不能包含 yield return 或 yield break 语句。
  • yield return 语句不能出现在具有 catch 块的 try 块中(yield break 是可以的)。
  • yield return 语句不能出现在 catch 块中(yield break 是可以的)。

 

记住这些限制是有好处的,但在多数编程情形中,这些限制根本不是问题,或者是可以解决的。

现在,有了迭代器的这些知识,您便可以构建执行一系列异步操作的迭代器。在图 3 中,请参考 ApmPatternWithIterator 成员。此成员是一个迭代器,因为该成员的返回类型为 IEnumerator<T>。请注意,此成员中的代码与前面讨论的 SynchronousPattern 方法非常相似。特别是,请注意迭代器是如何包含 using 语句,以及是如何使用参数 (ae) 和局部变量(fs 和 data)的。

可以看出,此成员执行异步操作,因为它调用了 BeginRead 和 EndRead(而非只调用 Read)。但是使用 APM 时,通常需要调用 BeginRead 并传递回调方法。在此情况下,我会传递调用 ae.End 的结果(我将在下一专栏中对此进行说明)。通常,方法会在调用 BeginRead 后返回,但在迭代器中我调用的是 yield return,它只会挂起代码。

通常,使用 APM 时,对 EndRead 的调用会采用另一种方式,但使用迭代器时,对 EndRead 的调用紧跟着 yield return 语句。这意味着它会在迭代器执行恢复时执行。并且其他线程也可以执行此代码。实际上,如果您看了前面所示的输出,就会发现 ApmPatternWithIterator 对 ProcessData 的调用是通过其他线程 (ThreadId=3),而不是应用程序的主线程 (ThreadId=1)。

如果您确实认真学习了此代码,就会发现此处发生的一切相当完美,使得异步编程几乎像同步编程一样易于执行。但是现在您的线程绝对不会被阻止,它允许应用程序对用户快速作出响应。由于没有阻止任何线程,不需要创建更多的线程,因此应用程序只需使用很少的资源。

现在即可让多个迭代器同时执行,这会提高应用程序的可伸缩性。而且可以轻松地增加一些功能,包括取消未完成操作、报告进度、设置计时器、协调多个重叠操作的结果,以及在需要更新 UI 时在 GUI 线程上执行所有这些操作。此外,通过一个编程模型即可实现所有这一切,这与更简便的同步编程模型相类似。

使所有这一切成为可能的就是 C# 迭代器和我为了驱动这些迭代器而开发的 AsyncEnumerator 类。希望我已经讲得够夸张,能够让您迫切地想了解我的 AsyncEnumerator 类及其工作原理。遗憾的是,我已经用光了本月专栏的空间,您只能等下一次的专栏了,下次我会详细说明我的 AsyncEnumerator 类。