Java與執行緒
1. 執行緒的實現
執行緒是比程序更輕量級的排程執行單位,執行緒的引人,可以把一個程序的資源分配和執行排程分開,各個執行緒既可以共享程序資源(記憶體地址、檔案IO等),又可以獨立排程。目前執行緒是Java裡面進行處理器資源排程的最基本單位。
主流的作業系統都提供了執行緒實現,Java語言則提供了在不同硬體和作業系統平臺下對執行緒操作的統一處理,每個已經呼叫過start()方法且還未結束的java.lang.Thread 類的例項就代表著一個執行緒。
實現執行緒主要有三種方式:使用核心執行緒實現(1:1實現),使用使用者執行緒實現(1:N實現),使用使用者執行緒加輕量級程序混合實現(N:M實現)。
1.1 核心執行緒實現
使用核心執行緒實現的方式也被稱為1:1實現。核心執行緒(Kernel-Level Thread,KLT)就是直接由作業系統核心(Kernel,下稱核心)支援的執行緒,這種執行緒由核心來完成執行緒切換,核心透過操縱排程器(Scheduler)對執行緒進行排程,並負責將執行緒的任務對映到各個處理器上。每個核心執行緒可以視為核心的一個分身,這樣作業系統就有能力同時處理多件事情,支援多執行緒的核心就稱為多執行緒核心(Multi-Threads Kernel)。
程式一般不會直接使用核心執行緒,而是使用核心執行緒的一種高階介面--輕量級程序(Light Weight Process,LWP),輕量級程序就是我們通常意義上所講的執行緒,由於每個輕量級程序都由一個核心執行緒支援,因此只有先支援核心執行緒,才能有輕量級程序。這種輕量級程序與核心執行緒之間1:1的關係稱為一對一的執行緒模型,如圖所示。
由於核心執行緒的支援,每個輕量級程序都成為一個獨立的排程單元,即使其中某一個輕量級程序在系統呼叫中被阻塞了,也不會影響整個程序繼續工作。輕量級程序也具有它的侷限性:首先、由於是基於核心執行緒實現的,所以各種執行緒操作,如建立、析構及同步,都需要進行系統呼叫。而系統呼叫的代價相對較高,需要在使用者態(User Mode)和核心態(Kerel Mode)中來回切換。其次,每個輕量級程序都需要有一個核心執行緒的支援,因此輕量級程序要消耗一定的核心資源(如核心執行緒的棧空間),因此一個系統支援輕量級程序的數量是有限的。
1.2 使用者執行緒的實現
使用使用者執行緒實現的方式被稱為1:N實現。廣義上來講,一個執行緒只要不是核心執行緒,都可以認為是使用者執行緒(User Thread,UT)的一種,因此從這個定義上看,輕量級程序也屬於使用者執行緒,但輕量級程序的實現始終是建立在核心之上的,許多操作都要進行系統呼叫,因此效率會受到限制,並不具備通常意義上的使用者執行緒的優點。
而狹義上的使用者執行緒指的是完全建立在使用者空間的執行緒庫上,系統核心不能感知到使用者執行緒的存在及如何實現的。使用者執行緒的建立同步、銷燬和排程完全在使用者態中完成,不需要核心的幫助。如果程式實現得當,這種執行緒不需要切換到核心態,因此操作可以是非常快速且低消耗的,也能夠支援規模更大的執行緒數量,部分高效能資料庫中的多執行緒就是由使用者執行緒實現的。這種程序與使用者執行緒之間1:N的關係稱為一對多的執行緒模型,如圖所示。
使用者執行緒的優勢在於不需要系統核心支援,劣勢也在於沒有系統核心的支援,所有的執行緒操作都需要由使用者程式自己去處理。執行緒的建立、銷燬、切換和排程都是使用者必須考慮的問題,而且由於作業系統只把處理器資源分配到程序,那諸如“阻塞如何處理”“多處理器系統中如何將執行緒對映到其他處理器上”這類問題解決起來將會異常困難,甚至有些是不可能實現的。因為使用使用者執行緒實現的程式通常都比較複雜。,除了有明確的需求外(譬如以前在不支援多執行緒的作業系統中的多執行緒程式、需要支援大規模執行緒數量的應用)一般的應用程式都不傾向使用使用者執行緒。Java、Ruby等語言都曾經使用過使用者執行緒,最終又都放棄了使用它。但是近年來許多新的、以高併發為賣點的程式語言又普遍支援了使用者執行緒,譬如Golang、Erlang等,使得使用者執行緒的使用率有所回升。
1.3 混合實現
執行緒除了依賴核心執行緒實現和完全由使用者程式自己實現之外,還有一種將核心執行緒與使用者執行緒一起使用的實現方式,被稱為N:M實現。在這種混合實現下,既在使用者執行緒也存在輕量級程序。使用者執行緒還是完全建立在使用者空間中,因此使用者執行緒的建立、切換、析構等操作依然廉價,並且可以支援大規模的使用者執行緒併發。而作業系統支援的輕量級程序則作為使用者執行緒和核心執行緒之間的橋樑,這樣可以使用核心提供的執行緒排程功能及處理器對映,並且使用者執行緒的系統呼叫要透過輕量級程序來完成,這大大降低了整個程序被完全阻塞的風險。在這種混合模式中,使用者執行緒與輕量級程序的數量比是不定的,是N:M的關係,如圖所示,這種就是多對多的執行緒模型。
許多UNIX系列的作業系統,如Solaris、HP-UX等都提供了M:N的執行緒模型實現。在這些作業系統上的應用也相對更容易應用M:N的執行緒模型
2. Java 執行緒的實現
從JDK1.3起,“主流”平臺上的“主流”商用Java虛擬機器的執行緒模型普遍都被替換為基於作業系統原生執行緒模型來實現,即採用1:1的執行緒模型。
以HotSpot為例,它的每一個Java執行緒都是直接對映到一個作業系統原生執行緒來實現的,而且中間沒有額外的間接結構,所以HotSpot自己是不會去幹涉執行緒排程的(可以設定執行緒優先順序給作業系統提供排程建議),全權交給底下的作業系統去處理,所以何時凍結或喚醒執行緒、該給執行緒分配多少處理器執行時間、該把執行緒安排給哪個處理器核心去執行等都是由作業系統完成的,也都是由作業系統全權決定的。
作業系統支援怎樣的執行緒模型,在很大程度上會影響上面的Java虛擬機器的執行緒是怎樣對映的,這一點在不同的平臺上很難達成一致,因此《Java虛擬機器規範》中才不去限定Java執行緒需要使用哪種執行緒模型來實現。執行緒模型只對執行緒的併發規模和操作成本產生影響,對Java程式的編碼和執行過程來說,這些差異都是完全透明的。
2.1 Java 執行緒排程
執行緒排程是指系統為執行緒分配處理器使用權的過程,排程主要方式有兩種,分別是協同式執行緒排程和搶佔式執行緒排程。
協同式排程是指:執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上去。
好處:實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對執行緒自己是可知的,所以一般沒有什麼執行緒同步的問題。
壞處:執行緒執行時間不可控制,甚至如果一個執行緒的程式碼編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。只要有一個程序堅持不讓出處理器執行時間,就可能會導致整個系統崩潰。
搶佔式排程是指:那麼每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定。
好處:執行緒的執行時間是系統可控的,不會有一個執行緒導致整個程序甚至整個系統阻塞的問題。
壞處:執行緒可以主動讓出執行時間,但是如果想要主動獲取執行時間,執行緒本身是沒有什麼辦法的。即使設定執行緒優先順序,但它並不是一項穩定的調節手段,很顯然因為主流虛擬機器上的Java執行緒是被對映到系統的原生執行緒上來實現的,所以執行緒排程最終還是由作業系統說了算。
2.2 狀態切換
Java語言定義了6種執行緒狀態,在任意一個時間點中,一個執行緒只能有且只有其中的一種狀態,並且可以透過特定的方法在不同狀態之間轉換。這6種狀態分別是:
-
新建(New):建立後尚未啟動的執行緒處於這種狀態。
-
執行(Runnable):包括作業系統執行緒狀態中的Running和Ready,也就是處於此狀態的執行緒有可能正在執行,也有可能正在等待著作業系統為它分配執行時間。
-
無限期等待(Waiting):處於這種狀態的執行緒不會被分配處理器執行時間,它們要等待被其他執行緒顯式喚醒。以下方法會讓執行緒陷人無限期的等待狀態:
-
沒有設定 Timeout引數的 Object::wait()方法;
-
沒有設定Timeout 引數的 Thread::join()方法
-
LockSupport::park()方法。
-
-
限期等待(Timed Waiting):處於這種狀態的執行緒也不會被分配處理器執行時間、不過無須等待被其他執行緒顯式喚醒,在一定時間之後它們會由系統自動喚醒。以下方法會讓執行緒進入限期等待狀態:
-
Thread::sleep()方法;
-
設定了Timeout引數的 Object::wait()方法
-
設定了 Timeout引數的 Thread::join()方法:
-
LockSupport::parkNanos()方法:
-
LockSupport::parkUntil()方法。
-
-
阻塞(Blocked):執行緒被阻寒了,“阻寒狀態”與“等待狀態”的區別是:“阻塞狀態“在等待著獲取到一個排它鎖,這個事件將在另外一個執行緒放棄這個鎖的時候發生;而“等待狀態”則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。
-
結束(Terminated):已終止執行緒的執行緒狀態,執行緒已經結束執行。
3. Java與協程
在Java 時代的早期,Java 語言抽象出來隱藏了各種作業系統執行緒差異性的統一執行緒介面,這曾經是它區別於其他程式語言的一大優勢。但時至今日,這種便捷的併發程式設計方式和同步的機制依然在有效地運作著,但是在某些場景下,卻也已經顯現出了疲態。
3.1 核心執行緒的侷限
舉個例子。現代B/S系統中一次對外部業務請求的響應,往往需要分佈在不同機器上的大量服務共同協作來實現,這種服務細分的架構在減少單個服務複雜度、增加複用性的同時,也不可避免地增加了服務的數量,縮短了留給每個服務的響應時間。這要求每一個服務都必須在極短的時間內完成計算,這樣組合多個服務的總耗時才不會太長;也要求每一個服務提供者都要能同時處理數量更龐大的請求,這樣才不會出現請求由於某個服務被阻塞而出現等待。
Java目前的併發程式設計機制就與上述架構趨勢產生了一些矛盾,1:1的核心執行緒模型是如今Java虛擬機器執行緒實現的主流選擇,但是這種對映到作業系統上的執行緒天然的缺陷是切換排程成本高昂,系統能容納的執行緒數量也很有限。
傳統的JavaWeb伺服器的執行緒池的容量通常在幾十個到兩百之間,當程式設計師把數以百萬計的請求往執行緒池裡面灌時,系統即使能處理得過來,但其中的切換損耗也是相當可觀的。
3.2 協程的復甦
為什麼核心執行緒排程切換起來成本就要更高?
核心執行緒的排程成本主要來自於使用者態與核心態之間的狀態轉換,而這兩種狀態轉換的開銷主要來自於響應中斷、保護和恢復執行現場的成本。假設發生了這樣一次執行緒切換:
執行緒A->系統中斷->執行緒B
處理器要去執行執行緒A的程式程式碼時,並不是僅有程式碼程式就能跑得起來,程式是資料與程式碼的組合體,程式碼執行時還必須要有上下文資料的支撐。而這裡說的“上下文”,以程式設計師的角度來看,是方法呼叫過程中的各種區域性的變數與資源;以執行緒的角度來看,是方法的呼叫棧中儲存的各類資訊;而以作業系統和硬體的角度來看,則是儲存在記憶體、快取和暫存器中的一個個具體數值。物理硬體的各種儲存裝置和暫存器是被作業系統內所有執行緒共享的資源,當中斷髮生,從執行緒A切換到執行緒B去執行之前,作業系統首先要把執行緒A的上下文資料妥善保管好,然後把暫存器、記憶體分頁等恢復到執行緒B掛起時候的狀態,這樣執行緒B被重新啟用後才能彷彿從來沒有被掛起過。這種保護和恢復現場的工作免不了涉及一系列資料在各種暫存器、快取中的來回複製,當然不可能是一種輕量級的操作。
如果說核心執行緒的切換開銷是來自於保護和恢復現場的成本,那如果改為採用使用者執行緒,這部分開銷就能夠省略掉嗎?答案是“不”。但是,一旦把保護、恢復現場及排程的工作從作業系統交到程式設計師手上,那我們就可以開啟腦洞,透過玩出很多新的花樣來縮藏這些開銷。
其中大致的原理是透過在記憶體裡劃出一片額外空間來模擬呼叫棧,只要其他“執行緒”中方法壓棧、退棧時遵守規則,不破壞這片空問即可。
由於最初多數的使用者執行緒是被設計成協同式排程(Cooperative Scheduling)的,所以它有了一個別名——協程(Coroutine)。協程的主要優勢是輕量。協程當然也有它的侷限,需要在應用層面實現的內容(呼叫、排程器這些)特別多。除此之外,協程在最初,甚至在今天很多語言和框架中會被設計成協同式排程,這樣在語言執行平臺或者框架上的排程器就可以做得非常簡單。
3.3 Java應對方式
對於有棧協程,有一種特例實現名為纖程(Fiber)。OpenJDK在2018年建立了Loom 專案,根據目前公開的資訊,如無意外,日後該專案為Java語言引人的、與現線上程模型平行的新併發程式設計機制中應該也會採用“纖程”這個名字。