簡單分析ThreadPoolExecutor回收工作執行緒的原理

kingsleylam發表於2019-07-25

最近閱讀了JDK執行緒池ThreadPoolExecutor的原始碼,對執行緒池執行任務的流程有了大體瞭解,實際上這個流程也十分通俗易懂,就不再贅述了,別人寫的比我好多了。

不過,我倒是對執行緒池是如何回收工作執行緒比較感興趣,所以簡單分析了一下,加深對執行緒池的理解吧。

那麼,就以JDK1.8為例分析吧。

1. runWorker(Worker w)

工作執行緒啟動後,就進入runWorker(Worker w)方法。

裡面是一個while迴圈,迴圈判斷任務是否為空,若不為空,執行任務;若取不到任務,或發生異常,退出迴圈,執行processWorkerExit(w, completedAbruptly); 在這個方法裡把工作執行緒移除掉。

取任務的來源有兩個,一個是firstTask,這個是工作執行緒第一次跑的時候執行的任務,最多隻能執行一次,後面得從getTask()方法裡取任務。看來,getTask()是關鍵,在不考慮異常的場景下,返回null,就表示退出迴圈,結束執行緒。下一步,就得看看,什麼情況下getTask()會返回null。

(篇幅有限,分段擷取,省略中間執行任務的步驟)

 

2. getTask() 返回null

一共有兩種情況會返回null,見紅框處 。

第一種情況,執行緒池的狀態已經是STOP,TIDYING, TERMINATED,或者是SHUTDOWN且工作佇列為空;

第二種情況,工作執行緒數已經大於最大執行緒數或當前工作執行緒已超時,且,還有其他工作執行緒或任務佇列為空。這點比較難理解,總之先記住,後面會用。

下面以條件1和條件2分別指代這兩種情況的判斷條件。

 

 

3. 分場景分析執行緒池回收工作執行緒

3.1 未呼叫shutdown() ,RUNNING狀態下全部任務執行完成的場景

這種場景,會將工作執行緒的數量減少到核心執行緒數大小(如果本來就沒有超過,則不需要回收)。

比如一個執行緒池,核心執行緒數為4,最大執行緒數為8。一開始是4個工作執行緒,當任務把任務佇列塞滿,就得將工作執行緒增加到8. 當後面任務執行到差不多了,執行緒取不到任務了,就會回收到4個工作執行緒的狀態(取決於allowCoreThreadTimeOut的值,這裡討論預設值false的情況,即核心執行緒不會超時。如果為true,工作執行緒可以全部銷燬)。

可以先排除上面提到的條件1,執行緒池的狀態已經是STOP,TIDYING, TERMINATED,或者是SHUTDOWN且工作佇列為空。因為執行緒池一直是RUNNING,這條判斷永遠是false。在這個場景中,可以當條件1不存在。

下面分析取不出任務時執行緒是怎麼執行的。

step1. 從任務佇列取任務有兩種方式,超時等待還是可以一直阻塞下去。決定因素是timed變數。該變數在前面賦值,如果當前執行緒數大於核心執行緒數,變數timed為true, 否則為false(上面說了,這裡只討論allowCoreThreadTimeOut為false的情況)。很明顯,現在討論的是timed為true的情況。keepAliveTime一般不設定,預設值為0,所以基本上可以認為是不阻塞,馬上返回取任務的結果。

線上程超時等待喚醒之後,發現取不出任務,timeOut變為true,進入下一次迴圈。

step2. 來到條件1的判斷,執行緒池一直RUNNING, 不進入程式碼塊。

step3. 來到條件2的判斷,這時任務佇列為空,條件成立,CAS減少執行緒數,若成功,返回null,否則,重複step1。

這裡要注意,有可能多條執行緒同時通過條件2的判斷,那會不會減少後執行緒的數量反而比預想的核心執行緒數少呢?

比如當前執行緒數已經只有5條了,此時有兩條執行緒同時喚醒,通過條件2的判斷,同時減少數量,那剩下的執行緒數反而只有3條,和預期不一致。

實際上是不會的。為了防止這種情況,compareAndDecrementWorkerCount(c) 用的是CAS方法,如果CAS失敗就continue,進入下一輪迴圈,重新判斷。

像上述例子,其中一條執行緒會CAS失敗,然後重新進入迴圈,發現工作執行緒數已經只有4了,timed為false, 這條執行緒就不會被銷燬,可以一直阻塞了(workQueue.take())。

這一點我思考了很久才得出答案,一直在想沒有加鎖的情況下是怎麼保證一定能不多不少回收到核心執行緒數的呢。原來是CAS的奧妙。

從這裡也可以看出,雖然有核心執行緒數,但執行緒並沒有區分是核心還是非核心,並不是先建立的就是核心,超過核心執行緒數後建立的就是非核心,最終保留哪些執行緒,完全隨機。

3.2 呼叫shutdown() ,全部任務執行完成的場景

這種場景,無論是核心執行緒還是非核心執行緒,所有工作執行緒都會被銷燬。

在呼叫shutdown()之後,會向所有的空閒工作執行緒傳送中斷訊號。

最終傳入false,呼叫下面這個方法。

可以看出,在發出中斷訊號前,會判斷是否已經中斷,以及要獲得工作執行緒的獨佔鎖。

發出中斷訊號的時候,工作執行緒要麼在getTask()裡準備獲取任務,要麼在執行任務,那就得等它執行完當前任務才會發出,因為工作執行緒在執行任務的時候,也會工作執行緒加鎖。工作執行緒執行完任務,又跑到getTask()裡面去了。

所以我們只要看getTask()裡面怎麼應對中斷異常的就可以了。

工作執行緒在getTask()裡,有兩種可能。

 3.2.1 任務已全部完成,執行緒在阻塞等待。

很簡單,中斷訊號將其喚醒,從而進入下一輪迴圈。到達條件1處,符合條件,減少工作執行緒數量,並返回null,由外層結束這條執行緒。

這裡的decrementWorkerCount()是自旋式的,一定會減1。

3.2.2 任務還沒有完全執行完

呼叫shutdown()之後,未執行完的任務要執行完畢,池子才能結束。所以此時有可能執行緒還在工作。

這裡又要分兩個階段討論

階段1 任務較多,工作執行緒都能獲得任務

這裡還不涉及到執行緒退出,可以跳過不看,只是分析一下收到中斷訊號後執行緒的表現。

假設有執行緒A,正通過getTask()裡獲取任務。此時A被中斷,在獲取任務時,無論是poll()還是take(),都會丟擲中斷異常。異常被捕獲,重新進入下一輪迴圈,只要佇列不為空,就可以繼續取任務。

執行緒A被中斷,再次取任務,呼叫workQueue.poll() or workQueue.take(),不會丟擲異常嗎?還可以正常取出任務嗎?

這就要看workQueue的實現了。workQueue是BlockingQueue型別,以常見的LinkedBlockingQueue和ArrayBlockingQueue為例,加鎖時都是呼叫lockInterruptibly(),是響應中斷的。該方法又呼叫了AQS的acquireInterruptibly(int arg)。

acquireInterruptibly(int arg),無論是在入口處判斷中斷異常,還是在parkAndCheckInterrupt()方法阻塞,被中斷喚醒並判斷中斷異常時,均使用了Thread.interrupted()。這個方法會返回執行緒的中斷狀態,並把中斷狀態重置!也就是說,執行緒不再是中斷狀態了,這樣在再次取任務時,就不會報錯了。

因此,這對於正在準備取任務的執行緒,只是相當於浪費了一次迴圈,這可能是執行緒中斷帶來的副作用吧,當然,對整體的執行不影響。

分析到這裡,我不禁感嘆,這裡BlockingQueue剛好是會重置中斷狀態,這到底是怎麼想出來的絕妙設計啊?Doug Lea大神Orz.

階段2 任務剛好要執行完了

這時任務已經快取完了,比如有4條工作執行緒,只剩下2個任務,那就可能出現2條執行緒獲得任務,2條執行緒阻塞。

因為在獲取任務前的判斷,沒有加鎖,那麼會不會出現,所有執行緒都通過了前面的校驗,來到workQueue獲取任務的地方,剛好任務佇列已經空了,執行緒全部阻塞了呢?因為shutdown() 已經執行完畢,無法再向執行緒發出中斷訊號,從而執行緒一直在阻塞,無法被回收。

這種是不會發生的。

假設有A,B,C,D四條工作執行緒,同時通過了條件1條件2的判斷,來到取任務的地方。那麼,工作佇列至少還有一個任務,至少會有一條執行緒能取到任務。

假設A,B獲得了任務,C,D阻塞。 

A, B接下來的步驟是:

step1.任務執行完成後,再次getTask(),此時符合條件1,返回null,執行緒準備被回收。

step2.processWorkerExit(Worker w, boolean completedAbruptly) 將執行緒回收。

回收就只是把執行緒幹掉這麼簡單嗎?來看看processWorkerExit(Worker w, boolean completedAbruptly) 的方法。

可以看到,在裡面除了workers.remove(w) 移除線,還呼叫了tryTerminate()。 

第一個判斷條件沒有一個子條件符合,跳過。第二個條件,工作執行緒還存在,那麼隨機中斷一條空閒執行緒。

那麼問題就來了,中斷一條空閒執行緒,也沒說是一定中斷正在阻塞的執行緒啊。如果A, B同時退出,有沒有可能出現A中斷B, B中斷A,AB互相中斷,從而沒有執行緒去中斷喚醒阻塞的執行緒呢?

答案仍然是,想多了……

假設A能走到這裡,說明A已經從工作執行緒的集合workers裡面移除了(processWorkerExit(Worker w, boolean completedAbruptly) 在tryTerminate()之前,已經將其移除)。那麼A中斷B,B來到這裡中斷,就不會在workers裡面找到A了。

也就是說,退出的執行緒不能互相中斷,我從集合中退出後,中斷了你,你不能中斷我,因為我已經退出集合,你只能中斷別人。那麼,即使有N個執行緒同時退出,至少在最後,也會有一條執行緒,會中斷剩餘的阻塞執行緒。

就像多米諾骨牌一樣,中斷訊號就會被傳播下去。

阻塞的C,D中的任意一條被中斷喚醒後,又會重複step1的動作,周而復始,直到所有阻塞執行緒都被中斷,喚醒。

這也是為什麼在tryTerminate()裡面,傳入false,只需要中斷任意一條空閒執行緒的原因。

想到這裡,再次對Doug Lea心生欽敬(粵語)之情。這設計得也太妙了叭。

4. 總結

ThreadPoolExecutor回收工作執行緒,一條執行緒getTask()返回null,就會被回收。

分兩種場景。

1) 未呼叫shutdown() ,RUNNING狀態下全部任務執行完成的場景

執行緒數量大於corePoolSize,執行緒超時阻塞,超時喚醒後CAS減少工作執行緒數,如果CAS成功,返回null,執行緒回收。否則進入下一次迴圈。當工作者執行緒數量小於等於corePoolSize,就可以一直阻塞了。 

2) 呼叫shutdown() ,全部任務執行完成的場景

shutdown() 會向所有執行緒發出中斷訊號,這時有兩種可能。

2.1)所有執行緒都在阻塞

中斷喚醒,進入迴圈,都符合第一個if判斷條件,都返回null,所有執行緒回收。

2.2)任務還沒有完全執行完

至少會有一條執行緒被回收。在processWorkerExit(Worker w, boolean completedAbruptly)方法裡會呼叫tryTerminate(),向任意空閒執行緒發出中斷訊號。所有被阻塞的執行緒,最終都會被一個個喚醒,回收。

 

這一次的分析,昨晚開始寫,寫到一半卡殼,今天早上接著寫,前後花了大概2+2=4個小時寫部落格以及1小時思考。

說實話自己還是有點亂,無法一下子理解透徹,也不知道自己理解得對不對。

有沒有用,我也不知道,只能說,加深了對執行緒池的理解吧(安慰自己),同時也感慨設計之精妙。

如有不正確的地方,請大家指正(如果有人看的話)。

 

相關文章