Java高階知識點:平行計算(外部排序) 及 死鎖分析

lemonGuo發表於2017-09-05

一. 平行計算(外部排序)

通常單機運算時將資料放入記憶體中進行計算,但隨著資料量的增大,最好是使用平行計算的方法。

1. 如何設計並行排序演算法?

在平行計算的工作中,將每一個參與的電腦當作一個節點,節點通常是比較廉價的,可通過增加節點來提高效率。分為以下三個步驟及待解決問題:

  • 將資料拆分到每個節點上(如何拆分,禁止拆分資料依賴)
  • 每個節點平行計算得出結果(每個節點要算出什麼結果?)
  • 將結果彙總(如何彙總?)

接下來以一個典型例子——外部排序,如何排序10G個元素?

熟悉演算法的肯定知道排序演算法中效率較優的快速排序、歸併排序,它們時間複雜度為O(n*logn)。使用這些高效演算法來對10g個元素排序,也只需要幾分鐘的時間,但是問題在於記憶體大小限制,無法一次性將所有元素放入一個大陣列中!只能一部分放在放在記憶體陣列中,另一部分放在記憶體之外(硬碟或網路其它節點),這就是所謂的外部排序。


2. 歸併排序演算法

其中外部排序會使用到擴充套件的歸併排序,來簡單回顧歸併排序核心:將資料分為左右兩部分,分別排序,再把兩個有序子陣列進行歸併。此演算法重點就是歸併過程,就是兩個已排序好的陣列來比較頭部的元素,取最小值放入最終陣列中。檢視以下動畫理解:

這裡寫圖片描述

3. 外部排序演算法

(1)三個步驟

其實擴充套件後的歸併排序演算法思想可用於外部排序中,核心演算法分為以下三個步驟:

這裡寫圖片描述

  • 第一步:將資料進行切分,例如以100m或1g元素為1一組,將每一段資料分配到節點進行排序,切分的大小符合節點記憶體大小限制。
  • 第二步:這樣每個節點各自對分配資料進行排序,採用歸併排序或快速排序皆可。
  • 第三步:將每個排序好的節點按照歸併演算法整合到一個節點。

這裡寫圖片描述

(2)k路歸併演算法實現 —– 堆(PriorityQueue)

其中第一、二步實現較容易,重點在於如何將多個節點歸併到一個節點,也就是 k路歸併。如下圖所示,歸併演算法為不斷比較各個節點的頭元素,取最小值放入最終節點中,可是如何比較k個頭結點(下圖中的2,1,3,4)?

這裡寫圖片描述

逐個比較則效率較低,熟悉資料結構的朋友此時應該想到一個資料結構——堆!堆是一棵二叉樹,具有以下特點:

  • 在二叉樹上任何一個子節點都不大(或小於)於其父節點。
  • 必須是一棵完全的二叉樹,即除了最後一層外,以上層數的節點都必須存在並且要集中在左側。

所以依據堆的特點,我們可以構造一棵最小二叉堆,使得頭結點一定是最小值,於是構造一個大小為k的堆,先將k個節點的頭元素插入到堆中,然後每次取出頭結點,取出來的元素屬於哪個子陣列,再新增這個子陣列的下一個元素進入堆中,來維護這個堆。

一般在編碼實現中無需重新構造堆結構,可直接使用庫中對應的 PriorityQueue優先佇列,將k個頭結點push進佇列,然後pop出頭結點,同時push進該頭結點對應子陣列的一個元素。以上就是演算法核心,其中push、pop操作都是O(logk)。

(3)緩衝區

但是,仍然存在一個問題:每個節點的記憶體可以分別容納資料,可是將所有節點歸併到一個節點時,又回到了10G,不可能全放入記憶體,到底在記憶體中放入多少?

這裡寫圖片描述

其實只需將每個節點的最小值放入記憶體即可,例如上圖中2,1,3,4放入記憶體,但是把最小值1拿掉之後需要補充一個元素,將外部記憶體的2拿到記憶體裡來,可是外部記憶體可能在硬碟或網路,此過程相比記憶體操作會很慢,不斷讀取外部記憶體效率很低,所以採用快取區,每次讀取k個節點前部分資料到快取區(幾k或幾M)。


4. 思考編碼實現

以上思想理解後,考慮發現其程式碼實現並不容易,首先要實現歸併演算法,還要維護PriorityQueue的資料結構,獲取資料的資料來源有記憶體、外部記憶體(硬碟)。

所以歸併通過Iterable介面實現,可實現以下功能:

  • 可以不斷獲取下一個元素的能力;
  • 元素儲存/獲取方式被抽象,與歸併節點無關;
  • Iterable merge(List< Iterable> soretedData);
  • Iterable是架與記憶體和檔案層次上;

這裡寫圖片描述

為了展示Iterable介面的功能強大,舉個例子Iterable.next()作用如下:

歸併資料來源來自 Iterable.next(),首先從每個節點呼叫Iterable<T>.next()獲取它們最小的元素,然後push到PriorityQueue中,完畢後不斷pop元素,同時補充上對應資料來源的後續元素。具體檢視Iterable<T>.next()操作:

  • 如果快取區空,讀取下一批元素放入快取區;
  • 給出快取區第一個元素;
  • 可配置項:快取區大小,如何讀取下一批元素;

優點:首先歸併節點的時候不需要考慮緩衝區的問題,只需要呼叫其next()方法,在可配置項中設定如何讀取,這樣歸併函式只需寫一次,可用在檔案上或網路上,主要實現被抽象出來。

5. 總結

將歸併排序擴充套件到外部排序的場景中,實現上有兩大重點:採用堆的資料結構(庫中的PriorityQueue)來實現歸併過程;使用Iterable介面實現資料來源的緩衝區。




二. 死鎖分析

多執行緒中最值得關注的是執行緒安全性,同一個資料在不同執行緒中被同時讀寫會出現問題,需要保證資料的安全性對其執行緒進行加鎖操作,但是鎖太多效率會降低,因此將鎖的範圍縮小,可是又會產生死鎖的問題。以一個常見的銀行取錢例子來分析防止死鎖。

void transfer(Account from, Account to, int amount){
    from.setAmount(from.getAmount() - amount);
    from.setAmount(to.getAmount() + amount);

}

一個很簡單的transfer函式,模擬銀行轉賬需求,此函式在單執行緒上絕對安全,但是在多執行緒下會出現問題,例如兩個人同時在這個賬號轉錢,可是此賬號只扣了一次錢,必然是不合理的,所說對其進行加鎖,如下程式碼:

void transfer(Account from, Account to, int amount){
    synchronized(from){
        synchronized(to){
            from.setAmount(from.getAmount() - amount);
            from.setAmount(to.getAmount() + amount);
        }
    }
}

將from、to加鎖,這樣第一個在操作賬戶轉錢結束之前,第二個人是無法操作的。synchronized是針對物件的,同一個賬戶不可多執行緒操作。但是以上程式碼是會產生死鎖的:

  • 在任何地方都可以執行緒切換,甚至在一句語句中間。

例如from.setAmount(from.getAmount() - amount);這樣一行程式碼,在from.getAmount() 地方或減號之後會斷掉,但是在上鎖之後即使斷掉,別的使用者也進不來。

  • 盡力設想最壞的情況

from物件被別人鎖住的,我們無法上鎖,這種情況是有利的,只需等待別的執行緒做完即可。但是我們剛鎖完from物件,別的執行緒就在等待,這才是不利的情況。那to物件同理嗎?不是!如果to物件被我們上鎖,這樣我們同時擁有兩個物件的鎖,可以進行操作了;而to物件的鎖被別的執行緒鎖了,我們需要等待,這才是不利的!

死鎖出現的場景

根據以上分析總結一下最壞的情況:

  • synchronized(from):別的執行緒在等待from物件;
  • synchronized(to):別的執行緒已經鎖住了to物件;

因此,可能出現死鎖的情況就是: transfer(a,b,100) 和 transfer(b,a,100)同時進行,這是對雙方都很不利的情況:左邊的搶走了a的鎖,右邊的搶走了b的鎖。

形成死鎖的條件

  • 互斥等待:說白了也就是要在有鎖的情況。
  • hold and wait:拿到一個鎖去等待另一個鎖的狀態,其實鎖是很珍貴的資源,最好得到鎖後儘快處理完畢將其釋放。
  • 迴圈等待:更槽糕的情況:例如執行緒1獲得鎖A在等待鎖B,而執行緒2獲取鎖B在等待鎖A。
  • 無法剝奪的等待:在出現迴圈等待情況後,有的鎖會出現超時後自動釋放,但是若是一直等待,則必定死鎖。

防止死鎖的辦法

若要避免死鎖,根據以上四個產生死鎖的原因,逐一破解即可:

  • 破除互斥等待:不可!鎖是保證執行緒安全的基本方法,無法實現。

  • 破除hold and wait:可以!最關鍵的一步,就是一次性獲取所有資源。例子中的from、to物件是分成兩步獲取的,從而會形成hold and wait情況,但是通常不允許同時鎖兩個物件,因此需要對程式碼做比較大的修改:

    • 暴露一個鎖名為getAmountLock,它是針對Amount的,from、to物件都可以getAmountLock,鎖的時候可以帶上一個短的超時,先鎖住from再鎖住to,當to鎖不住的時候,把from鎖放掉,過段時間再嘗試。
    • 或者在這兩行的外面加一個全域性的鎖,保證可以同時拿到這兩個鎖,拿到這兩個鎖之後再將全域性的鎖釋放掉。但是需要結合實際,銀行系統中Amount的量很大,全域性鎖未必好,第一個方案較好。
  • 破除迴圈等待:可以!按順序獲取資源。

    • 讓例子中的Amount之間有序,不要先synchronized物件from,再synchronized物件to,銀行中AmountID肯定是惟一值,所以定製一個規則先處理較小值,這樣即使同時互相轉賬,也不會出現死鎖情況。
  • 破除無法剝奪的等待:可以!加入超時。

    • 設定超時時間5秒或者其它,但此方法並不理想,因為超時需要時間等待,耗時長,使用者體驗差。

總結

根據以上的分析,也許你認為第四種加入超時措施相對簡單實現,但是如此一來不能使用synchronized,還要暴露一個鎖;第二種 from.getAmountLock()方法實現較複雜。

因此,第二種解決方法較好,即破除迴圈等待—–按順序獲取資源,出現併發時根據AmountID值先處理值較小的使用者,但是這並不是最好的解決方法,因為此解決方法重點為按順序獲取資源,而銀行賬戶中的ID順序性是我假設出來的,並非實際。

所以,最理想的解決方法還是破除hold and wait,就是一次性獲取所有資源!但是通常不允許同時鎖兩個物件,所以還是先鎖住A再鎖住B,當B鎖不住的時候,把A鎖放掉,過段時間再嘗試。

完美的解決辦法不存在!所以只能根據實際問題具體分析,選擇一個折中的辦法實現。




若有錯誤,虛心指教~

相關文章