第29章 并行编程
在多核处理器出现以前,英特尔和AMD每年都会推陈出新,处理器的主频越来越高,这也意味着应用程序的运行速度可以越来越快。然而,因为受处理器技术的限制,处理器的主频最高只能达到3GHz左右,主频成了阻碍处理器速度进一步提升的瓶颈。但是,用户对更高频率处理器的需求却不会因此而停止,因此英特尔和AMD采取了增加单个处理器的内核数量或执行单元数量的方案来解决这个矛盾,于是多核处理器便应然而生了。
多核是指在一个处理器中集成两个或多个完整的计算引擎,计算引擎就是俗称的“内核”,操作系统会将每个内核作为独立的逻辑处理器,通过在两个内核之间划分任务,多核处理器可在特定的时钟周期内执行更多的任务。因此,多核处理器具有并行执行的能力,从而提升了处理器的处理速度。
相对于单核处理器来说,虽然多核处理器可以让多个应用程序具有更快的运行速度[1],但却并不一定会让应用程序运行的更快,因为多数的.NET应用程序默认都是单线程的,因此它们虽然运行于多核处理器上,但实际使用的仍只是一个内核。
因此,要充分利用多核处理器,我们需要对旧的应用程序进行改造,从而让应用程序具有并行处理的能力。第25章讲了多线程编程方面的知识,将单线程应用改造成多线程应用也可以提升一部分性能,但多线程编程和并行编程是不同的,稍后会详细讲解。
.NET 4.0新增了对并行编程的支持,也是本章的核心内容。实际上,在.NET 3.5中,通过使用"Microsoft Parallel Extensions to the.NET Framework 3.5"这一扩展也可以支持并行编程。.NET 4.0通过新增加的“并行运行时”以支持并行编程,同时在BCL中也增加了一组公共类型和API。API主要包括“任务并行库(TPL)”和“并行LINQ(PLINQ)”两个部分。在进一步学习之前,先来看一段简单的代码示例,示例中使用了并行计算的代码,运行期间会使用多个线程并行处理多个任务,任务由任务计划分配给多核处理器中的多个核心并行执行。示例程序的输出中会包括每个线程的id,如代码清单29-1所示。
代码清单29-1 一段并行计算的示例代码
using System;
using System.Threading;
using System.Threading.Tasks;
namespace ProgrammingCSharp4
{
class ParallelSample
{
static void Main(string[]args)
{
Parallel.For(0,10,i=>
{
Console.WriteLine("Thread[{0}]:{1}",
Thread.CurrentThread.ManagedThreadId,i);
});
}
}
}
上述代码的运行结果为:
Thread[4]:1
Thread[1]:0
Thread[1]:4
Thread[1]:6
Thread[4]:2
Thread[1]:7
Thread[1]:8
Thread[1]:9
Thread[4]:3
Thread[3]:5
请按任意键继续……
Thread后面的方括号中的数字就是线程id,可见有多个线程在执行(也可能只有一个线程,具体有多少线程执行计算由“并行运行时”控制并分配)。由于是并行执行的,因此输出的i值并不是连续的,可见i的自增操作被分配到了不同的线程并行执行。尽管我们并没有使用任何多线程的代码,而仅仅使用了Parallel.For方法(Parallel类会在稍后讲解),但“并行运行时”会自动将任务分配给多个线程,实际上是分配到每个逻辑处理器并行运行的。
29.1 任务并行库
任务并行库(TPL)是作为.NET Framework 4并行编程支持的一个组成部分发布的,它包含一组新增的公共类型和API,位于System.Threading和System.Threading.Tasks命名空间中。
TPL的目的是尽量简化向应用程序中添加并行性和并发性的过程,从而可以让开发人员把更多的精力放在问题本身,而不是过多地关注线程如何创建、同步、停止等技术细节。其中,Task是TPL中的一个非常重要的类,Task类的实例代表一个需要并行执行的任务或操作。在.NET 4.0中,要在程序中使用并行编程的特性,只需使用Task类就可以了,具体的细节由TPL来处理,例如工作分区、ThreadPool上的线程计划、对取消的支持、状态管理以及其他低级别的细节操作等。一般情况下,我们只需要使用,而无须考虑这些细节,但这并不代表我们无法控制。事实上TPL也提供了各种API以便我们进行细粒度的控制。另外,TPL还会根据实际情况动态地调节并发程度,从而更有效地使用所有可用的处理器。
综上所述,从.NET Framework 4开始,TPL是编写并发代码和并行代码的首选方法。但是,需要说明的是,并非所有代码都适合并行化。例如,某个循环在每次迭代时只执行少量工作,那么并行化的开销可能会抵消并行化带来的效率提升,甚至导致并行化后代码运行得更慢。另外,像多线程编程一样,并行化同样会增加程序执行的复杂性。因此,尽管TPL简化了使用多线程的难度和步骤,但还是建议读者对线程处理的概念(例如,锁、死锁等)有个基本的了解,以便能更有效地使用TPL。
接下来我们区分一下“并发”和“并行”这两个概念,并从更高的层次来看并行编程架构的架构图。
29.1.1 并发和并行
需要注意的是,并行和并发这两个词形态上相近,很容易混淆。事实上,它们并不是同一个概念,它们的区别如图29-1所示。
图 29-1 并发和并行
我们对上图进行阐述,具体如下。
❑并发:在微观上并非同时执行,多个任务使用同一个处理器,操作系统为每个执行线程分配一个时间片,操作系统会定时中断当前正在执行的任务线程,然后将处理器分配给等待队列中的另一个线程,如此循环往复,这意味着任何一个线程都不能完全独占处理器;
❑并行:无论是微观还是宏观上,由于可以使用多个处理器核心,多个任务是同时执行的。
[1]Windows的任务调度可以将不同的进程分配到不同的核心运行。