[軟件構造] 08 Java併發學習1
軟件構造課程的第7章(併發和分佈式編程)是關於併發、線程、線程安全、鎖、同步等知識的內容,因爲之前沒有編寫過多線程的程序,所以這幾周閱讀了一些關於Java併發的內容(Java編程思想的第21章,MIT 6.031 2019Fall的Reading 19、20、21),希望通過這篇文章較詳細地總結一下Java中關於併發的基礎部分,在下一篇文章中再總結一些較難理解的高級部分。
文章目錄
1. 併發
第一次接觸併發是在上計算機系統課的時候,當時對併發的定義就是在時間上重疊的邏輯控制流。如硬件的異常處理程序,進程,信號處理等等,在最後還有一章專門來討論併發編程。
而軟件構造這門課對併發的定義是在同一時間發生的多個計算,其實大體的含義都是差不多的。但需要注意併發與並行的區別,並行是指兩個或者多個事件在同一時刻發生;而併發是指兩個或多個事件在同一時間間隔內發生。對於單(核)處理器只能夠實現併發,提供一種併發執行的假象,而對於多(核)處理器則可以將線程分配給不同的處理器,從而實現真正的並行執行。
2. 兩種併發通訊模型
下面是兩種經典的併發編程中的併發通訊模型
Shared memory共享內存
併發的模塊通過讀寫內存中的共享對象來實現通訊。
對於Java的併發線程來說,在這種模型中,不同的線程之間沒有直接的聯繫,都是通過二者之間的共享對象這個"中間人"來實現相互通訊。當多個線程同時對某一個共享對象進行讀寫操作時,就必須要考慮共享對象的同步問題,這也是共享內存模型容易出錯的原因。
Message passing消息傳遞
併發的模塊通過在信道上互相發送消息來實現通訊。
模塊之間相互發送消息,而發送到每個模塊的消息排隊等待處理。而應用消息傳遞比較有名的模型之一就是actor模型。
3. 進程與線程的概念
進程:一個運行中的程序的實例。
Recall:計算機系統 CSAPP
進程提供給應用程序兩個關鍵的抽象。
- 獨立的邏輯控制流:提供一種假象,好像我們的程序在獨佔地使用處理器,通過進程之間的上下文切換來實現。
- 私有的地址空間:提供一種假象,好像我們的程序在獨佔地使用內存,通過虛擬內存來實現。
因而進程的抽象是一臺虛擬的計算機,它使得我們的程序感覺自己獨佔地擁有整個的處理器和內存去運行。進程之間一般是不共享內存的,因而進程之間的通訊通常採用的是消息傳遞的模型(IPC機制)。
線程:運行在進程上下文中的一條順序的邏輯控制流。
Recall:計算機系統 CSAPP
現代操作系統允許我們編寫一個進程裏同時運行多個線程的程序。線程由內核自動調度。每個線程都有自己的線程上下文,包括一個唯一的整數線程ID、棧、棧指針、程序計數器、通用目的寄存器和條件碼。
所有運行在一個進程裏的線程共享該進程的整個虛擬地址空間,其中包括進程的代碼、數據、堆、共享庫和打開的文件。
因而線程的抽象是一臺虛擬計算機中的一個虛擬的處理器,它和同一臺虛擬計算機中的所有虛擬處理器一樣,都運行着相同的程序,共享着相同的內存。因此線程之間通常採用的是共享內存的模型,但有時候顯式地設立消息傳遞模型也是必要的。
4. Java中編寫多線程程序
Java的Thread類官方API的spec指出了創建一個新的線程的兩種方式:
- 實例化Thread類的一個重寫run()方法的子類
下面是我在學習過程中編寫的一個簡單的測試程序及某次的運行結果,能夠清晰地看出併發的執行效果。
public class ThreadTest {
public static void main(String[] args) {
MyThread myThread=new MyThread();
myThread.start();
for(int i=0;i<5;i++) {
System.out.println("main ----> "+i);
}
}
}
class MyThread extends Thread{
@Override public void run() {
for(int i=0;i<5;i++) {
System.out.println("run ----> "+i);
}
}
}
// result:
// main ----> 0
// main ----> 1
// run ----> 0
// main ----> 2
// run ----> 1
// main ----> 3
// run ----> 2
// main ----> 4
// run ----> 3
// run ----> 4
- 通過Runnable接口構造一個新的Thread
這種方式是一種Use-a依賴型的委託方式,將Thread的run()方法的代碼實現委託給了Runnable的run()方法。
public class ThreadTest2 {
public static void main(String[] args) {
Thread myThread=new Thread(new Runnable() {
public void run() {
for(int i=0;i<5;i++) {
System.out.println("run ----> "+i);
}
}
});
myThread.start();
for(int i=0;i<5;i++) {
System.out.println("main ----> "+i);
}
}
}
5. 時間分片,交織與競爭條件
時間分片(Time Slicing)
當有多個線程在操作時,如果系統只有一個CPU,把CPU運行時間劃分成若干個時間片,分配給各個線程執行,在一個時間段的線程代碼運行時,其它線程處於掛起狀態。
如果線程數不多於CPU核心數,會把各個線程都分配一個核心,不需分片,而當線程數多於CPU核心數時纔會分片。
交織(Interleaving)
public class ThreadTest3 {
// suppose all the cash machines share a single bank account
private static int balance = 0;
private static void deposit() {
balance = balance + 1;
}
private static void withdraw() {
balance = balance - 1;
}
//each ATM does a bunch of transactions that
//modify balance, but leave it unchanged afterward
public static void cashMachine() {
new Thread(new Runnable() {
public void run() {
for (int i = 0; i < 1000; ++i) {
deposit(); // put a dollar in
withdraw(); // take it back out
}
}
}).start();
}
public static void main(String[] args) {
cashMachine();
cashMachine();
cashMachine();
System.out.println("after:"+balance);
}
}
// result:
// after:7
上面的代碼是我將MIT官網的代碼拷貝下來,並添加了main方法後的程序及某一次的運行結果。
正常情況下,每一個現金取款機存一塊錢,然後取一塊錢,最終的賬戶餘額應該爲0,可是上邊的結果顯式最終的餘額爲7。
出現上述錯誤的原因正是因爲語句之間出現了交織的情況。
例如:
deposit()方法中僅有的一條語句其實並不是原子的操作,還可以將它分解爲底層的三步操作。
- 讀取balance
- balance的值加一
- 將修改後的balance寫回
而可能會出現如下兩個線程A,B同時讀取同一賬戶的balance的情況:
- A1. 讀取balance=0
- B1. 讀取balance=0
- A2. balance的值加一
- B2. balance的值加一
- A3. 將修改後的balance=1寫回
- B3. 將修改後的balance=1寫回
在一開始時,A、B兩個線程都讀取到相同的賬戶餘額值,然後二者都將讀取到的餘額值加一,而在最後寫回時兩個線程相互競爭,不管誰先寫回修改後的值,另外一個線程總會將先前寫回的值進行覆蓋,從而出現了餘額不爲零的現象。
上述問題也反映了一個可見性問題:A寫回了修改後的balance值,但B看到的還是未修改之前balance值,因而B進行寫回時會將A寫回的值進行錯誤的覆蓋。
競爭條件(Race Condition)
競爭條件就是說程序的正確性(滿足每一個類的後置條件和不變量)依賴於特定線程不同操作之間的時序。