任務通常是一些抽象的且離散的工作單元。通過把應用程序的工作分解到多個任務中,可以簡化程序的組織結構,提供一種自然的事務邊界來優化錯誤恢復過程,以及提供一種自然的并行工作結構來提升并發性。
6.1 在線程中執行任務
- 當圍繞“任務執行”來設計應用程序時,第一步是要找出清晰的任務邊界。
- 在理想情況下,各個任務之間是相互獨立的:任務不依賴其他任務的狀態,結果或邊界效應。
- 獨立性有助于實現并發。
- 對于大多數服務器應用程序都以獨立的客戶請求作為邊界。
6.1.1 串行地執行任務
class SingleThreadWebServer{
public stati void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket(80);
while (true){
Socket connection = socket.accept();
handleRequest(connection);
}
}
}
如上為串行的Web服務器實現,在理論上是正確的,但在實際應用上它的執行性能是非常糟糕的,因為它每次只能處理一個請求。
- 適用場景:
當任務數量很少且執行時間很長時,或者當服務器只為單個用戶提供服務,并且客戶每次只發出一個請求時。
6.1.2 顯式地為任務創建線程
class ThreadPerTaskWebServer{
public stati void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket(80);
while (true){
final Socket connection = socket.accept();
Runnable task = new Runnable(){
public void run(){
handleRequest(connection);
}
};
new Thread(task).start();
}
}
}
如上采用的是為每個請求創建一個新的線程來提供服務,從而實現更高的響應性。
但要注意的是這里的任務處理代碼handleRequest方法必須是線程安全的,因為當有多個任務時會并發地調用這段代碼。
6.1.3 無限制創建線程的不足
- 線程生命周期的開銷非常高。線程的創建過程需要時間,延遲處理的請求,并且需要JVM和操作系統提供一些輔助幫助。如果請求的到達率非常高且請求的處理過程是輕量級的,那么為每個請求創建一個新線程會消耗大量的計算資源。
- 資源消耗。活躍的線程會消耗系統資源,尤其是內存。如果有大量的空閑線程,那么會占用許多內存,給垃圾回收器帶來壓力。如果大量線程在競爭CPU資源,再創建線程反而會降低性能。
- 穩定性。可創建線程的數量存在一個限制。這個限制值將隨著平臺的不同而不同。如果破壞了這些限制,那么很可能拋出OutOfMemoryError異常。
6.2 Executor框架
串行執行的問題在于其糟糕的響應性和吞吐量,而“為每個任務分配一個線程”的問題在于資源管理的復雜性。因此,為了提供了一種靈活的線程池來實現作為Executor框架的一部分,來簡化線程的管理工作。
- Executor基于生產者-消費者模式,提交任務的操作相當于生產者,執行任務的線程則相當于消費者。
如下為基于線程池的Web服務器:
class ThreadPerTaskWebServer{
//定義線程池大小
private static final int NTHREAD = 100;
//定義Executor
private static final Executor exec =
Executors.newFixedThreadPool(NTHREAD);
public static void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket(80);
while (true){
final Socket connection = socket.accept();
Runnable task = new Runnable(){
public void run(){
handleRequest(connection);
}
};
//將任務添加到線程池中
exec.execute(task);
}
}
}
6.2.2 執行策略
在定義執行策略時,需要考慮任務的“What,Where,When,How”等方面。
- 在什么(What)線程中執行任務?
- 任務按照(What)順序執行(FIFO, LIFO,優先級)?
- 在隊列中有多少個(How Many)任務在等待執行?
- 如果系統由于過載而需要拒絕一個任務,那么應該選擇哪一個(Which)任務?另外如何(How)通知應用程序有任務被拒絕?
- 在執行一個任務之前或之后,應該進行哪些(What)動作?
6.2.3 線程池
線程池指的是管理一組同構工作線程的資源池。線程池往往與工作隊列有關。在工作隊列中保存了所有等待執行的任務。工作者線程從工作隊列中獲取一個任務,執行任務,然后返回線程池并等待下一個任務。
線程池的優點:通過重用現有的線程而不是創建新線程,可以避免線程創建和銷毀的開銷。并且當請求到達時,工作線程通常已經存在,減少了等待線程創建的時間,從而提高響應性。
幾種常見創建線程池的靜態工廠方法:
a. newFixedThreadPool:創建一個固定長度的線程池,每提交一個任務就創建一個線程,直到達到線程的最大數量,則規模不再變化。
b. newCachedThreadPool:創建一個可緩存的線程池,如果線程池的當前規模超過了處理需求,那么會回收空閑的線程,而當需求增加時,則可以添加新的線程,且線程池的規模沒有限制。
c. newSingleThreadPool:創建單個線程來執行任務,確保依照任務在工作隊列中的順序來串行執行。
d. newScheduledThreadPool:創建一個固定長度的線程池,且以延遲或定時的方式來執行任務。
6.2.4 Executor的生命周期
Executor的實現通常會創建線程來執行任務,但JVM只有在所有(非守護線程)線程全部終止后才會退出。因此,如果無法正確地關閉Executor,那么JVM將無法結束。
- 為了解決執行服務的生命周期問題,Executor擴展了ExecutorService接口:
public interface ExecutorService extends Executor{
void shutdown();
List<Runnable> shutdownNow();
boolean isShutdown();
boolean isTerminated();
boolean awaitTerminated(long timeout, TimeUtil unit)
throws InterruptedException;
}
- ExecutorService的生命周期有3中狀態:運行,關閉和已終止。
a. ExecutorService在初始創建時處于運行狀態。
b. shutdown方法將執行平緩的關閉過程:不再接受新的任務,同時等待已經提交的任務執行完成-包括那些還未開始執行的任務。
c. shutdownNow方法將執行粗暴的關閉過程:它將嘗試取消所有運行中的任務,并且不再啟動隊列中尚未開始執行的任務。
d. 等所有任務都完成后,ExecutorService將轉入終止狀態,可以調用awaitTermination來等待ExecutorService到達終止狀態,或者通過調用isTerminated來輪詢ExecutorService是否已經終止。
如下為支持關閉操作的Web服務器
class LifecycleWebServer{
private final ExecutorService exec = ...;
public static void main(String[] args) throws IOException{
ServerSocket socket = new ServerSocket(80);
while (! exec.isShutdown()){
try{
final Socket connection = socket.accept();
exec.execute(new Runnable(){
public void run() { handleRequest(connection); }
})
} catch (RejectedExecutionException e){
if (!exec.isShutdown())
log("task submission rejected",e);
}
}
}
public void stop() { exec.shutdown(); }
void handleRequest(Socket connection){
Request req = readRequest(connection);
//判斷是否為請求關閉的指令
if (isShutdownRequest(req))
stop();
else
dispatchRequest(req);
}
}
假如我們需要關閉服務器,那么可以在程序中調用stop方法,或者以客戶端請求形式向Web服務器發送一個特定格式的HTTP請求。
6.3 找出可利用的并行性
我們來實現一個瀏覽器的頁面渲染功能,它的作用是將HTML頁面繪制到圖像緩存中,為了簡單起見,我們假設HTML頁面中只包含標簽文本和圖片。
- 方案一:串行地渲染頁面元素
public class SingleThreadRenderer{
void renderPage(CharSequence source){
//加載文本
renderText(source);
List<ImageData> ImageData = new ArrayList<ImageData>();
//下載圖片
for (ImageData imageInfo : scanForImageInfo(source))
ImageData.add(imageInfo.downloadImage());
//加載圖片
for (ImageData data : ImageData)
renderImage(data);
}
}
評價:該中方式在圖片下載過程中大部分時間都是在等待I/O操作執行完成,在這期間CPU幾乎不做任何工作,使得用戶在看到最終頁面之前要等待很長的時間。
- 方案二:使用Future等待圖片下載
Future表示一個任務的生命周期,并提供了相應的方法判斷是否已經完成或取消,以及獲取任務的結果和取消任務等。
public interface Future<V> {
boolean cancel(boolean mayInterruptIfRunning);
boolean isCancelled();
boolean isDone();
V get() throws InterruptedException,ExecutionException,
CancellationException;
//限時獲取
V get(long timeout, TimeUtil unit) throws InterruptedException,
ExecutionException, CancellationException, TimeoutException;
}
使用Future實現頁面渲染器:
public class FutureRenderer {
private final ExecutorService exec = ....;
void renderPage(CharSequence source){
//獲取圖片信息
final List<ImageInfo> imageInfos = scanForImageInfo(source);
//定義圖片下載任務
Callable<List<ImageData>> task =
new Callable<List<ImageData>>() {
//通過call方法返回結果
public List<ImageData> call(){
public List<ImageData> result
= new ArrayList<ImageData>();
for (ImageInfo imageInfo : imageInfos)
result.add(imageInfo.downloadImage());
return result;
}
};
//將任務添加到線程池中
Future<List<ImageData>> future = exec.submit(task);
//加載文本信息
renderText(source);
try{
//獲取圖片結果,并加載圖片
List<ImageData> imageData = future.get();
for (ImageData data : imageData)
renderImage(data);
} catch (InterruptedException e){
//重新設置線程的中斷狀態
Thread.currentThread().interrupt();
//取消任務
future.cancel(true);
} catch (ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
如上,我們將渲染過程分解為文本渲染和圖片渲染,使得兩者并發執行。
6.3.4 在異構任務并行化中存在的局限
- 在上面的FutureRender中使用了兩個任務,一個是負責渲染文本,一個是負責渲染圖片。如果渲染文本的速度遠遠高于渲染圖片的速度,那么程序的最終性能與串行執行的性能差別并不大,而代碼卻變復雜了。
因此,只有當大量相互獨立且同構的任務可以并發進行處理時,才能體現出將程序的工作負載分配到多個任務中帶來的真正性能。
將所有的圖片下載任務分解為若干個獨立的下載任務并發進行
public class FutureRenderer {
private final ExecutorService exec = ....;
void renderPage(CharSequence source){
//獲取圖片信息
final List<ImageInfo> imageInfos = scanForImageInfo(source);
//定義任務結果
final List<Future<ImageData>> futures = new ArrayList<Future<ImageData>>();
for (ImageInfo imageInfo : imageInfos){
//定義任務
Callable<ImageData> task = new Callable<ImageData>(){
public ImageData call(){
return imageInfo.downloadImage();
}
}
//添加到線程池中
futures.add(exec.submit(task));
}
//遍歷任務結果
for (Future future : futures){
try {
//獲取圖片信息,并加載
ImageData imageData = future.get();
renderImage(imageData);
}catch (InterruptedException e){
//重新設置線程的中斷狀態
Thread.currentThread().interrupt();
//取消任務
future.cancel(true);
} catch (ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
}
如上,我們為每張圖片都創建一個任務執行。但這里存在一個缺陷,我們在最后遍歷futures時,調用get方法獲取圖片,我們直到這個的get方法若任務已經完成,那么會直接獲取到圖片,若任務還未完成,那么會阻塞,直到任務完成。那么存在這么個問題:若第一張圖未下載完畢,而第二張下載完畢,這時候第二張會因為第一張未下載完成而導致被阻塞獲取到。
- 為了解決這個問題,我們提供了CompletionService來更好地實現。
CompletionService的實現是維護一個保存Future對象的BlockingQueue。只有當這個Future對象狀態是結束的時候,才會加入到這個Queue中,take()方法其實就是Producer-Consumer中的Consumer。它會從Queue中取出Future對象,如果Queue是空的,就會阻塞在那里,直到有完成的Future對象加入到Queue中。
- CompletionService采取的是BlockingQueue<Future<V>>無界隊列來管理Future。若有一個線程執行完畢把返回結果放到BlockingQueue<Future<V>>里面,就可以通過completionServcie.take().get()取出結果。
public class Renderer {
private final ExecutorService exec;
Renderer(ExecutorService exec) { this.exec = exec; }
void renderPage(CharSequence source){
//獲取圖片信息
final List<ImageInfo> imageInfos = scanForImageInfo(source);
//定義CompletionService
CompletionService<ImageData> completionService =
new ExecutorCompletionService<ImageData>(exec);
//將每張圖片封裝為任務
for (final ImageInfo imageInfo : imageInfos){
completionService.submit(new Callable<ImageData>(){
public ImageData call(){
return imageInfo.downloadImage();
}
})
}
renderText(source);
//獲取圖片信息
for (int t = 0; t < imageInfos.size(); i ++){
try {
Future<ImageData> f = completionService.take();
ImageData imageData = f.get();
renderImage(imageData);
}catch (InterruptedException e){
//重新設置線程的中斷狀態
Thread.currentThread().interrupt();
} catch (ExecutionException e){
throw launderThrowable(e.getCause());
}
}
}
}
如上,為每一張圖下載都創建一個獨立任務,并在線程池中執行它們,從而將串行的下載過程轉換為并行的過程,這將減少下載所有圖片的總時間。
6.3.7 為任務設置時限
如果某個任務無法在指定時間內完成,那么將不再需要它的結果,此時可以放棄這個任務。但要注意,當這些任務超時后應該立即停止,從而避免浪費計算不必要的資源。
我們設置一個獲取廣告的機制,若在規定時間內獲取到廣告,則加載廣告,否則設置默認廣告。
Page renderPageWithAd() thorws InterruptedException{
//設定結束時間
long endNanos = System.nanoTime() + TIME_BUGGET;
//提交任務
Future<Ad> f = exec.submit(new FetchAdTask());
//加載主界面
Page page = renderPageBody();
Ad ad;
try {
//在限定時間內獲取廣告,若線程異常或超時則設置為默認的廣告
long timeleft = endNanos - System.nanoTime();
ad = f.get(timeleft, NANOSECONDS);
} catch (ExecutionException e) {
ad = DEFAULT_AD;
} catch (TimeoutException e){
ad = DEFAULT_AD;
//超時后,取消任務
f.cancel(true);
}
page.setAd(ad);
return page;
}
- ExecutorService的invokeAll方法也能批量執行任務,并批量返回結果,但有個很致命的缺點,必須等待所有的任務執行完成后統一返回,一方面內存持有的時間長;另一方面響應性也有一定的影響,畢竟大家都喜歡看看刷刷的執行結果輸出,而不是苦苦的等待;
public class InvokeAllTest {
public static void main(String[] args) throws InterruptedException {
ExecutorService exec = Executors.newFixedThreadPool(10);
List<Callable<Integer>> tasks = new ArrayList<>();
Callable<Integer> task = null;
for (int i = 0; i < 10; i ++){
task = new Callable<Integer>() {
@Override
public Integer call() throws Exception {
int random = new Random().nextInt(1000);
Thread.sleep(random);
System.out.println(Thread.currentThread().getName() + "休眠了 " + random);
return random;
}
};
tasks.add(task);
}
long s = System.currentTimeMillis();
List<Future<Integer>> results = exec.invokeAll(tasks);
System.out.println("執行任務消耗了:" + (System.currentTimeMillis() - s) + "ms");
for (int i = 0; i < results.size(); i ++){
try {
System.out.println(results.get(i).get());
} catch (ExecutionException e) {
e.printStackTrace();
}
}
}
}
輸出結果:
pool-1-thread-5休眠了 276
pool-1-thread-1休眠了 426
pool-1-thread-8休眠了 479
pool-1-thread-10休眠了 561
pool-1-thread-4休眠了 641
pool-1-thread-6休眠了 760
pool-1-thread-9休眠了 780
pool-1-thread-3休眠了 854
pool-1-thread-2休眠了 949
pool-1-thread-7休眠了 949
執行任務消耗了:974ms
426
949
854
641
276
760
949
479
780
561
如上,我們可以看到最后任務結果的輸出是按照順序輸出的。