java中為什麼要用多執行緒
阿新 • • 發佈:2019-01-31
我們可以在計算機上執行各種計算機軟體程式。每一個執行的程式可能包括多個獨立執行的執行緒(Thread)。
執行緒(Thread)是一份獨立執行的程式,有自己專用的執行棧。執行緒有可能和其他執行緒共享一些資源,比如,記憶體,檔案,資料庫等。
當多個執行緒同時讀寫同一份共享資源的時候,可能會引起衝突。這時候,我們需要引入執行緒“同步”機制,即各位執行緒之間要有個先來後到,不能一窩蜂擠上去搶作一團。
同步這個詞是從英文synchronize(使同時發生)翻譯過來的。我也不明白為什麼要用這個很容易引起誤解的詞。既然大家都這麼用,咱們也就只好這麼將就。
執行緒同步的真實意思和字面意思恰好相反。執行緒同步的真實意思,其實是“排隊”:幾個執行緒之間要排隊,一個一個對共享資源進行操作,而不是同時進行操作。
因此,關於執行緒同步,需要牢牢記住的第一點是:執行緒同步就是執行緒排隊。同步就是排隊。執行緒同步的目的就是避免執行緒“同步”執行。這可真是個無聊的繞口令。
關於執行緒同步,需要牢牢記住的第二點是 “共享”這兩個字。只有共享資源的讀寫訪問才需要同步。如果不是共享資源,那麼就根本沒有同步的必要。
關於執行緒同步,需要牢牢記住的第三點是,只有“變數”才需要同步訪問。如果共享的資源是固定不變的,那麼就相當於“常量”,執行緒同時讀取常量也不需要同步。至少一個執行緒修改共享資源,這樣的情況下,執行緒之間就需要同步。
關於執行緒同步,需要牢牢記住的第四點是:多個執行緒訪問共享資源的程式碼有可能是同一份程式碼,也有可能是不同的程式碼;無論是否執行同一份程式碼,只要這些執行緒的程式碼訪問同一份可變的共享資源,這些執行緒之間就需要同步。
為了加深理解,下面舉幾個例子。
有兩個採購員,他們的工作內容是相同的,都是遵循如下的步驟:
(1)到市場上去,尋找併購買有潛力的樣品。
(2)回到公司,寫報告。
這兩個人的工作內容雖然一樣,他們都需要購買樣品,他們可能買到同樣種類的樣品,但是他們絕對不會購買到同一件樣品,他們之間沒有任何共享資源。所以,他們可以各自進行自己的工作,互不干擾。
這兩個採購員就相當於兩個執行緒;兩個採購員遵循相同的工作步驟,相當於這兩個執行緒執行同一段程式碼。
下面給這兩個採購員增加一個工作步驟。採購員需要根據公司的“布告欄”上面公佈的資訊,安排自己的工作計劃。
這兩個採購員有可能同時走到布告欄的前面,同時觀看布告欄上的資訊。這一點問題都沒有。因為布告欄是隻讀的,這兩個採購員誰都不會去修改布告欄上寫的資訊。
下面增加一個角色。一個辦公室行政人員這個時候,也走到了布告欄前面,準備修改布告欄上的資訊。
如果行政人員先到達布告欄,並且正在修改布告欄的內容。兩個採購員這個時候,恰好也到了。這兩個採購員就必須等待行政人員完成修改之後,才能觀看修改後的資訊。
如果行政人員到達的時候,兩個採購員已經在觀看布告欄了。那麼行政人員需要等待兩個採購員把當前資訊記錄下來之後,才能夠寫上新的資訊。
上述這兩種情況,行政人員和採購員對布告欄的訪問就需要進行同步。因為其中一個執行緒(行政人員)修改了共享資源(布告欄)。而且我們可以看到,行政人員的工作流程和採購員的工作流程(執行程式碼)完全不同,但是由於他們訪問了同一份可變共享資源(布告欄),所以他們之間需要同步。
同步鎖
前面講了為什麼要執行緒同步,下面我們就來看如何才能執行緒同步。
執行緒同步的基本實現思路還是比較容易理解的。我們可以給共享資源加一把鎖,這把鎖只有一把鑰匙。哪個執行緒獲取了這把鑰匙,才有權利訪問該共享資源。
生活中,我們也可能會遇到這樣的例子。一些超市的外面提供了一些自動儲物箱。每個儲物箱都有一把鎖,一把鑰匙。人們可以使用那些帶有鑰匙的儲物箱,把東西放到儲物箱裡面,把儲物箱鎖上,然後把鑰匙拿走。這樣,該儲物箱就被鎖住了,其他人不能再訪問這個儲物箱。(當然,真實的儲物箱鑰匙是可以被人拿走複製的,所以不要把貴重物品放在超市的儲物箱裡面。於是很多超市都採用了電子密碼鎖。)
執行緒同步鎖這個模型看起來很直觀。但是,還有一個嚴峻的問題沒有解決,這個同步鎖應該加在哪裡?
當然是加在共享資源上了。反應快的讀者一定會搶先回答。
沒錯,如果可能,我們當然儘量把同步鎖加在共享資源上。一些比較完善的共享資源,比如,檔案系統,資料庫系統等,自身都提供了比較完善的同步鎖機制。我們不用另外給這些資源加鎖,這些資源自己就有鎖。
但是,大部分情況下,我們在程式碼中訪問的共享資源都是比較簡單的共享物件。這些物件裡面沒有地方讓我們加鎖。
讀者可能會提出建議:為什麼不在每一個物件內部都增加一個新的區域,專門用來加鎖呢?這種設計理論上當然也是可行的。問題在於,執行緒同步的情況並不是很普遍。如果因為這小概率事件,在所有物件內部都開闢一塊鎖空間,將會帶來極大的空間浪費。得不償失。
於是,現代的程式語言的設計思路都是把同步鎖加在程式碼段上。確切的說,是把同步鎖加在“訪問共享資源的程式碼段”上。這一點一定要記住,同步鎖是加在程式碼段上的。
同步鎖加在程式碼段上,就很好地解決了上述的空間浪費問題。但是卻增加了模型的複雜度,也增加了我們的理解難度。
現在我們就來仔細分析“同步鎖加在程式碼段上”的執行緒同步模型。
首先,我們已經解決了同步鎖加在哪裡的問題。我們已經確定,同步鎖不是加在共享資源上,而是加在訪問共享資源的程式碼段上。
其次,我們要解決的問題是,我們應該在程式碼段上加什麼樣的鎖。這個問題是重點中的重點。這是我們尤其要注意的問題:訪問同一份共享資源的不同程式碼段,應該加上同一個同步鎖;如果加的是不同的同步鎖,那麼根本就起不到同步的作用,沒有任何意義。
這就是說,同步鎖本身也一定是多個執行緒之間的共享物件。
Java語言的synchronized關鍵字
為了加深理解,舉幾個程式碼段同步的例子。
不同語言的同步鎖模型都是一樣的。只是表達方式有些不同。這裡我們以當前最流行的Java語言為例。Java語言裡面用synchronized關鍵字給程式碼段加鎖。整個語法形式表現為
synchronized(同步鎖) {
// 訪問共享資源,需要同步的程式碼段
}
這裡尤其要注意的就是,同步鎖本身一定要是共享的物件。
… f1() {
Object lock1 = new Object(); // 產生一個同步鎖
synchronized(lock1){
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
上面這段程式碼沒有任何意義。因為那個同步鎖是在函式體內部產生的。每個執行緒呼叫這段程式碼的時候,都會產生一個新的同步鎖。那麼多個執行緒之間,使用的是不同的同步鎖。根本達不到同步的目的。
同步程式碼一定要寫成如下的形式,才有意義。
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
你不一定要把同步鎖宣告為static或者public,但是你一定要保證相關的同步程式碼之間,一定要使用同一個同步鎖。
講到這裡,你一定會好奇,這個同步鎖到底是個什麼東西。為什麼隨便宣告一個Object物件,就可以作為同步鎖?
在Java裡面,同步鎖的概念就是這樣的。任何一個Object Reference都可以作為同步鎖。我們可以把Object Reference理解為物件在記憶體分配系統中的記憶體地址。因此,要保證同步程式碼段之間使用的是同一個同步鎖,我們就要保證這些同步程式碼段的synchronized關鍵字使用的是同一個Object Reference,同一個記憶體地址。這也是為什麼我在前面的程式碼中宣告lock1的時候,使用了final關鍵字,這就是為了保證lock1的Object Reference在整個系統執行過程中都保持不變。
一些求知慾強的讀者可能想要繼續深入瞭解synchronzied(同步鎖)的實際執行機制。Java虛擬機器規範中(你可以在google用“JVM Spec”等關鍵字進行搜尋),有對synchronized關鍵字的詳細解釋。synchronized會編譯成 monitor enter, … monitor exit之類的指令對。Monitor就是實際上的同步鎖。每一個Object Reference在概念上都對應一個monitor。
這些實現細節問題,並不是理解同步鎖模型的關鍵。我們繼續看幾個例子,加深對同步鎖模型的理解。
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
… f2() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 B
// 訪問共享資源 resource1
// 需要同步
}
}
上述的程式碼中,程式碼段A和程式碼段B就是同步的。因為它們使用的是同一個同步鎖lock1。
如果有10個執行緒同時執行程式碼段A,同時還有20個執行緒同時執行程式碼段B,那麼這30個執行緒之間都是要進行同步的。
這30個執行緒都要競爭一個同步鎖lock1。同一時刻,只有一個執行緒能夠獲得lock1的所有權,只有一個執行緒可以執行程式碼段A或者程式碼段B。其他競爭失敗的執行緒只能暫停執行,進入到該同步鎖的就緒(Ready)佇列。
每一個同步鎖下面都掛了幾個執行緒佇列,包括就緒(Ready)佇列,待召(Waiting)佇列等。比如,lock1對應的就緒佇列就可以叫做lock1 - ready queue。每個佇列裡面都可能有多個暫停執行的執行緒。
注意,競爭同步鎖失敗的執行緒進入的是該同步鎖的就緒(Ready)佇列,而不是後面要講述的待召佇列(Waiting Queue,也可以翻譯為等待佇列)。就緒佇列裡面的執行緒總是時刻準備著競爭同步鎖,時刻準備著執行。而待召佇列裡面的執行緒則只能一直等待,直到等到某個訊號的通知之後,才能夠轉移到就緒佇列中,準備執行。
成功獲取同步鎖的執行緒,執行完同步程式碼段之後,會釋放同步鎖。該同步鎖的就緒佇列中的其他執行緒就繼續下一輪同步鎖的競爭。成功者就可以繼續執行,失敗者還是要乖乖地待在就緒佇列中。
因此,執行緒同步是非常耗費資源的一種操作。我們要儘量控制執行緒同步的程式碼段範圍。同步的程式碼段範圍越小越好。我們用一個名詞“同步粒度”來表示同步程式碼段的範圍。
同步粒度
在Java語言裡面,我們可以直接把synchronized關鍵字直接加在函式的定義上。
比如。
… synchronized … f1() {
// f1 程式碼段
}
這段程式碼就等價於
… f1() {
synchronized(this){ // 同步鎖就是物件本身
// f1 程式碼段
}
}
同樣的原則適用於靜態(static)函式
比如。
… static synchronized … f1() {
// f1 程式碼段
}
這段程式碼就等價於
…static … f1() {
synchronized(Class.forName(…)){ // 同步鎖是類定義本身
// f1 程式碼段
}
}
但是,我們要儘量避免這種直接把synchronized加在函式定義上的偷懶做法。因為我們要控制同步粒度。同步的程式碼段越小越好。synchronized控制的範圍越小越好。
我們不僅要在縮小同步程式碼段的長度上下功夫,我們同時還要注意細分同步鎖。
比如,下面的程式碼
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
… f2() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 B
// 訪問共享資源 resource1
// 需要同步
}
}
… f3() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 C
// 訪問共享資源 resource2
// 需要同步
}
}
… f4() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 D
// 訪問共享資源 resource2
// 需要同步
}
}
上述的4段同步程式碼,使用同一個同步鎖lock1。所有呼叫4段程式碼中任何一段程式碼的執行緒,都需要競爭同一個同步鎖lock1。
我們仔細分析一下,發現這是沒有必要的。
因為f1()的程式碼段A和f2()的程式碼段B訪問的共享資源是resource1,f3()的程式碼段C和f4()的程式碼段D訪問的共享資源是resource2,它們沒有必要都競爭同一個同步鎖lock1。我們可以增加一個同步鎖lock2。f3()和f4()的程式碼可以修改為:
public static final Object lock2 = new Object();
… f3() {
synchronized(lock2){ // lock2 是公用同步鎖
// 程式碼段 C
// 訪問共享資源 resource2
// 需要同步
}
}
… f4() {
synchronized(lock2){ // lock2 是公用同步鎖
// 程式碼段 D
// 訪問共享資源 resource2
// 需要同步
}
}
這樣,f1()和f2()就會競爭lock1,而f3()和f4()就會競爭lock2。這樣,分開來分別競爭兩個鎖,就可以大大較少同步鎖競爭的概率,從而減少系統的開銷。
訊號量
同步鎖模型只是最簡單的同步模型。同一時刻,只有一個執行緒能夠運行同步程式碼。
有的時候,我們希望處理更加複雜的同步模型,比如生產者/消費者模型、讀寫同步模型等。這種情況下,同步鎖模型就不夠用了。我們需要一個新的模型。這就是我們要講述的訊號量模型。
訊號量模型的工作方式如下:執行緒在執行的過程中,可以主動停下來,等待某個訊號量的通知;這時候,該執行緒就進入到該訊號量的待召(Waiting)隊列當中;等到通知之後,再繼續執行。
很多語言裡面,同步鎖都由專門的物件表示,物件名通常叫Monitor。
同樣,在很多語言中,訊號量通常也有專門的物件名來表示,比如,Mutex,Semphore。
訊號量模型要比同步鎖模型複雜許多。一些系統中,訊號量甚至可以跨程序進行同步。另外一些訊號量甚至還有計數功能,能夠控制同時執行的執行緒數。
我們沒有必要考慮那麼複雜的模型。所有那些複雜的模型,都是最基本的模型衍生出來的。只要掌握了最基本的訊號量模型——“等待/通知”模型,複雜模型也就迎刃而解了。
我們還是以Java語言為例。Java語言裡面的同步鎖和訊號量概念都非常模糊,沒有專門的物件名詞來表示同步鎖和訊號量,只有兩個同步鎖相關的關鍵字——volatile和synchronized。
這種模糊雖然導致概念不清,但同時也避免了Monitor、Mutex、Semphore等名詞帶來的種種誤解。我們不必執著於名詞之爭,可以專注於理解實際的執行原理。
在Java語言裡面,任何一個Object Reference都可以作為同步鎖。同樣的道理,任何一個Object Reference也可以作為訊號量。
Object物件的wait()方法就是等待通知,Object物件的notify()方法就是發出通知。
具體呼叫方法為
(1)等待某個訊號量的通知
public static final Object signal = new Object();
… f1() {
synchronized(singal) { // 首先我們要獲取這個訊號量。這個訊號量同時也是一個同步鎖
// 只有成功獲取了signal這個訊號量兼同步鎖之後,我們才可能進入這段程式碼
signal.wait(); // 這裡要放棄訊號量。本執行緒要進入signal訊號量的待召(Waiting)佇列
// 可憐。辛辛苦苦爭取到手的訊號量,就這麼被放棄了
// 等到通知之後,從待召(Waiting)佇列轉到就緒(Ready)佇列裡面
// 轉到了就緒佇列中,離CPU核心近了一步,就有機會繼續執行下面的程式碼了。
// 仍然需要把signal同步鎖競爭到手,才能夠真正繼續執行下面的程式碼。命苦啊。
…
}
}
需要注意的是,上述程式碼中的signal.wait()的意思。signal.wait()很容易導致誤解。signal.wait()的意思並不是說,signal開始wait,而是說,執行這段程式碼的當前執行緒開始wait這個signal物件,即進入signal物件的待召(Waiting)佇列。
(2)發出某個訊號量的通知
… f2() {
synchronized(singal) { // 首先,我們同樣要獲取這個訊號量。同時也是一個同步鎖。
// 只有成功獲取了signal這個訊號量兼同步鎖之後,我們才可能進入這段程式碼
signal.notify(); // 這裡,我們通知signal的待召佇列中的某個執行緒。
// 如果某個執行緒等到了這個通知,那個執行緒就會轉到就緒佇列中
// 但是本執行緒仍然繼續擁有signal這個同步鎖,本執行緒仍然繼續執行
// 嘿嘿,雖然本執行緒好心通知其他執行緒,
// 但是,本執行緒可沒有那麼高風亮節,放棄到手的同步鎖
// 本執行緒繼續執行下面的程式碼
…
}
}
需要注意的是,signal.notify()的意思。signal.notify()並不是通知signal這個物件本身。而是通知正在等待signal訊號量的其他執行緒。
以上就是Object的wait()和notify()的基本用法。
實際上,wait()還可以定義等待時間,當執行緒在某訊號量的待召佇列中,等到足夠長的時間,就會等無可等,無需再等,自己就從待召佇列轉移到就緒佇列中了。
另外,還有一個notifyAll()方法,表示通知待召佇列裡面的所有執行緒。
這些細節問題,並不對大局產生影響。
綠色執行緒
綠色執行緒(Green Thread)是一個相對於作業系統執行緒(Native Thread)的概念。
作業系統執行緒(Native Thread)的意思就是,程式裡面的執行緒會真正對映到作業系統的執行緒,執行緒的執行和排程都是由作業系統控制的
綠色執行緒(Green Thread)的意思是,程式裡面的執行緒不會真正對映到作業系統的執行緒,而是由語言執行平臺自身來排程。
當前版本的Python語言的執行緒就可以對映到作業系統執行緒。當前版本的Ruby語言的執行緒就屬於綠色執行緒,無法對映到作業系統的執行緒,因此Ruby語言的執行緒的執行速度比較慢。
難道說,綠色執行緒要比作業系統執行緒要慢嗎?當然不是這樣。事實上,情況可能正好相反。Ruby是一個特殊的例子。執行緒排程器並不是很成熟。
目前,執行緒的流行實現模型就是綠色執行緒。比如,stackless Python,就引入了更加輕量的綠色執行緒概念。線上程併發程式設計方面,無論是執行速度還是併發負載上,都優於Python。
另一個更著名的例子就是ErLang(愛立信公司開發的一種開源語言)。
ErLang的綠色執行緒概念非常徹底。ErLang的執行緒不叫Thread,而是叫做Process。這很容易和程序混淆起來。這裡要注意區分一下。
ErLang Process之間根本就不需要同步。因為ErLang語言的所有變數都是final的,不允許變數的值發生任何變化。因此根本就不需要同步。
final變數的另一個好處就是,物件之間不可能出現交叉引用,不可能構成一種環狀的關聯,物件之間的關聯都是單向的,樹狀的。因此,記憶體垃圾回收的演算法效率也非常高。這就讓ErLang能夠達到Soft Real Time(軟實時)的效果。這對於一門支援記憶體垃圾回收的語言來說,可不是一件容易的事情。
執行緒(Thread)是一份獨立執行的程式,有自己專用的執行棧。執行緒有可能和其他執行緒共享一些資源,比如,記憶體,檔案,資料庫等。
當多個執行緒同時讀寫同一份共享資源的時候,可能會引起衝突。這時候,我們需要引入執行緒“同步”機制,即各位執行緒之間要有個先來後到,不能一窩蜂擠上去搶作一團。
同步這個詞是從英文synchronize(使同時發生)翻譯過來的。我也不明白為什麼要用這個很容易引起誤解的詞。既然大家都這麼用,咱們也就只好這麼將就。
執行緒同步的真實意思和字面意思恰好相反。執行緒同步的真實意思,其實是“排隊”:幾個執行緒之間要排隊,一個一個對共享資源進行操作,而不是同時進行操作。
因此,關於執行緒同步,需要牢牢記住的第一點是:執行緒同步就是執行緒排隊。同步就是排隊。執行緒同步的目的就是避免執行緒“同步”執行。這可真是個無聊的繞口令。
關於執行緒同步,需要牢牢記住的第二點是 “共享”這兩個字。只有共享資源的讀寫訪問才需要同步。如果不是共享資源,那麼就根本沒有同步的必要。
關於執行緒同步,需要牢牢記住的第三點是,只有“變數”才需要同步訪問。如果共享的資源是固定不變的,那麼就相當於“常量”,執行緒同時讀取常量也不需要同步。至少一個執行緒修改共享資源,這樣的情況下,執行緒之間就需要同步。
關於執行緒同步,需要牢牢記住的第四點是:多個執行緒訪問共享資源的程式碼有可能是同一份程式碼,也有可能是不同的程式碼;無論是否執行同一份程式碼,只要這些執行緒的程式碼訪問同一份可變的共享資源,這些執行緒之間就需要同步。
為了加深理解,下面舉幾個例子。
有兩個採購員,他們的工作內容是相同的,都是遵循如下的步驟:
(1)到市場上去,尋找併購買有潛力的樣品。
(2)回到公司,寫報告。
這兩個人的工作內容雖然一樣,他們都需要購買樣品,他們可能買到同樣種類的樣品,但是他們絕對不會購買到同一件樣品,他們之間沒有任何共享資源。所以,他們可以各自進行自己的工作,互不干擾。
這兩個採購員就相當於兩個執行緒;兩個採購員遵循相同的工作步驟,相當於這兩個執行緒執行同一段程式碼。
下面給這兩個採購員增加一個工作步驟。採購員需要根據公司的“布告欄”上面公佈的資訊,安排自己的工作計劃。
這兩個採購員有可能同時走到布告欄的前面,同時觀看布告欄上的資訊。這一點問題都沒有。因為布告欄是隻讀的,這兩個採購員誰都不會去修改布告欄上寫的資訊。
下面增加一個角色。一個辦公室行政人員這個時候,也走到了布告欄前面,準備修改布告欄上的資訊。
如果行政人員先到達布告欄,並且正在修改布告欄的內容。兩個採購員這個時候,恰好也到了。這兩個採購員就必須等待行政人員完成修改之後,才能觀看修改後的資訊。
如果行政人員到達的時候,兩個採購員已經在觀看布告欄了。那麼行政人員需要等待兩個採購員把當前資訊記錄下來之後,才能夠寫上新的資訊。
上述這兩種情況,行政人員和採購員對布告欄的訪問就需要進行同步。因為其中一個執行緒(行政人員)修改了共享資源(布告欄)。而且我們可以看到,行政人員的工作流程和採購員的工作流程(執行程式碼)完全不同,但是由於他們訪問了同一份可變共享資源(布告欄),所以他們之間需要同步。
同步鎖
前面講了為什麼要執行緒同步,下面我們就來看如何才能執行緒同步。
執行緒同步的基本實現思路還是比較容易理解的。我們可以給共享資源加一把鎖,這把鎖只有一把鑰匙。哪個執行緒獲取了這把鑰匙,才有權利訪問該共享資源。
生活中,我們也可能會遇到這樣的例子。一些超市的外面提供了一些自動儲物箱。每個儲物箱都有一把鎖,一把鑰匙。人們可以使用那些帶有鑰匙的儲物箱,把東西放到儲物箱裡面,把儲物箱鎖上,然後把鑰匙拿走。這樣,該儲物箱就被鎖住了,其他人不能再訪問這個儲物箱。(當然,真實的儲物箱鑰匙是可以被人拿走複製的,所以不要把貴重物品放在超市的儲物箱裡面。於是很多超市都採用了電子密碼鎖。)
執行緒同步鎖這個模型看起來很直觀。但是,還有一個嚴峻的問題沒有解決,這個同步鎖應該加在哪裡?
當然是加在共享資源上了。反應快的讀者一定會搶先回答。
沒錯,如果可能,我們當然儘量把同步鎖加在共享資源上。一些比較完善的共享資源,比如,檔案系統,資料庫系統等,自身都提供了比較完善的同步鎖機制。我們不用另外給這些資源加鎖,這些資源自己就有鎖。
但是,大部分情況下,我們在程式碼中訪問的共享資源都是比較簡單的共享物件。這些物件裡面沒有地方讓我們加鎖。
讀者可能會提出建議:為什麼不在每一個物件內部都增加一個新的區域,專門用來加鎖呢?這種設計理論上當然也是可行的。問題在於,執行緒同步的情況並不是很普遍。如果因為這小概率事件,在所有物件內部都開闢一塊鎖空間,將會帶來極大的空間浪費。得不償失。
於是,現代的程式語言的設計思路都是把同步鎖加在程式碼段上。確切的說,是把同步鎖加在“訪問共享資源的程式碼段”上。這一點一定要記住,同步鎖是加在程式碼段上的。
同步鎖加在程式碼段上,就很好地解決了上述的空間浪費問題。但是卻增加了模型的複雜度,也增加了我們的理解難度。
現在我們就來仔細分析“同步鎖加在程式碼段上”的執行緒同步模型。
首先,我們已經解決了同步鎖加在哪裡的問題。我們已經確定,同步鎖不是加在共享資源上,而是加在訪問共享資源的程式碼段上。
其次,我們要解決的問題是,我們應該在程式碼段上加什麼樣的鎖。這個問題是重點中的重點。這是我們尤其要注意的問題:訪問同一份共享資源的不同程式碼段,應該加上同一個同步鎖;如果加的是不同的同步鎖,那麼根本就起不到同步的作用,沒有任何意義。
這就是說,同步鎖本身也一定是多個執行緒之間的共享物件。
Java語言的synchronized關鍵字
為了加深理解,舉幾個程式碼段同步的例子。
不同語言的同步鎖模型都是一樣的。只是表達方式有些不同。這裡我們以當前最流行的Java語言為例。Java語言裡面用synchronized關鍵字給程式碼段加鎖。整個語法形式表現為
synchronized(同步鎖) {
// 訪問共享資源,需要同步的程式碼段
}
這裡尤其要注意的就是,同步鎖本身一定要是共享的物件。
… f1() {
Object lock1 = new Object(); // 產生一個同步鎖
synchronized(lock1){
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
上面這段程式碼沒有任何意義。因為那個同步鎖是在函式體內部產生的。每個執行緒呼叫這段程式碼的時候,都會產生一個新的同步鎖。那麼多個執行緒之間,使用的是不同的同步鎖。根本達不到同步的目的。
同步程式碼一定要寫成如下的形式,才有意義。
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
你不一定要把同步鎖宣告為static或者public,但是你一定要保證相關的同步程式碼之間,一定要使用同一個同步鎖。
講到這裡,你一定會好奇,這個同步鎖到底是個什麼東西。為什麼隨便宣告一個Object物件,就可以作為同步鎖?
在Java裡面,同步鎖的概念就是這樣的。任何一個Object Reference都可以作為同步鎖。我們可以把Object Reference理解為物件在記憶體分配系統中的記憶體地址。因此,要保證同步程式碼段之間使用的是同一個同步鎖,我們就要保證這些同步程式碼段的synchronized關鍵字使用的是同一個Object Reference,同一個記憶體地址。這也是為什麼我在前面的程式碼中宣告lock1的時候,使用了final關鍵字,這就是為了保證lock1的Object Reference在整個系統執行過程中都保持不變。
一些求知慾強的讀者可能想要繼續深入瞭解synchronzied(同步鎖)的實際執行機制。Java虛擬機器規範中(你可以在google用“JVM Spec”等關鍵字進行搜尋),有對synchronized關鍵字的詳細解釋。synchronized會編譯成 monitor enter, … monitor exit之類的指令對。Monitor就是實際上的同步鎖。每一個Object Reference在概念上都對應一個monitor。
這些實現細節問題,並不是理解同步鎖模型的關鍵。我們繼續看幾個例子,加深對同步鎖模型的理解。
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
… f2() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 B
// 訪問共享資源 resource1
// 需要同步
}
}
上述的程式碼中,程式碼段A和程式碼段B就是同步的。因為它們使用的是同一個同步鎖lock1。
如果有10個執行緒同時執行程式碼段A,同時還有20個執行緒同時執行程式碼段B,那麼這30個執行緒之間都是要進行同步的。
這30個執行緒都要競爭一個同步鎖lock1。同一時刻,只有一個執行緒能夠獲得lock1的所有權,只有一個執行緒可以執行程式碼段A或者程式碼段B。其他競爭失敗的執行緒只能暫停執行,進入到該同步鎖的就緒(Ready)佇列。
每一個同步鎖下面都掛了幾個執行緒佇列,包括就緒(Ready)佇列,待召(Waiting)佇列等。比如,lock1對應的就緒佇列就可以叫做lock1 - ready queue。每個佇列裡面都可能有多個暫停執行的執行緒。
注意,競爭同步鎖失敗的執行緒進入的是該同步鎖的就緒(Ready)佇列,而不是後面要講述的待召佇列(Waiting Queue,也可以翻譯為等待佇列)。就緒佇列裡面的執行緒總是時刻準備著競爭同步鎖,時刻準備著執行。而待召佇列裡面的執行緒則只能一直等待,直到等到某個訊號的通知之後,才能夠轉移到就緒佇列中,準備執行。
成功獲取同步鎖的執行緒,執行完同步程式碼段之後,會釋放同步鎖。該同步鎖的就緒佇列中的其他執行緒就繼續下一輪同步鎖的競爭。成功者就可以繼續執行,失敗者還是要乖乖地待在就緒佇列中。
因此,執行緒同步是非常耗費資源的一種操作。我們要儘量控制執行緒同步的程式碼段範圍。同步的程式碼段範圍越小越好。我們用一個名詞“同步粒度”來表示同步程式碼段的範圍。
同步粒度
在Java語言裡面,我們可以直接把synchronized關鍵字直接加在函式的定義上。
比如。
… synchronized … f1() {
// f1 程式碼段
}
這段程式碼就等價於
… f1() {
synchronized(this){ // 同步鎖就是物件本身
// f1 程式碼段
}
}
同樣的原則適用於靜態(static)函式
比如。
… static synchronized … f1() {
// f1 程式碼段
}
這段程式碼就等價於
…static … f1() {
synchronized(Class.forName(…)){ // 同步鎖是類定義本身
// f1 程式碼段
}
}
但是,我們要儘量避免這種直接把synchronized加在函式定義上的偷懶做法。因為我們要控制同步粒度。同步的程式碼段越小越好。synchronized控制的範圍越小越好。
我們不僅要在縮小同步程式碼段的長度上下功夫,我們同時還要注意細分同步鎖。
比如,下面的程式碼
public static final Object lock1 = new Object();
… f1() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 A
// 訪問共享資源 resource1
// 需要同步
}
}
… f2() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 B
// 訪問共享資源 resource1
// 需要同步
}
}
… f3() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 C
// 訪問共享資源 resource2
// 需要同步
}
}
… f4() {
synchronized(lock1){ // lock1 是公用同步鎖
// 程式碼段 D
// 訪問共享資源 resource2
// 需要同步
}
}
上述的4段同步程式碼,使用同一個同步鎖lock1。所有呼叫4段程式碼中任何一段程式碼的執行緒,都需要競爭同一個同步鎖lock1。
我們仔細分析一下,發現這是沒有必要的。
因為f1()的程式碼段A和f2()的程式碼段B訪問的共享資源是resource1,f3()的程式碼段C和f4()的程式碼段D訪問的共享資源是resource2,它們沒有必要都競爭同一個同步鎖lock1。我們可以增加一個同步鎖lock2。f3()和f4()的程式碼可以修改為:
public static final Object lock2 = new Object();
… f3() {
synchronized(lock2){ // lock2 是公用同步鎖
// 程式碼段 C
// 訪問共享資源 resource2
// 需要同步
}
}
… f4() {
synchronized(lock2){ // lock2 是公用同步鎖
// 程式碼段 D
// 訪問共享資源 resource2
// 需要同步
}
}
這樣,f1()和f2()就會競爭lock1,而f3()和f4()就會競爭lock2。這樣,分開來分別競爭兩個鎖,就可以大大較少同步鎖競爭的概率,從而減少系統的開銷。
訊號量
同步鎖模型只是最簡單的同步模型。同一時刻,只有一個執行緒能夠運行同步程式碼。
有的時候,我們希望處理更加複雜的同步模型,比如生產者/消費者模型、讀寫同步模型等。這種情況下,同步鎖模型就不夠用了。我們需要一個新的模型。這就是我們要講述的訊號量模型。
訊號量模型的工作方式如下:執行緒在執行的過程中,可以主動停下來,等待某個訊號量的通知;這時候,該執行緒就進入到該訊號量的待召(Waiting)隊列當中;等到通知之後,再繼續執行。
很多語言裡面,同步鎖都由專門的物件表示,物件名通常叫Monitor。
同樣,在很多語言中,訊號量通常也有專門的物件名來表示,比如,Mutex,Semphore。
訊號量模型要比同步鎖模型複雜許多。一些系統中,訊號量甚至可以跨程序進行同步。另外一些訊號量甚至還有計數功能,能夠控制同時執行的執行緒數。
我們沒有必要考慮那麼複雜的模型。所有那些複雜的模型,都是最基本的模型衍生出來的。只要掌握了最基本的訊號量模型——“等待/通知”模型,複雜模型也就迎刃而解了。
我們還是以Java語言為例。Java語言裡面的同步鎖和訊號量概念都非常模糊,沒有專門的物件名詞來表示同步鎖和訊號量,只有兩個同步鎖相關的關鍵字——volatile和synchronized。
這種模糊雖然導致概念不清,但同時也避免了Monitor、Mutex、Semphore等名詞帶來的種種誤解。我們不必執著於名詞之爭,可以專注於理解實際的執行原理。
在Java語言裡面,任何一個Object Reference都可以作為同步鎖。同樣的道理,任何一個Object Reference也可以作為訊號量。
Object物件的wait()方法就是等待通知,Object物件的notify()方法就是發出通知。
具體呼叫方法為
(1)等待某個訊號量的通知
public static final Object signal = new Object();
… f1() {
synchronized(singal) { // 首先我們要獲取這個訊號量。這個訊號量同時也是一個同步鎖
// 只有成功獲取了signal這個訊號量兼同步鎖之後,我們才可能進入這段程式碼
signal.wait(); // 這裡要放棄訊號量。本執行緒要進入signal訊號量的待召(Waiting)佇列
// 可憐。辛辛苦苦爭取到手的訊號量,就這麼被放棄了
// 等到通知之後,從待召(Waiting)佇列轉到就緒(Ready)佇列裡面
// 轉到了就緒佇列中,離CPU核心近了一步,就有機會繼續執行下面的程式碼了。
// 仍然需要把signal同步鎖競爭到手,才能夠真正繼續執行下面的程式碼。命苦啊。
…
}
}
需要注意的是,上述程式碼中的signal.wait()的意思。signal.wait()很容易導致誤解。signal.wait()的意思並不是說,signal開始wait,而是說,執行這段程式碼的當前執行緒開始wait這個signal物件,即進入signal物件的待召(Waiting)佇列。
(2)發出某個訊號量的通知
… f2() {
synchronized(singal) { // 首先,我們同樣要獲取這個訊號量。同時也是一個同步鎖。
// 只有成功獲取了signal這個訊號量兼同步鎖之後,我們才可能進入這段程式碼
signal.notify(); // 這裡,我們通知signal的待召佇列中的某個執行緒。
// 如果某個執行緒等到了這個通知,那個執行緒就會轉到就緒佇列中
// 但是本執行緒仍然繼續擁有signal這個同步鎖,本執行緒仍然繼續執行
// 嘿嘿,雖然本執行緒好心通知其他執行緒,
// 但是,本執行緒可沒有那麼高風亮節,放棄到手的同步鎖
// 本執行緒繼續執行下面的程式碼
…
}
}
需要注意的是,signal.notify()的意思。signal.notify()並不是通知signal這個物件本身。而是通知正在等待signal訊號量的其他執行緒。
以上就是Object的wait()和notify()的基本用法。
實際上,wait()還可以定義等待時間,當執行緒在某訊號量的待召佇列中,等到足夠長的時間,就會等無可等,無需再等,自己就從待召佇列轉移到就緒佇列中了。
另外,還有一個notifyAll()方法,表示通知待召佇列裡面的所有執行緒。
這些細節問題,並不對大局產生影響。
綠色執行緒
綠色執行緒(Green Thread)是一個相對於作業系統執行緒(Native Thread)的概念。
作業系統執行緒(Native Thread)的意思就是,程式裡面的執行緒會真正對映到作業系統的執行緒,執行緒的執行和排程都是由作業系統控制的
綠色執行緒(Green Thread)的意思是,程式裡面的執行緒不會真正對映到作業系統的執行緒,而是由語言執行平臺自身來排程。
當前版本的Python語言的執行緒就可以對映到作業系統執行緒。當前版本的Ruby語言的執行緒就屬於綠色執行緒,無法對映到作業系統的執行緒,因此Ruby語言的執行緒的執行速度比較慢。
難道說,綠色執行緒要比作業系統執行緒要慢嗎?當然不是這樣。事實上,情況可能正好相反。Ruby是一個特殊的例子。執行緒排程器並不是很成熟。
目前,執行緒的流行實現模型就是綠色執行緒。比如,stackless Python,就引入了更加輕量的綠色執行緒概念。線上程併發程式設計方面,無論是執行速度還是併發負載上,都優於Python。
另一個更著名的例子就是ErLang(愛立信公司開發的一種開源語言)。
ErLang的綠色執行緒概念非常徹底。ErLang的執行緒不叫Thread,而是叫做Process。這很容易和程序混淆起來。這裡要注意區分一下。
ErLang Process之間根本就不需要同步。因為ErLang語言的所有變數都是final的,不允許變數的值發生任何變化。因此根本就不需要同步。
final變數的另一個好處就是,物件之間不可能出現交叉引用,不可能構成一種環狀的關聯,物件之間的關聯都是單向的,樹狀的。因此,記憶體垃圾回收的演算法效率也非常高。這就讓ErLang能夠達到Soft Real Time(軟實時)的效果。這對於一門支援記憶體垃圾回收的語言來說,可不是一件容易的事情。