淺談並發與並行(一)

來源:互聯網
上載者:User
文章目錄
  • 三、為啥要並發和並行
一、引言

   前天在GitHub上看到一幅圖,問如何向五歲的小孩講解並發和並行。然後有人以這幅圖做答:

    這幅圖有點兒意思,用咖啡機的比喻來形容並發和並行,從中最直接的體會是,並發是有狀態的,某一線程同時執行一個任務,完了才能進行到下一個,而並行是無狀態的。

    近些年,電腦的處理能力成指數能力增長。處理能力也越來越快,以前的一些工作站現在都可以移植到膝上型電腦或者手持功能上。但是近幾年,由於處理器的處理速度已經達到了極限,所以處理器開始向多核方向發展,而提高程式效能的一個最簡單的方式之一就是充分利用多核處理器的計算資源。但要編寫利用多核處理器處理的程式並不那麼簡單。所以一些函數是程式設計語言,如F#,Scala,Erlang等又開始流行起來,因為他們帶來的不可變性,遞迴思想等在一定程度上簡化了並行和並發編程。

    本文和下文從任務並行和資料並行兩個方面,簡要討論一下.NET中的並行編程。這篇文章不可能講完所有的API,架構,工具或者設計模式。對著方面感興趣的同學可以看看專門的書籍如Concurrent Programming on Windows 、Concurrency and Parallelism in .NET, 這些書專門講解了.NET中的並發和並行編程,本文主要參考.NET Performance 一書的部分章節。

二、問題及挑戰

    對於並行,我們面臨的另外一個挑戰是多核系統的異質性。CPU製造商現在開始為面向使用者的系統開發4核,8核或者更多核心的CPU。而且,目前的工作站或者進階點的膝上型電腦(移動工作站)通常具有強大的圖形處理器(GPU), 這種強大的GPU支援成百上千的個線程同步。 如何充分利用GPU在某些方面的計算效能,以及如何在CPU與GPU上分配任務,CPU和GPU的異質性在一定程度上影響了並行開發。

    但是,我們可以從並行,非同步中獲得一些效能提升。I/O受限的應用程式可以將I/O操作移到另一個線程,通過執行非同步I/O操作可以提高應用程式的響應性,並能夠很容易的擴充。CPU計算受限的應用程式通過並行,可以利用所有已有的CPU核心或者升級可以提升若干個數量級的效能,或者是將一部分計算受限的任務利用 GPU核心來運算,以提高應用程式的效能。後面將會看到,一個簡單的執行數組乘法的操作,通過修改幾行簡單的演算法讓這些代碼運行在GPU上,就能將效能提升130倍。

    然而,並行會帶來另外一些問題-死結,線程爭用(race conditions),饑餓,以及在單步調試時的記憶體崩潰。現在的一些並行架構,比如.NET 4.0中的並行庫Task Parallel Library(TPL) 以及C++11 中的AMP,將一定程度上減輕編寫並行程式的複雜度,並獲得採用並行而形成的效能提升。

三、為啥要並發和並行

    通過並發和並行能夠使得應用程式可以充分利用多核以及GPU的計算能力,從而提高應用程式的效能,比如在以下幾個方面中:

  • 使用非同步I/O操作可以提高應用程式的響應性。大多數的GUI應用程式都是用單個線程來控制所有UI介面的更新。UI線程不應該被佔用過長時間,不然UI介面就會失去對使用者的響應。
  • 跨多線程的並行工作可以更好的利用系統的資源。具有多CPU和GPU的現代電腦,通過並行可以指數級的提高CPU計算受限的應用程式的效能。
  • 同時執行多個I/O操作(如同時從多個網站上擷取資訊)可以提高總體的輸送量(throughput),等待I/O相應的操作可以用來發起新的操作,或者是處理操作返回的結果。

 

四、.NET 中並發和非同步演變1. Threads->Thread Pool->Tasks

    為瞭解決並行的問題,在最開始的時候就有了多線程的概念。線程也是處理並行和分布式非同步作業的最簡單的方法。

    為了說明問題,我們使用尋找質數的例子來說明問題:有一系列自然數,我們需要從這些自然數中找出所有的質數並將它們儲存到一個結合中。首先我們編寫一個傳統的運行在一個CPU線程上的方法:

public static IEnumerable<int> PrimesInRange_Sequential(int start, int end){    List<int> primes = new List<int>();    for (int i = start; i < end; i++)    {        if (IsPrime(i)) primes.Add(i);    }    return primes;}
public static bool IsPrime(int number){    if (number == 2)        return true;    for (int divisor = 2; divisor < number; divisor += 1)    {        if (number % divisor == 0) return false;    }    return true;}

 

    這是個尋找素數最原始最簡單的一種辦法,或許這個演算法已經很快了。現在假設有一個很大的集合,比如[100,200000),在我的i5的筆記本,上述演算法花費了將近12000ms,所以有很大的最佳化空間。

    首先第一個步的最佳化就是對演算法效率本身的最佳化,比如說可以將線性時間複雜度最佳化為根號n的時間複雜度,這裡為了示範差異,暫不處理。但是無論演算法怎麼最佳化,似乎看起來仍舊不好進行平行處理。但仔細想想判斷4977是否是一個質數和判斷3221是否是質數是獨立的,所以,將上面的數組劃分到不同的線程中去計算就可以並行化。顯然,在並行化的時候,我們需要注意多線程同步的問題,我們需要防止同一集合約時被多個線程修改,下面是一個改版後的代碼,我們將任務均分,然後放在不同的線程上執行:

public static IEnumerable<int> PrimesInRange_Thread(int start, int end){    List<int> primes = new List<int>();    int range = end - start;    int threadNum = (int)Environment.ProcessorCount;    int chunk = range / threadNum;    Thread[] threads = new Thread[threadNum];    for (int i = 0; i < threadNum; i++)    {        int chunkStart = start + i * chunk;        int chunkEnd = chunkStart + chunk;        threads[i] = new Thread(() =>        {            for (int number = chunkStart; number < chunkEnd; ++number)            {                if (IsPrime(number))                {                    lock (primes)                    {                        primes.Add(number);                    }                }            }        });        threads[i].Start();    }    foreach (Thread thread in threads)    {        thread.Join();    }    return primes;}

    現在,我們將10-200000這些資料平均分配到了四個線程上。

      順序執行的演算法平均需要執行12000ms, 而使用多線程的方法則只需要大約4000ms。如果在多核的機器上,運行速度會更快。但是通過Concurrency Profile的分析,可以看出問題。本人機器是i5處理器,有四個邏輯核心,可以看出,在最開始的一段時間,CPU使用了四個核心處理,但是到最後只有一個CPU在運行。可以看到整體的CPU使用率從開始的4個核,然後下降到最後的 1個核

     進一步從可以看出,四個線程的已耗用時間並不一樣,可以看到一些線程比另外一些線程結束的早,使得整個的CPU的使用率遠低於100%。

    實際上如果核心更多的話,程式會啟動並執行更快,但是有幾個問題需要考慮:

  • 到底建立多少個線程合適?如果系統有8個CPU核心,我們應該建立8個線程嗎?
  • 我們怎樣保證我們不會壟斷系統資源或者給電腦帶來過大負擔。比如,如果我們的進程中有其他線程需要使用和我們一樣的並行演算法計算指數,改怎麼辦?
  • 線程如何同步的訪問結果集。多個線程中同時訪問List<int>是不安全的,可能會導致資料的不確定性。但是在我們每一次將一個結果加入集合中時進行加鎖,代價很高,而且會帶來嚴重的效能瓶頸,尤其是在將演算法擴充到更多處理器核心的機器上時,問題更多。
  • 對於小的計算問題,建立那麼多的線程是否值得。或者說,對於這種情況,在單一線程中同步執行這些計算操作是否更恰當,建立和銷毀一個線程開銷是很大的。
  • 如何保證所有的線程都分配相同的工作量?一些線程可能比其他線程更快的完成任務,特別是在處理一些結果集比較小的計算的時候。將100-100000劃分為4個相等的工作量,那麼尋找100-25075中的素數基本會比75025-10000的快兩倍,因為我們尋找素數的演算法會隨著數位增大效率會下降。
  • 如果某個線程中拋出了異常,我們怎麼處理呢?在這種情況下,似乎IsPrimer不會拋出什麼異常,但是在實際的編程中,並發的工作可能會產生潛在的異常。(CLR的預設策略是,當某個線程發生未處理的異常時,整個進程都會終止,這個策略是正常的,但是他不允許PrimersInRange_Sequential 這個方法來處理這個異常

    這些問題不是三言兩語就能回答的了的, 一種架構要使得並存執行不會產生太多的進程,能夠保證工作量能夠平均的分配到所有線程上,並且能夠報告錯誤和產生可靠地結果,這就是並行庫Task Parallel Library的工作,後面我們會討論。

    從手動的線程管理來看,最自然的就是線程池了。線程池能管理很多個線程。和手動建立一個線程來執行特定操作不同,我們將工作任務扔到線程池中,它會選擇合適的線程,然後去執行我們給定的方法。線程池解決了上面列出的若干問題,線程池通過限制建立線程的總數,根據給定的工作量來決定建立合適的線程數量,降低了在極端情況下,如處理量比較少的情況下建立和銷毀線程的開銷,協助我們解決了對系統資源的獨佔和過度使用。

     在我們的例子中,我們將上面的處理過程分成大塊,然後放到線程池中讓其處理。

public static IEnumerable<int> PrimesInRange_ThreadPool(int start, int end){    List<int> primes = new List<int>();    const int chunkSize = 100;    int completed = 0;    ManualResetEvent allDone = new ManualResetEvent(false);    int chunks = (end - start) / chunkSize;    for (int i = 0; i < chunks; i++)    {        int chunkStart = start + i * chunkSize;        int chunkEnd = chunkStart + chunkSize;        ThreadPool.QueueUserWorkItem(_ =>        {            for (int number = chunkStart; number < chunkEnd; number++)            {                if (IsPrime(number))                {                    lock (primes)                    {                        primes.Add(number);                    }                }            }            if (Interlocked.Increment(ref completed) == chunks)            {                allDone.Set();            }        });    }    allDone.WaitOne();    return primes;}

    這樣,較前一版本,代碼更具有可擴充性。教前面那種複雜的手動建立線程的版本,使用線程池的方式,時間大概為2000ms,其次,使用Concurrency Profiler查看,CPU使用率大概接近100%,而且每一個線程執行的結束時間差不多。

 

    在CLR 4.0中,CLR線程池包含幾個協作的部分。當不屬於線程池的線程,比如說主線程,將任務分配到線程池時,實際上是將任務壓入到一個全域的處理隊列FIFO中。然後線程池中的每一個線程有一個本地的棧LIFO,然後不斷的執行這個棧上的任務。如果線程池中的棧為空白,他會試圖嘗試擷取其他線程的本地棧中的任務,以隊列的方式(FIF0)去執行。當所有的本地隊列為空白時,線程會詢問全域的FIFO隊列,然後從哪兒擷取任務並執行。

 

 

    當將任務壓進全域的隊列時,沒有一個子線程有優先順序去執行特定的任務,只有按順序執行,所以FIFO適合全域隊列。但是當線程池裡的線程來執行一個任務時,它通常會使用當前的資料以及指令來執行下一個任務,這充分利用了CPU的資料和指令級緩衝。更進一步,訪問執行緒區域隊列需要較少的同步,並且很少會遇到在訪問全域隊列時,其他線程爭奪資源的問題。同樣,當本地線程從其它線程搶工作任務是,他是以一種FIFO的形式,所以LIFO最佳化考慮到了CPU在本地原來線程上的緩衝。

    簡言之,線程池通過一些管理機制協助開發人員從複雜的線程生命週期管理及調度中解放出來。雖然CLR的線程池有一些控制的API,比如ThreadPool.SetMinThreads以及SetMaxThreads方法控制線程的最大最小個數,也沒有一些API能夠控制線程或者任務的優先順序。但是,他能夠很方便的擴充到更強的系統上去,並且不需要為生命週期比較短的對象建立和銷毀線程。

    往線程池中添加的工作項目比較簡單,他們沒有狀態,也不能攜帶異常資訊,不支援非同步繼續和取消任務操作,當任務結束時,不能夠返回處理結果。.NET 4.0中的Task Parallel Library並行庫中引入了task,它是對線程池中工作項目的一種高層次抽象,Task是一種對線程和線程池裡面的工作項目的一種結構化抽象。

2. 任務並行化

    任務並行化是指通過一系列API將大的任務分解成一系列小的任務,然後再多線程上並存執行。Task Parallel Library(TPL)並行庫有一系列API能夠基於線程池同時管理成千上萬個任務。TPL的核心是System.Threading.Tasks類,他代表一個小的任務,Task類提供了如下這些功能。

  • 能夠調度任務在非指定的線程上獨立執行,要在特定的線程上執行給定任務需要通過task scheduler來確定,預設的task scheduler會將任務放到CLR的線程池中,但是有一些task scheduler可以將任務發送到特定的線程,如UI線程上。
  • 能夠等待任務結束,並擷取執行的結果
  • 能夠提供一種機制,等待某一任務執行結束後立即執行繼續的操作,通常我們稱之為回調,但是在這裡我們使用繼續這一術語。
  • 能夠處理單一任務拋出的異常,甚至是有層次關係的任務在原始的線程上,或者是任何一個對任務結果會產生影響的異常。
  • 在任務還沒有開始的時候,可以取消任務,或者是在任務執行過程中,提交結束工作要求。

    因為我們可以將task想象成為對線程的一種更高層次的抽象,我們可以使用任務代替線程來改寫前面尋找素數的代碼。實際上,使用task可以使得代碼更加短小,在每一次執行的時候,我們不需要任務結束時進行計數,也不需要重設ManualResetEvent對象來追蹤任務執行的進度。但是在後面我們可以看到,TPL提供的資料並行API可能更加適合迴圈尋找素數的應用情境。所以我們來舉另外一個例子。

    快速排序是一個比較有名的基於遞迴比較的排序演算法。快速排序通過遞迴調用很容易實現並行化,其平均時間複雜度為nlog(n)。快速排序的代碼如下:

public static void QuickSort_Sequential<T>(T[] items) where T : IComparable<T>{    QuickSort_Sequential(items, 0, items.Length);}private static void QuickSort_Sequential<T>(T[] items, int left, int right) where T : IComparable<T>{    if (left == right) return;    int pivot = Partition(items, left, right);    QuickSort_Sequential(items, left, pivot);    QuickSort_Sequential(items, pivot + 1, right);}private static int Partition<T>(T[] items, int left, int right) where T : IComparable<T>{    int pivotPosition = (right - left) / 2;    T pivotValue = items[pivotPosition];    Swap(ref items[right - 1], ref items[pivotPosition]);    int store = left;    for (int i = left; i < right - 1; ++i)    {        if (items[i].CompareTo(pivotValue) < 0)        {            Swap(ref items[i], ref items[store]);            ++store;        }    }    Swap(ref items[right - 1], ref items[store]);    return store;}private static void Swap<T>(ref T a, ref T b){    T temp = a;    a = b;    b = temp;}

    QuickSort遞迴調用的每一步都可以並行化。 左側和右側數組排序是獨立的任務,不需要同步操作。這個很容易使用task來表示。下面是使用task對QuickSort進行並行化的第一步:

private static void QuickSort_Parallel<T>(T[] items) where T : IComparable<T>{    QuickSort_Parallel(items, 0, items.Length);}private static void QuickSort_Parallel<T>(T[] items, int left, int right) where T : IComparable<T>{    if (left == right) return;    int pivot = Partition(items, left, right);    Task leftTask = Task.Run(() => QuickSort_Parallel(items, left, pivot));    Task rightTask = Task.Run(() => QuickSort_Parallel(items, pivot + 1, right));    Task.WaitAll(leftTask, rightTask);}

    Task.Run方法建立了一個新的任務,效果和new Task相同,然後讓該任務執行(和調用start方法類似)。Task.WaitAll靜態方法等待所有的方法執行完畢,然後返回。注意我們沒有處理具體如何等待任務完成,比如編寫建立和銷毀線程的邏輯。

    TPL中還有一個Parallel.Invoke 的協助類,它能夠執行一系列的任務,然後當所有任務結束之後,返回結果值,使用該方法可以重寫QuickSort的主體代碼。

Parallel.Invoke(() => QuickSort_Parallel_Threshold(items, left, pivot),                () => QuickSort_Parallel_Threshold(items, pivot + 1, right));

    不論是使用Parallel.Invoke或者是手動建立任務,如果我們將該版本和前面的順序執行的版本相比,我們會看到,並行的版本執行的比較慢,即使在配置比較好的機器上。實際上,排1000000個隨機的認證,順序版本執行大概需要2500ms,而我們的並行版本竟然需要花4100ms。

     為什麼並行版本比順序執行的代碼啟動並執行慢,問題在於,並行化需要一些足夠的資料。當我們使用遞迴分解待排序的數組時,當數組變得非常小時,在使用並行化來進行進一步劃分可能不如直接使用傳統的方式進行排序來的快,因為當數組比較小時,使用並行排序大部分的花費都耗在了建立task對象,將帶對象放到線程池以及等待任務完成上,這些操作浪費的時間遠遠大於元素對比所花費的時間。

3.控制遞迴演算法中的並行化

    下面有幾個方法可以對上面的並行演算法進行最佳化:

  • 只要數組的大小大於某一個閾值,就採用並行版本,否則採用順序執行的版本。
  • 只要遞迴的深度小於某一閾值,採用並行版本,否則採用順序執行版本。(這一點甚至要優先於第一條,除非哨兵元素一直恰好排在元素的中間。
  • 只要需要執行的任務個數小於特定的閾值,採用並行版本,否則採用順序執行版本(這一條在沒有其他的限制並行化的原則下比如遞迴的深度以及輸入的大小上。

    在上面的例子中,限制並行集合的大小會產生比較好的效果。在本人機器上,只需要600ms大概會比順序版本快4倍多。而代碼改動則很小,只需要設定一個閾值,該閾值需要不斷測試獲得。

private static void QuickSort_Parallel_Threshold<T>(T[] items, int left, int right) where T : IComparable<T>{    if (left == right) return;    int pivot = Partition(items, left, right);    if (right - left > 500)    {        Parallel.Invoke(() => QuickSort_Parallel_Threshold(items, left, pivot),                        () => QuickSort_Parallel_Threshold(items, pivot + 1, right));    }    else    {        QuickSort_Sequential(items, left, pivot);        QuickSort_Sequential(items, pivot + 1, right);    }}

   

    使用相同的技巧,可以將很多其他使用遞迴講解的演算法並行化。事實上,大部分的遞迴演算法,會將輸入分解為幾個部分獨立運行,然後將結果集進行合并。

    可以看到,使用TPL對課遞迴的演算法進行並行化是很容易的,而且效果不錯,在這裡總結一下大致的方法為:

  1. 對與可遞迴處理的演算法,先寫出正常的遞迴式,即順序處理的演算法。
  2. 對遞迴的每一步分進行並行化處理。
  3. 通過實驗,設定並行化處理和順序處理的閾值。

    除了以上的例子外,還有很多使用遞迴處理的演算法可以很容易的進行並行化,比如我們熟知的合并排序,Strassen矩陣乘法等等,這裡有很多使用TPL進行並行化處理的例子,您感興趣可以看看。

http://code.msdn.microsoft.com/windowsdesktop/Samples-for-Parallel-b4b76364

五、結語

    本文簡要介紹了並行和並發編程所面臨的問題,並以尋找素數的例子示範了在.NET中並發編程的演變,最後以快速排序示範了如何使用TPL對能夠進行遞迴處理的演算法進行並行化。本文中的代碼點擊此處下載。希望這些對您瞭解並發和並行編程有所協助。

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.