【操做系統—併發】常見併發問題與事件併發模型

常見併發問題

多年來,研究人員花了大量的時間和精力研究併發編程的缺陷。併發缺陷有不少常見的模式,從大的方面來講能夠分爲兩類:非死鎖缺陷和死鎖缺陷。瞭解這些模式是寫出健壯、正確程序的第一步。node

非死鎖缺陷

研究代表,非死鎖問題佔了併發問題的大多數。它們是怎麼發生的?以及如何修復?咱們主要討論其中兩種:違反原子性(atomicity violation)缺陷和違反順序(order violation)缺陷。程序員

違反原子性缺陷

這是一個MySQL中出現的例子。算法

1    Thread 1::
2    if (thd->proc_info) {
3      ...
4      fputs(thd->proc_info, ...);
5      ...
6    }
7
8    Thread 2::
9    thd->proc_info = NULL;

這個例子中,兩個線程都要訪問thd結構中的成員proc_info。第一個線程檢查proc_info非空,而後打印出值;第二個線程設置其爲空。顯然,假如當第一個線程檢查以後,在fputs()調用以前被中斷,第二個線程把指針置爲空;當第一個線程恢復執行時,因爲引用空指針,會致使程序崩潰。數據庫

正式的違反原子性的定義是:「違反了屢次內存訪問中預期的可串行性(即代碼段本意是原子的,但在執行中並無強制實現原子性)」編程

這種問題的修復一般很簡單。咱們只要給共享變量的訪問加鎖,確保每一個線程訪問proc_info字段時,都持有鎖。固然,訪問這個結構的全部其餘代碼,也應該先獲取鎖。服務器

1    pthread_mutex_t proc_info_lock = PTHREAD_MUTEX_INITIALIZER;
2
3    Thread 1::
4    pthread_mutex_lock(&proc_info_lock);
5    if (thd->proc_info) {
6      ...
7      fputs(thd->proc_info, ...);
8      ...
9    }
10    pthread_mutex_unlock(&proc_info_lock);
11
12   Thread 2::
13   pthread_mutex_lock(&proc_info_lock);
14   thd->proc_info = NULL;
15   pthread_mutex_unlock(&proc_info_lock);
違反順序缺陷

下面是一個簡單的例子。網絡

1    Thread 1::
2    void init() {
3        ...
4        mThread = PR_CreateThread(mMain, ...);
5        ...
6    }
7
8    Thread 2::
9    void mMain(...) {
10       ...
11       mState = mThread->State;
12       ...
13   }

你可能已經發現,線程2的代碼中彷佛假定變量mThread已經被初始化了。然而,若是線程1並無率先執行,線程2就可能由於引用空指針崩潰(假設mThread初始值爲空,不然可能會產生更加奇怪的問題,由於線程2中會讀到任意的內存位置並引用)。數據結構

違反順序更正式的定義是:「兩個內存訪問的預期順序被打破了(即A應該在B以前執行,可是實際運行中卻不是這個順序)」多線程

咱們能夠經過強制順序來修復這種缺陷,條件變量(condition variables)就是一種簡單可靠的方式。在上面的例子中,咱們能夠把代碼修改爲這樣:併發

1    pthread_mutex_t mtLock = PTHREAD_MUTEX_INITIALIZER;
2    pthread_cond_t mtCond = PTHREAD_COND_INITIALIZER;
3    int mtInit            = 0;
4
5    Thread 1::
6    void init() {
7       ...
8       mThread = PR_CreateThread(mMain, ...);
9
10      // signal that the thread has been created...
11      pthread_mutex_lock(&mtLock);
12      mtInit = 1;
13      pthread_cond_signal(&mtCond);
14      pthread_mutex_unlock(&mtLock);
15      ...
16   }
17
18   Thread 2::
19   void mMain(...) {
20      ...
21      // wait for the thread to be initialized...
22      pthread_mutex_lock(&mtLock);
23      while (mtInit == 0)
24          pthread_cond_wait(&mtCond,  &mtLock);
25      pthread_mutex_unlock(&mtLock);
26
27      mState = mThread->State;
28      ...
29   }

死鎖缺陷

除了上面提到的併發缺陷,死鎖(deadlock)是一種在許多複雜併發系統中出現的經典問題。例如,當線程1持有鎖L1,正在等待另一個鎖L2,而線程2持有鎖L2,卻在等待鎖L1釋放時,死鎖就產生了。如下的代碼片斷就可能出現這種死鎖:

Thread 1:    Thread 2:
lock(L1);    lock(L2);
lock(L2);    lock(L1);

這段代碼運行時,不是必定會出現死鎖的。當線程1佔有鎖L1,上下文切換到線程2。線程2鎖住L2,試圖鎖住L1。這時纔會產生死鎖,兩個線程互相等待。如圖所示,其中的圈(cycle)代表了死鎖。

image.png

產生死鎖的條件

死鎖的產生須要以下4個條件:

  • 互斥:線程對於須要的資源進行互斥的訪問。
  • 持有並等待:線程持有了資源,同時又在等待其餘資源。
  • 非搶佔:線程得到的資源,不能被搶佔。
  • 循環等待:線程之間存在一個環路,環路上每一個線程都額外持有一個資源,而這個資源又是下一個線程要申請的。

若是這4個條件的任何一個沒有知足,死鎖就不會產生。所以,解決死鎖的方法也顯而易見:只要設法阻止其中某一個條件便可。

死鎖預防
循環等待

也許最實用的預防技術,就是讓代碼不會產生循環等待。最直接的方法就是獲取鎖時提供一個全序(total ordering)。假如系統共有兩個鎖(L1和L2),那麼咱們每次都先申請L1而後申請L2,這樣嚴格的順序避免了循環等待,也就不會產生死鎖。

固然,更復雜的系統中不會只有兩個鎖,鎖的全序可能很難作到。所以,偏序(partial ordering)多是一種有用的方法,安排鎖的獲取順序並避免死鎖。Linux中的內存映射代碼就是一個偏序鎖的優秀範例。代碼開頭的註釋代表了10組不一樣的加鎖順序,包括簡單的關係,好比i_mutex早於i_mmap_mutex,也包括複雜的關係,好比i_mmap_mutex早於private_lock,早於swap_lock,早於mapping->tree_lock。

不過,全序和偏序都須要細緻的鎖策略的設計和實現。另外,順序只是一種約定,粗心的程序員很容易忽略,致使死鎖。最後,有序加鎖須要深刻理解代碼庫,瞭解各類函數的調用關係,即便一個錯誤,也會致使嚴重的後果。

注:能夠根據鎖的地址做爲獲取鎖的順序,按照地址從高到低,或者從低到高的順序加鎖。

持有並等待

死鎖的持有並等待條件,能夠經過原子地搶鎖來避免。實踐中,能夠經過以下代碼來實現:

lock(prevention);
lock(L1);
lock(L2);
...
unlock(prevention);

代碼保證了某個線程先搶到prevention這個鎖以後,即便有不合時宜的線程切換,其餘線程也搶不到任何鎖。

不過,這個方案的問題也顯而易見。首先它不適用於封裝,由於這個方案須要咱們準確地知道要搶哪些鎖,而且提早搶到這些鎖。而且由於要提早搶到全部鎖,而不是在真正須要的時候,因此可能下降了併發。

非搶佔

在調用unlock以前,都認爲鎖是被佔有的。多個搶鎖操做一般會帶來麻煩,由於咱們等待一個鎖時,可能會同時持有另外一個鎖。不少線程庫提供更爲靈活的接口來避免這種狀況。具體來講,trylock()函數會嘗試得到鎖,返回−1則表示鎖已經被佔有,線程並不會掛起。

能夠用這一接口來實現無死鎖的加鎖方法:

top:
    lock(L1);
    if (trylock(L2) == -1) {
        unlock(L1);
        goto top;
    }

注意,當另外一個線程使用相同的加鎖方式,可是不一樣的加鎖順序(L2而後L1),程序仍然不會產生死鎖。可是會引來一個新的問題:活鎖(livelock)。兩個線程有可能一直重複這一序列,又同時都搶鎖失敗。這種狀況下,系統一直在運行這段代碼,所以名爲活鎖。也有活鎖的解決方法:例如,能夠在循環結束的時候,先隨機等待一個時間,而後再重複整個動做,這樣能夠下降線程之間的重複互相干擾。

使用trylock方法可能還會有其餘一些困難。第一個問題仍然是封裝:若是其中的某一個鎖是封裝在函數內部的,那麼這個跳回開始處就很難實現。還有若是代碼在中途獲取了某些資源,必需要確保也能釋放這些資源。例如,在搶到L1後,咱們的代碼分配了一些內存,當搶L2失敗時,在goto以前,須要釋放這些內存。固然,在某些場景下,這種方法頗有效。

互斥

最後的預防方法是徹底避免互斥。一般來講,代碼都會存在臨界區,所以很難避免互斥。那麼咱們應該怎麼作呢?想法很簡單:經過強大的硬件指令,咱們能夠構造出不須要鎖的數據結構

好比,咱們可使用比較並交換(compare-and-swap)指令來實現一個無鎖同步的鏈表插入操做。
這是在鏈表頭部插入元素的代碼:

void insert(int value) {
    node_t *n = malloc(sizeof(node_t));
    assert(n != NULL);
    n->value = value;
    n->next = head;
    head = n;
}

一種可能的實現是:

void insert(int value) {
    node_t *n = malloc(sizeof(node_t));
    assert(n != NULL);
    n->value = value;
    do {
        n->next = head;
    } while (CompareAndSwap(&head, n->next, n) == 0);
}

這段代碼,首先把next指針指向當前的鏈表頭head,而後試着把新節點交換到鏈表頭。若是此時其餘的線程成功地修改了head的值,這裏的交換就會失敗,線程會一直重試。

死鎖避免

除了死鎖預防,某些場景更適合死鎖避免(avoidance)。咱們須要瞭解全局的信息,包括不一樣線程在運行中對鎖的需求狀況,從而使得後續的調度可以避免產生死鎖。

例如,假設咱們須要在兩個處理器上調度4個線程,進一步假設咱們知道線程1(T1)須要用鎖L1和L2,T2也須要搶L1和L2,T3只須要L2,T4不須要鎖。咱們用下表來表示線程對鎖的需求。

image.png

一種可行的調度方式是,只要T1和T2不一樣時運行,就不會產生死鎖。下面就是這種方式:

image.png

Dijkstra提出的銀行家算法也是一種相似的解決方案。不過這些方案的適用場景很侷限。例如,在嵌入式系統中,你知道全部任務以及它們須要的鎖。另外這種方法會限制併發。所以,經過調度來避免死鎖不是普遍使用的通用方案。

死鎖檢查和恢復

最後一種經常使用的策略就是容許死鎖偶爾發生,檢查到死鎖時再採起行動。若是死鎖不多見,這種不是辦法的辦法也很實用。

不少數據庫系統使用了死鎖檢測和恢復技術。死鎖檢測器會按期運行,經過構建資源圖來檢查循環。當循環(死鎖)發生時,系統會根據既定的策略進行回滾甚至重啓。若是還須要更復雜的數據結構相關的修復,那麼須要人工參與。

注:也許最好的解決方案是開發一種新的併發編程模型:在相似MapReduce這樣的系統中,程序能夠完成一些類型的並行計算,無須任何鎖。鎖必然帶來各類困難,咱們應該儘量地避免使用鎖,除非確信必須使用。

基於事件的併發

目前爲止,咱們提到的併發,彷佛只能用線程來實現。這不徹底對,一些基於圖形用戶界面(GUI)的應用,或某些類型的網絡服務器,經常採用另外一種併發方式。這種方式稱爲基於事件的併發(event-based concurrency),在一些現代系統中較爲流行。

基於事件的併發針對兩方面的問題。一方面是多線程應用中,正確處理併發頗有難度。另外一方面,開發者沒法控制多線程在某一時刻的調度。程序員只是建立了線程,而後就依賴操做系統可以合理地調度線程,可是某些時候操做系統的調度並非最優的。

基本想法:事件循環

咱們的想法很簡單:咱們等待某些事件的發生,當它發生時,檢查事件類型,而後作少許的相應工做(多是I/O請求,或者調度其餘事件準備後續處理)

咱們看一個典型的基於事件的服務器。這種應用都是基於一個簡單的結構,稱爲事件循環(event loop)。事件循環的僞代碼以下:

while (1) {
    events = getEvents(); 
    for (e in events)
        processEvent(e);
}

主循環等待某些事件發生,而後依次處理這些發生的事件。處理事件的代碼叫做事件處理程序(event handler)。處理程序在處理一個事件時,它是系統中發生的惟一活動。所以,調度就是決定接下來處理哪一個事件。這種對調度的顯式控制,是基於事件方法的一個重要優勢。

但這也帶來一個更大的問題:基於事件的服務器如何知道哪一個事件發生,尤爲是對於網絡和磁盤I/O?

重要API:select()/poll()

知道了基本的事件循環,咱們接下來必須解決如何接收事件的問題。大多數系統提供了基本的API,即經過select()或poll()系統調用。這些接口對程序的支持很簡單:檢查是否接收到任何應該關注的I/O。例如,假定網絡應用程序(如Web服務器)但願檢查是否有網絡數據包已到達,以便爲它們提供服務。

下面以select()爲例,它的定義以下:

int select(int nfds,
           fd_set *restrict readfds, 
           fd_set *restrict writefds, 
           fd_set *restrict errorfds,
           struct timeval *restrict timeout);

select()檢查I/O描述符集合,它們的地址經過readfds、writefds和errorfds傳入,分別查看它們中的某些描述符是否已準備好讀取,是否準備好寫入,或有異常狀況待處理。在每一個集合中檢查前nfds個描述符,返回時用給定操做已經準備好的描述符組成的子集替換給定的描述符集合。select()返回全部集合中就緒描述符的總數。

這裏的一個常見用法是將超時設置爲NULL,這會致使select()無限期地阻塞,直到某個描述符準備就緒。可是,更健壯的服務器一般會指定某個超時時間。一種常見的作法是將超時設置爲零,讓調用select()當即返回。

使用select()

咱們來看看如何使用select()來查看哪些描述符有接收到網絡消息,下面是一個簡單示例:

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <sys/time.h>
4    #include <sys/types.h>
5    #include <unistd.h>
6
7    int main(void) {
8        // open and set up a bunch of sockets (not shown)
9        // main loop
10        while (1) {
11           // initialize the fd_set to all zero
12           fd_set readFDs;
13           FD_ZERO(&readFDs);
14
15           // now set the bits for the descriptors
16           // this server is interested in
17           // (for simplicity, all of them from min to max)
18           int fd;
19           for (fd = minFD; fd < maxFD; fd++)
20               FD_SET(fd, &readFDs);
21
22           // do the select
23           int rc = select(maxFD+1, &readFDs, NULL, NULL, NULL);
24
25           // check which actually have data using FD_ISSET()
26           int fd;
27           for (fd = minFD; fd < maxFD; fd++)
28               if (FD_ISSET(fd, &readFDs))
29                   processFD(fd);
30       }
31   }

這段代碼很容易理解。初始化完成後,服務器進入無限循環。在循環內部,它使用FD_ZERO()宏首先清除文件描述符集合,而後使用FD_SET()將全部從minFD到maxFD的文件描述符包含到集合中。最後,服務器調用select()來查看哪些鏈接有可用的數據。而後,經過在循環中使用FD_ISSET(),事件服務器能夠查看哪些描述符已準備好數據並處理傳入的數據。

使用單個CPU和基於事件的應用程序,併發程序中常見的問題再也不存在。由於一次只處理一個事件,因此不須要獲取或釋放鎖。基於事件的服務器是單線程的,所以也不能被另外一個線程中斷。

問題:阻塞系統調用

不過,這裏存在一個問題:若是某個事件要求你發出可能會阻塞的系統調用,該怎麼辦?

例如,假定一個請求從客戶端進入服務器,要從磁盤讀取文件並將其內容返回給發出請求的客戶端。爲了處理這樣的請求,某些事件處理程序會發出open()系統調用來打開文件,而後經過read()調用來讀取文件。當文件被讀入內存時,服務器可能會開始將結果發送到客戶端。

open()和read()調用均可能向存儲系統發出I/O請求,所以可能須要很長時間才能提供服務。使用基於線程的服務器時,這不是問題:在發出I/O請求的線程掛起時,其餘線程能夠運行。可是,使用基於事件的方法時,沒有其餘線程能夠運行。這意味着若是一個事件處理程序發出一個阻塞的調用,整個服務器就會阻塞直到調用完成。當事件循環阻塞時,系統處於閒置狀態,所以是潛在的巨大資源浪費。所以,咱們在基於事件的系統中必須遵照一條規則:不容許阻塞調用

解決方案:異步I/O

爲了克服這個限制,許多現代操做系統引入了新的方法來向磁盤系統發出I/O請求,通常稱爲異步I/O(asynchronous I/O)。這些接口使應用程序可以發出I/O請求,在I/O完成以前能夠當即將控制權返回給調用者,並可讓應用程序可以肯定各類I/O是否已完成。

當程序須要讀取文件時,能夠調用異步I/O的相關接口。若是成功,它會當即返回,應用程序能夠繼續其工做。對於每一個未完成的異步I/O,應用程序能夠經過調用接口來週期性地輪詢(poll)系統,以肯定所述I/O是否已經完成。

若是一個程序在某個特定時間點發出數十或數百個I/O,重複檢查它們中的每個是否完成是很低效的。爲了解決這個問題,一些系統提供了基於中斷(interrupt)的方法。此方法使用UNIX信號(signal)在異步I/O完成時通知應用程序,從而消除了重複詢問系統的須要

信號提供了一種與進程進行通訊的方式。具體來講,能夠將信號傳遞給應用程序。這樣作會讓應用程序中止當前的任何工做,開始運行信號處理程序(signal handler),即應用程序中某些處理該信號的代碼。完成後,該進程就恢復其先前的行爲。

另外一個問題:狀態管理

基於事件的方法的另外一個問題是,當事件處理程序發出異步I/O時,它必須打包一些程序狀態,以便下一個事件處理程序在I/O最終完成時使用。

咱們來看一個簡單的例子,在這個例子中,一個基於線程的服務器須要從文件描述符(fd)中讀取數據,一旦完成,將從文件中讀取的數據寫入網絡套接字描述符sd。

在一個多線程程序中,作這種工做很容易。當read()最終返回時,程序當即知道要寫入哪一個套接字,由於該信息位於線程堆棧中。在基於事件的系統中,爲了執行相同的任務,咱們使用AIO調用異步地發出讀取,而後按期檢查讀取的完成狀況。當讀取完成時,基於事件的服務器如何知道該怎麼作?也即該向哪一個套接字寫入數據?

解決方案很簡單:在某些數據結構中,記錄完成處理該事件須要的信息。當事件發生時(即磁盤I/O完成時),查找所需信息並處理事件。

在這個特定例子中,解決方案是將套接字描述符(sd)記錄在由文件描述符(fd)索引的某種數據結構(例如,散列表)中。當磁盤I/O完成時,事件處理程序將使用文件描述符來查找該數據結構,這會將套接字描述符的值返回給調用者。而後,服務器能夠完成最後的工做將數據寫入套接字。

相關文章
相關標籤/搜索