6. 任務執行

任務通常是一些抽象的且離散的工作單元。通過把應用程序的工作分解到多個任務中,可以簡化程序的組織結構,提供一種自然的事務邊界來優化錯誤恢復過程,以及提供一種自然的并行工作結構來提升并發性。

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的實現是維護一個保存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

如上,我們可以看到最后任務結果的輸出是按照順序輸出的。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容