派生自 System.Threading.Tasks.Task - C# 中的“打结”






4.57/5 (9投票s)
你可以继承 Task - 但这很棘手。下面是如何通过“打结”来实现这一点 - 使用 lambda 表达式和变量捕获来实现惰性求值
引言
也许你想在 C# 程序中异步运行一些计算或其他操作,并且想使用 `Task`1。预期的方法是直接向任务工厂提供你要运行的代码,通常以 lambda 表达式的形式:
Task t = Task.Factory.StartNew( () => MyLongAsynchronousCalculation() );
如果 lambda 表达式基本上是自包含的,或者使用了创建异步活动的对象的资源,那么这样做效果很好。
但也许异步活动比较复杂,并且有持续的状态,换句话说,是你希望封装在一个类中的那种东西。有两种基本的面向对象的处理方法:使用继承,或使用组合。
每种技术总的来说都有其优缺点,但在使用 `Task` 的特定情况下,组合通常是首选。就连 Jon Skeet2 这个权威人士也说:
I wouldn't personally extend Task<T>, I'd compose it instead.3
但是……为什么呢?几乎所有当前面向对象语言的一个缺点是它们没有对委托的一等支持;这使得继承比组合更适合许多需要使用另一个类的行为的设计问题。而且,.NET Framework 的开发者也深谙面向对象的正确设计4,他们本可以像 Framework 中的许多其他类一样将 `Task` 类设为 sealed,以防止继承——但他们没有。
class D : Task {
public D() : base(??) { }
public void Run() { … }
}
…
D d = new D();
问号处应该放什么?你不能简单地引用 `D` 的方法 `Run`:它不是静态成员,所以你需要一个对象引用——而你没有。你不能使用 `this` 关键字:语言不允许。没有好的选择,所以你就卡住了?
或者你没有卡住?这就是本文将要描述的:一种正确派生 `Task` 以使其运行你的子类实例中的方法的方式。这种技术借鉴自惰性函数式语言5,称为“打结”(Tying The Knot)。6, 7
在使用一个值之前计算它:打结
在函数式语言中,所有值都是不可变的。如果一个值有多个字段,它们是不能被修改的。那么如何构造一个引用自身的结构呢?这在(例如)循环列表或非 DAG 图结构中会出现。
例如,考虑将范围 [0..1) 内的有理数表示为基数为 10 的数字的链表。有理数 \(\frac{1}{8} = 0.125\) 是有限的,所以很容易。但 \(\frac{1}{7} = 0.\overline{142857}\) 是一个不终止的循环小数呢?9
在命令式语言中,这并不难,因为你可以在创建字段后对其进行修改。
var OneEighth = new SinglyLinkedList<int> { 1, 2, 5 };
Console.WriteLine("1/8 = " + string.Join(",", OneEighth.Take(30).Select(e => e.ToString())));
Console: 1/8 = 1,2,5
var OneSeventh = new SinglyLinkedList<int> { 1, 4, 2, 8, 5, 7 };
OneSeventh.Next.Next.Next.Next.Next.Next = OneSeventh;
Console.WriteLine("1/7 = " + string.Join(",", OneSeventh.Take(30).Select(e => e.ToString())));
Console: 1/7 = 1,4,2,8,5,7,1,4,2,8,5,7,1,4,2,8,5,7,1,4,2,8,5,7,1,4,2,8,5,7
在惰性函数式语言(如 Haskell10)中,你必须以不同的方式处理,因为列表是不可变的。但该语言有一个名为“letrec”的特性,代表“let 递归”(其中“let”是语言中的绑定构造),它允许你在一个变量尚未计算出来时引用它——只要你不要**使用**它!
oneEighth = 1 : 2 : 5
oneSeventh = let x = 1 : 4 : 2 : 8 : 5 : 7 : x
in x
这里,名称 `x` 指向正在构建的列表,并用于构建列表的尾部。它之所以有效,是因为 `x` 指向一个内存位置,直到某个代码使用变量 `oneSeventh` 并遍历完列表的第 6 个元素后,该内存位置才会被引用。(注意**值**和**变量**的区别:变量是可持有值的那个位置。)
这就是**打结**!
C# 中的打结:闭包和变量捕获
我们需要延迟求值,并且能够在一个数据结构构建完成后绑定一个值。两个相关的语言机制将协同工作。首先,为了实现延迟求值,我们将引入一个额外的间接层13,通过使用委托——方法指针;在 C# 中,这通常会写成 lambda 表达式。其次,为了实现**事后**绑定,我们将使用 C# 出色的变量捕获实现——C# 几乎拥有真正的闭包14——在数据结构构建完成后绑定一个值。
为了具体化这一点,假设我们有一个类 `T`,它的构造函数接受一个 `Action`,称为 `A`,我们将其存储在一个只读字段中,因此在 `T` 实例构造后无法(重新)设置 `A`。然后它有一个方法 `T.M`,在构造完成后某个时候被调用,并调用 `Action` `A`。
(并且假设我们无法更改 `T` 来为 `A` 提供 setter 或其他任何东西来修复这种情况。)
现在假设我们用类 `D` 继承 `T`,并且我们希望 `Action` `A` 来运行我们自己的实例 `D` 中的一个方法。通常我们的 `Action` `A` 会是这样的:
class D : T {
public D(Action a) : base(a) { }
…
Action f = () => this.Foo();
…
}
但这行不通,因为我们还没有 `this`,而且实际上,我们直到 `T` 的成员——基类构造函数运行并且 `D` 构造函数返回之后——才能获得我们新实例 `D` 的引用。
解决这个问题的方法是在创建 lambda 表达式时使用变量捕获:
… D d = null; // 1: Create a variable to hold a D. Action g = () => d.Foo(); // 2: Capture the variable in our lambda. d = new D(g); // 3: Create a new D, passing in our lambda, // and store it in the variable we captured. d.M(); // 4: Execute the method that is going to // run our Action and call d.Foo(). …
真正棘手的地方在于获得一个 API,该 API 可以接受一个 `Action` 或 `Func` 而不是它期望的对象——某个特定类型的实例。但幸运的是,在我们继承 `Task` 的问题中,这个问题已经解决了,因为 `Task` 接受两个参数:一个 `Action` 和一个任意的 `object`——我们将我们的延迟 lambda 作为任意对象传递!
回到问题:继承 Task 类
回到留给我们的 `Task`:我们如何继承 `Task`?
如前所述,`Task` 接受一个 `Action`,当 `Task` 启动时,它就是需要运行的代码。15 我们希望它运行我们子类中的一个方法。现在只剩下两个问题需要解决:`Action` 从何而来,以及它如何传递给 `Task`?
回答第一个问题:`Action` 以及派生实例将在一个工厂方法中创建。我们派生类的构造函数将是受保护的访问级别,因此开发者不能直接创建它。
回答第二个问题:让我们的派生类拥有一个构造函数,除了用于初始化自身的其他参数外,它还可以接受 `Action` 并立即将其传递给基类,这很容易。
protected D(Action a) : base(a) { … }
那么本文就结束了。但我倾向于不重复编写相同的代码。我希望提供一个通用的抽象类来处理所有工作。它将继承 `Task`,我所有不同的子类都将继承它。
一旦我这样做,我就会遇到一个问题:我的工厂方法将在我的最派生子类中是泛型的。这样,作为一个工厂,它可以返回该子类的实例(而不是需要转换为实际子类的超类——它自己的类型)。但如果它在其创建(并返回)的类型中是泛型的,那么它只能创建具有零参数构造函数的类型的实例(由于泛型约束 `new()` 的工作方式)。有了这个零参数构造函数,`Action` 如何被传递进去?
答案有点不令人满意:它将通过一个构造函数可以引用的静态字段来传递。这是我们要解决的最后一个问题:确保工厂方法是可序列化的,这样设置静态字段然后立即创建一个引用该字段的构造函数中的新对象就是安全的,这样如果工厂方法在两个不同的线程上同时调用,就不会发生竞争条件,导致其中一个新实例获得**另一个**实例的 `Action`。
实际上,如果你想拥有一个可重用的抽象基类来处理所有将 `Action` 传递给 `Task` 的代码,那么这个复杂性是必不可少的。
总之,抽象泛型类 `DeriveFromTaskBase` 的代码在此文章关联的zip 归档中,所以我只会在这里评论亮点。
DeriveFromTaskBase 的公共 API
`DeriveFromTaskBase` 的公共 API 包括创建实例并启动它的工厂方法 `Create`,以及一个必须重写的抽象方法 `Run`,以提供继承 `Task` 的目的的子类特定计算。
`Create` 工厂方法接受一个可选的 `Action<T>`,称为 `beforeStartInitializer`,它在实例启动之前运行。它的目的是提供一个初始化实例的机会,并弥补只有一个零参数构造函数的不足。你提供的 `Action<T>` 将接收实例本身,并可以设置该实例的属性或调用其方法。(请记住,当你使用 lambda 表达式创建 `Action` 时,你可以捕获任何你当时需要的值。)如果你还(或另外)可以在构造时执行某些操作(这些操作必然不能依赖于任何外部输入),你可以(可选地)重写 `Constructor` 方法并执行该初始化。
/// <summary>
/// Abstract base class for classes that want to derive from Task.
/// </summary>
public abstract class DeriveFromTaskBase : Task
{
#region Public interface
public static T Create<T>(Action<T> beforeStartInitializer = null)
where T : DeriveFromTaskBase, new()
{
…
}
public virtual void Constructor() { }
public abstract void Run();
#endregion
派生实例的构造
构造函数相当简单:引用一个保存间接 `Action` 的静态字段,它将该 `Action` 传递给基类 `Task`,然后调用(可选的)`Constructor` 方法。
private static Action thisDeferred;
protected DeriveFromTaskBase() : base(thisDeferred)
{
Constructor();
}
“打结”并创建派生实例的工厂
有趣的是,有两个结要打!
工厂方法会获取一个锁以确保序列化。然后它提供一个待创建实例的位置,并提供一个真正 `Action` 的位置。它通过创建间接 `Action` 来打第一个结,该间接 `Action` 通过捕获真正 `Action` 的位置来调用它。然后它执行 `new T()` 来**最终**创建你真正想要的派生实例。它通过创建真正 `Action` 来打第二个结,该真正 `Action` 捕获新实例的位置。在完成所有这些之后,它启动实例——`Task` 调用其启动 `Action`,该启动 `Action` 调用真正 `Action`,后者调用实例的 `Run` 方法,最后 `Task` 就启动了!
private static object createLock = new object();
private static Action thisDeferred;
private static T Create<T>(Action<T> beforeStartInitializer = null)
where T : DeriveFromTaskBase, new()
{
T t = null;
lock (createLock)
{
Action thisDeferredInner = null;
thisDeferred = () => thisDeferredInner();
t = new T();
thisDeferredInner = () =>
{
if (null != beforeStartInitializer)
beforeStartInitializer(t);
t.Run();
};
}
t.Start();
return t;
}
(到目前为止,你应该不需要注释就能理解上面的代码……但别担心:源代码中有注释,都在 zip 文件中。)
文章摘要
是否值得?这取决于。对于当前情况,利用 `Task` 来运行需要持续状态的复杂代码,如果你从零开始,那么最简单的方法是编写一个类,它不继承 `Task`,而是简单地拥有一个 `Task` 实例。也就是说,使用组合。
但是,YNK。16 现在这个类已经为你写好了(并解释了!),你可能会发现因为各种原因你需要 `Task` 来执行复杂的计算,并且跟踪计算实例和 `Task` 实例之间的关系可能会很烦人。
此外,在一般情况下(脱离 `Task`),了解如何将**打结**作为一种通用技术,以及你具体如何用 C# 中的 lambda 和变量捕获来实现它,可能会对你有所帮助(也很有趣)。我希望如此。
文章修订历史
- 2014年2月1日:原始文章。
脚注
1 也就是说,一个 `System.Threading.Tasks.Task`。
2Jon Skeet 的《C# 深入》是一本优秀的著作,Jon Skeet 的博客也是如此。
4 他们写了一本关于这个的书:《Framework Design Guidelines: Conventions, Idioms, and Patterns for Reusable .NET Libraries》。
6 《打结》的完整描述。打结的经典算法是repmin,这是一种单遍算法,用于构建一个与给定树形状相同的树,只是每个叶子都具有原始树中的最小叶子值。
7 这些脚注到底是怎么回事?这是一个 CodeProject 网页文章,而不是学术论文!8
9 这里有一个读者练习:给定这种范围 [0..1) 内的有理数表示,编写相等函数。确保它发现以下两个相同的数字表示,\(\frac{1}{5} = 0.2 = 0.1\overline{9}\),它们是相等的:
12 惰性求值(维基百科)意味着一个表达式在绑定到变量时不会被求值,而是在使用时才会被求值。几乎所有的语言都使用严格求值,即表达式在绑定到变量(或过程参数)时会被完全求值。事实上,这就是为什么一些日志库有时会竭尽全力使用非语言机制,如 C/C++ 中的预处理器宏,来高效处理格式化消息及其参数:它们试图通过避免对消息参数的求值来提高性能并减少日志记录的开销,除非消息的日志/跟踪级别足够高,足以将日志消息实际写入某个接收器。
We can solve any problem by introducing an extra level of indirection..
14 关于 C# 闭包是否是“真正的”闭包存在一些疑问。在维基百科上曾有过激烈的讨论,以及在编程语言博客Lambda The Ultimate上,关于维基百科上关于闭包的文章。有些人只接受“返回绑定”(即允许使用 call-with-current-continuation)的闭包。其他人则认为 C# 3.0 和 Javascript 中的闭包与“真正的”闭包几乎没有区别。
15 还有一种将数据传入 `Task` 的方法:在构造时将一个 `Async State Object` 传入。但这与启动 `Action` 存在同样的问题:构造函数运行后没有 setter 可以用来更改 `Async State Object`。你**可以**改为提供一个自定义类包装器来包装实例引用(以及你想要传入的任何其他信息)。在构造时将其传入(填充为 null),但保留其引用。然后,一旦有了新实例的引用,就可以用它来填充其字段,然后启动实例。但是……到那时,你不如用这种方式来做。