從任務到線程:Java結構化并發應用程序

并發設計的本質,就是要把程序的邏輯分解為多個任務,這些任務獨立而又協作的完成程序的功能。而其中最關鍵的地方就是如何將邏輯上的任務分配到實際的線程中去執行。換而言之,任務是目的,而線程是載體,線程的實現要以任務為目標。

1. 在線程中執行任務

并發程序設計的第一步就是要劃分任務的邊界,理想情況下就是所有的任務都獨立的:每個任務都是不依賴于其他任務的狀態,結果和邊界。因為獨立的任務是最有利于并發設計的。

有一種最自然的任務劃分方法就是以獨立的客戶請求為任務邊界。每個用戶請求是獨立的,則處理任務請求的任務也是獨立的。

在劃分完任務之后,下一問題就是如何調度這些任務,最簡單的方法就是串行調用所有任務,也就是一個一個的執行。

比如下面的這個套接字服務程序,每次都只能響應一個請求,下一個請求需要等上一個請求執行完畢之后再被處理。

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

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

這種設計當然是不能滿足要求的,并發的高吞吐和高響應速度的優勢都沒發揮出來。

1.1 顯示地創建線程

上述代碼的優化版就是為每個請求都分配獨立的線程來執行,也就是每一個請求任務都是一個獨立線程。

public 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();
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

這樣設計的優點在于:

  • 任務處理線程從主線程分離出來,使得主線程不用等待任務完畢就可以去快速地去響應下一個請求,以達到高響應速度;
  • 任務處理可以并行,支持同時處理多個請求;
  • 任務處理是線程安全的,因為每個任務都是獨立的。

不過需要注意的是,任務必須是線程安全的,否者多線程并發時會有問題。

1.3 無限制創建線程的不足

但是以上的方案還是有不足的:

  1. 線程的生命周期的開銷很大:每創建一個線程都是要消耗大量的計算資源;
  2. 資源的消耗:活躍的線程要消耗內存資源,如果有太多的空閑資源就會使得很多內存資源浪費,導致內存資源不足,多線程并發時就會出現資源強占的問題;
  3. 穩定性:可創建線程的個數是有限制的,過多的線程數會造成內存溢出;

利用創建線程來攻擊的例子中,最顯而易見的就是不斷創建死循環的線程,最終導致整個計算機的資源都耗盡。

2.Executor框架

任務是一組邏輯工作單元,而線程則是任務異步執行的機制。為了讓任務更好地分配到線程中執行,java.util.concurrent提供了Executor框架。

Executor基于生產者-消費者模式:提交任務的操作相當于生產者(生成待完成的工作單元),執行任務的線程則相當于消費者(執行完這些工作單元)。

將以上的服務端代碼改造為Executor框架如下:

public class TaskExecutionWebServer {
    //設定線程池大小;
    private static final int NTHREADS = 100;
    private static final Executor exec
            = Executors.newFixedThreadPool(NTHREADS);

    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);
        }
    }

    private static void handleRequest(Socket connection) {
        // request-handling logic here
    }
}

2.1 線程池

Executor的本質就是管理和調度線程池。所謂線程池就是指管理一組同構工作線程的資源池。線程池和任務隊列相輔相成:任務隊列中保存著所有帶執行的任務,而線程池中有著可以去執行任務的工作線程,工作線程從任務隊列中領域一個任務執行,執行任務完畢之后在回到線程池中等待下一個任務的到來。

任務池的優勢在于:

  1. 通過復用現有線程而不是創建新的線程,降低創建線程時的開銷;
  2. 復用現有線程,可以直接執行任務,避免因創建線程而讓任務等待,提高響應速度。

Executor可以創建的線程池共有四種:

  1. newFixedThreadPool,即固定大小的線程池,如果有線程因發生了異常而崩潰,會創建新的線程代替:
  2. newCachedThreadPool,即支持緩存的線程池,如果線程池的規模超過了需求的規模,就會回收空閑線程,如果需求增加,則會增加線程池的規模;
  3. newScheduledThreadPool,固定大小的線程池,而且以延時或者定時的方式執行;
  4. newSingleThreadExecutor,單線程模式,串行執行任務;

2.2 Executor的生命周期

這里需要單獨說下Executor的生命周期。由于JVM只有在非守護線程全部終止才會退出,所以如果沒正確退出Executor,就會導致JVM無法正常結束。但是Executor是采用異步的方式執行線程,并不能立刻知道所有線程的狀態。為了更好的管理Executor的生命周期,Java1.5開始提供了Executor的擴展接口ExecutorService

ExecutorService提供了兩種方法關閉方法:

  • shutdown: 平緩的關閉過程,即不再接受新的任務,等到已提交的任務執行完畢后關閉進程池;
  • shutdownNow: 立刻關閉所有任務,無論是否再執行;

服務端ExecutorService版的實現如下:

public class LifecycleWebServer {
    private final ExecutorService exec = Executors.newCachedThreadPool();

    public void start() throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (!exec.isShutdown()) {
            try {
                final Socket conn = socket.accept();
                exec.execute(new Runnable() {
                    public void run() {
                        handleRequest(conn);
                    }
                });
            } catch (RejectedExecutionException e) {
                if (!exec.isShutdown())
                    log("task submission rejected", e);
            }
        }
    }

    public void stop() {
        exec.shutdown();
    }

    private void log(String msg, Exception e) {
        Logger.getAnonymousLogger().log(Level.WARNING, msg, e);
    }

    void handleRequest(Socket connection) {
        Request req = readRequest(connection);
        if (isShutdownRequest(req))
            stop();
        else
            dispatchRequest(req);
    }

    interface Request {
    }

    private Request readRequest(Socket s) {
        return null;
    }

    private void dispatchRequest(Request r) {
    }

    private boolean isShutdownRequest(Request r) {
        return false;
    }
}

2.3 延遲任務和周期性任務

Java中提供Timer來執行延時任務和周期任務,但是Timer類有以下的缺陷:

  1. Timer只會創建一個線程來執行任務,如果有一個TimerTask執行時間太長,就會影響到其他TimerTask的定時精度;
  2. Timer不會捕捉TimerTask未定義的異常,所以當有異常拋出到Timer中時,Timer就會崩潰,而且也無法恢復,就會影響到已經被調度但是沒有執行的任務,造成“線程泄露”。

建議使用ScheduledThreadPoolExecutor來代替Timer類。

3. Callable & Future

如上文所說,Executor以Runnable的形式描述任務,但是Runnable有很大的局限性:

  • 沒有返回值,只是執行任務;
  • 不能處理被拋出的異常;

為了彌補以上的問題,Java中設計了另一種接口Callable

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

Callable支持任務有返回值,并支持異常的拋出。如果希望獲得子線程的執行結果,那Callable將比Runnable更為合適。

無論是Callable還是Runnable都是對于任務的抽象描述,即表明任務的范圍:有明確的起點,并且都會在一定條件下終止。

Executor框架下所執行的任務都有四種生命周期:

  • 創建;
  • 提交;
  • 開始;
  • 完成;

對于一個已提交但還沒有開始的任務,是可以隨時被停止;但是如果一個任務已經如果已經開始執行,就必須等到其相應中斷時再取消;當然,對于一個已經執行完成的任務,對其取消任務是沒有任何作用的。

既然任務有生命周期,那要如何才能知道一個任務當前的生命周期狀態呢?Callable既然有返回值,如何去在主線程中獲取子線程的返回值呢?為了解決這些問題,就需要Future類的幫助。

public interface Future<V> {
    //取消任務
    boolean cancel(boolean mayInterruptIfRunning);
    // 任務是否被取消
    boolean isCancelled();
    // 任務是否完成
    boolean isDone();
    // 獲得任務的返回值
    V get() throws InterruptedException, ExecutionException;
    // 在超時期限內等待返回值
    V get(long timeout, TimeUnit unit)
        throws InterruptedException, ExecutionException, TimeoutException;
}

Future類表示任務生命周期狀態,其命名體現了任務的生命周期只能向前不能后退。

Future類提供方法查詢任務狀態外,還提供get方法獲得任務的返回值,但是get方法的行為取決于任務狀態:

  • 如果任務已經完成,get方法則會立刻返回;
  • 如果任務還在執行中,get方法則會擁塞直到任務完成;
  • 如果任務在執行的過程中拋出異常,get方法會將該異常封裝為ExecutionException中,并可以通過getCase方法獲得具體異常原因;

如果將一個Callable對象提交給ExecutorService,submit方法就會返回一個Future對象,通過這個Future對象就可以在主線程中獲得該任務的狀態,并獲得返回值。

除此之外,可以顯式地把Runnable和Callable對象封裝成FutureTask對象,FutureTask不光繼承了Future接口,也繼承Runnable接口,所以可以直接調用run方法執行。

既然是并發處理,當然會遇到一次性提交一組任務的情況,這個時候可以使用CompletionService,CompletionService可以理解為Executor和BlockingQueue的組合:當一組任務被提交后,CompletionService將按照任務完成的順序將任務的Future對象放入隊列中。

CompletionService的接口如下:

public interface CompletionService<V> {
    
    Future<V> submit(Callable<V> task);
   
    Future<V> submit(Runnable task, V result);
    //如果隊列為空,就會阻塞以等待有任務被添加
    Future<V> take() throws InterruptedException;
 
    //如果隊列為空,就會返回null;
    Future<V> poll();

    Future<V> poll(long timeout, TimeUnit unit) throws InterruptedException;
}

請注意take方法和poll方法的區別。

除了使用CompletionService來一個一個獲取完成任務的Future對象外,還可以調用ExecutorSerive的invokeAll()方法。

invokeAll支持限時提交一組任務(任務的集合),并獲得一個Future數組。invokeAll方法將按照任務集合迭代器的順序將任務對應的Future對象放入數組中,這樣就可以把傳入的任務(Callable)和結果(Future)聯系起來。當全部任務執行完畢,或者超時,再或者被中斷時,invokeAll將返回Future數組。

當invokeAll方法返回時,每個任務要么正常完成,要么被取消,即都是終止的狀態了。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,001評論 6 537
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,786評論 3 423
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 176,986評論 0 381
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,204評論 1 315
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,964評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,354評論 1 324
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,410評論 3 444
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,554評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,106評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,918評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,093評論 1 371
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,648評論 5 362
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,342評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,755評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,009評論 1 289
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,839評論 3 395
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,107評論 2 375

推薦閱讀更多精彩內容

  • 一.線程安全性 線程安全是建立在對于對象狀態訪問操作進行管理,特別是對共享的與可變的狀態的訪問 解釋下上面的話: ...
    黃大大吃不胖閱讀 860評論 0 3
  • 下面是我自己收集整理的Java線程相關的面試題,可以用它來好好準備面試。 參考文檔:-《Java核心技術 卷一》-...
    阿呆變Geek閱讀 14,884評論 14 507
  • 剛出來工作,一切很辛苦,但一直努力拼搏,卻始終未做出成就,換來領導一句,你走錯行業了吧!
    ChenZhuangsheng閱讀 204評論 0 0
  • 項目要求,產品的詳情頁打開,在首頁上層顯示,首頁內容還能在詳情的半透背景下隱約可見,但是如果要復制地址欄或者分享...
    瘋花血月_0e96閱讀 300評論 0 0
  • 今天經濟學討論了一個問題:實物補貼和貨物補貼的權衡 文中舉了一個例子:幫助窮人 經濟學家無論是左派的還是右派的經濟...
    思遠同學閱讀 183評論 0 0