《JAVA併發程式設計實戰》任務執行

sayWhat_sayHello發表於2018-10-26

線上程中執行任務

當圍繞“任務執行”來設計應用程式結構時,第一步是找出清晰的任務邊界。在理想情況下,各個任務之間是相互獨立的。

在正常的負載下,伺服器應用程式應該同時表現出良好的吞吐量和快速的響應性。應用程式提供商希望程式支援儘可能多的使用者,從而降低每個使用者的服務成本,而使用者則希望獲得儘快的響應。而且,當符合過載時,應用程式的效能應該是逐漸降低的,而不是直接失敗。

大多數伺服器應用程式都提供了一種自然的任務邊界選擇方式:以獨立的客戶請求為邊界。

序列執行任務

在應用程式中單個執行緒中序列的執行各項任務是最簡單的策略。

class SingleThreadWebServer{
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while(true){
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
}

執行性比較糟糕,因為一次只能處理一個請求。主執行緒在接受連線和處理相關請求等操作之間不斷的交替執行。當伺服器正在處理請求時,新到來的連線必須等到請求處理完成,然後伺服器再次呼叫accept。

在伺服器應用程式中,序列處理機制通常無法提供高吞吐率或快速響應性。

顯式的為任務建立執行緒

通過為每個請求建立一個新的執行緒來提供服務,從而實現更高的響應性。

class ThreadPerTaskWebServer {
    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);
                }
            }
            new Thread(task).start();
        }
    }
}

和上面的區別在於,對於每個連線,主迴圈都將建立一個新執行緒來處理請求,而不是在主迴圈中進行處理。由此得出:

  • 任務處理過程從主執行緒中分離出來,使得主迴圈能夠更快的重新等待下一個到來的連線。這使得程式在完成前面的請求之前可以接受新的請求,從而提高響應性。
  • 任務可以並行處理,從而能同時服務多個請求。如果有多個處理器,或者任務由於某種原因被阻塞,程式的吞吐量將得到提高。
  • 任務處理程式碼必須是執行緒安全的,因為有多個任務時會併發的呼叫這段程式碼。

在正常負載情況下,“為每個任務分配一個執行緒”能提高序列執行的效能,只要請求的到達速率不超過伺服器的請求處理能力,那麼這種方法可以同時帶來更快的響應性和更高的吞吐率。

無限制建立執行緒的不足

在生產環境中,上述方法仍然存在一些缺陷:

  • 執行緒生命週期的開銷非常高。執行緒建立和銷燬都有代價,如果為每個請求建立一個新執行緒將銷燬大量的計算資源。
  • 資源消耗。活躍的執行緒會消耗系統資源,尤其是記憶體。如果可執行的執行緒數量多於可以處理器的數量,那麼有些執行緒將閒置。大量的空閒執行緒會佔用許多記憶體。
  • 穩定性。在可建立執行緒的數量上存在一個限制。這個限制隨著平臺的不同而不同,並且受多個因素制約,包括JVM啟動引數等。如果破壞了這些限制,那麼很可能丟擲OutOfMemoryError.

為“每個任務分配一個執行緒”的問題在於,它沒有限制可建立執行緒的數量。

Executor框架

執行緒池簡化了執行緒的管理工作,並且jdk提供了一種靈活的執行緒池實現作為Exector的框架的一部分。在java類庫中,任務執行的主要抽象不是Thread,而是Executor.

public interface Executor{
    void execute(Runnable command);
}

該框架能支援多種不同型別的任務執行策略。它提供了一種標準的方法將任務的提交過程和執行過程解耦,並用Runnable來表示任務。Executor的實現還提供了對生命週期的支援,以及統計資訊收集、應用程式管理機制和效能監控等。

Executor基於生產者-消費者模式,提供任務的操作相當於生產者,執行任務的執行緒相當於消費者。

示例:基於Executor的Web伺服器

class TaskExecutionWebServer {
    private static final int SIZE = 100;
    private static final Executor exec = Executors.newFixedThreadPool(SIZE);
    
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while(true) {
            final Socket connection = socket.accept();
            Runnable task = new Runnable() {
                handleRequest(connection);
            }
            exec.execute(task);
        }
    }
}

在上述案例中,通過使用Executor將請求處理任務的提交和任務的實際執行解耦開來,並且只要常用另一種不同的Executor實現就可以改變伺服器的行為。

“每次請求建立新執行緒”

public class ThreadPerTaskExecutor implements Executor{
    public void execute(Runnable r){
        new Thread(r).start();
    };
}

類似單執行緒行為即以同步的方式執行每個任務

public class WithinThreadExecutor implements Executor {
    public void execute(Runnable r) {
        r.run();
    };
}

執行策略

通過將任務的提交和執行解耦,從而無須太大的困難就可以為某種型別的任務指定和修改執行策略。在執行策略中定義了任務執行的:

  • 在什麼(What)執行緒中執行任務
  • 任務按照什麼(What)順序執行(FIFO,LIFO,優先順序)?
  • 有多少個(how many)任務能併發執行?
  • 在佇列中有多少個(how many)任務在等待執行?
  • 如果系統由於過載需要拒絕一個任務,那麼應該選擇哪一個(Which)任務?另外,怎樣(How)通知應用程式有任務被拒絕?
  • 在執行一個任務之前或者之後,一個進行哪些(what)動作?

各種執行策略都是一種資源管理工具,最佳策略取決於可用的計算資源以及對服務質量需求。通過限制併發任務的數量,可用確保應用程式不會由於資源耗盡而失敗,或者由於在稀缺資源上發生競爭而嚴重影響效能。

通過將任務的提交和任務的執行策略分離開來,有助於在部署階段選擇和可用硬體資源最匹配的執行策略。

每當看到下面這種形式的程式碼時:new Thread(runnable).start() 並且你希望獲得一種更加靈活的執行策略時,考慮使用Executor來代替Thread.

執行緒池

管理一組同構工作執行緒的資源池。執行緒池和工作佇列密切相關,其中在工作佇列中儲存了所有等待執行的任務。工作者執行緒(Worker Thread)的任務很簡單:從工作佇列中獲取一個任務,執行任務,然後返回執行緒池並等待下一個任務。

通過重用現有執行緒而不是建立新執行緒可以在處理多個請求時分攤線上程建立和銷燬過程中產生的巨大開銷。另外一個好處是,當請求到達時,工作執行緒通常已經存在,因此不會由於等待建立執行緒而延遲任務的執行,從而提高了響應性。

類庫中提供了一些Executor的建立方式:

  • newFixedThreadPool:建立固定長度的執行緒池,每當提交一個任務時建立一個執行緒,直到達到執行緒池的最大數量,這時執行緒池的規模將不再變化。
  • newCachedThreadPool:建立一個可快取的執行緒池,如果執行緒池的當前規模超過了處理需求時,那麼將回收空閒的執行緒,而當需求增加時,則可以新增新的執行緒,執行緒池的規模不受限制。
  • newSingleThreadExecutor:單執行緒的Executor,它建立單個工作者執行緒來執行任務,如果這個執行緒異常結束,會建立另一個執行緒來代替。確保依照任務在佇列中的順序來序列執行。
  • newScheduledThreadPool:建立一個固定長度的執行緒池,而且以延遲或定時的方式來執行任務,類似於Timer

Executor的生命週期

Executor的實現通常會建立執行緒來執行任務。但JVM只有在所有(非守護)執行緒全部終止後才會退出。因此,如果無法正確的關閉Executor,你們JVM將無法結束。

由於Executor以非同步方式來執行任務,因此在關閉應用程式時,可能採用最平緩的關閉形式(完成所有已啟動的任務,並且不再接受任何新的任務),也可能是最粗暴的形式(停電),或者其他。既然Executor是為應用程式提供服務的,因此他們也是可以被關閉的,並將在關閉操作中受影響的任務的狀態反饋給應用程式。

為了解決執行服務的生命週期問題,Executor擴充套件了ExecutorService介面,新增了一些用於生命週期管理的方法(同時還有一些用於任務提交的便利方法)。

public interface ExecutorService extends Executor {
    void shutdown();
    List<Runnable> shutdownNow();
    boolean isShutdown();
    boolean isTerminated();
    boolean awaitTermination(long timeout,TimeUnit unit) throws InterruptedExeception;
    //...
}

ExecutorService的生命週期有3中狀態:執行、關閉、已終止。
ExecutorService在建立時處於執行狀態。shutdown方法將執行平緩的關閉操作:不再接受新的任務,同時等待已經提交的任務執行完成,包括那些提交了還沒有開始執行的任務。shutdownNow方法執行粗暴的關閉操作:嘗試取消所有執行中的任務,並且不再啟動佇列中尚未開始執行的任務。

class LifeCycleWebServer {
    private int ExecutorService exec =  ...;
    
    public void start() throws IOExeception {
        ServerSocket socket = new ServerSocket(80);
        while(!exec.isShutdown()) {
            try {
                final Socket conn = socket.accept();
                exec.execute(new Runnable() {
                    public void run() {
                        handleRequest(conn);
                    }
                });
            } catch(RejectedExecutionExeception e) {
                if(!exec.isShutdown()){
                    log("task submission rejected",e);
                }
            }
        }
    }
    
    public void stop() {
        exec.shutdown();
    }
    
    void handleRequest(Socket conn) {
        Request req = readRequest(conn);
        if(isShutdownRequest(req)) {
            stop();
        }
        else {
            dispatchRequest(req);
        }
    }
    
}

延遲任務和週期任務

Timer類負責管理延遲任務(100秒後執行該任務)和週期任務(每10秒執行一次該任務),然而Timer存在一些缺陷,因此考慮使用ScheduledThreadPoolExecutor來代替它。

Timer在執行所有定時任務時只會建立一個執行緒。如果某個任務的執行時間過長,那麼將破壞其他TimerTask的定時精準性。

Timer的另一個問題是,如果TimerTask丟擲一個異常,那麼Timer將表現出糟糕的行為。Timer執行緒不捕獲異常,因此TimerTask丟擲未檢查的異常時將終止定時執行緒。這種情況下Timer也不會恢復執行緒的執行,而是錯誤的認為整個Timer都被取消了。因此,已經排程但尚未執行的TimerTask將不會再執行,新的任務也不能被排程。(這個問題稱為執行緒洩漏)

如果要構建自己的排程服務,那麼可以使用DelayQueue,它實現了BlockingQueue,並未ScheduledThreadPoolExecutor提供排程功能。DelayQueue管理著一組Delayed物件。每個Delayed物件都有一個相應的延遲時間:在DelayQueue中,只有某個元素逾期後,才能從DelayQueue中執行take操作。從DelayQueue中返回的物件將根據他們的延遲時間進行排序。

public class OutOfTime {
    public static void main(Stirng[] args) throws Exeception {
        Timer timer = new Timer();
        timer.schedule(new ThrowTask(),1);
        SECONDS.sleep(1);
        timer.schedule(new ThrowTask(),1);
        SECONDS.sleep(5);
    }
    
    static class ThrowTask extends TimerTask {
        public void run() {
            throw new RuntimeExeception();
        }
    }
}

程式在1秒就終止了,並丟擲一個異常“Timer already cancelled”

找出可利用的並行性

Executor框架幫助指定執行策略,但如果要使用Executor,必須將任務表述為一個Runnable.

示例:序列的頁面渲染器

最簡單的方法就是對HTML文件進行序列處理。當遇到文字標籤時,將其繪製到影象快取中。當遇到影象引用時,先通過網路獲取它,然後再將其繪製到影象快取中。
這種方式可能會讓使用者感到煩惱,因為他們必須等待很長時間,直到顯示所有文字。

另一種序列方法更好一些,它先繪製文字元素,同時為影象預留出矩形的佔位空間,在處理完第一遍文字後,程式再開始下載影象,並將它們繪製到相應的佔位空間中。

public class SingleThreadRenderer {
    void renderPage(CharSequence source) {
        renderText(source);
        List<ImageData> imageData = new ArrayList<>();
        for(ImageInfo imageInfo : scanForImageInfo(source)){
            imageData.add(imageInfo.downloadImage());
        }
        for(ImageData data : imageData) {
            renderImage(data);
        }
    }
}

攜帶結果的任務Callable和Future

Runnable和Callable描述的都是抽象的計算任務。這些任務通常是有範圍的,即都有一個明確的起始點,並且最終會結束。Executor執行的任務又4個生命週期階段:建立、提交、開始和完成。由於有些任務可能要執行很長時間,因此通常希望能夠取消這些任務,只有他們能響應中斷,才能取消。取消一個已經完成的任務不會有任何影響。

Future表示一個任務的生命週期,並提供了相應的方法判斷是否已經完成或取消,已經獲取任務的結果和取消任務等。

public interface Callable<V> {
    V call() throws Exception;
}

public interface Futute<V> {
    boolean cancel(boolean mayInterruptIfRunning);
    boolean isCancelled();
    boolean isDone();
    V get() throws InterruptedException,ExecutionException,CancellationException;
    V get(long timeout,TimeUnit unit)throws InterruptedException,ExecutionException,CancellationException,TimeoutException;
}

可以通過許多方法建立一個Future來描述任務。ExecutorService中所有的submit方法都將返回一個Future,從而將一個Runnable或者Callable提交給Executor,並得到一個Future用來獲得任務的執行結果或者取消任務。還可以顯式的為某個指定的Runnable或Callable例項化一個FutureTask.

從java6開始,ExecutorService實現可以改寫AbstractExecutorService中的newTaskFor方法,從而根據已提交的Runnable或者Callable來控制Future的例項化過程。

protected <T> RunnableFuture<T> newTaskFor(Callable<T> task) {
    return new FutureTask<T>(task);
}

通過Future實現頁面渲染器

我們將渲染分為兩個任務:

  1. 渲染所有文字
  2. 下載所有影象

Callable和Future有助於表示這些協同任務之間的互動。

public class FutureRenderer {
    private final ExecutorService executor = ...;
    
    void renderPage(CharSequence source) {
        final List<ImageInfo> imageInfos = scanForImageInfo(source);
        Callable<List<ImageData>> task = new Callable<>() {
            public List<ImageData> call(){
                List<ImageData> result = new ArrayList<>();
                for(ImageInfo imageinfo : imageInfos) {
                    result.add(imageInfo.downloadImage());
                }
            };
        }
        
        Future<ListImageData>> future = executor.submit(task);
        renderText(source);
        
        try {
            List<ImageData> imageData = future.get();
            for(ImageData data : imageData){
                renderImage(data);
            }
        } catch(InterriptedException e){
            Thread.currentThread().interrupt();
            future.cancel(true);
        } catch(ExecutionException e) {
            throw launderThrowable(e.getCause());
        }
    }
}

上面這個類使得渲染文字任務和下載影象資料的任務併發的執行。當所有影象下載完成後會顯示到頁面上,這將提升使用者體驗,不僅使使用者更快的看到結果,還有效的利用了並行性,但我們可以做的更好。使用者不必等到所有影象都下載完成,而希望看到每當下載完一副影象時就立即顯示出來。

在異構任務並行化中存在的侷限

FutureRenderer使用了兩個任務,其中一個負責渲染文字,另一個負責下載影象。如果渲染文字的速度遠遠高於下載影象的速度,那麼程式的最終效能和序列執行的效能差別不大,而程式碼卻變複雜了。

只有大量相互獨立且同構的任務可以併發進行處理時,才能體現出程式的工作負載分配到多個任務中帶來的真正效能提升。

CompletionService:Executor和BlockingQueue

如果向Executor提交了一組計算任務,並且希望在計算完成後獲得結果,那麼可以保留和每個任務關聯的Future,然後反覆使用get方法,同時將引數timeout指定為0,從而通過輪詢來判斷任務是否完成。這種方法雖然可行,但卻有些繁瑣。幸運的是,我們還有一種更好的方法:完成服務(CompletionService)

CompletionService將Executor和BlockingQueue的功能融合在一起。你可以將Callable任務提交給它來執行,然後使用類似於佇列操作的take和poll方法來獲得已完成的結果,而這些結果會在完成時被封裝成Future。ExecutorCompletionService實現了CompletionService,並將計算部分委託給一個Executor.

ExecutorCompletionService的實現非常簡單。在建構函式中建立一個BlockingQueue來儲存計算完成的結果。當計算完成時,呼叫Future-Task中的done方法。當提交某個任務時,該任務將首先包裝成一個QueueingFuture,這是FutureTask的一個子類,然後再改寫子類的done方法,並將結果放入BlockingQueue中。take和poll方法委託給了BlockingQueue,這些方法會在得到結果前阻塞。

private class QueueingFuture<V> extends FutureTask<V> {
    QueueingFuture(Callable<V> c) {
        super(c);
    }
    QueueingFuture(Runnable t,V r) {
        super(t,r);
    }
    
    protected void done() {
        completionQueue.add(this);
    }
}

示例:使用CompletionService實現頁面渲染器

為每一幅影象的下載都建立一個獨立任務,並線上程池中執行他們。從而將序列的下載過程轉換為並行的過程:這將減少下載所有影象的總時間。此外,通過CompletionService獲取結果以及使每張圖片都在下載完成後立即顯示出來,能使使用者獲得一個更加動態和更高響應的使用者介面。

public class Renderer {
    private final ExecutorService executor;
    
    Renderer(ExecutorService executor) {
        this.executor = executor;
    }
    
    void renderPage(CharSequence source) {
        List<ImageInfo> info = scanForImageInfo(source);
        CompletionService<ImageData> completionService = new ExecutorCompletionService<ImageData>(executor);
        for(final Imageinfo imageInfo : info) {
            completionService.submit(new Callable<ImageData>(){
                public ImageData call() {
                    return imageInfo.downloadImage();
                }
            });
        }
        
        renderText(source);
        
        try {
            for(int i = 0,n = info.size();t < n;t++){
                Future<ImageData> f = completinService.take();
                ImageData imageData = f.get();
                renderImage(imageData);
            }
        } catch(InterruptedException e) {
            Thread.currentThread().interrpt();
        } catch(ExecutionException e) {
            throw launderThrowable(e.getCause());
        }
    }
}

多個ExecutorCompletionService可以共享一個Executor,因此可以建立一個對於特定計算私有,又能共享一個公共Executor的ExecutorCompletionService。因此CompetionService的作用相當於一組計算的控制程式碼,這和Future作為單個計算的控制程式碼是非常類似的。通過記錄提交個CompletionService的任務數量,並計算出已經獲得的已完成結果的數量,即使使用一個共享的Executor也能知道已經獲得了所有任務結果的時間。

為任務設定時限

如果某個任務無法在指定時間完成,那麼將不再需要它的結果,此時可以放棄這個任務。在有限時間內執行任務的主要困難在於,要確保得到答案的時間不會超過限定的時間,或者在限定時間內無法獲得答案。在支援時間限制的Future.get中支援這種需求:當結果可用時,它立即返回,否則丟擲TimeoutException.

Page renderPageWithAd() throws InterruptedException {
    long endNanos = System.nanoTime() + TIME_BUDGET;
    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;
}

示例:旅行預定入口網站

invokeAll將多個任務提交到一個ExecutorService並獲得結果。InvokeAll方法的引數為一組任務,並返回一組Future。invokeAll按照任務集合中迭代器的順序間將所有的Future新增到返回的集合中,從而使呼叫者能將各個Future和其表示的Callable關聯起來。當所有任務都執行完畢時,或者呼叫執行緒被中斷時,又或者超過指定時限時,invokeAll將返回。當超過指定時限後,然後還未完成的任務都將會取消。當invokeAll返回後,每個任務要麼正常完成,要麼被取消,客戶端程式碼可以呼叫get或者isCancelled判斷是哪種情況。

在預定時間請求旅遊報價:

private class QuoteTask implements Callable<TravelQuote> {
    private final TravelCompany company;
    private final TravelInfo travelInfo;
    
    public TravelQuote call() throws Exception {
        return company.solicitQuote(travelInfo);
    }
}

public List<TravelQuote> getRankedTravelQuotes(TravelInfo travelInfo,Set<TravelCompany> companies,Comparator<TravelQuote> ranking,long time,TimeUnit unit ) throws InterruptedException{
    List<QuoteTask> tasks = new ArrayList<>();
    for(TravelCompany company : companies) {
        tasks.add(new QuoteTask(company,travelInfo());
    }
    List<Future<TravelQuote>> futures = exec.invokeAll(tasks,time,unit);
    
    List<TravelQuote> quotes = new ArrayList<>(tasks.size());
    Iterator<QuoteTask> taskIter = tasks.iterator();
    for(Future<TravelQuote> f : futures) {
        QuoteTask task = taskIter.next;
        try{
            quotes.add(f.get());
        } catch(ExecutionException e){
            quotes.add(task.getFailureQuote(e.getCause()));
        } catch(CancellationException e){
            quotes.add(task.getTimeoutQuote(e));
        }
    }
    Collections.sort(quotes,ranking);
    return quotes;
}

相關文章