在並行計算領域有一個廣爲流傳的笑話——並行計算是將來之事而且永遠都是。這個小笑話幾十年來一直都是對的。一種相似的觀點在計算機架構社區中流傳,處理器時鐘速度的極限彷佛近在眼前,但時鐘速度卻一直在加快。多核革命是並行社區的樂觀和架構社區的悲觀的衝突。html
如今主流的CPU廠商開始從追求時鐘頻率轉移到經過多核處理器來增長並行支持。緣由很簡單:把多個CPU內核封裝在一個芯片裏可讓雙核單處理器系統就像雙處理器系統同樣、四核單處理器系統像四處理器系統同樣。這一實用方法讓CPU廠商在可以提供更強大的處理器的同時規避了加速頻率的諸多障礙。程序員
到此爲止這聽起來是一個好消息,但事實上若是你的程序沒有從多核裏獲取優點的話,它並不會運行得更快。這就是OpenMP的用武之地了。OpenMP能夠幫助C++開發者更快地開發出多線程應用程序。算法
在這短小的篇幅裏完整講述OpenMP這個大而強的API庫的相關內容是不可能的。所以,本文僅做一些初始介紹,經過示例讓你可以快速地應用OpenMP的諸多特性編寫多線程應用程序。若是你但願閱讀更深刻的內容,咱們建議你去OpenMP的網站看看。編程
在Visual C++中使用OpenMP數組
OpenMP標準做爲一個用以編寫可移植的多線程應用程序的API庫,規劃於1997年。它一開始是一個基於Fortran的標準,但很快就支持C和C++了。當前的版本是OpenMP 2.0(譯者注:最新版本已是2.5版), Visual C++ 2005和XBox360平臺都徹底支持這一標準。多線程
在咱們開始編碼以前,你須要知道如何讓編譯器支持OpenMP。Visual C++ 2005提供了一個新的/openmp開關來使能編譯器支持OpenMP指令。(你也能夠經過項目屬性頁來使能OpenMP指令。點擊配置屬性頁,而後[C/C++],而後[語言],選中OpenMP支持。)當/openmp參數被設定,編譯器將定義一個標識符_OPENMP,使得能夠用#ifndef _OPENMP來檢測OpenMP是否可用。架構
OpenMP經過導入vcomp.lib來鏈接應用程序,相應的運行時庫是vcomp.dll。Debug版本導入的鏈接庫和運行時庫(分別爲vcompd.lib和vcompd.dll)有額外的錯誤消息,當發生異常操做時被髮出以輔助調試。記住儘管Xbox360平臺支持靜態鏈接OpenMP,但Visual C++並不支持。函數
OpenMP中的並行oop
OpenMP應用程序剛運行時只有一條線程,這個線程咱們叫它主線程。當程序執行時,主線程生成一組線程(包括主線程),隨着應用程序執行可能會有一些區域並行執行。在並行結束後,主線程繼續執行,其它線程被掛起。在一個並行區域內可以嵌套並行區域,此時原來的線程就成爲它所擁有的線程組的主線程。嵌套的並行區域可以再嵌套並行區域。性能
(圖1)OpenMP並行段
圖1展現了OpenMP如何並行工做。在最左邊黃色的線是主線程,以前這一線程就像單線程程序那樣運行,直到在執行到點1——它的第一個並行區域。在並行區域主線程生成了一個線程組(參照黃色和桔黃色的線條),而且這組線程同時運行在並行區域。
在點2,有4條線程運行在並行區域而且在嵌套並行區域裏生成了新的線程組(粉紅、綠和藍)。黃色和桔黃色進程分別做爲他們生成的線程組的主線程。記住每個線程均可以在不一樣的時間點生成一個新的線程組,即使它們沒有遇到嵌套並行區域。
在點3,嵌套的並行區域結束,每個嵌套線程在並行區域同步,但並不是整個區域的嵌套線程都同步。點4是第一個並行區域的終點,點5則開始了一個新的並行區域。在點5開始的新的並行區域,每個線程從前一併行區域繼承利用線程本地數據。
如今你基本瞭解了執行模型,能夠真正地開始練習並行應用程序開發了。
OpenMP的構成
OpenMP易於使用和組合,它僅有的兩個基本構成部分:編譯器指令和運行時例程。OpenMP編譯器指令用以告知編譯器哪一段代碼須要並行,全部的OpenMP編譯器指令都 以#pragma omp開始。就像其它編譯器指令同樣,在編譯器不支持這些特徵的時候OpenMP指令將被忽略。
OpenMP運行時例程本來用以設置和獲取執行環境相關的信息,它們當中也包含一系列用以同步的API。要使用這些例程,必須包含OpenMP頭文件——omp.h。若是應用程序僅僅使用編譯器指令,你能夠忽略omp.h。
爲一個應用程序增長OpenMP並行能力只須要增長几個編譯器指令或者在須要的地方調用OpenMP函數。這些編譯器指令的格式以下:
#pragma omp [clause[ [,] clause]…]
dierctive(指令)包含以下幾種:parallel,for,parallel for,section,sections,single,master,criticle,flush,ordered和atomic。這些指令指定要麼是用以工做共享要麼是用以同步。本文將討論大部分的編譯器指令。
對於directive(指令)而言clause(子句)是可選的,但子句能夠影響到指令的行爲。每個指令有一系列適合它的子句,但有五個指令(master,cirticle,flush,ordered和atomic)不能使用子句。
指定並行
儘管有不少指令,但易於做爲初學用例的只有極少數的一部分。最經常使用而且最重要的指令是parallel。這條指令爲動態長度的結構化程序塊建立一個並行區域。如:
#pragma omp [clause[ [,] clause]…]
structured-block
這條指令告知編譯器這一程序塊應被多線程並行執行。每一條指令都執行同樣的指令流,但可能不是徹底相同的指令集合。這可能依賴於if-else這樣的控制流語句。
這裏有一個慣常使用的「Hello, World!」程序:
#pragma omp parallel
{
printf("Hello World\n");
}
在一個雙處理器系統上,你可能認爲輸入出下:
Hello World
Hello World
但你可能獲得的輸出以下:
HellHell oo WorWlodrl
d
出現這種狀況是由於兩條線程同時並行運行而且都在同一時間嘗試輸出。任什麼時候候超過一個線程嘗試讀取或者改變共享資源(在這裏共享資源是控制檯窗口),那就可能會發生紊亂。這是一種非肯定性的bug而且難以查出。程序員有責任讓這種狀況不會發生,通常經過使用線程鎖或者避免使用共享資源來解決。
如今來看一個比較實用的例子——計算一個數組裏兩個值的平均值並將結果存放到另外一個數組。這裏咱們引入一個新的OpenMP指令:#pragma omp parallel for。這是一個工做共享指令。工做共享指令並不產生並行,#pragma omp for工做共享指令告訴OpenMP將緊隨的for循環的迭代工做分給線程組並行處理:
#pragma omp parallel
{
#pragma omp for
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
在這個例子中,設size的值爲100而且運行在一個四處理器的計算機上,那麼循環的迭代可能分配給處理器p1迭代1-25,處理器p2迭代26-50,處理器p3迭代51-75,處理器p4迭代76-99。在這裏假設使用靜態調度的調度策略,咱們將在下文討論更深層次的調度策略。
還有一點須要指出的是這一程序在並行區域的結束處須要同步,即全部的線程將阻塞在並行區域結束處,直到全部線程都完成。
若是前面的代碼沒有使用#pragma omp for指令,那麼每個線程都將徹底執行這個循環,形成的後果就是線程冗餘計算:
#pragma omp parallel
{
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
}
由於並行循環是極常見的的可並行工做共享結構,因此OpenMP提供了一個簡短的寫法用以取代在#pragma omp parallel後面緊跟#pragma omp for的形式:
#pragma omp parallel for
for(int i = 1; i < size; ++i)
x[i] = (y[i-1] + y[i+1])/2;
你必須確保沒有循環依賴,即循環中的某一次迭代不依賴於其它迭代的結果。例以下面兩個循環就有不一樣的循環依賴問題:
for(int i = 1; i <= n; ++i) // Loop (1)
a[i] = a[i-1] + b[i];
for(int i = 0; i < n; ++i) // Loop (2)
x[i] = x[i+1] + b[i];
並行的Loop1的問題是由於當執行第i層迭代時須要用到i-1次迭代的結果,這是迭代i到i-1的依賴。並行的Loop2一樣有問題,儘管緣由有些不一樣。在這個循環中可以在計算x[i-1]的值以前計算x[i]的值,但在這樣並行的時候不能再計算x[i-1]的值,這是迭代i-1到i的依賴。
當並行執行循環的時候必須確保沒有循環依賴。當沒有循環依賴的時候,編譯器將可以以任意的次序執行迭代,甚至在並行中也同樣。這是一個編譯器並不檢測的重要需求。你應該有力地向編譯器斷言將要並行執行的循環中沒有循環依賴。若是一個循環存在循環依賴而你告訴編譯器要並行執行它,編譯器仍然會按你說的作,但結果應該是錯誤的。
另外,OpenMP對在#pragma omp for或#pragma omp parallel for裏的循環體有形式上的限制,循環必須使用下面的形式:
for([integer type] i = loop invariant value;
i {<,>,=,<=,>=} loop invariant value;
i {+,-}= loop invariant value)
這樣OpenMP才能知道在進入循環時須要執行多少次迭代。
OpenMP和Win32線程比較
當使用Windows API進行線程化的時候,用#pragma omp parallel爲例來比較它們有利於更好地比較異同。從圖2可見爲達到一樣的效果Win32線程須要更多的代碼,而且有不少幕後魔術般的細節難以瞭解。例如ThreadData的構造函數必須指定每個線程被調用時開始和結束的值。OpenMP自動地掌管這些細節,並額外地給予程序員配置並行區域和代碼的能力。
DWORD ThreadFn(void* passedInData)
{
ThreadData *threadData = (ThreadData *)passedInData;
for(int i = threadData->start; i < threadData->stop; ++i )
x[i] = (y[i-1] + y[i+1]) / 2;
return 0;
}
void ParallelFor()
{
// Start thread teams
for(int i=0; i < nTeams; ++i)
ResumeThread(hTeams[i]);
// ThreadFn implicitly called here on each thread
// Wait for completion
WaitForMultipleObjects(nTeams, hTeams, TRUE, INFINITE);
}
int main(int argc, char* argv[])
{
// Create thread teams
for(int i=0; i < nTeams; ++i)
{
ThreadData *threadData = new ThreadData(i);
hTeams[i] = CreateThread(NULL, 0, ThreadFn, threadData,
CREATE_SUSPENDED, NULL);
}
ParallelFor(); // simulate OpenMP parallel for
// Clean up
for(int i=0; i < nTeams; ++i)
CloseHandle(hTeams[i]);
}
(圖2)Win32多線程編程
共享數據與私有數據
在編寫並行程序的時候,理解什麼數據是共享的、什麼數據是私有的變得很是重要——不只由於性能,更由於正確的操做。OpenMP讓共享和私有的差異顯而易見,而且你能手動干涉。
共享變量在線程組內的全部線程間共享。所以在並行區域裏某一條線程改變的共享變量可能被其它線程訪問。反過來講,在線程組的線程都擁有一份私有變量的拷貝,因此在某一線程中改變私有變量對於其它線程是不可訪問的。
默認地,並行區域的全部變量都是共享的,除非以下三種特別狀況:1、在並行for循環中,循環變量是私有的。如圖3裏面的例子,變量i是私有的,變量j默認是共享的,但使用了firstprivate子句將其聲明爲私有的。
float sum = 10.0f;
MatrixClass myMatrix;
int j = myMatrix.RowStart();
int i;
#pragma omp parallel
{
#pragma omp for firstprivate(j) lastprivate(i) reduction(+: sum)
for(i = 0; i < count; ++i)
{
int doubleI = 2 * i;
for(; j < doubleI; ++j)
{
sum += myMatrix.GetElement(i, j);
}
}
}
(圖3)OpenMP子句與嵌套for循環
2、並行區域代碼塊裏的本地變量是私有的。在圖3中,變量doubleI是一個私有變量——由於它聲明在並行區域。任一聲明在myMatrix::GetElement裏的非靜態變量和非成員變量都是私有的。
3、全部經過private,firstprivate,lastprivate和reduction子句聲明的變量爲私有變量。在圖3中變量i,j和sum是線程組裏每個線程的私有變量,它們將被拷貝到每個線程。
這四個子句每一個都有一序列的變量,但它們的語義徹底不一樣。private子句說明變量序列裏的每個變量都應該爲每一條線程做私有拷貝。這些私有拷貝將被初始化爲默認值(使用適當的構造函數),例如int型的變量的默認值是0。
firstprivate有着與private同樣的語義外,它使用拷貝構造函數在線程進入並行區域以前拷貝私有變量。
lastprivate有着與private同樣的語義外,在工做共享結構裏的最後一次迭代或者代碼段執行以後,lastprivate子句的變量序列裏的值將賦值給主線程的同名變量,若是合適,在這裏使用拷貝賦值操做符來拷貝對象。
reduction與private的語義相近,但它同時接受變量和操做符(可接受的操做符被限制爲圖4列出的這幾種之一),而且reduction變量必須爲標量變量(如浮點型、整型、長整型,但不可爲std::vector,int[]等)。reduction變量初始化爲圖4表中所示的值。在代碼塊的結束處,爲變量的私有拷貝和變量原值一塊兒應用reduction操做符。
(圖4)Reductoin操做符
在圖3的例子中,sum對應於每個線程的私有拷貝的值在後臺被初始化爲0.0f(記住圖4表中的規範值爲0,若是數據類型爲浮點型就轉化爲0.0f。)在#pragma omp for代碼塊完成後,線程爲全部的私有sum和原值作+操做(sum的原值在例子中是10.0f),再把結果賦值給本來的共享的sum變量。
非循環並行
OpenMP常常用以循環層並行,但它一樣支持函數層並行,這個機制稱爲OpenMP sections。sections的結構是簡明易懂的,而且不少例子都證實它至關有用。
如今來看一下計算機科學裏一個極其重要的算法——快速排序(QuickSort)。在這裏使用的例子是爲一序列整型數進行遞歸的快速排序。爲了簡單化,咱們不使用泛型模板版本,但其仍然能夠表達OpenMP的思想。圖5的代碼展現瞭如何在快速排序的主要函數中應用sections(爲簡單起見咱們忽略了劃分函數)。
void QuickSort (int numList[], int nLower, int nUpper)
{
if (nLower < nUpper)
{
// create partitions
int nSplit = Partition (numList, nLower, nUpper);
#pragma omp parallel sections
{
#pragma omp section
QuickSort (numList, nLower, nSplit - 1);
#pragma omp section
QuickSort (numList, nSplit + 1, nUpper);
}
}
}
(圖5)用OpenMP sections實現Quicksort
在這個例子中,第一個#pragma建立一個sections並行區域,每個section用#pragma omp section前綴指令聲明。每個section都將被分配線程組裏的單獨線程執行,而且全部的sectoins可以確保一致並行。每個並行section都遞歸地調用QuickSort。
就像在#pragma omp parallel for結構中同樣,你有責任確保每個section都不依賴於其它的sectoin,以使得它們可以並行執行。若是sectoins在沒有同步存取資源的狀況下改變了共享資源,將致使未定義結果。
在本例中像使用#pragma omp parallel for同樣使用了簡短的#pragma omp parallel sections。你也可使用單獨使用#pragma omp sections,後跟一個並行區域,就像你在#pragma omp for裏作的同樣。
在圖5的程序實現中咱們須要瞭解一些東西。首先,並行的sections遞歸調用,並行區域是支持遞歸調用的,特別在本例中並行sectoins就只是遞歸調用。所以若是使能並行嵌套機制,程序遞歸調用QuickSort時將產生大量新線程。這多是也可能不是程序員所指望的,由於它致使產生至關多的線程。程序可以不使能並行嵌套機制以限制線程數量。不使能嵌套機制的時候,應用程序將在兩條線程上遞歸調用QuickSort,而毫不會產生多於兩條的線程。
另外,若是沒有打開/openmp開關,編譯器將生成完美的正確的串行快速排序實現。OpenMP的好處之一就是可以與不支持OpenMP的編譯器中共存。