面試官讓我講下執行緒的 WAITING 狀態,我笑了

  • 2020 年 2 月 19 日
  • 筆記

來源:國棟

my.oschina.net/goldenshaw/blog/802620

  • 執行緒間的協作(cooperate)機制
  • wait 的場景
    • 競爭關係
    • 條件
    • 等待是必要的嗎?
    • 協作關係
    • 生產者與消費者問題
    • 程式碼的演示:
  • join場景及其它
  • 與傳統 waiting 狀態的關係

面試官Q:你講下執行緒狀態中的WAITING狀態,什麼時候會處於這個狀態?什麼時候離開這個狀態?

小菜J 會心一笑...

一個正在無限期等待另一個執行緒執行一個特別的動作的執行緒處於WAITING狀態。

A thread that is waiting indefinitely for another thread to perform a particular action is in this state.

然而這裡並沒有詳細說明這個「特別的動作」到底是什麼,詳細定義還是看 javadoc(jdk8):

一個執行緒進入 WAITING 狀態是因為調用了以下方法:

  • 不帶時限的 Object.wait 方法
  • 不帶時限的 Thread.join 方法
  • LockSupport.park

然後會等其它執行緒執行一個特別的動作,比如:

  • 一個調用了某個對象的 Object.wait 方法的執行緒會等待另一個執行緒調用此對象的 Object.notify() 或 Object.notifyAll()。
  • 一個調用了 Thread.join 方法的執行緒會等待指定的執行緒結束。

對應的英文原文如下:

A thread is in the waiting state due to calling one of the following methods:

  • Object.wait with no timeout
  • Thread.join with no timeout
  • LockSupport.park

A thread in the waiting state is waiting for another thread to perform a particular action. For example, a thread that has called Object.wait() on an object is waiting for another thread to call Object.notify() or Object.notifyAll() on that object. A thread that has called Thread.join() is waiting for a specified thread to terminate.

執行緒間的協作(cooperate)機制

顯然,WAITING 狀態所涉及的不是一個執行緒的獨角戲,相反,它涉及多個執行緒,具體地講,這是多個執行緒間的一種協作 機制。談到執行緒我們經常想到的是執行緒間的競爭(race) ,比如去爭奪鎖,但這並不是故事的全部,執行緒間也會有協作機制。

就好比在公司里你和你的同事們,你們可能存在在晉陞時的競爭,但更多時候你們更多是一起合作以完成某些任務。

wait/notify 就是執行緒間的一種協作機制,那麼首先,為什麼 wait?什麼時候 wait?它為什麼要等其它執行緒執行「特別的動作」?它到底解決了什麼問題?

wait 的場景

首先,為什麼要 wait 呢?簡單講,是因為條件(condition) 不滿足。那麼什麼是條件呢?為方便理解,我們設想一個場景:

有一節列車車廂,有很多乘客,每個乘客相當於一個執行緒;裡面有個廁所,這是一個公共資源,且一次只允許一個執行緒進去訪問(畢竟沒人希望在上廁所期間還與他人共享~)。

競爭關係

假如有多個乘客想同時上廁所,那麼這裡首先存在的是競爭的關係。

如果將廁所視為一個對象,它有一把鎖,想上廁所的乘客執行緒需要先獲取到鎖,然後才能進入廁所。

Java 在語言級直接提供了同步的機制,也即是 synchronized 關鍵字:

synchronized(expression) {……}

它的機制是這樣的:對表達式(expresssion)求值(值的類型須是引用類型(reference type)),獲取它所代表的對象,然後嘗試獲取這個對象的鎖:

  • 如果能獲取鎖,則進入同步塊執行,執行完後退出同步塊,並歸還對象的鎖(異常退出也會歸還);
  • 如果不能獲取鎖,則阻塞在這裡,直到能夠獲取鎖。

在一個執行緒還在廁所期間,其它同時想上廁所的執行緒被阻塞,處在該廁所對象的 entry set 中,處於 BLOCKED 狀態。

完事之後,退出廁所,歸還鎖。

之後,系統再在 entry set 中挑選一個執行緒,將鎖給到它。

對於以上過程,以下為一個 gif 動圖演示:

當然,這就是我們所熟悉的鎖的競爭過程。以下為演示的程式碼:

@Test  public void testBlockedState() throws Exception {  	class Toilet { // 廁所類  		public void pee() { // 尿尿方法  			try {  				Thread.sleep(21000);// 研究表明,動物無論大小尿尿時間都在21秒左右  			} catch (InterruptedException e) {  				Thread.currentThread().interrupt();  			}  		}  	}    	Toilet toilet = new Toilet();    	Thread passenger1 = new Thread(new Runnable() {  		public void run() {  			synchronized (toilet) {  				toilet.pee();  			}  		}  	});    	Thread passenger2 = new Thread(new Runnable() {  		public void run() {  			synchronized (toilet) {  				toilet.pee();  			}  		}  	});    	passenger1.start();    	// 確保乘客1先啟動  	Thread.sleep(100);    	passenger2.start();    	// 確保已經執行了 run 方法  	Thread.sleep(100);    	// 在乘客1在廁所期間,乘客2處於 BLOCKED 狀態  	assertThat(passenger2.getState()).isEqualTo(Thread.State.BLOCKED);  }  

條件

現在,假設有個女乘客,她搶到了鎖,進去之後褲子脫了一半,發現馬桶的墊圈紙沒了,於是拒絕尿。

或許是因為她比較講究衛生,怕直接坐上去會弄髒她白花花的屁股~

現在,條件出現了:有紙沒紙,這就是某種條件。

那麼,現在條件不滿足,這位女執行緒改怎麼辦呢?如果只是在裡面乾等,顯然是不行的。

這不就是人民群眾所深惡痛絕的「占著茅坑不拉尿」嗎?

  • 一方面,外面 entry set 中可能好多群眾還嗷嗷待尿呢(其中可能有很多大老爺執行緒,他們才不在乎有沒有馬桶墊圈紙~)
  • 另一方面,假定外面同時有「乘務員執行緒」,準備進去增加墊圈紙,可你在裡面霸佔著不出來,別人也沒法進去,也就沒法加紙。

所以,當條件不滿足時,需要出來,要把鎖還回去,以使得諸如「乘務員執行緒」的能進去增加紙張。

等待是必要的嗎?

那麼出來之後是否一定需要等待呢?當然也未必。

這裡所謂「等待」,指的是使執行緒處於不再活動的狀態,即是從調度隊列中剔除。

如果不等待,只是簡單歸還鎖,用一個反覆的循環來判斷條件是否滿足,那麼還是可以再次回到調度隊列,然後期待在下一次被調度到的時候,可能條件已經發生變化:

比如某個「乘務員執行緒」已經在之前被調度並增加了裡面的墊圈紙。自然,也可能再次調度到的時候,條件依舊是不滿足的。

現在讓我們考慮一種比較極端的情況:廁所外一大堆的「女乘客執行緒」想進去方便,同時還有一個焦急的「乘務員執行緒」想進去增加廁紙。

如果執行緒都不等待,而廁所又是一個公共資源,無法並發訪問。調度器每次挑一個執行緒進去,挑中「乘務員執行緒」的幾率反而降低了,entry set 中很可能越聚越多無法完成方便的「女乘客執行緒」,「乘務員執行緒」被選中執行的幾率越發下降。

當然,同步機制會防止產生所謂的「飢餓(starvation) 」現象,「乘務員執行緒」最終還是有機會執行的,只是系統運行的效率下降了。

所以,這會干擾正常工作的執行緒,擠佔了資源,反而影響了自身條件的滿足。另外,「乘務員執行緒」可能這段時間根本沒有啟動,此時,不願等待的「女乘客執行緒」不過是徒勞地進進出出,佔用了 CPU 資源卻沒有辦成正事。

效果上還是在這種沒有進展的進進出出中等待,這種情形類似於所謂的忙等待 (busy waiting) 。

協作關係

綜上,等待還是有必要的,我們需要一種更高效的機制,也即是 wait/notify 的協作機制。

當條件不滿足時,應該調用 wait()方法,這時執行緒釋放鎖,並進入所謂的 wait set 中,具體的講,是進入這個廁所對象的 wait set 中:

這時,執行緒不再活動,不再參與調度,因此不會浪費 CPU 資源,也不會去競爭鎖了,這時的執行緒狀態即是 WAITING。

現在的問題是:她們什麼時候才能再次活動呢?顯然,最佳的時機是當條件滿足的時候。

之後,「乘務員執行緒」進去增加廁紙,當然,此時,它也不能只是簡單加完廁紙就完了,它還要執行一個特別的動作 ,也即是「通知(notify) 」在這個對象上等待的女乘客執行緒:

大概就是向她們喊一聲:「有紙啦!趕緊去尿吧!」顯然,如果只是「女乘客執行緒」方面一廂情願地等待,她們將沒有機會再執行。

所謂「通知」,也即是把她們從 wait set 中釋放出來,重新進入到調度隊列(ready queue)中。

  • 如果是 notify,則選取所通知對象的 wait set 中的一個執行緒釋放;
  • 如果是 notifyAll,則釋放所通知對象的 wait set 上的全部執行緒。

整個過程如下圖所示:

對於上述過程,我們也給出以下 gif 動圖演示:

注意 :哪怕只通知了一個等待的執行緒,被通知執行緒也不能立即恢復執行,因為她當初中斷的地方是在同步塊內,而此刻她已經不持有鎖,所以她需要再次嘗試去獲取鎖(很可能面臨其它執行緒的競爭),成功後才能在當初調用 wait 方法之後的地方恢復執行。(這也即是所謂的 「reenter after calling Object.wait」)

  • 如果能獲取鎖,執行緒就從 WAITING 狀態變成 RUNNABLE 狀態;
  • 否則,從 wait set 出來,又進入 entry set,執行緒就從 WAITING 狀態又變成 BLOCKED 狀態。

綜上,這是一個協作機制,「女乘客執行緒」和「乘務員執行緒」間存在一個協作關係。顯然,這種協作關係的存在,「女乘客執行緒」可以避免在條件不滿足時的盲目嘗試,也為「乘務員執行緒」的順利執行騰出了資源;同時,在條件滿足時,又能及時得到通知。協作關係的存在使得彼此都能受益。

生產者與消費者問題

不難發現,以上實質上也就是經典的「生產者與消費者」的問題:

乘務員執行緒生產廁紙,女乘客執行緒消費廁紙。當廁紙沒有時(條件不滿足),女乘客執行緒等待,乘務員執行緒添加廁紙(使條件滿足),並通知女乘客執行緒(解除她們的等待狀態)。接下來,女乘客執行緒能否進一步執行則取決於鎖的獲取情況。

程式碼的演示:

在以下程式碼中,演示了上述的 wait/notify 的過程:

@Test  public void testWaitingState() throws Exception {    	class Toilet { // 廁所類  		int paperCount = 0; // 紙張    		public void pee() { // 尿尿方法  			try {  				Thread.sleep(21000);// 研究表明,動物無論大小尿尿時間都在21秒左右  			} catch (InterruptedException e) {  				Thread.currentThread().interrupt();  			}  		}  	}    	Toilet toilet = new Toilet();    	// 兩乘客執行緒  	Thread[] passengers = new Thread[2];  	for (int i = 0; i < passengers.length; i++) {  		passengers[i] = new Thread(new Runnable() {  			public void run() {  				synchronized (toilet) {  					while (toilet.paperCount < 1) {  						try {  							toilet.wait(); // 條件不滿足,等待  						} catch (InterruptedException e) {  							Thread.currentThread().interrupt();  						}  					}  					toilet.paperCount--; // 使用一張紙  					toilet.pee();  				}  			}  		});  	}    	// 乘務員執行緒  	Thread steward = new Thread(new Runnable() {  		public void run() {  			synchronized (toilet) {  				toilet.paperCount += 10;// 增加十張紙  				toilet.notifyAll();// 通知所有在此對象上等待的執行緒  			}  		}  	});    	passengers[0].start();  	passengers[1].start();    	// 確保已經執行了 run 方法  	Thread.sleep(100);    	// 沒有紙,兩執行緒均進入等待狀態  	assertThat(passengers[0].getState()).isEqualTo(Thread.State.WAITING);  	assertThat(passengers[1].getState()).isEqualTo(Thread.State.WAITING);    	// 乘務員執行緒啟動,救星來了  	steward.start();    	// 確保已經增加紙張並已通知  	Thread.sleep(100);    	// 其中之一會得到鎖,並執行 pee,但無法確定是哪個,所以用 "或 ||"  	// 註:因為 pee 方法中實際調用是 sleep, 所以很快就從 RUNNABLE 轉入 TIMED_WAITING(sleep 時對應的狀態)  	assertTrue(Thread.State.TIMED_WAITING.equals(passengers[0].getState())  			|| Thread.State.TIMED_WAITING.equals(passengers[1].getState()));    	// 其中之一則被阻塞,但無法確定是哪個,所以用 "或 ||"  	assertTrue(  			Thread.State.BLOCKED.equals(passengers[0].getState()) || Thread.State.BLOCKED.equals(passengers[1].getState()));  }  

join場景及其它

從定義中可知,除了 wait/notify 外,調用 join 方法也會讓執行緒處於 WAITING 狀態。

join 的機制中並沒有顯式的 wait/notify 的調用,但可以視作是一種特殊的,隱式的 wait/notify 機制。

假如有 a,b 兩個執行緒,在 a 執行緒中執行 b.join(),相當於讓 a 去等待 b,此時 a 停止執行,等 b 執行完了,系統內部會隱式地通知 a,使 a 解除等待狀態,恢復執行。

換言之,a 等待的條件是 「b 執行完畢」,b 完成後,系統會自動通知 a。

關於 LockSupport.park 的情況則由讀者自行分析。

與傳統 waiting 狀態的關係

Thread.State.WAITING 狀態與傳統的 waiting 狀態類似:



Tags: