该代码的含义是 a 和 b 的调用可随 c 的主体并行执行,对此的决策由 Future<int> 引擎的实现来做出。当 c 需要这些调用的结果时,它会访问 future 的 Value 属性。这所产生的结果是:等待工作完成;或者,如果工作还未开始异步执行,则在调用线程上本地执行函数。该语法与现有 IAsyncResult 类很相似,但多出了一个优点,就是在有关将多少并发操作引入程序方面更加智能化。尽管很容易就可以设想出更多智能化的实现方法,但此代码的直接译文可能如下所示:
int a() { /* 一些工作 */ }
int b() { /* 一些工作 */ }
delegate int Del();
int c()
{
Del da = a; IAsyncResult fa = da.BeginInvoke(null, null);
Del db = b; IAsyncResult fb = db.BeginInvoke(null, null);
该例显示了一个分布于四个线程上的由 100,000 个元素组成的数组。您会留意到,为进行数组分割连续支付了一定的额外开销。在需要合并时,经常要为合并结果支付附加成本,包括连接待处理线程。
For-all 循环通常是以编程语言表示基于分区的并行性的一种传统方式。图 3 中显示了 ForAll<T> API 实现的示例。也可使用类似方法将循环并行化 — 例如,可以不采用 IList<T>,而改为采用 int from 和 int to 参数集,然后将循环迭代数馈入 Action<int> 委托。
此代码做出了一个可能具有灾难性的重大假定:预期传入的 Action<T> 委托会安全地并行执行。这意味着如果它指的是共享状态,则需要使用适当的同步来消除并发操作程序错误。如果不是,则可以预期我们程序的正确性和可靠性都相当差。
另一个数据并行性技术是管道操作,其中多个运算并行执行,使用一个快速的共享缓冲区来相互输送数据。这类似于装配线,其中流程中的每个步骤都有机会与一些数据交互,然后将其传递给装配线中的下一步骤。此技术需要巧妙的同步代码以尽量缩短花费在明显瓶颈处的时间:在瓶颈处,管道中的相邻阶段通过一个共享缓冲区进行通信。 多少任务?
选择要创建的任务数也是一个棘手的因素。如果吞吐量是唯一的优先考虑因素,则可以使用如下所示的一些理论目标,其中 BP 是任务将阻塞的时间百分比:
NumThreads = NumCPUs / (1 – BP)
复制代码
也就是说,线程数最好等于 CPU 数与任务要花费在实际工作上的时间百分比的比率。这已在先前的 ForAll 示例中进行了说明。可惜的是,尽管理论上这是一个良好起点,但它不会带给您准确的答案。例如,它没有解释采用 HT 的原因(其中高内存滞后时间允许引发并行计算),但在其他方面它不应该是使用完整处理器的原因。而且它相当天真地假定您实际可以预测 BP 值,这一点我可以保证是相当困难的,特别是对于试图调度异类工作的组件,这非常像 CLR 的线程池。如果有疑虑,最好依靠线程池将任务调度给 OS 线程,并倾向于过度表示并发操作。
任何算法都有一个自带的加速曲线。关于这条曲线,有两点特别重要的问题要考虑。首先,可从计算并行化获益的最少任务数是多少?对于小型计算,情况可能会是这样:使用少量任务会导致过多的额外开销(线程创建和高速缓存缺失),但使用大量任务会使执行进度赶上相继的版本并超过它。其次,假定硬件线程的数量无穷大,则在开始发现性能下降而不是持续上升之前可以分配给某问题的最多任务数是多少?所有问题都会达到这一递减返回点。随着继续细分问题,最终将达到单个指令的粒度。
线性加速意味着使用 p 个处理器执行问题花费的时间是使用一个处理器执行问题所花费时间的 1/p。Amdahl 定律往往限制了实现这种加速的能力。它相当简单地指出最大加速受到采用并行性后保持的序列执行量的限制。更正式地说,此定律指出,如果 S 是必须保持有序的问题(无法并行化)的百分比,p 是所使用 CPU 的数量,则预期的近似加速可如下表示:
1/(S + ((1 – S)/p))
复制代码
随着处理器数量的增加,此表达式接近于 1/S。因此,如果只能并行化问题的(例如)85%,则只能达到 1/.85(大约 6.6)的加速。与同步化和采用并发操作相关的任何额外开销往往都成为 S 的一个因素。但是,在现实中还是有一个好消息:在多个处理器之间分配工作也具备难以量化和测度的好处,例如,使并发线程可以保持其(各自的)高速缓存随时可用。
任何管理实际资源的算法还必须考虑跨计算机使用情况。完全进行本地决策以最大化并行性的软件(特别是在 ASP.NET 之类的服务器环境中)可能会(并且总会!)导致混乱并增加对资源(包括 CPU)的争用。例如,ForAll 式循环在动态决定最佳任务数之前可能会查询处理器使用情况。可考虑使用图4 中所示的 GetFreeProcessors 函数,而取代图3 中使用的依赖于 System.Environment.ProcessorCount 属性的算法。
该算法并非十全十美。它只是在其运行时的计算机状态的一个统计快照,而并未表明在其返回结果之后出现的任何情况。它可能过度乐观或过度悲观。并且它当然不会解释这样的事实,被查询的某一处理器就是执行 GetFreeProcessors 函数的处理器本身,这会是一项有帮助的改进。另一个要考虑的值得关注的统计度量标准是系统处理器队列长度性能计数器,它可以告诉您在调度队列中有多少线程正在等待空闲处理器。如果结果是一个很大的数字,则表示引入的新工作只能等待队列清空(假定所有线程都具有同等优先级)。
存在一些重要理由来创建过多而不是过少的并发操作。如果正在考虑异类任务,则让每个任务在某一线程上一直执行到完成的模型会带来公平性问题。如果不释放另外的资源,则运行时间远多于任务 B 的任务 A 会导致任务 B 资源缺乏。如果 A 决定阻塞而您的算法没有考虑到这一点,则这种情况会更加糟糕。
有意过度并行化的另一个原因是针对异步 I/O。Windows 为实现高可伸缩性提供了 I/O 完成端口,在这种情况下,待处理的 I/O 请求甚至不需要使用 OS 线程。I/O 开始异步执行,一旦完成,Windows 即会向下层端口发布一个完成数据包。通常情况下,有效设定大小的线程池会被绑定到端口(在 CLR 上由该线程池负责此端口),等到完成数据包一旦可用就对它们进行处理。假定完成率不足,则尽可能快地并行创建大量 I/O 请求比起让每个任务排在其他任务后面等待轮流启动异步 I/O 会实现更好的可伸缩性。这适用于文件、网络和内存映射 I/O,但应始终认识到这一事实,计算机上共享资源的数量有限,过度争用这些资源只会降低而不是增强伸缩性。