第十章 線程和分布式系統
本章關注復雜軟件系統的構造。 本章關注復雜軟件系統的構造。 這里的“復雜”包括三方面: 這里的“復雜”包括三方面: (1)多線程程序 (2)分布式程序 (3) GUI 程序
Outline
- 並發編程
- Shared memory
- Message passing
- 進程和線程
- 線程的創建和啟動,runable
- 時間分片、交錯執行、競爭條件
- 線程的休眠、中斷
- 線程安全的四種策略
- 約束(Confinement)
- 不變性
- 使用線程安全的數據類型
- 同步與鎖
- 死鎖
- 以注釋的形式撰寫線程安全策略
Notes
## 並發編程
【並發(concurrency)】
- 定義:指的是多線程場景下對共享資源的爭奪運行
- 並發的應用背景:
- 網絡上的多台計算機
- 一台計算機上的多個應用
- 一個CPU上的多核處理器
- 為什么要有並發:
- 摩爾定律失效、“核”變得越來越多
- 為了充分利用多核和多處理器需要將程序轉化為並行執行
- 並發編程的兩種模式:
- 共享內存:在內存中讀寫共享數據
- 信息傳遞(Message Passing):通過channel交換消息
【共享內存】
- 共享內存這種方式比較常見,我們經常會設置一個共享變量,然后多個線程去操作同一個共享變量。從而達到線程通訊的目的。
- 例子:
- 兩個處理器,共享內存
- 同一台機器上的兩個程序,共享文件系統
- 同一個Java程序內的兩個線程,共享Java對象
【信息傳遞】
- 消息傳遞方式采取的是線程之間的直接通信,不同的線程之間通過顯式的發送消息來達到交互目的
- 接收方將收到的消息形成隊列逐一處理,消息發送者繼續發送(異步方式)
- 消息傳遞機制也無法解決競爭條件問題
- 仍然存在消息傳遞時間上的交錯
- 例子:
- 網絡上的兩台計算機,通過網絡連接通訊
- 瀏覽器和Web服務器,A請求頁面,B發送頁面數據給A
- 即時通訊軟件的客戶端和服務器
- 同一台計算機上的兩個程序,通過管道連接進行通訊
並發模型 | 通信機制 | 同步機制 |
共享內存 | 線程之間共享程序的公共狀態,線程之間通過寫-讀內存中的公共狀態來隱式進行通信。 |
同步是顯式進行的。程序員必須顯式指定某個方法或某段代碼需要在線程之間互斥執行。 |
消息傳遞 | 線程之間沒有公共狀態,線程之間必須通過明確的發送消息來顯式進行通信。 |
由於消息的發送必須在消息的接收之前,因此同步是隱式進行的。 |
## 進程和線程
- 進程:是執行中一段程序,即一旦程序被載入到內存中並准備執行,它就是一個進程。進程是表示資源分配的的基本概念,又是調度運行的基本單位,是系統中的並發執行的單位。
- 程序運行時在內存中分配自己獨立的運行空間
- 進程是資源分配的獨立單元
- 多進程之間不共享內存
- 進程之間通過消息傳遞進行協作
- 一般來說,進程==程序==應用(但一個應用中可能包含多個進程)
- OS支持的IPC機制(pipe/socket)支持進程間通信(IPC不僅是本機的多個進程之間, 也可以是不同機器的多個進程之間)
- JVM通常運行單一進程,但也可以創建新的進程。
- 線程:它是位於進程中,負責當前進程中的某個具備獨立運行資格的空間。
- 線程有自己的堆棧和局部變量,但是多個線程共享內存空間
- 進程=虛擬機;線程=虛擬CPU
- 程序共享、資源共享,都隸屬於進程
- 很難獲得線程私有的內存空間
- 線程需要同步:在改變對象時要保持lock狀態
- 清理線程是不安全的
- 進程是負責整個程序的運行,而線程是程序中具體的某個獨立功能的運行。
- 一個進程中至少應該有一個線程。
- 主線程可以創建其他的線程。
## 線程的創建和啟動
【方式1:繼承Thread類】
- 方法:用Thread類實現了Runnable接口,但它其中的run方法什么都沒做,所以用一個類做Thread的子類,提供它自己實現的run方法。用Thread.start()來開始一個新的線程。
- 創建:A類 a = new A類();
- 啟動: a.start();
- 步驟:
- 定義一個類A繼承於java.lang.Thread類.
- 在A類中覆蓋Thread類中的run方法.
- 我們在run方法中編寫需要執行的操作:run方法里的代碼,線程執行體.
- 在main方法(線程)中,創建線程對象,並啟動線程.
- 栗子:
1 //1):定義一個類A繼承於java.lang.Thread類. 2 class MusicThread extends Thread{ 3 //2):在A類中覆蓋Thread類中的run方法. 4 public void run() { 5 //3):在run方法中編寫需要執行的操作 6 for(int i = 0; i < 50; i ++){ 7 System.out.println("播放音樂"+i); 8 } 9 } 10 } 11 12 public class ExtendsThreadDemo { 13 public static void main(String[] args) { 14 15 for(int j = 0; j < 50; j ++){ 16 System.out.println("運行游戲"+j); 17 if(j == 10){ 18 //4):在main方法(線程)中,創建線程對象,並啟動線程. 19 MusicThread music = new MusicThread(); 20 music.start(); 21 } 22 } 23 } 25 }
- 創建:Thread t = new Thread(new A());
- 調用:t.start();
- 步驟:
- 定義一個類A實現於java.lang.Runnable接口,注意A類不是線程類.
- 在A類中覆蓋Runnable接口中的run方法.
- 我們在run方法中編寫需要執行的操作:run方法里的,線程執行體.
- 在main方法(線程)中,創建線程對象,並啟動線程.
1 //1):定義一個類A實現於java.lang.Runnable接口,注意A類不是線程類. 2 class MusicImplements implements Runnable{ 3 //2):在A類中覆蓋Runnable接口中的run方法. 4 public void run() { 5 //3):在run方法中編寫需要執行的操作 6 for(int i = 0; i < 50; i ++){ 7 System.out.println("播放音樂"+i); 8 } 9 10 } 11 } 12 13 public class ImplementsRunnableDemo { 14 public static void main(String[] args) { 15 for(int j = 0; j < 50; j ++){ 16 System.out.println("運行游戲"+j); 17 if(j == 10){ 18 //4):在main方法(線程)中,創建線程對象,並啟動線程 19 MusicImplements mi = new MusicImplements(); 20 Thread t = new Thread(mi); 21 t.start(); 22 } 23 } 24 }
- 實現Runnable接口相比繼承Thread類有如下好處:
- 避免點繼承的局限,一個類可以繼承多個接口。
- 適合於資源的共享
- 可以使用lamada表達式簡潔表示
- Java8特性,lamada:(parameters) -> expression 或 (parameters) ->{ statements; }
-
new Thread(()->{ for(int i = 0;i<10;i++) system.out.println("hello world"+ i ); }; ).start();
- 創建並運行一個線程所犯的常見錯誤是調用線程的 run()方法而非 start()方法,如下所示:
Thread newThread = new Thread(MyRunnable()); newThread.run(); //should be start();
起初並不會感覺到有什么不妥,因為 run()方法的確如你所願的被調用了。但是,事實上,run()方法並非是由剛創建的新線程所執行的,而是被創建新線程的當前線程所執行了。想要讓創建的新線程執行 run()方法,必須調用新線程的 start 方法。
## 時間分片、交錯執行、競爭條件
【時間分片】
- 雖然有多線程,但只有一個核,每個時刻只能執行一個線程。
- 通過時間分片,再多個線程/進程之間共享處理器
- 即使是多核CPU,進程/線程的數目也往往大於核的數目
- 通過時間分片,在多個進程/線程之間共享處理器。(時間分片是由OS自動調度的)
- 當線程數多於處理器數量時,並發性通過時間片來模擬,處理器切換處理不同的線程
【交錯執行】
顧名思義,就是說在線程運行的過程中,多個線程同時運行相互交錯。而且,由於線程運行一般不是連續的,那么就會導致線程間的交錯。可以說,所有線程安全問題的本質都是線程交錯的問題。
【競爭條件】
競爭是發生在線程交錯的基礎上的。當多個線程對同一對象進行讀寫訪問時,就可能會導致競爭的問題。程序中可能出現的一種問題就是,讀寫數據發生了不同步。例如,我要用一個數據,在該數據修改還沒寫回內存中時就讀取出來了,那么就會導致程序出現問題。
程序運行時有一種情況,就是程序如果要正確運行,必須保證A線程在B線程之前完成(正確性意味着程序運行滿足其規約)。當發生這種情況時,就可以說A與B發生競爭關系。
- 計算機運行過程中,並發、無序、大量的進程在使用有限、獨占、不可搶占的資源,由於進程無限,資源有限,產生矛盾,這種矛盾稱為競爭(Race)。
- 由於兩個或者多個進程競爭使用不能被同時訪問的資源,使得這些進程有可能因為時間上推進的先后原因而出現問題,這叫做競爭條件(Race Condition)。
- 競爭條件分為兩類:
-Mutex(互斥):兩個或多個進程彼此之間沒有內在的制約關系,但是由於要搶占使用某個臨界資源(不能被多個進程同時使用的資源,如打印機,變量)而產生制約關系。
-Synchronization(同步):兩個或多個進程彼此之間存在內在的制約關系(前一個進程執行完,其他的進程才能執行),如嚴格輪轉法。 - 解決互斥方法:
Busy Waiting(忙等待):等着但是不停的檢查測試,不睡覺,知道能進行為止
Sleep and Wakeup(睡眠與喚醒):引入Semapgore(信號量,包含整數和等待隊列,為進程睡覺而設置),喚醒由其他進程引發。 - 臨界區(Critical Region):
- 一段訪問臨界資源的代碼。
- 為了避免出現競爭條件,進入臨界區要遵循四條原則:
- 任何兩個進程不能同時進入訪問同一臨界資源的臨界區
- 進程的個數,CPU個數性能等都是無序的,隨機的
- 臨界區之外的進程不得阻塞其他進程進入臨界區
- 任何進程都不應被長期阻塞在臨界區之外
- 解決互斥的方法:
• 禁用中斷 Disabling interrupts
• 鎖變量 Lock variables (no)
• 嚴格輪轉 Strict alternation (no)
• Peterson’s solution (yes)
• The TSL instruction (yes)
## 線程的休眠、中斷
【Thread.sleep】
- 在線程中允許一個線程進行暫時的休眠,直接使用Thread.sleep()方法即可。
- 將某個線程休眠,意味着其他線程得到更多的執行機會
- 進入休眠的線程不會失去對現有monitor或鎖的所有權
- sleep定義格式:
public static void sleep(long milis,int nanos) throws InterruptedException
首先,static,說明可以由Thread類名稱調用,其次throws表示如果有異常要在調用此方法處處理異常。
所以sleep()方法要有InterruptedException 異常處理,而且sleep()調用方法通常為Thread.sleep(500) ;形式。
- 實例:
【Thread.interrupt】
- 一個線程可以被另一個線程中斷其操作的狀態,使用 interrupt() 方法完成。
- 通過線程的實例來調用interrupt()函數,向線程發出中斷信號
- t.interrupt():在其他線程里向t發出中斷信號
- t.isInterrupted():檢查t是否已在中斷狀態中
- 當某個線程被中斷后,一般來說應停止 其run()中的執行,取決於程序員在run()中處理
- 一般來說,線 程在收到中斷信號時應該中斷,直接終止
- 但是,線程收到其他線程發出來的中斷信號,並不意味着一定要“停止”
- 實例:
- 實例二:
package Thread1; class MyThread implements Runnable{ // 實現Runnable接口 public void run(){ // 覆寫run()方法 System.out.println("1、進入run()方法") ; try{ Thread.sleep(10000) ; // 線程休眠10秒 System.out.println("2、已經完成了休眠") ; }catch(InterruptedException e){ System.out.println("3、休眠被終止") ; return ; // 返回調用處 } System.out.println("4、run()方法正常結束") ; } }; public class demo1{ public static void main(String args[]){ MyThread mt = new MyThread() ; // 實例化Runnable子類對象 Thread t = new Thread(mt,"線程"); // 實例化Thread對象 t.start() ; // 啟動線程 try{ Thread.sleep(2000) ; // 線程休眠2秒 }catch(InterruptedException e){ System.out.println("3、休眠被終止") ; } t.interrupt() ; // 中斷線程執行 } };
運行結果:
1、進入run()方法 3、休眠被終止
## 線程安全的四個策略
- 線程安全的定義:ADT或方法在多線程中要執行正確,即無論如何執行,不許調度者做額外的協作,都能滿足正確性
- 四種線程安全的策略:
- Confinement 限制數據共享
- Immutability 共享不可變數據
- Threadsafe data type 共享線程安全的可變數據(ThreadLocal)
- Synchronization 同步機制共享共享線程 不安全的可變數據,對外即為線程安全的ADT.
【Confinement 限制數據共享】
- 核心思想:線程之間不共享mutable數據類型
- 將可變數據限制在單一線程內部,避免競爭
- 不允許任何縣城直接讀寫該數據
- 在多線程環境中,取消全局變量,盡量避免使用不安全的靜態變量。
- 限制數據共享主要是在線程內部使用局部變量,因為局部變量在每個函數的棧內,每個函數都有自己的棧結構,互不影響,這樣局部變量之間也互不影響。
- 如果局部變量是一個指向對象的引用,那么就需要檢查該對象是否被限制住,如果沒有被限制住(即可以被其他線程所訪問),那么就沒有限制住數據,因此也就不能用這種方法來保證線程安全
- 栗子:
public class Factorial { /** * Computes n! and prints it on standard output. * @param n must be >= 0 */ private static void computeFact(final int n) { BigInteger result = new BigInteger("1"); for (int i = 1; i <= n; ++i) { System.out.println("working on fact " + n); result = result.multiply(new BigInteger(String.valueOf(i))); } System.out.println("fact(" + n + ") = " + result); } public static void main(String[] args) { new Thread(new Runnable() { // create a thread using an public void run() { // anonymous Runnable computeFact(99); } }).start(); computeFact(100); } }
解釋:主函數開啟了兩個線程,調用的是相同函數。因為線程共享局部變量的類型,但每個函數調用有不同的棧,因此有不同的i,n,result。由於每個函數都有自己的局部變量,那么每個函數就可以獨立運行,更新它們自己的函數值,線程之間不影響結果。
【Immutability 共享不可變數據】
不可變數據類型,指那些在整個程序運行過程中,指向內存的引用是一直不變的,通常使用final來修飾。不可變數據類型通常來講是線程安全的,但也可能發生意外。
但是,程序在運行過程中,有時為了優化程序結構,默默地將這個引用更改了。此時,客戶端程序員是不知道它被更改了,對於客戶端而言,這個引用還是不可變的,但其實已經被悄悄更改了。這時就會發生一些線程安全問題。
解決方案就是給這些不可變數據類型再增加一些限制:
- 所有的方法和屬性都是私有的。
- 不提供可變的方法,即不對外開放可以更改內部屬性的方法。
- 沒有數據的泄露,即返回值而不是引用。
- 不在其中存儲可變數據對象。
這樣就可以保證線程的安全了。
【Threadsafe data type(共享線程安全的可變數據)】
- 方法:如果必須要用mutable的數據類型在多線程之間共享數據,要使用線程安全的數據類型。(在JDK中的類,文檔中明確指明了是否threadsafe)
- 一般來說,JDK同時提供兩個相同功能的類,一個是threadsafe,另一個不是。原因:threadsafe的類一般性能上受影響。
- List、Set、Map這些集合類都是線程不安全的,Java API為這些集合類提供了進一步的decorator
private static Map<Integer,Boolean> cache = Collections.synchronizedMap(new HashMap<>()); public static <T> Collection<T> synchronizedCollection(Collection<T> c); public static <T> Set<T> synchronizedSet(Set<T> s); public static <T> List<T> synchronizedList(List<T> list); public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m); public static <T> SortedSet<T> synchronizedSortedSet(SortedSet<T> s); public static <K,V> SortedMap<K,V> synchronizedSortedMap(SortedMap<K,V> m);
- ***在使用synchronizedMap(hashMap)之后,不要再把參數hashMap共享給其他線程,不要保留別名,一定要徹底銷毀.(可以用private static Map cache =Collections.synchronizedMap(new HashMap<>());的方式實例化集合類)
- 即使在線程安全的集合類上,使用iterator也 是不安全的:
List<Type> c = Collections.synchronizedList(new ArrayList<Type>()); synchronized(c) { // to be introduced later (the 4-th threadsafe way) for (Type e : c) foo(e); }
- 需要注意用java提供的包裝類包裝集合后,只是將集合的每個操作都看成了原子操作,也就保證了每個操作內部的正確性,但是在兩個操作之間不能保證集合類不被修改,因此需要用lock機制,例如
如果在isEmpty和get中間,將元素移除,也就產生了競爭。
前三種策略的核心思想:避免共享 --> 即使共享,也只能讀/不可寫(immutable) -->即使可寫(mutable),共享的可寫數據應自己具備在多線程之間協調的能力,即“使用線程安全的mutable ADT”
【Synchronization 同步與鎖】
- 為什么要同步
-
java允許多線程並發控制,當多個線程同時操作一個可共享的資源變量時(如數據的增刪改查)
-
將會導致數據不准確,相互之間產生沖突,因此加入同步鎖以避免在該線程沒有完成操作之前,被其他線程的調用,
-
從而保證了該變量的唯一性和准確性。
-
- 同步方法
-
即有synchronized關鍵字修飾的方法。
-
由於java的每個對象都有一個內置鎖,當用此關鍵字修飾方法時,內置鎖會保護整個方法。
- 在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。
- 代碼如下:
public synchronized void save(){}
- 注: synchronized關鍵字也可以修飾靜態方法,此時如果調用該靜態方法,將會鎖住整個類
-
- 同步代碼塊
- 在調用該方法前,需要獲得內置鎖,否則就處於阻塞狀態。
- 被該關鍵字修飾的語句塊會自動被加上內置鎖,從而實現同步。
- 代碼如:
synchronized(object){ }
-
注:同步是一種高開銷的操作,因此應該盡量減少同步的內容。
- 使用鎖機制,獲得對數據的獨家mutation權,其他線程被阻塞,不得訪問
- Lock是Java語言提供的內嵌機制,每個object都有相關聯的lock
- 任何共享的mutable變量/對象必須被lock所保護
- 涉及到多個mutable變量的時候,它們必須被同一個lock所保護
## 死鎖
- 定義:兩個或多個線程相互等待對方釋放鎖,則會出現死鎖現象。
- java虛擬機沒有檢測,也沒有采用措施來處理死鎖情況,所以多線程編程是應該采取措施避免死鎖的出現。一旦出現死鎖,整個程序即不會發生任何異常,也不會給出任何提示,只是所有線程都處於堵塞狀態。
- 形成死鎖的條件:
- 互斥條件:線程使用的資源必須至少有一個是不能共享的(至少有鎖);
- 請求與保持條件:至少有一個線程必須持有一個資源並且正在等待獲取一個當前被其它線程持有的資源(至少兩個線程持有不同鎖,又在等待對方持有鎖);
- 非剝奪條件:分配資源不能從相應的線程中被強制剝奪(不能強行獲取被其他線程持有鎖);
- 循環等待條件:第一個線程等待其它線程,后者又在等待第一個線程(線程A等線程B;線程B等線程C;...;線程N等線程A。如此形成環路)。
- 防止死鎖的方法:
- 加鎖順序:當多個線程需要相同的一些鎖,但是按照不同的順序加鎖,死鎖就很容易發生。如果能確保所有的線程都是按照相同的順序獲得鎖,那么死鎖就不會發生。這種方式是一種有效的死鎖預防機制。但是,這種方式需要你事先知道所有可能會用到的鎖,但總有些時候是無法預知的
-
- 使用粗粒度的鎖,用單個鎖來監控多個對象
- 對整個社交網 絡設置 一個鎖 ,並且對其任何組成部分的所有操作都在該鎖上進行同步。
- 例如:所有的Wizards都屬於一個Castle, 可使用 castle 實例的鎖
- 如果用一個鎖保護大量的可變數據,那么久放棄了同時訪問這些數據的能力;
- 在最糟糕的情況下,程序可能基本上是順序執行的,喪失了並發性
- 使用粗粒度的鎖,用單個鎖來監控多個對象
-
- 加鎖時限:在嘗試獲取鎖的時候加一個超時時間,這也就意味着在嘗試獲取鎖的過程中若超過了這個時限該線程則放棄對該鎖請求。若一個線程沒有在給定的時限內成功獲得所有需要的鎖,則會進行回退並釋放所有已經獲得的鎖。
- 用 jstack 等工具進行死鎖檢測
## 以注釋的形式撰寫線程安全策略
- 在代碼中以注釋的形式添加說明:該ADT采取了什么設計決策來保證線程安全
- 闡述如何使rep線程安全;
- 寫入表示不變性的說明中,以便代碼維護者知道你是如何為類設計線程安全性的。
- 需要對安全性進行這種仔細的論證,闡述使用了哪種技術,使用threadsafe data types, or synchronization時,需要論證所有對數據的訪問都是具有原子性的
- 栗子:
- 反例
- 字符串是不可變的並且是線程安全的; 但是指向該字符串的rep,特別是文本變量,並不是不可變的;
- 文本不是最終變量,因為我們需要數據類型來支持插入和刪除操作;
- 因此讀取和寫入文本變量本身不是線程安全的。