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

線程同步能夠說在平常開發中是用的不少,
但對於其內部如何實現的,通常人可能知道的並很少。
本篇文章將從如何實現簡單的鎖開始,介紹linux中的鎖實現futex的優勢及原理,最後分析java中同步機制如wait/notify, synchronized, ReentrantLock。java

本身實現鎖

首先,若是要你實現操做系統的鎖,該如何實現?先想一想這個問題,暫時不考慮性能、可用性等問題,就用最簡單、粗暴的方式。當你心中有個大體的思路後,再接着往下看。linux

下文中的代碼都是僞代碼。bash

自旋

最容易想到多是自旋:架構

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()方法,看看下面的例子:ui

volatile int status=0;

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

}

void unlock(){
	status=0;
}

複製代碼

當線程競爭鎖失敗時,會調用yield方法讓出cpu。須要注意的是該方法只是當前讓出cpu,有可能操做系統下次仍是選擇運行該線程。其實現是
將當期線程移動到所在優先調度隊列的末端(操做系統線程調度瞭解一下?有時間的話,下次寫寫這塊內容)。也就是說,若是該線程處於優先級最高的調度隊列且該隊列只有該線程,那操做系統下次仍是運行該線程。atom

自旋+yield的方式並無徹底解決問題,當系統只有兩個線程競爭鎖時,yield是有效的。可是若是有100個線程競爭鎖,當線程1得到鎖後,還有99個線程在反覆的自旋+yield,線程2調用yield後,操做系統下次運行的多是線程3;而線程3CAS失敗後調用yield後,操做系統下次運行的多是線程4...
假如運行在單核cpu下,在競爭鎖時最差只有1%的cpu利用率,致使得到鎖的線程1一直被中斷,執行實際業務代碼時間變得更長,從而致使鎖釋放的時間變的更長。spa

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架構筆記

相關文章
相關標籤/搜索