1. 程式人生 > >面試官問:多執行緒同步內部如何實現的,你知道怎麼回答嗎?

面試官問:多執行緒同步內部如何實現的,你知道怎麼回答嗎?

自己實現鎖

首先,如果要你實現作業系統的鎖,該如何實現?先想想這個問題,暫時不考慮效能、可用性等問題,就用最簡單、粗暴的方式。當你心中有個大致的思路後,再接著往下看。

下文中的程式碼都是虛擬碼。

自旋

最容易想到可能是自旋:


volatile int status=0;

void lock(){
	
	while(!compareAndSet(0,1)){
	}
	//get lock

}

void unlock(){
	status=0;
}

boolean compareAndSet(int except,int newValue){
	//cas操作,修改status成功則返回true
}

上面的程式碼通過自旋和cas來實現一個最簡單的鎖。

這樣實現的鎖顯然有個致命的缺點:耗費cpu資源。沒有競爭到鎖的執行緒會一直佔用cpu資源進行cas操作,假如一個執行緒獲得鎖後要花費10s處理業務邏輯,那另外一個執行緒就會白白的花費10s的cpu資源。(假設系統中就只有這兩個執行緒的情況)。

yield+自旋

要解決自旋鎖的效能問題必須讓競爭鎖失敗的執行緒不忙等,而是在獲取不到鎖的時候能把cpu資源給讓出來,說到讓cpu資源,你可能想到了yield()方法,看看下面的例子:


volatile int status=0;

void lock(){
	
	while(!compareAndSet(0,1)){
		yield();
	}
	//get lock

}

void unlock(){
	status=0;
}

當執行緒競爭鎖失敗時,會呼叫yield方法讓出cpu。需要注意的是該方法只是當前讓出cpu,有可能作業系統下次還是選擇執行該執行緒。其實現是
將當期執行緒移動到所在優先排程佇列的末端(作業系統執行緒排程瞭解一下?有時間的話,下次寫寫這塊內容)。也就是說,如果該執行緒處於優先順序最高的排程佇列且該佇列只有該執行緒,那作業系統下次還是執行該執行緒。

自旋+yield的方式並沒有完全解決問題,當系統只有兩個執行緒競爭鎖時,yield是有效的。但是如果有100個執行緒競爭鎖,當執行緒1獲得鎖後,還有99個執行緒在反覆的自旋+yield,執行緒2呼叫yield後,作業系統下次執行的可能是執行緒3;而執行緒3CAS失敗後呼叫yield後,作業系統下次執行的可能是執行緒4...
假如執行在單核cpu下,在競爭鎖時最差只有1%的cpu利用率,導致獲得鎖的執行緒1一直被中斷,執行實際業務程式碼時間變得更長,從而導致鎖釋放的時間變的更長。

sleep+自旋

你可能從一開始就想到了,當競爭鎖失敗後,可以將用Thread.sleep將執行緒休眠,從而不佔用cpu資源:


volatile int status=0;

void lock(){
	
	while(!compareAndSet(0,1)){
		sleep(10);
	}
	//get lock

}

void unlock(){
	status=0;
}

上述方式我們可能見的比較多,通常用於實現上層鎖。該方式不適合用於作業系統級別的鎖,因為作為一個底層鎖,其sleep時間很難設定。sleep的時間取決於同步程式碼塊的執行時間,sleep時間如果太短了,會導致執行緒切換頻繁(極端情況和yield方式一樣);sleep時間如果設定的過長,會導致執行緒不能及時獲得鎖。因此沒法設定一個通用的sleep值。就算sleep的值由呼叫者指定也不能完全解決問題:有的時候呼叫鎖的人也不知道同步塊程式碼會執行多久。

park+自旋

那可不可以在獲取不到鎖的時候讓執行緒釋放cpu資源進行等待,當持有鎖的執行緒釋放鎖的時候將等待的執行緒喚起呢?


volatile int status=0;

Queue parkQueue;

void lock(){
	
	while(!compareAndSet(0,1)){
		//
		lock_wait();
	}
	//get lock

}

void synchronized  unlock(){
	lock_notify();
}

void lock_wait(){
	//將當期執行緒加入到等待佇列
	parkQueue.add(nowThread);
	//將當期執行緒釋放cpu
	releaseCpu();
}
void lock_notify(){
	//得到要喚醒的執行緒
	Thread t=parkList.poll();
	//喚醒等待執行緒
	wakeAThread(t);
}

上面是虛擬碼,描述這種設計思想,至於釋放cpu資源、喚醒等待執行緒的的具體實現,後文會再說。這種方案相比於sleep而言,只有在鎖被釋放的時候,競爭鎖的執行緒才會被喚醒,不會存在過早或過完喚醒的問題。

小結

對於鎖衝突不嚴重的情況,用自旋鎖會更適合,試想每個執行緒獲得鎖後很短的一段時間內就釋放鎖,競爭鎖的執行緒只要經歷幾次自旋運算後就能獲得鎖,那就沒必要等待該執行緒了,因為等待執行緒意味著需要進入到核心態進行上下文切換,而上下文切換是有成本的並且還不低,如果鎖很快就釋放了,那上下文切換的開銷將超過自旋。

目前作業系統中,一般是用自旋+等待結合的形式實現鎖:在進入鎖時先自旋一定次數,如果還沒獲得鎖再進行等待。

futex

linux底層用futex實現鎖,futex由一個核心層的佇列和一個使用者空間層的atomic integer構成。當獲得鎖時,嘗試cas更改integer,如果integer原始值是0,則修改成功,該執行緒獲得鎖,否則就將當期執行緒放入到 wait queue中(即作業系統的等待佇列)。

上述說法有些抽象,如果你沒看明白也沒關係。我們先看一下沒有futex之前,linux是怎麼實現鎖的。

futex誕生之前

在futex誕生之前,linux下的同步機制可以歸為兩類:使用者態的同步機制 和核心同步機制。 使用者態的同步機制基本上就是利用原子指令實現的自旋鎖。關於自旋鎖其缺點也說過了,不適用於大的臨界區(即鎖佔用時間比較長的情況)。

核心提供的同步機制,如semaphore等,使用的是上文說的自旋+等待的形式。 它對於大小臨界區和都適用。但是因為它是核心層的(釋放cpu資源是核心級呼叫),所以每次lock與unlock都是一次系統呼叫,即使沒有鎖衝突,也必須要通過系統呼叫進入核心之後才能識別。

理想的同步機制應該是沒有鎖衝突時在使用者態利用原子指令就解決問題,而需要掛起等待時再使用核心提供的系統呼叫進行睡眠與喚醒。換句話說,在使用者態的自旋失敗時,能不能讓程序掛起,由持有鎖的執行緒釋放鎖時將其喚醒?
如果你沒有較深入地考慮過這個問題,很可能想當然的認為類似於這樣就行了(虛擬碼):

void lock(int lockval) {
	//trylock是使用者級的自旋鎖
	while(!trylock(lockval)) {
		wait();//釋放cpu,並將當期執行緒加入等待佇列,是系統呼叫
	}
}

boolean trylock(int lockval){
	int i=0; 
	//localval=1代表上鎖成功
	while(!compareAndSet(lockval,0,1)){
		if(++i>10){
			return false;
		}
	}
	return true;
}

void unlock(int lockval) {
	 compareAndSet(lockval,1,0);
	 notify();
}

上述程式碼的問題是trylock和wait兩個呼叫之間存在一個視窗:
如果一個執行緒trylock失敗,在呼叫wait時持有鎖的執行緒釋放了鎖,當前執行緒還是會呼叫wait進行等待,但之後就沒有人再將該執行緒喚醒了。

futex誕生之後

我們來看看futex的方法定義:

	 //uaddr指向一個地址,val代表這個地址期待的值,當*uaddr==val時,才會進行wait
	 int futex_wait(int *uaddr, int val);
	 //喚醒n個在uaddr指向的鎖變數上掛起等待的程序
	 int futex_wake(int *uaddr, int n);
	 

futex_wait真正將程序掛起之前會檢查addr指向的地址的值是否等於val,如果不相等則會立即返回,由使用者態繼續trylock。否則將當期執行緒插入到一個佇列中去,並掛起。

futex內部維護了一個佇列,線上程掛起前會執行緒插入到其中,同時對於佇列中的每個節點都有一個標識,代表該執行緒關聯鎖的uaddr。這樣,當用戶態呼叫futex_wake時,只需要遍歷這個等待佇列,把帶有相同uaddr的節點所對應的程序喚醒就行了。

作為優化,futex維護的其實是個類似java 中的concurrent hashmap的結構。其持有一個總連結串列,總連結串列中每個元素都是一個帶有自旋鎖的子連結串列。呼叫futex_wait掛起的程序,通過其uaddr hash到某一個具體的子連結串列上去。這樣一方面能分散對等待佇列的競爭、另一方面減小單個佇列的長度,便於futex_wake時的查詢。每個連結串列各自持有一把spinlock,將"*uaddr和val的比較操作"與"把程序加入佇列的操作"保護在一個臨界區中。
另外,futex是支援多程序的,當使用futex在多程序間進行同步時,需要考慮同一個實體記憶體地址在不同程序中的虛擬地址是不同的。

End

本文講述了實現鎖的幾種形式以及linux中futex的實現,下篇文章會講講Java中ReentrantLock,包括其java層的實現以及使用到的LockSupport.park的底層實現。

 

原文:Java架構筆記

免費Java高階資料需要自己領取,涵蓋了Java、Redis、MongoDB、MySQL、Zookeeper、Spring Cloud、Dubbo高併發分散式等教程,一共30G。              
傳送門:               https://mp.weixin.qq.com/s/JzddfH-7yNudmkjT