使用多線程編程來實現並發時,需要考慮並發所帶來的哪些風險呢?

並發與並行

分布式系統的一個重要特徵就是計算能力是可以並發或者並行的。

在分布式系統中,往往會將一個大任務進行分解,而後下發給不同的節點去計算,從而節省整個任務的計算時間。

並發與並行的區別

計算機用戶很容易認為他們的系統在一段時間內可以做多件事。比如,用戶一邊用瀏覽器下載視頻文件,一邊可以繼續在瀏覽器上瀏覽網頁。可以做這樣的事情的軟體被稱為並發軟體(ConcurrentSoftware)。

計算機能實現多個程序的同時執行,主要基於以下因素。

·資源利用率。某些情況下,程序必須等待其他外部的某個操作完成,才能往下繼續執行,而在等待的過程中,該程序無法執行其他任何工作。因此,如果在等待的同時可以運行另外一個程序,無疑將提高資源的利用率。

·公平性。不同的用戶和程序對於計算機上的資源有著同等的使用權。一種高效的運行方式是粗粒度的時間分片(Time Slicing)使這些用戶和程序能共享計算機資源,而不是一個程序從頭運行到尾,然後再啟動下一個程序。

·便利性。通常來說,在計算多個任務時,應該編寫多個程序,每個程序執行一個任務並在必要時相互通信,這比只編寫一個程序來計算所有任務更加容易實現。

那麼並發與並行到底是如何區別的呢?

The Practice of Programming一書的作者Rob Pike對並發與並行做了如下描述。

並發是同一時間應對(Dealing With)多件事情的能力;並行是同一時間動手做(Doing)多件事情的能力。並發(Concurrency)屬於問題域(Problem Domain),並行(Parallelism)屬於解決域(SolutionDomain)。並行和並發的區別在於有無狀態,並行計算適合無狀態應用,而並發解決的是有狀態的高性能;有狀態要著力解決並發計算,無狀態要著力並行計算,雲計算要能做到這兩種計算自動伸縮擴展。

上述的描述貌似有點抽象。舉一個生活中的例子,某些人工作很忙,那麼會請鐘點工來打掃衛生。一個鐘點工在一小時可以幫你掃地、擦桌子、洗菜、做飯。客戶並不關心哪件活兒先干哪件活兒後做,客戶所關心的是,付費的這一小時,需要看到所有活兒都完成。在客戶看來,這一小時內,所有的活兒都是一個鐘點工乾的,這就是並發。

再舉一例子,客戶的親戚要來看望客戶,還有不到一小時親戚就要登門了。那麼平時要花一小時才能做完的家務,如何才能提前做完呢?

答案是加人。多請幾個鐘點工來一起做事,比如某個鐘點工專門掃地,某人專門抹桌子,某人專門負責洗菜、做飯。這樣3人同時開工,就能縮短整體的時間,這就是並行。

從計算機的角度來說,單個CPU是需要被某個任務獨占的,就如同鐘點工,在掃地的同時不能做擦桌子的動作。如果她想去擦桌子,就需要將手頭的掃地任務先停下來。當然,由於多個任務是不斷切換的,因此,在外界看來,就有了「同時」執行多個任務的錯覺。

現代的計算機大多是多核的,因此,多個CPU同時執行任務,就實現了任務的並行。

使用多線程編程來實現並發時,需要考慮並發所帶來的哪些風險呢?

線程與並發

早期的分時系統中,每個進程以串行方式執行指令,並通過一組I/O指令來與外部設備通信。每條被執行的指令都有相應的「下一條指令」,程序中的控制流就是按照指令集的規則來確定的。

串行編程模型的優勢是直觀性和簡單性,因為它模仿了人類的工作方式:每次只做一件事,做完再做其他事情。例如,早上起床,先穿衣,然後下樓,吃早飯。在程式語言中,這些現實世界的動作可以進一步被抽象為一組粒度更細的動作。例如,喝茶的動作可以被細化為:打開櫥櫃,挑選茶葉,將茶葉倒入杯中,查看茶壺的水是否夠,不夠要加水,將茶壺放在火爐上,點燃火爐,然後等水煮沸,等等。在等水煮沸這個過程中包含了一定程序的異步性。例如,在燒水過程中,你可以一直等,也可以做其他事情,比如開始烤麵包,或者看報紙(這就是另一個異步任務),同時留意水是否煮沸了。但凡做事高效的人,總能在串行性和異步性之間找到合理的平衡,程序也是如此。

線程允許在同一個進程中同時存在多個線程控制流。線程會共享進程範圍內的資源,例如記憶體句柄和文件句柄,但每個線程都有各自的程序計數器、棧以及局部變量。線程還提供了一種直觀的分解模式來充分利用作業系統中的硬體並行性,而在同一個程序中的多個線程也可以被同時調度到多個CPU上運行。

毫無疑問,多線程編程使得程序任務並發成為可能。而並發控制主要是為了解決多個線程之間資源爭奪等問題。並發一般發生在數據聚合的地方,只要有聚合,就有爭奪發生,傳統解決爭奪的方式是採取線程鎖機制,這是強行對CPU管理線程進行人為干預,線程喚醒成本高,新的無鎖並發策略來源於異步編程、非阻塞I/O等編程模型。

並髮帶來的風險

多線程並發會帶來以下問題。

·安全性問題。在沒有充足同步的情況下,多個線程中的操作執行順序是不可預測的,甚至會產生奇怪的結果。線程間的通信主要是通過共享訪問欄位及其欄位所引用的對象來實現的。這種形式的通信是非常有效的,但可能導致兩種錯誤:線程干擾(Thread Interference)和記憶體一致性錯誤(Memory Consistency Errors)。

·活躍度問題。一個並行應用程式的及時執行能力被稱為它的活躍度(Liveness)。安全性的含義是「永遠不發生糟糕的事情」,而活躍度則關注另外一個目標,即「某件正確的事情最終會發生」。當某個操作無法繼續執行下去,就會發生活躍度問題。在串行程序中,活躍度問題形式之一就是無意中造成的無限循環(死循環)。而在多線程程序中,常見的活躍度問題主要有死鎖、飢餓以及活鎖。

·性能問題。在設計良好的並發應用程式中,線程能提升程序的性能,但無論如何,線程總是帶來某種程度的運行時開銷。而這種開銷主要是在線程調度器臨時關閉活躍線程並轉而運行另外一個線程的上下文切換操作(Context Switch)上,因為執行上下文切換,需要保存和恢復執行上下文,丟失局部性,並且CPU時間將更多地花在線程調度而不是在線程運行上。當線程共享數據時,必須使用同步機制,而這些機制往往會抑制某些編譯器優化,使記憶體緩存區中的數據無效,以及增加貢獻記憶體總線的同步流量。所以這些因素都會帶來額外的性能開銷。

死鎖

死鎖(Deadlock)是指兩個或兩個以上的線程永遠被阻塞,一直等待對方的資源。下面是一個用Java編寫的死鎖的例子。

Alphonse和Gaston是朋友,都很有禮貌。禮貌的一個嚴格的規則是,當你給一個朋友鞠躬時,你必須保持鞠躬,直到你的朋友回給你鞠躬。不幸的是,這條規則有個缺陷,那就是如果兩個朋友同一時間向對方鞠躬,那就永遠不會完了。這個示例應用程式中,死鎖模型如下。

package com.waylau.java.demo.concurrency;
public class Deadlock {
public static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend gaston = new Friend("Gaston");
new Thread(new Runnable() {
public void run() {
alphonse.bow(gaston);
}
}).start();
new Thread(new Runnable() {
public void run() {
gaston.bow(alphonse);
}
}).start();
}
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s" + " has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s" + " has bowed back to me!%n",
this.name, bower.getName());
}
}
}

當它們嘗試調用bowBack時兩個線程將被阻塞。無論是哪個線程,都永遠不會結束,因為每個線程都在等待對方鞠躬。這就是死鎖了。

本節示例,可以在java-concurrency項目下找到。

飢餓

飢餓(Starvation)描述了一個線程由於訪問足夠的共享資源而不能執行程序的現象。這種情況一般出現在共享資源被某些「貪婪」線程占用,而導致資源長時間不被其他線程可用。例如,假設一個對象提供一個同步的方法,往往需要很長時間返回。如果一個線程頻繁調用該方法,其他線程若也需要頻繁地同步訪問同一個對象則通常會被阻塞。

活鎖

一個線程常常處於響應另一個線程的動作,如果其他線程也常常響應該線程的動作,那麼就可能出現活鎖(Livelock)。與死鎖的線程一樣,程序無法進一步執行。然而,線程是不會阻塞的,它們只是會忙於應對彼此的恢復工作。現實中的例子是,兩人面對面試圖通過一條走廊:Alphonse移動到他的左側給Gaston讓路,而Gaston移動到他的右側想讓Alphonse過去,兩個人同時讓路,但其實兩人都擋住了對方,他們仍然彼此阻塞。

下節將介紹幾種解決並發風險的常用方法。

解決並發風險

同步(Synchronization)和原子訪問(Atomic Access),是解決並發風險的兩種重要方式。

同步

同步是避免線程干擾和記憶體一致性錯誤的常用手段。下面就用Java來演示這幾種問題,以及如何用同步解決這類問題。

1.線程干擾

下面描述當多個線程訪問共享數據時錯誤是如何出現的。考慮下面的一個簡單的類Counter。

public class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}

其中的increment方法用來對c加1;decrement方法用來對c減1。然而,多個線程中都存在對某個Counter對象的引用,那麼線程間的干擾就可能導致出現我們不想要的結果。

線程間的干擾出現在多個線程對同一個數據進行多個操作的時候,也就是出現了「交錯(Interleave)」。這就意味著操作是由多個步驟構成的,而此時,在這多個步驟的執行上出現了疊加。Counter類對象的操作貌似不可能出現這種「交錯」,因為其中的兩個關於c的操作都很簡單,只有一條語句。然而,即使是一條語句也會被虛擬機翻譯成多個步驟,在這里,我們不深究虛擬機具體將上面的操作翻譯成了什麼樣的步驟。只需要知道即使簡單的像c++這樣的表達式也會被翻譯成3個步驟。

(1)獲取c的當前值。

(2)對其當前值加1。

(3)將增加後的值存儲到c中。

表達式c–也會被按照同樣的方式進行翻譯,只不過第二步變成了減1,而不是加1。

假定線程A中調用increment方法,線程B中調用decrement方法,而調用時間基本相同。如果c的初始值為0,那麼這兩個操作的「交錯」順序可能如下。

(1)線程A:獲取c的值。

(2)線程B:獲取c的值。

(3)線程A:對獲取到的值加1,其結果是1。

(4)線程B:對獲取到的值減1,其結果是-1。

(5)線程A:將結果存儲到c中,此時c的值是1。

(6)線程B:將結果存儲到c中,此時c的值是-1。

這樣線程A計算的值就丟失了,也就是被線程B的值覆蓋了。上面的這種「交錯」只是其中的一種可能性。在不同的系統環境中,有可能是線程B的結果丟失了,或者是根本就不會出現錯誤。由於這種「交錯」是不可預測的,線程間相互干擾造成的Bug是很難定位和修改的。

2.記憶體一致性錯誤

下面介紹通過共享記憶體出現的不一致的錯誤。

記憶體一致性錯誤發生在不同線程對同一數據產生不同的「看法」。導致記憶體一致性錯誤的原因很複雜,超出了本書的描述範圍。慶幸的是,程式設計師並不需要知道出現這些原因的細節,我們需要的是一種可以避免這種錯誤的方法。

避免出現記憶體一致性錯誤的關鍵在於理解happens-before關係。這種關係是一種簡單的方法,能夠確保一條語句中對記憶體的寫操作對於其他特定的語句都是可見的。為了理解這點,我們可以考慮如下的示例。假設定義了一個簡單的int類型的欄位並對其進行初始化。

int counter = 0;

該欄位由兩個線程共享:A和B。假定線程A對counter進行了自增操作。

counter ++;

然後,線程B輸出counter的值。

System.out.println(counter);

如果以上兩條語句是在同一個線程中執行的,那麼輸出的結果自然是1。但是如果這兩條語句是在兩個不同的線程中,那麼輸出的結果有可能是0。這是因為沒有保證線程A對counter的修改操作對線程B來說是可見的,除非程式設計師在這兩條語句間建立了一定的happens-before關係。

我們可以採取多種方式建立這種happens-before關係。使用同步就是其中之一。到目前為止,我們已經看到了兩種建立這種happens-before的方式。

·當一條語句中調用了Thread.start方法,那麼每一條和該語句已經建立了happens-before關係的語句都和新線程中的每一條語句有這種happens-before關係。引入並創建這個新線程的代碼產生的結果對該新線程來說都是可見的。

·當一個線程終止了並導致另外的線程中調用Thread.join的語句返回時,這個終止了的線程中執行了的所有語句都與隨後的join語句中的所有語句建立了這種happens-before關係。也就是說,終止了的線程中的代碼效果對調用join方法的線程來說是可見的。

3.同步方法

Java程式語言中提供了兩種基本的同步用語:同步方法(Synchronized Method)和同步語句(Synchronized Statement)。同步語句相對而言更為複雜,本節重點討論同步方法。我們只需要在聲明方法的時候增加關鍵字synchronized。

public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}

如果count是SynchronizedCounter類的實例,設置其方法為同步方法會有兩個效果。

·首先,不可能出現對同一對象的同步方法的兩個調用的「交錯」。

當一個線程在執行一個對象的同步方法的時候,其他所有調用該對象的同步方法的線程都會被「掛起」,直到第一個線程對該對象操作完畢。

·其次,當一個同步方法退出時,會自動與該對象的同步方法的後續調用建立happens-before關係。這就確保了對該對象的修改對於其他線程是可見的。同步方法是一種簡單的、可以避免線程相互干擾和記憶體一致性錯誤的策略:如果一個對象對多個線程都是可見的,那麼所有對該對象的變量的讀寫都應該是通過同步方法完成的(一個例外就是final欄位,它在對象創建完成後是不能被修改的。因此,在對象創建完畢後,可以通過非同步的方法對其進行安全的讀取)。這種策略是有效的,但是可能導致「活躍度問題」。這點我們會在後面進行描述。

4.內部鎖和同步

同步是構建在被稱為「內部鎖(Intrinsic Lock)」或者是「監視鎖(Monitor Lock)」的內部實體上的。在API中通常被稱為「監視器(Monitor)」。內部鎖在兩個方面都扮演著重要的角色:保證對對象狀態訪問的排他性,建立對象可見性相關的happens-before關係。每一個對象都有一個與之相關聯的內部鎖。按照傳統的做法,當一個線程需要對一個對象的欄位進行排他性訪問並保持訪問的一致性時,它必須在訪問前先獲取該對象的內部鎖,然後才能訪問,最後釋放該內部鎖。在線程獲取對象的內部鎖到釋放對象的內部鎖的這段時間,我們說該線程擁有該對象的內部鎖。只要有一個線程已經擁有了一個內部鎖,其他線程就不能再擁有該鎖了,其他線程在試圖獲取該鎖的時候會被阻塞。當一個線程釋放了一個內部鎖,那麼就會建立起該動作和後續獲取該鎖之間的happens-before關係。

5.同步方法中的鎖

當一個線程調用一個同步方法的時候,它就自動地獲得了該方法所屬對象的內部鎖,並在方法返回的時候釋放該鎖。即使由於出現了沒有被捕獲的異常而導致方法返回,該鎖也會被釋放。

我們可能會感到疑惑:當調用一個靜態的同步方法的時候會怎樣?

靜態方法是和類相關的,而不是和對象相關的。在這種情況下,線程獲取的是該類的類對象的內部鎖。這樣對於靜態欄位的方法來說,這是由和類的實例的鎖相區別的另外的一個鎖來進行操作的。

6.同步語句

另外一種創建同步代碼的方式就是使用同步語句。和同步方法不同,使用同步語句必須指明要使用哪個對象的內部鎖。

public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}

在上面的示例中,方法addName需要對lastName和nameCount的修改進行同步,還要避免同步調用其他對象的方法(在同步代碼段中調用其他對象的方法可能導致出現「活躍度」中描述的問題)。如果沒有使用同步語句,那麼將不得不使用一個單獨、未同步的方法來完成對nameList.add的調用。

在改善並發性時,巧妙地使用同步語句能起到很大的幫助作用。例如,我們假定類MsLunch有兩個實例欄位,c1和c2,這兩個變量絕不會一起使用。所有對這兩個變量的更新都需要進行同步。但是沒有理由阻止對c1的更新和對c2的更新出現交錯——這樣做會創建不必要的阻塞,進而降低並發性。此時,我們沒有使用同步方法或者使用和this相關的鎖,而是創建了兩個單獨的對象來提供鎖。

public class MsLunch {
private long c1 = 0;
private long c2 = 0;
private Object lock1 = new Object();
private Object lock2 = new Object();
public void inc1() {
synchronized(lock1) {
c1++;}
}
public void inc2() {
synchronized(lock2) {
c2++;
}
}
}

採用這種方式時需要特別小心,我們必須確保相關欄位的訪問交錯是完全安全的。

7.重入同步

回憶前面提到的:線程不能獲取已經被別的線程獲取的鎖。但是線程可以獲取自身已經擁有的鎖。允許一個線程能重複獲得同一個鎖就稱為重入同步(Reentrant Synchronization)。它是這樣的一種情況:在同步代碼中直接或者間接地調用了還有同步代碼的方法,兩個同步代碼段中使用的是同一個鎖。如果沒有重入同步,在編寫同步代碼時需要額外小心,以避免線程將自己阻塞。

原子訪問

下面介紹另外一種可以避免被其他線程干擾的做法的總體思路——原子訪問。

在編程中,原子性動作就是指一次性有效完成的動作。原子性動作是不能在中間停止的:要麼一次性完全執行完畢,要麼就不執行。在動作沒有執行完畢之前,是不會產生可見結果的。

通過前面的示例,我們已經發現了諸如c++這樣的自增表達式並不屬於原子性動作。即使是非常簡單的表達式也包含了複雜的動作,這些動作可以被解釋成許多別的動作。然而,的確存在一些原子性動作。

·對幾乎所有的原生數據類型變量(除了long和double)的讀寫以及引用變量的讀寫都是原子的。

·對所有聲明為volatile的變量的讀寫都是原子的,包括long和double類型。

原子性動作是不會出現交錯的,因此,使用這些原子性動作時不用考慮線程間的干擾。然而,這並不意味著可以移除對原子性動作的同步,因為記憶體一致性錯誤還是有可能出現的。使用volatile變量可以降低記憶體一致性錯誤的風險,因為任何對volatile變量的寫操作都和後續對該變量的讀操作建立了happens-before關係。這就意味著對volatile類型變量的修改對於別的線程來說是可見的。更重要的是,這意味著當一個線程讀取一個volatile類型的變量時,它看到的不僅僅是對該變量的最後一次修改,還看到了導致這種修改的代碼帶來的其他影響。

使用簡單的原子變量訪問比通過同步代碼來訪問變量更高效,但是需要程式設計師更多細心的考慮,以避免出現記憶體一致性錯誤。這種額外的付出是否值得完全取決於應用程式的大小和複雜度。

提升系統並發能力

除了使用多線程外,還有以下方式可以提升系統的並發能力。

無鎖化設計提升並發能力

加鎖是為了避免在並發環境下,同時訪問共享資源產生的風險問題。那麼,在並發環境下,必須加鎖嗎?答案是否定的。並非所有的並發都需要加鎖。適當降低鎖的粒度,甚至是採用無鎖化的設計,更能提升並發能力。

比如,JDK中的ConcurrentHashMap,巧妙採用了桶粒度的鎖,避免了put和get中對整個map的鎖定,尤其在get中,只對一個HashEntry做鎖定操作,性能提升是顯而易見的。

又比如,程序中可以合理考慮業務數據的隔離性,實現無鎖化的並發。比如,程序中預計會有2個並發任務,那麼每個任務可以對所需要處理的數據進行分組,任務1去處理尾數為0~4的業務數據,任務2處理尾數為5~9的業務數據。那麼,這兩個並發任務所要處理的數據,就是天然隔離的,也就無須加鎖。

緩存提升並發能力

有時,為了提升整個網站的性能,我們會將經常需要訪問的數據緩存起來,這樣,在下次查詢的時候,能快速地找到這些數據。緩存系統往往有著比傳統的數據存儲設備(如關係型資料庫)更快的訪問速度。

緩存的使用與系統的時效性有著非常大的關係。當我們的系統時效性要求不高時,選擇使用緩存是極好的。當系統要求的時效性比較高時,則並不適合用緩存。在第14章中,我們還將詳細探討緩存的應用。

更細顆粒度的並發單元

在前面章節中,我們也討論了線程是作業系統內核級別最小的並發單元。雖然與進程相比,創建線程的開銷要小很多,但當在高並發場景下,創建大量的線程仍然會耗費系統大量的資源。為此,某些程式語言提供了更細顆粒度的並發單元,比如纖程,類似於Golang的goroutine、Erlang風格的actor。與線程相比,纖程可以輕鬆實現百萬級的並發量,而且占用更加少的硬體資源。

Java雖然沒有定義纖程,但仍有一些第三方庫可供選擇,比如Quasar。讀者有興趣的話,可以參閱Quasar在線手冊。

本文給大家講解的內容是使用多線程編程來實現並發時,需要考慮並發所帶來的哪些風險

  1. 下篇文章給大家講解的是分布式系統核心:面向對象的分布式架構;
  2. 覺得文章不錯的朋友可以轉發此文關注小編;
  3. 感謝大家的支持!
來源:kknews使用多線程編程來實現並發時,需要考慮並發所帶來的哪些風險呢?