在多核处理器上运行查询
多核处理器的时代已经来临。多核处理器曾经主要普遍应用于服务器和台式计算机,现在正转向移动电话和 PDA,这为节省耗电量带来了极大的好处。面对市场上多核处理器平台的日益增多,并行语言集成查询 (PLINQ) 为利用并行硬件(包括传统的多处理器计算机和新一代的多核处理器)的优势提供了一种简单的方法。
PLINQ 是一个查询执行引擎,它接受任何“LINQ 到对象”或“LINQ 到 XML”的查询,并在多处理器或多核可用时自动将其用于执行。编程模型中的变化微乎其微,这意味着您无需成为并发专家便可使用它。事实上,除非您真的想要深入了解所有相关工作原理,否则线程和锁的问题甚至都不会接触到。PLINQ 是 Parallel FX 的关键组件,后者则是 Microsoft® .NET Framework 中的下一代并发支持。
在确保未来并行微处理器体系结构的软件可伸缩性方面,使用 PLINQ 之类的技术将变得日益重要。如果现在就在整个应用程序中的首选位置采用 LINQ(例如在可表达为查询的数据或计算密集型运算之处),即可确保在 PLINQ 成为可用且运行您应用程序的计算机从 2 处理器升级到 4 处理器,再升级到 32 处理器甚至更多处理器时,您程序的片段会继续更好地执行。而且即使您只在单处理器计算机上运行该代码,PLINQ 的开销通常也很小,您甚至不会察觉出差异。除此之外,PLINQ 的数据并行特性可确保您的程序会随着数据集规模的增加而继续扩展。
在本文中,我们将回顾 PLINQ 技术的目标、该技术与更多种 .NET Framework 及其他并发产品的接合点,以及 LINQ 开发人员对该技术的看法。最后,我们将结合一些示例情景进行总结,从中可以看出 PLINQ 已经显现出巨大的价值。
请注意,包含 PLINQ 的 Parallel FX 库目前仍处于开发阶段,但是社区技术预览版 (CTP) 将在 2007 年秋季的 MSDN® 中首次推出。请阅读博客 blogs.msdn.com/somasegar 以了解详细信息。
从 LINQ 到 PLINQ
人们初次听到 PLINQ 项目时,通常会问:为什么要并行化 LINQ?简单的答案是,LINQ 具有表达计算的一次处理一个集合 (set-at-a-time) 的编程模型,侧重的是指定要实现的目标,而不是实现方式。如果没有 LINQ,实现部分通常会通过使用循环和中间数据结构来表达,但是在编码这么多特定信息的情况下,编译器和运行时就无法轻松地并行化。另一方面,LINQ 的声明性特性使 PLINQ 之类的智能化实现可以更灵活地利用并行化来取得相同的结果。
接下来必然会问到这样的问题:如果您打算查询足够的数据以证明 PLINQ 的意义,那为什么只用一个数据库?我们将以一个反问句来回答这个问题:如果不使用 LINQ,您会编写什么代码?您可能会做同样多的数据和计算密集型工作,但可能会陷入一系列杂乱无章的循环、函数调用等等。所以,这个问题说明所有程序都应驻留在数据库中,但是我相信没有多少人会同意这个观点。
有了 PLINQ,您不必将整个数据库服务器处理逻辑移到客户端的内存中“LINQ 到对象”查询。相反,PLINQ 提供了一种增量式方法,利用现有解决方案到现有问题的并行性。如果您的问题数据密集性足够高,而且传统上会要求您寻求数据库解决方案,那么您应仍然寻求这样的解决方案。PLINQ 几乎没有改变这一点。但是,如果有若干想要一起查询的数据源(可能是异类、跨平台数据库、XML 文件等),PLINQ 可在数据位于客户端后接管并并行化它们。
人们接着自然会问:“LINQ 到对象”本身为什么没有并行运行查询?并行性是完全隐藏的,开发人员无需更改任何一行代码便能获得并行性的好处。遗憾的是,当从 LINQ 迁移到 PLINQ 时可能会出现一些细微的限制,使我们无法实现此愿景,至少对 PLINQ 1.0 来说是这样的。本文稍后将更详细地讨论这些问题。
PLINQ 编程模型
使用 PLINQ 几乎就与使用“LINQ 到对象”和“LINQ 到 XML”一模一样。您可以使用 C# 3.0 和 Visual Basic® 9.0 查询理解语法或 System.Linq.Enumerable 类提供的任何运算符,包括 OrderBy、Join、Select、Where 等。PLINQ 支持所有 LINQ 运算符,而不仅仅支持那些语言理解中的运算符。此外,除了使用 System.Xml.Linq API 加载的 XML 文档外,您还可以查询任何内存中集合,如 T[], List<T>,或任何其他类型的 IEnumerable<T>。如果已经拥有大量的 LINQ 查询,则 PLINQ 有能力来运行它们。
“LINQ 到 SQL”和“LINQ 到实体”查询将仍然由各自的数据库和查询提供程序执行,因此,PLINQ 并不提供并行化这些查询的方法。如果您希望在内存中处理这些查询的结果(包括联接许多异类查询的输出),那么 PLINQ 的用处将很大。
除了用您习惯的同一方法编写 LINQ 查询之外,使用 PLINQ 还需要两个额外的步骤:
在编译期间引用 System.Concurrency.dll 程序集。
通过调用 System.Linq.ParallelEnumerable.AsParallel 扩展方法,将数据源封装到 IParallelEnumerable<T> 中。
在步骤 2 中调用 AsParallel 扩展方法可确保 C# 或 Visual Basic 编译器绑定到标准查询运算符的 System.Linq.ParallelEnumerable 版本,而非 System.Linq.Enumerable。这使得 PLINQ 能够取得控制权并并行执行查询。AsParallel 定义为接受任何 IEnumerable<T>:
public static class System.Linq.ParallelEnumerable {
public static IParallelEnumerable<T> AsParallel<T>(
this IEnumerable<T> source);
... the other standard query operators ...
}
IParallelEnumerable<T> 从 IEnumerable<T> 派生而来,但并无多大补充,它存在的目的只是为了帮助绑定到 PLINQ 的 ParallelEnumerable 查询提供程序,以利用 C# 3.0 和 Visual Basic .NET 9.0 中的新扩展方法支持。该接口派生自 IEnumerable<T>,以便您仍然可以对实例执行 foreach,并将它们传递给其他接受 IEnumerable<T> 的 API。在 ParallelEnumerable 上定义的标准查询运算符是 Enumerable 上运算符的镜像,其唯一差别在于每个运算符都取 IParallelEnumerable<T> 而不是 IEnumerable<T> 来作为其扩展源参数,然后返回 IParallelEnumerable<T> 而非 IEnumerable<T>(只返回简单类型的聚合除外),当然,还会在内部采用并行性以进行查询评估。
以 C# 中定义的一个简单 LINQ 查询为例:
IEnumerable<T> data = ...;
var q = data.Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
此例中使用 PLINQ 所要求的全部条件是添加一个对数据的 AsParallel 调用:
IEnumerable<T> data = ...;
var q = data.AsParallel().Where(x => p(x)).Orderby(x => k(x)).Select(x => f(x));
foreach (var e in q) a(e);
使用 C# 查询理解语法可以更加简洁地编写此代码,此时的 PLINQ 版本如下所示:
IEnumerable<T> data = ...;
var q = from x in data.AsParallel() where p(x) orderby k(x) select f(x);
foreach (var e in q) a(e);
一旦您进行此更改后,PLINQ 将使用典型的数据并行评估技术,在所有可用处理器上透明地执行 Where、OrderBy 和 Select。PLINQ 就像 LINQ 一样采用延迟执行,这表示在对查询执行 foreach、直接调用 GetEnumerator 或通过 ToList 或 ToDictionary 之类的某些其他 API 将结果强制列入某个列表之后,该查询才会开始运行。当查询执行时,PLINQ 将通过对多线程的隐藏使用,将查询的各个部分安排在可用处理器上运行。您甚至不必了解这一切是如何完成的;您只用看到更高的性能和对可用处理器利用率的提高就可以了。
虽然不是很明显,上文所示的普通“LINQ 到对象”和 PLINQ 查询之间的 q 推断类型是不同的。在第一个示例中,q 被类型化为 IEnumerable<U>,其中 U 是 f 方法传递给 Select 运算符的任何类型。但在第二个示例中,q 被类型化为 IParallelEnumerable<U>。通常这无关紧要:例如,如果您已经将 q 的类型显式声明为 IEnumerable<U>,那么对 AsParallel 的更改将仍然有效,因为 IParallelEnumerable<U> 派生自 IEnumerable<U>。但是,这确实表示 q 的任何后续使用会被当作 IParallelEnumerable<U>。例如,如果您以后查询它,PLINQ 会被选为查询提供程序。
请注意,有些 LINQ 运算符是二元的——它们取两个 IEnumerable<T> 作为输入。Join 就是此类运算符的一个最好的例子。在这些情况下,最左边数据源的类型决定了是使用 LINQ 还是 PLINQ。因此,您只需在第一个数据源上调用 AsParallel 便能使查询并行运行。
IEnumerable<T> leftData = ..., rightData = ...;
var q = from x in leftData.AsParallel()
join y in rightData on x.a == y.b
select f(x, y);
所有这些讨论都假设您使用扩展方法编写查询。但是,如果您已经选择直接调用 Enumerable 类上的方法,那么将需要多做一点工作才能迁移到 PLINQ。除了调用 AsParallel 外,还必须引用 ParallelEnumerable 类型。例如,假设上述查询是通过直接调用 Enumerable 来编写的:
IEnumerable<T> data = …;
var q = Enumerable.Select(
Enumerable.OrderBy(
Enumerable.Where(data, (x) => p(x)),
(x) => k(x)),
(x) => f(x));
foreach (var e in q) a(e);
要使用 PLINQ,必须按如下方式重新编写该查询:
IEnumerable<T> data = …;
var q = ParallelEnumerable.Select(
ParallelEnumerable.OrderBy(
ParallelEnumerable.Where(data.AsParallel(), (x) => p(x)),
(x) => k(x)),
(x) => f(x));
foreach (var e in q) a(e);
显而易见,使用理解和扩展方法是更为方便的一种查询编写方法,而且还有一个额外的好处,那就是大大简化了迁移到 PLINQ 的过程。
就是这些! 用 PLINQ 代替“LINQ 到对象”就要求这些变动。由于“LINQ 到 XML”将 XML 文档作为 IEnumerable<T> 数据结构公开,因此上述所有内容也都适用于查询 XML 内容。
处理查询输出
前面已经提到,由于延迟评估,在开始处理查询输出之前不会引入并行性。如果您熟悉 IEnumerable<T>,就该知道这相当于调用 GetEnumerator 方法。处理 PLINQ 查询的基本方法有三种,各自都会产生一种稍有差别的并行性模型。
第一种方法是管道式处理。在这种情况下,负责枚举的线程与专门运行查询的线程是分开的。PLINQ 将使用许多线程以执行查询,但是会将并行度减少一个,以使枚举线程不受干扰。例如,如果有八个处理器可用,其中七个将运行 PLINQ 查询,剩下的一个处理器便可在元素可用时对 PLINQ 查询的输出运行 foreach 循环。这样的好处是允许对输出进行更多增量处理,从而减少存放结果所需的内存要求;但是,如果有许多生产者线程而只有一个使用者线程,这通常会导致工作分配不平衡,结果是处理器效率低下。
第二种模型是准动态 (stop-and-go) 处理。在这种模型下,启动枚举的线程会联接所有其他线程以执行查询。一旦所有线程已经生成一组完整输出,该线程就会继续枚举输出。这样做的好处是所有处理能力都会集中在尽快产生输出上。此外,它的效率也比管道式处理稍有提高,因为实现过程中的增量式同步系统开销减少了:对于这些方面,PLINQ 可以更加智能化,因为它确切地知道输出数据的目的地以及访问它的方式。如果使用者和生产者之间的工作分配不平衡,这通常是一个更恰当的消耗方法。
最后一种方法是反转枚举。该方法会为并行运行的 PLINQ 提供一个 lambda 函数,为输出中的每个元素提供一次。这是最高效的一种机制,因为公开给 PLINQ 的并行性更多,而且该实现也避免了高成本的运算,例如合并多个线程的输出。但是它也存在一些缺陷:您不能简单地使用 foreach 循环,而必须使用特殊的 ForAll API,必须特别注意 lambda 不依赖共享状态。否则,引入并行性将会导致查询出错,并可能导致不可预料的崩溃或数据损坏。但是,如果您可以就此 API 表达您的问题,这始终是一个首选方法。
采用这三种模型中的哪一种取决于您将如何处理查询结果。默认的模型是第一种,即管道式处理。一旦对产生的查询枚举器调用 MoveNext 后,一组额外的工作线程会立即执行查询,并且此次调用以及所有后续 MoveNext 调用的结果(当它们可用时)都会返回。如果调用了 MoveNext,但是查询生产者线程没有任何输出,则调用线程将阻塞,直至某个元素可用。如果只用了 foreach 来处理 PLINQ 查询的输出,您将获得如下所示的结果:
var q = ... some query ...;
foreach (var e in q) {
a(e); // this runs in parallel with the execution of 'q'
}
IParallelEnumerable<T> 接口实际上提供了 GetEnumerator 的重载,该函数接受一个 bool 参数(称为管道式),并允许您选择准动态处理来代替(true 表示管道式,false 表示准动态)。第一次后续调用 MoveNext 时,将执行整个查询,而且仅在所有输出可用时调用才会返回。后续调用 MoveNext 将只枚举包含以下输出的缓冲区:
var q = ... some query ...;
using (var e = q.GetEnumerator(false)) {
while (e.MoveNext()) {
// after the 1st call, the query is finished executing
// we then just enumerate the results from an in-memory list
a(e.Current);
}
}
在几种特殊情况下,默认使用准动态处理:如果您使用 ToArray 或 ToList 方法,这些运算符将在内部强制执行准动态操作。如果在查询中有排序,则将转而使用准动态操作,因为将排序输出管道化纯属浪费。排序表现出极度滞后性(因为它通常需要对整个输入排序后才能生成单一输出元素),因此,PLINQ 更愿意将所有处理能力用于尽快完成排序。
要使用反转枚举,必须使用一个不同的 PLINQ 特定 API:
public static class System.Linq.ParallelEnumerable {
public static void ForAll<T>(
this IParallelEnumerable<T> source, Action<T> action);
... the other standard query operators ...
}
使用 ForAll API 与使用 foreach 循环非常相似,正如您所看到的:
var q = … some query …;
q.ForAll(e => a(e));