為什麼要使用執行緒池

xz43發表於2015-12-02
在Java中,如果每當一個請求到達就建立一個新執行緒,開銷是相當大的。在實際使用中,每個請求建立新執行緒的伺服器在建立和銷燬執行緒上花費的時間和消耗的系統資源,甚至可能要比花在處理實際的使用者請求的時間和資源要多得多。除了建立和銷燬執行緒的開銷之外,活動的執行緒也需要消耗系統資源。如果在一個JVM裡建立太多的執行緒,可能會導致系統由於過度消耗記憶體或“切換過度”而導致系統資源不足。為了防止資源不足,伺服器應用程式需要一些辦法來限制任何給定時刻處理的請求數目,儘可能減少建立和銷燬執行緒的次數,特別是一些資源耗費比較大的執行緒的建立和銷燬,儘量利用已有物件來進行服務,這就是“池化資源”技術產生的原因。 


執行緒池主要用來解決執行緒生命週期開銷問題和資源不足問題。透過對多個任務重用執行緒,執行緒建立的開銷就被分攤到了多個任務上了,而且由於在請求到達時執行緒已經存在,所以消除了執行緒建立所帶來的延遲。這樣,就可以立即為請求服務,使應用程式響應更快。另外,透過適當地調整執行緒池中的執行緒數目可以防止出現資源不足的情況。 


建立一個執行緒池


一個比較簡單的執行緒池至少應包含執行緒池管理器、工作執行緒、任務佇列、任務介面等部分。其中執行緒池管理器(ThreadPool Manager)的作用是建立、銷燬並管理執行緒池,將工作執行緒放入執行緒池中;工作執行緒是一個可以迴圈執行任務的執行緒,在沒有任務時進行等待;任務佇列的作用是提供一種緩衝機制,將沒有處理的任務放在任務佇列中;任務介面是每個任務必須實現的介面,主要用來規定任務的入口、任務執行完後的收尾工作、任務的執行狀態等,工作執行緒透過該介面排程任務的執行。


執行緒池適合應用的場合


當一個Web伺服器接受到大量短小執行緒的請求時,使用執行緒池技術是非常合適的,它可以大大減少執行緒的建立和銷燬次數,提高伺服器的工作效率。但如果執行緒要求的執行時間比較長,此時執行緒的執行時間比建立時間要長得多,單靠減少建立時間對系統效率的提高不明顯,此時就不適合應用執行緒池技術,需要藉助其它的技術來提高伺服器的服務效率。


 使用執行緒池的風險


雖然執行緒池是構建多執行緒應用程式的強大機制,但使用它並不是沒有風險的。用執行緒池構建的應用程式容易遭受任何其它多執行緒應用程式容易遭受的所有併發風險,諸如同步錯誤和死鎖,它還容易遭受特定於執行緒池的少數其它風險,諸如與池有關的死鎖、資源不足和執行緒洩漏。


死鎖


任何多執行緒應用程式都有死鎖風險。當一組程式或執行緒中的每一個都在等待一個只有該組中另一個程式才能引起的事件時,我們就說這組程式或執行緒死鎖了。死鎖的最簡單情形是:執行緒 A 持有物件 X 的獨佔鎖,並且在等待物件 Y 的鎖,而執行緒 B 持有物件 Y 的獨佔鎖,卻在等待物件 X 的鎖。除非有某種方法來打破對鎖的等待(Java 鎖定不支援這種方法),否則死鎖的執行緒將永遠等下去。


雖然任何多執行緒程式中都有死鎖的風險,但執行緒池卻引入了另一種死鎖可能,在那種情況下,所有池執行緒都在執行已阻塞的等待佇列中另一任務的執行結果的任務,但這一任務卻因為沒有未被佔用的執行緒而不能執行。當執行緒池被用來實現涉及許多互動物件的模擬,被模擬的物件可以相互傳送查詢,這些查詢接下來作為排隊的任務執行,查詢物件又同步等待著響應時,會發生這種情況。


資源不足


執行緒池的一個優點在於:相對於其它替代排程機制(有些我們已經討論過)而言,它們通常執行得很好。但只有恰當地調整了執行緒池大小時才是這樣的。執行緒消耗包括記憶體和其它系統資源在內的大量資源。除了 Thread 物件所需的記憶體之外,每個執行緒都需要兩個可能很大的執行呼叫堆疊。除此以外,JVM 可能會為每個 Java 執行緒建立一個本機執行緒,這些本機執行緒將消耗額外的系統資源。最後,雖然執行緒之間切換的排程開銷很小,但如果有很多執行緒,環境切換也可能嚴重地影響程式的效能。


如果執行緒池太大,那麼被那些執行緒消耗的資源可能嚴重地影響系統效能。線上程之間進行切換將會浪費時間,而且使用超出比您實際需要的執行緒可能會引起資源匱乏問題,因為池執行緒正在消耗一些資源,而這些資源可能會被其它任務更有效地利用。除了執行緒自身所使用的資源以外,服務請求時所做的工作可能需要其它資源,例如 JDBC 連線、套接字或檔案。這些也都是有限資源,有太多的併發請求也可能引起失效,例如不能分配 JDBC 連線。


併發錯誤


執行緒池和其它排隊機制依靠使用 wait() 和 notify() 方法,這兩個方法都難於使用。如果編碼不正確,那麼可能丟失通知,導致執行緒保持空閒狀態,儘管佇列中有工作要處理。使用這些方法時,必須格外小心;即便是專家也可能在它們上面出錯。而最好使用現有的、已經知道能工作的實現,例如在下面的無須編寫您自己的池中討論的 util.concurrent 包。


執行緒洩漏


各種型別的執行緒池中一個嚴重的風險是執行緒洩漏,當從池中除去一個執行緒以執行一項任務,而在任務完成後該執行緒卻沒有返回池時,會發生這種情況。發生執行緒洩漏的一種情形出現在任務丟擲一個 RuntimeException 或一個 Error 時。如果池類沒有捕捉到它們,那麼執行緒只會退出而執行緒池的大小將會永久減少一個。當這種情況發生的次數足夠多時,執行緒池最終就為空,而且系統將停止,因為沒有可用的執行緒來處理任務。


有些任務可能會永遠等待某些資源或來自使用者的輸入,而這些資源又不能保證變得可用,使用者可能也已經回家了,諸如此類的任務會永久停止,而這些停止的任務也會引起和執行緒洩漏同樣的問題。如果某個執行緒被這樣一個任務永久地消耗著,那麼它實際上就被從池除去了。對於這樣的任務,應該要麼只給予它們自己的執行緒,要麼只讓它們等待有限的時間。


請求過載


僅僅是請求就壓垮了伺服器,這種情況是可能的。在這種情形下,我們可能不想將每個到來的請求都排隊到我們的工作佇列,因為排在佇列中等待執行的任務可能會消耗太多的系統資源並引起資源缺乏。在這種情形下決定如何做取決於您自己;在某些情況下,您可以簡單地拋棄請求,依靠更高階別的協議稍後重試請求,您也可以用一個指出伺服器暫時很忙的響應來拒絕請求。




有效使用執行緒池的準則


只要您遵循幾條簡單的準則,執行緒池可以成為構建伺服器應用程式的極其有效的方法:


不要對那些同步等待其它任務結果的任務排隊。這可能會導致上面所描述的那種形式的死鎖,在那種死鎖中,所有執行緒都被一些任務所佔用,這些任務依次等待排隊任務的結果,而這些任務又無法執行,因為所有的執行緒都很忙。


在為時間可能很長的操作使用合用的執行緒時要小心。如果程式必須等待諸如 I/O 完成這樣的某個資源,那麼請指定最長的等待時間,以及隨後是失效還是將任務重新排隊以便稍後執行。這樣做保證了:透過將某個執行緒釋放給某個可能成功完成的任務,從而將最終取得某些進展。


理解任務


要有效地調整執行緒池大小,您需要理解正在排隊的任務以及它們正在做什麼。它們是 CPU 限制的(CPU-bound)嗎?它們是 I/O 限制的(I/O-bound)嗎?您的答案將影響您如何調整應用程式。如果您有不同的任務類,這些類有著截然不同的特徵,那麼為不同任務類設定多個工作佇列可能會有意義,這樣可以相應地調整每個池。


調整池的大小


調整執行緒池的大小基本上就是避免兩類錯誤:執行緒太少或執行緒太多。幸運的是,對於大多數應用程式來說,太多和太少之間的餘地相當寬。


請回憶:在應用程式中使用執行緒有兩個主要優點,儘管在等待諸如 I/O 的慢操作,但允許繼續進行處理,並且可以利用多處理器。在執行於具有 N 個處理器機器上的計算限制的應用程式中,線上程數目接近 N 時新增額外的執行緒可能會改善總處理能力,而線上程數目超過 N 時新增額外的執行緒將不起作用。事實上,太多的執行緒甚至會降低效能,因為它會導致額外的環境切換開銷。


執行緒池的最佳大小取決於可用處理器的數目以及工作佇列中的任務的性質。若在一個具有 N 個處理器的系統上只有一個工作佇列,其中全部是計算性質的任務,線上程池具有 N 或 N+1 個執行緒時一般會獲得最大的 CPU 利用率。


對於那些可能需要等待 I/O 完成的任務(例如,從套接字讀取 HTTP 請求的任務),需要讓池的大小超過可用處理器的數目,因為並不是所有執行緒都一直在工作。透過使用概要分析,您可以估計某個典型請求的等待時間(WT)與服務時間(ST)之間的比例。如果我們將這一比例稱之為 WT/ST,那麼對於一個具有 N 個處理器的系統,需要設定大約 N*(1+WT/ST) 個執行緒來保持處理器得到充分利用。


處理器利用率不是調整執行緒池大小過程中的唯一考慮事項。隨著執行緒池的增長,您可能會碰到排程程式、可用記憶體方面的限制,或者其它系統資源方面的限制,例如套接字、開啟的檔案控制程式碼或資料庫連線等的數目。


無須編寫您自己的池


Doug Lea 編寫了一個優秀的併發實用程式開放原始碼庫 util.concurrent,它包括互斥、訊號量、諸如在併發訪問下執行得很好的佇列和雜湊表之類集合類以及幾個工作佇列實現。該包中的 PooledExecutor 類是一種有效的、廣泛使用的以工作佇列為基礎的執行緒池的正確實現。您無須嘗試編寫您自己的執行緒池,這樣做容易出錯,相反您可以考慮使用 util.concurrent 中的一些實用程式。
util.concurrent 庫也激發了 JSR 166,JSR 166 是一個 Java 社群過程(Java Community Process (JCP))工作組,他們正在打算開發一組包含在 java.util.concurrent 包下的 Java 類庫中的併發實用程式,這個包應該用於 Java 開發工具箱 1.5 發行版。


UTIL.CONCURRENT包介紹


1概述
紐約奧斯維果州立大學的Doug Lea 編寫了一個優秀的併發實用程式開放原始碼庫 util.concurrent,它包括互斥、訊號量、諸如在併發訪問下執行得很好的佇列和雜湊表之類集合類以及幾個工作佇列實現。該包中的 PooledExecutor 類是一種有效的、廣泛使用的以工作佇列為基礎的執行緒池的正確實現。該工具包已經過了大量的測試和實踐的考驗,是個功能強大的成熟的工具包,在很多著名的伺服器如oc4j、jboss裡面您都可以找到util.concurrent的影子。Doug Lea 當時設計這個包的目標就是“簡單的介面、高質量實現”。JSR 166(Java Community Process (JCP))工作組已計劃將 java.util.concurrent 包納入 JDK 1.5 發行版,正在進行一些標準化的工作,讓我們拭目以待。也許很多程式設計師願意嘗試自己寫開發包,但是最終您會發現,您寫的開發包也許並不比Doug Lea寫的強大,甚至更容易出錯。程式設計師的青春和生命是有限的,大量的時間和精力花在重複撰寫已有的通用開發包,我認為這通常是不必要的,當然有志於挑戰自我的和想深入研究該問題的讀者除外。牛頓曾經很謙虛的說過,他的巨大的成功,是應為他站在巨人的肩膀上。所以,您並非必須編寫您自己的執行緒池,這樣做容易出錯,相反您可以考慮使用 util.concurrent 中的一些實用程式。當然這裡筆者並不是鼓勵大家懶惰得思想,而是認為在具體的情況下,應該有所取捨,將寶貴的開發時間用在解決更重要的核心問題上來。在這裡順便提一下,Doug Lea 的 《Concurrent Programming in Java, Second Edition》是一本圍繞 Java 應用程式中多執行緒程式設計方面可能出現的難解問題的權威著作,有時間的話您可找來讀一讀。






2    框架與結構
下面讓我們來看看util.concurrent的框架結構。關於這個工具包概述的e文原版連結地址是http: //gee.cs.oswego.edu/dl/cpjslides/util.pdf。該工具包主要包括三大部分:同步、通道和執行緒池執行器。第一部分主要是用來定製鎖,資源管理,其他的同步用途;通道則主要是為緩衝和佇列服務的;執行緒池執行器則提供了一組完善的複雜的執行緒池實現。
--主要的結構如下圖所示


2.1 Sync
acquire/release協議的主要介面
- 用來定製鎖,資源管理,其他的同步用途
- 高層抽象介面
- 沒有區分不同的加鎖用法


實現
-Mutex, ReentrantLock, Latch, CountDown,Semaphore, WaiterPreferenceSemaphore, FIFOSemaphore, PrioritySemaphore
還有,有幾個簡單的實現,例如ObservableSync, LayeredSync


舉例:如果我們要在程式中獲得一獨佔鎖,可以用如下簡單方式:
try {
lock.acquire();
try {
action();
}
finally {
lock.release();
}
}catch(Exception e){
}


程式中,使用lock物件的acquire()方法獲得一獨佔鎖,然後執行您的操作,鎖用完後,使用release()方法釋放之即可。呵呵,簡單吧,想想看,如果您親自撰寫獨佔鎖,大概會考慮到哪些問題?如果關鍵的鎖得不到怎末辦?用起來是不是會複雜很多?而現在,以往的很多細節和特殊異常情況在這裡都無需多考慮,您儘可以把精力花在解決您的應用問題上去。


2.2 通道(Channel)
為緩衝,佇列等服務的主介面


具體實現
LinkedQueue, BoundedLinkedQueue,BoundedBuffer, BoundedPriorityQueue, SynchronousChannel, Slot


通道例子
class Service { // ...
final Channel msgQ = new LinkedQueue();
public void serve() throws InterruptedException {
String status = doService();
msgQ.put(status);
}
public Service() { // start background thread
Runnable logger = new Runnable() {
public void run() {
try {
for(;;)
System.out.println(msqQ.take());
}
catch(InterruptedException ie) {} }
};
new Thread(logger).start();
}
}
在後臺伺服器中,緩衝和佇列都是最常用到的。試想,如果對所有遠端的請求不排個佇列,讓它們一擁而上的去爭奪cpu、記憶體、資源,那伺服器瞬間不當掉才怪。而在這裡,成熟的佇列和緩衝實現已經提供,您只需要對其進行正確初始化並使用即可,大大縮短了開發時間。


2.3執行器(Executor)
Executor是這裡最重要、也是我們往往最終寫程式要用到的,下面重點對其進行介紹。
類似執行緒的類的主介面
- 執行緒池
- 輕量級執行框架
- 可以定製排程演算法


只需要支援execute(Runnable r)
- 同Thread.start類似


實現
- PooledExecutor, ThreadedExecutor, QueuedExecutor, FJTaskRunnerGroup


PooledExecutor(執行緒池執行器)是個最常用到的類,以它為例:
可修改得屬性如下:
- 任務佇列的型別
- 最大執行緒數
- 最小執行緒數
- 預熱(預分配)和立即(分配)執行緒
- 保持活躍直到工作執行緒結束
-- 以後如果需要可能被一個新的代替
- 飽和(Saturation)協議
-- 阻塞,丟棄,生產者執行,等等


可不要小看上面這數條屬性,對這些屬性的設定完全可以等同於您自己撰寫的執行緒池的成百上千行程式碼。下面以筆者撰寫過得一個GIS伺服器為例:
該GIS伺服器是一個典型的“請求-服務”型別的伺服器,遵循後端程式設計的一般框架。首先對所有的請求按照先來先服務排入一個請求佇列,如果瞬間到達的請求超過了請求佇列的容量,則將溢位的請求轉移至一個臨時佇列。如果臨時佇列也排滿了,則對以後達到的請求給予一個“伺服器忙”的提示後將其簡單拋棄。這個就夠忙活一陣的了。
然後,結合連結串列結構實現一個執行緒池,給池一個初始容量。如果該池滿,以x2的策略將池的容量動態增加一倍,依此類推,直到匯流排程數服務達到系統能力上限,之後執行緒池容量不在增加,所有請求將等待一個空餘的返回執行緒。每從池中得到一個執行緒,該執行緒就開始最請求進行GIS資訊的服務,如取座標、取地圖,等等。服務完成後,該執行緒返回執行緒池繼續為請求佇列離地後續請求服務,週而復始。當時用向量連結串列來暫存請求,用wait()、 notify() 和 synchronized等原語結合向量連結串列實現執行緒池,總共約600行程式,而且在執行時間較長的情況下伺服器不穩定,執行緒池被取用的執行緒有異常消失的情況發生。而使用util.concurrent相關類之後,僅用了幾十行程式就完成了相同的工作而且伺服器執行穩定,執行緒池沒有丟失執行緒的情況發生。由此可見util.concurrent包極大的提高了開發效率,為專案節省了大量的時間。
使用PooledExecutor例子
import java.net.*;
/**



結束語


Title: 
*


Description: 負責初始化執行緒池以及啟動伺服器
*


Copyright: Copyright (c) 2003
*


Company: 
* @author not attributable
* @version 1.0
*/
public class MainServer {
//初始化常量
public static final int MAX_CLIENT=100; //系統最大同時服務客戶數
//初始化執行緒池
public static final PooledExecutor pool =
new PooledExecutor(new BoundedBuffer(10), MAX_CLIENT); //chanel容量為10,
//在這裡為執行緒池初始化了一個
//長度為10的任務緩衝佇列。


public MainServer() {
//設定執行緒池執行引數
pool.setMinimumPoolSize(5); //設定執行緒池初始容量為5個執行緒
pool.discardOldestWhenBlocked();//對於超出佇列的請求,使用了拋棄策略。
pool.createThreads(2); //線上程池啟動的時候,初始化了具有一定生命週期的2個“熱”執行緒
}


public static void main(String[] args) {
MainServer MainServer1 = new MainServer();
new HTTPListener().start();//啟動伺服器監聽和處理執行緒
new manageServer().start();//啟動管理執行緒
}
}


類HTTPListener
import java.net.*;
/**
*


Title: 
*


Description: 負責監聽埠以及將任務交給執行緒池處理
*


Copyright: Copyright (c) 2003
*


Company: 
* @author not attributable
* @version 1.0
*/


public class HTTPListener extends Thread{
public HTTPListener() {
}
public void run(){
try{
ServerSocket server=null;
Socket clientconnection=null;
server = new ServerSocket(8008);//服務套接字監聽某地址埠對
while(true){//無限迴圈
clientconnection =server.accept();
System.out.println("Client connected in!");
//使用執行緒池啟動服務
MainServer.pool.execute(new HTTPRequest(clientconnection));//如果收到一個請求,則從執行緒池中取一個執行緒進行服務,任務完成後,該執行緒自動返還執行緒池
}
}catch(Exception e){
System.err.println("Unable to start serve listen:"+e.getMessage());
e.printStackTrace();
}
}
}


關於util.concurrent工具包就有選擇的介紹到這,更詳細的資訊可以閱讀這些java原始碼的API文件。Doug Lea是個很具有“open”精神的作者,他將util.concurrent工具包的java原始碼全部公佈出來,有興趣的讀者可以下載這些原始碼並細細品味。


執行緒池是組織伺服器應用程式的有用工具。它在概念上十分簡單,但在實現和使用一個池時,卻需要注意幾個問題,例如死鎖、資源不足和 wait() 及 notify() 的複雜性。如果您發現您的應用程式需要執行緒池,那麼請考慮使用 util.concurrent 中的某個 Executor 類,例如 PooledExecutor,而不用從頭開始編寫。如果您要自己建立執行緒來處理生存期很短的任務,那麼您絕對應該考慮使用執行緒池來替代。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/9399028/viewspace-1852029/,如需轉載,請註明出處,否則將追究法律責任。

相關文章