併發程式設計之安全性、活躍性與高效能

2020-08-11 23:13:03

前面我們介紹了可見性、原子性與有序性是導致併發問題的根源。那麼併發程式設計有什麼指導原則呢?安全性、活躍性與高效能就是宏觀上的指導原則。

安全性

相信你經常會提起某某方法是不是執行緒安全?那麼什麼是執行緒安全的呢?其本質就是正確性,正確性指的是執行緒能夠按照我們期望的方式執行。前面我們介紹了可見性、原子性與有序性是導致併發問題的根源。那麼是不是所以的程式碼都要關注可見性、原子性與有序性呢?我們只需要關心那種存在共用且數據會發生變化的場景,也就是存在多個執行緒同時讀寫共用變數的場景。那如果能夠做到不共用數據或者數據狀態不發生變化,不就能夠保證執行緒的安全性了嘛。有不少技術方案都是基於這個理論的,例如執行緒本地儲存等
但是實際使用的很多場景下會有不少的情況是有多個執行緒需要存取共用數據,並且至少有一個執行緒會修改共用數據,如果不採取措施那麼會導致bug,這種情況我們稱之爲數據競爭。如下的程式當有多個執行緒同時存取時,結果就不可預測

static int count = 0;
void func()
{
	for(int i=0; i<10000; ++i)
	{
		++count;
	}
}

這個方法我們按如下的方式通過加鎖已解決問題

static int count = 0;
void func()
{
	lock();
	for(int i=0; i<10000; ++i)
	{
		++count;
	}
	unlock();
}

那是不是所有的併發問題都通過簡單的新增一個鎖就可以解決呢?顯然不是,比如如下的程式碼

static int count = 0;
int get()
{
	lock();
	int temp = count;
	unlock();
	return temp;
}
void set(int value)
{
	lock();
	count = value;
	unlock();
}
void func()
{
	set(get()+1);
}

雖然獲取與更新count的方法都新增了鎖,但是當第一個執行緒先呼叫fun函數,第二個執行緒在呼叫fun函數,那麼結果是2,但是如果2個執行緒同時呼叫func函數,當2個執行緒都先呼叫了get方法獲取值,那麼2個執行緒獲取到的值都是0,當各自呼叫set函數時,都把count更新成了1。顯然函數的執行結果依賴於函數的呼叫順序,我們把這種執行結構依賴於函數的呼叫順序的問題稱之爲競態條件。
我們可以通過鎖來解決競態條件與數據競爭的問題。我的高併發程式設計專欄的部落格裏面已經有文章介紹鎖了,這裏就不在贅述。

活躍性

活躍性主要包括三個方面:死鎖、活鎖與飢餓。

死鎖需要滿足如下的條件:

互斥,共用資源 X 和 Y 只能被一個執行緒佔用;
回圈等待,執行緒 T1 等待執行緒 T2 佔有的資源,執行緒 T2 等待執行緒 T1 佔有的資源,就是回圈等待;
不可搶佔,其他執行緒不能強行搶佔執行緒 T1 佔有的資源;
佔有且等待,執行緒 T1 已經取得共用資源 X,在等待共用資源 Y 的時候,不釋放共用資源 X。
我們只需要破壞其中一條就可以避免死鎖。
對於第一條互斥,顯然是必須滿足的,因爲對於共用資源,是需要互斥存取的;
對於第二條回圈等待,可以通過按固定的順序申請鎖;
對於第三條不可搶佔,一般通過設定等鎖的超時時間來解決,如果超時則釋放已經申請到的鎖;
最後一條佔有且等待,我們一般通過第三方申請到全部資源後,在申請鎖。
佔有且等待,由於引入了第三方,會影響系統效能,而多執行緒就是希望解決效能問題,所以不經常使用,我們一般通過破壞回圈等待與不可搶佔條件來解決死鎖問題。
那麼系統如果真的死鎖了,應該怎麼辦呢?我們一般只能通過重新啓動應用程式,在重新啓動前可以用pstack等堆疊分析工具找到死鎖程式碼。

活鎖

我們通過超時時間來破解佔有且不可搶佔的問題,但如果超時時間設定的都一樣,那麼2個執行緒可能會同時申請鎖,同時超時釋放鎖,又同時申請鎖,從而導致死回圈,這就是活鎖。爲此我們一般通過設定隨機超時時間來解決。

飢餓

當系統的資源緊張時,如果不同的執行緒優先順序不同,那麼優先順序低的執行緒可能會長時間獲取不到鎖,從而導致飢餓問題。我們一般通過公平鎖來解決這個問題,公平鎖通過一個先進先出的佇列來實現。

高效能

我們使用多執行緒程式設計的目的就是爲了支援高併發,爲此我們使用鎖的時候,一定需要考慮效能問題,並不是遇到共用問題,不管三七二十一就直接加鎖。我們知道鎖是通過序列化共用資源來實現原子性的。序列化的執行時間越長程式的併發性就越低,故我們加鎖時需要提高併發降低序列,以實現更高的效能。
那麼我們如何才能 纔能提高效能呢?
1、既然鎖會降低效能,那麼我們能不能不用鎖呢?我們可以使用原子變數、執行緒本地儲存、寫時複製、常數、無鎖程式設計等技術;
2、使用細粒度的鎖,提高併發性,比如自旋鎖、讀寫鎖、stampedlock、段鎖、行鎖等,但是使用更細粒度的鎖,程式設計難度就更大,可能會導致安全性、活躍性問題,故我們還需要綜合考慮實際場景,是否真的需要那麼高的併發性。
既然鎖宏觀上需要考慮安全性、活躍性與高效能問題,微觀上需要考慮可見性、原子性與有序性,那麼用鎖時是否有什麼規範呢?
1、鎖應該跟物件導向的程式設計一樣,需要封裝好共用變數與鎖,永遠只在更新物件的成員變數時加鎖,永遠只在存取可變的成員變數時加鎖,永遠不在呼叫其他物件的方法時加鎖。