根据计算平台和规模的不同,并行计算可以分为两种:第一种是基于单一计算机系统的多核处理器或多处理器进行多线程并行计算,采用共享存储的方式,主要的标准有OpenMP,如下左图所示;第二种就是基于多台计算机组件的集群(Cluster)计算系统进行并行计算,采用消息传递方式,主要的标准有MPI,如下右图所示。本文将主要介绍多线程方式的并行计算。
首先来了解一下单核处理器上程序运行方式,系统中包括操作系统和应用程序等都以进程(Process)形式存在,当程序结束时这个进程也就跟着消亡。每个进程中至少包含一个线程(Thread),一个线程用于完成程序的某个功能,一个程序中一般都包含多个线程,所有的这些线程在系统中都成队列形式。对于一个核心的处理器来说,某一时刻,它只能处理一个线程。这个线程处理之后就处理下一个线程,依次循环处理。由于CPU的主频都非常高,如Intel的奔四可到3GHz,所以每个线程处理的时间都非常短,以致我们并不会察觉。但是它们实际上是以串行的形式在CPU上运行。所以在物理上,对于单核处理器来说,是无法实现物理上的并行。在单核处理器上,即使使用多线程来分发程序,但实际上还是以单线程的形式在运行。
如果将执行核增加一个,那么在同一时刻,将会有两个线程在运行,这样将会在一定程度上提供计算机的运行速度,但对于某些单线程的程序过程来说,实际情况并没用得到改善。至于系统中的线程怎么分发到两个核上,这就是操作系统的任务了。很多应用程序都不止包含一个线程,一般都包含有多个线程,如用Spy++工具查看系统所有的线程,如下图所示:
这多个线程如果在一个执行核上运行,它们呈一个队列来执行。如果在两个执行核上运行的话,它们将呈两队来执行。如果某个程序中包括有四个线程,而这四个线程又分别在两个核上执行,那么执行该程序将节约一半的时间。但如果该程序是单线程,无论是单核还是多核,运行该程序所需的时间都将是一样的。所以,以前有人在某单线程程序中将一些程序分开放在两个线程中分别执行,效率得到了提高,节约将近一倍的时间,其原因就在此。
OpenMP提供的就是一个多线程编程标准,现在.Net平台也提供了并行编程的System.Threading.Tasks.Parallel类,可以使多个线程能够同时执行。如果直接采用多个线程去实现并行,需要经常处理线程或线程池,采用这些多线程编程标准可以简化并行开发,也不必直接处理线程或线程池。
在C/C++中采用OpenMP指令的格式如下:
#pragma omp …
关键字omp表示这个指令是OpenMP指令,所以它会被OpenMP编译器处理,其他非OpenMP编译器将不会理会。由于OpenMP指令都预先定义了,所以很容易被识别出来。这样程序员编写的并行代码就可以在不同的平台上运行。如果平台不支持并行,也会直接跳过并行指令把程序当作串行程序来运行。
OpenMP提供了两种控制并行的结构:第一种就是提供了一个用于创建多线程程序的指令,这些线程相互之间是并行的,这个指令实际上就是创建了一些线程去执行并形体中的程序;第二种就是对已存在的并行结构进行分工的指令,像循环中的do指令(Fortran)或for(C/C ++)。
一个OpenMp程序通常都是从一个单线程程序开始,我们通常把这个单线程程序叫做主线程(Master Thread),在主线程的程序中应该要包含整个程序中需要使用的数据变量,包括全局变量。当主线程遇到并行结构时,将会创建新的线程来执行并形体中的程序。每个线程都会独立的执行并形体中的程序,相互之间不会影响,但是它们之间可以共用主线程里面定义的全局变量。在并行过程中具体哪些变量是共享的、哪些变量是线程私有的,可以通过条件clauses(…)对每个变量进行指定,这些条件用于并行线程中决定哪些可用。一个变量可以有三种类型,即shared、private和reduction。其中shared表示在并行结构中将有一个单独的内存位置来存放这个变量,所有的并行线程都可以使用这个变量,所有的并行线程将共享这块内存地址,因此,线程间的通信通过普通的读写操作方式就可以实现,当然,这个变量也可以随意被任何一个线程修改。相反,private变量将会有多个内存地址,每个线程里面一个。这个变量的所有读写操作都只限于本线程,其他线程是无法访问本线程中该变量的内存地址的。所以,一般都用于定义临时变量。reduction就有点难理解了,它具有shared和private的特征,就像它的字面意思一样,reduction属性用于需要下降的变量(指值的减少)。Reduction操作在很多程序中都非常重要,最常见的例子就是计算并行结构中最后的临时局部变量的总和。除了这三种之外,OpenMP还提供了许多其他数据属性参数。
多个OpenMP线程之间可以采用共享变量(shared)通过简单的读写操作来进行通信,但是这需要在多个线程中协调一致。如果协调不一致,可能会出现多个线程同时修改这个变量,或者这个线程正在读而那个线程又正在写,这些潜在的冲突都将导致数据的错误,因此,在多线程中必须避免这种情况,必须明确地协调好。在并行程序中设置同步(synchronization)就可以协调这些执行的多线程。最常见的两种情况就是相互排斥和事件同步,互斥就是在这段代码中通过一个线程不让其他线程读取这个共享变量。当很多线程正在修改同一个变量时,为了确保这个变量值是对的,在修改之前就需要进行互斥存取。OpenMP中提供了一个critical指标来表示互斥。事件同步常用于表示多线程间的事件,最简单的形式就是barrier阻塞。在并行程序中barrier指标表示在某点处每个线程都在这等待其他的线程也运行到这里,一旦所有的线程都达到这个点后,它们又继续执行。就像跑步的时候,有的人跑得快,有的人跑得慢,在跑了两圈的时候,所有人都在这里等待最后一个人,当最后一个人也跑到两圈的时候,然后大家又接着继续跑,这个过程就称为barrier阻塞。barrier指令能保证所有线程都执行了在barrier之前的代码。
一个典型的并行程序结构如下图所示:
OpenMP API是一套非常简便的共享存储并行计算应用程序接口,它是一个多线程、共享存储的模型。线程间通过共享的变量进行交换,并可以通过线程同步来防止数据冲突,当然,同步是需要耗费很多的资源的,所以尽量减少同步的需要。