多個執行緒為了同個資源打起架來了,該如何讓他們安分?

千鋒Python唐小強發表於2020-10-16

已經晚上 11 點了,程式設計師小明的雙手還在鍵盤上飛舞著,眼神依然注視著電腦螢幕。

沒辦法,這段時間公司業績增長中,需求自然也多了起來,加班自然也少不了。

天氣變化莫測,這時窗外下起了瓢潑大雨,同時閃電轟鳴。

但這一絲都沒有影響到小明,始料未及,突然一道巨大的雷一閃而過,辦公樓就這麼停電了,隨後整棟樓都在迴盪著小明那一聲撕心裂肺的「臥槽」。

此時,求小明心理陰影面積有多大?

等小明心裡平復後,突然肚子非常的痛,想上廁所,小明心想肯定是晚上吃的某堡王有問題。

整棟樓都停了電,小明兩眼一抹黑,啥都看不見,只能靠摸牆的方法,一步一步地來到了廁所門口。

到了廁所( 共享資源),由於實在太急,小明直接衝入了廁所裡,用手摸索著,剛好第一個門沒鎖,便奪門而入。

這就荒唐了,這個門裡面正好小紅在上著廁所,正好這個廁所門是壞了的,沒辦法鎖門。

黑暗中,小紅雖然看不見,但靠著聲音,發現自己面前的這扇門有動靜,覺得不對勁,於是鉚足了力氣,用她穿著高跟鞋的腳,用力地一腳踢了過去。

小明很幸運,被踢中了「命根子」,撕心裂肺地喊出了一個字「痛」!

故事說完了,扯了那麼多,實際上是為了說明, 對於共享資源,如果沒有上鎖,在多執行緒的環境裡,那麼就可能會發生翻車現場。

接下來,用 30+ 張圖,帶大家走進作業系統中避免多執行緒資源競爭的 互斥、同步的方法。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?


正文

競爭與協作

在單核 CPU 系統裡,為了實現多個程式同時執行的假象,作業系統通常以時間片排程的方式,讓每個程式每次執行一個時間片,時間片用完了,就切換下一個程式執行,由於這個時間片的時間很短,於是就造成了「併發」的現象。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

併發

另外,作業系統也為每個程式建立巨大、私有的虛擬記憶體的假象,這種地址空間的抽象讓每個程式好像擁有自己的記憶體,而實際上作業系統在背後秘密地讓多個地址空間「複用」實體記憶體或者磁碟。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

虛擬記憶體管理-換入換出

如果一個程式只有一個執行流程,也代表它是單執行緒的。當然一個程式可以有多個執行流程,也就是所謂的多執行緒程式,執行緒是排程的基本單位,程式則是資源分配的基本單位。

所以,執行緒之間是可以共享程式的資源,比如程式碼段、堆空間、資料段、開啟的檔案等資源,但每個執行緒都有自己獨立的棧空間。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

多執行緒

那麼問題就來了,多個執行緒如果競爭共享資源,如果不採取有效的措施,則會造成共享資料的混亂。

我們做個小實驗,建立兩個執行緒,它們分別對共享變數 i 自增 1 執行 10000 次,如下程式碼(雖然說是 C++ 程式碼,但是沒學過 C++ 的同學也是看到懂的):

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

按理來說,i 變數最後的值應該是 20000,但很不幸,並不是如此。我們對上面的程式執行一下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

執行了兩次,發現出現了 i 值的結果是 15173,也會出現 20000 的 i 值結果。

每次執行不但會產生錯誤,而且得到不同的結果。在計算機裡是不能容忍的,雖然是小機率出現的錯誤,但是小機率事件它一定是會發生的,「墨菲定律」大家都懂吧。

為什麼會發生這種情況?

為了理解為什麼會發生這種情況,我們必須瞭解編譯器為更新計數器 i 變數生成的程式碼序列,也就是要了解彙編指令的執行順序。

在這個例子中,我們只是想給 i 加上數字 1,那麼它對應的彙編指令執行過程是這樣的:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

可以發現,只是單純給 i 加上數字 1,在 CPU 執行的時候,實際上要執行 3 條指令。

設想我們的執行緒 1 進入這個程式碼區域,它將 i 的值(假設此時是 50 )從記憶體載入到它的暫存器中,然後它向暫存器加 1,此時在暫存器中的 i 值是 51。

現在,一件不幸的事情發生了: 時鐘中斷髮生。因此,作業系統將當前正在執行的執行緒的狀態儲存到執行緒的執行緒控制塊 TCP。

現在更糟的事情發生了,執行緒 2 被排程執行,並進入同一段程式碼。它也執行了第一條指令,從記憶體獲取 i 值並將其放入到暫存器中,此時記憶體中 i 的值仍為 50,因此執行緒 2 暫存器中的 i 值也是 50。假設執行緒 2 執行接下來的兩條指令,將暫存器中的 i 值 + 1,然後將暫存器中的 i 值儲存到記憶體中,於是此時全域性變數 i 值是 51。

最後,又發生一次上下文切換,執行緒 1 恢復執行。還記得它已經執行了兩條彙編指令,現在準備執行最後一條指令。回憶一下, 執行緒 1 暫存器中的 i 值是51,因此,執行最後一條指令後,將值儲存到記憶體,全域性變數 i 的值再次被設定為 51。

簡單來說,增加 i (值為 50 )的程式碼被執行兩次,按理來說,最後的 i 值應該是 52,但是由於 不可控的排程,導致最後 i 值卻是 51。

針對上面執行緒 1 和執行緒 2 的執行過程,我畫了一張流程圖,會更明確一些:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

藍色表示執行緒 1 ,紅色表示執行緒 2

互斥的概念

上面展示的情況稱為 競爭條件( race condition ,當多執行緒相互競爭操作共享變數時,由於運氣不好,即在執行過程中發生了上下文切換,我們得到了錯誤的結果,事實上,每次執行都可能得到不同的結果,因此輸出的結果存在 不確定性( indeterminate

由於多執行緒執行操作共享變數的這段程式碼可能會導致競爭狀態,因此我們將此段程式碼稱為 臨界區( critical section ),它是訪問共享資源的程式碼片段,一定不能給多執行緒同時執行。

我們希望這段程式碼是 互斥( mutualexclusion )的,也就說保證一個執行緒在臨界區執行時,其他執行緒應該被阻止進入臨界區,說白了,就是這段程式碼執行過程中,最多隻能出現一個執行緒。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

互斥

另外,說一下互斥也並不是只針對多執行緒。在多程式競爭共享資源的時候,也同樣是可以使用互斥的方式來避免資源競爭造成的資源混亂。

同步的概念

互斥解決了併發程式/執行緒對臨界區的使用問題。這種基於臨界區控制的互動作用是比較簡單的,只要一個程式/執行緒進入了臨界區,其他試圖想進入臨界區的程式/執行緒都會被阻塞著,直到第一個程式/執行緒離開了臨界區。

我們都知道在多執行緒裡,每個執行緒並一定是順序執行的,它們基本是以各自獨立的、不可預知的速度向前推進,但有時候我們又希望多個執行緒能密切合作,以實現一個共同的任務。

例子,執行緒 1 是負責讀入資料的,而執行緒 2 是負責處理資料的,這兩個執行緒是相互合作、相互依賴的。執行緒 2 在沒有收到執行緒 1 的喚醒通知時,就會一直阻塞等待,當執行緒 1 讀完資料需要把資料傳給執行緒 2 時,執行緒 1 會喚醒執行緒 2,並把資料交給執行緒 2 處理。

所謂同步,就是併發程式/執行緒在一些關鍵點上可能需要互相等待與互通訊息,這種相互制約的等待與互通訊息稱為程式/執行緒同步

舉個生活的同步例子,你肚子餓了想要吃飯,你叫媽媽早點做菜,媽媽聽到後就開始做菜,但是在媽媽沒有做完飯之前,你必須阻塞等待,等媽媽做完飯後,自然會通知你,接著你吃飯的事情就可以進行了。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

吃飯與做菜的同步關係

注意,同步與互斥是兩種不同的概念:

  • 同步就好比:「操作 A 應在操作 B 之前執行」,「操作 C 必須在操作 A 和操作 B 都完成之後才能執行」等。
  • 互斥就好比:「操作 A 和操作 B 不能在同一時刻執行」。

互斥與同步的實現和使用

在程式/執行緒併發執行的過程中,程式/執行緒之間存在協作的關係,例如有互斥、同步的關係。

為了實現程式/執行緒間正確的協作,作業系統必須提供實現程式協作的措施和方法,主要的方法有兩種:

  • :加鎖、解鎖操作。
  • 訊號量:P、V 操作。

這兩個都可以方便地實現程式/執行緒互斥,而訊號量比鎖的功能更強一些,它還可以方便地實現程式/執行緒同步。

使用加鎖操作和解鎖操作可以解決併發執行緒/程式的互斥問題。

任何想進入臨界區的執行緒,必須先執行加鎖操作。若加鎖操作順利透過,則執行緒可進入臨界區;在完成對臨界資源的訪問後再執行解鎖操作,以釋放該臨界資源。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

加鎖-解鎖

根據鎖的實現不同,可以分為「忙等待鎖」和「無忙等待鎖」。

我們先來看看「忙等待鎖」的實現

在說明「忙等待鎖」的實現之前,先介紹現代 CPU 體系結構提供的特殊 原子操作指令 —— 測試和置位( Test-and-Set )指令

如果用 C 程式碼表示 Test-and-Set 指令,形式如下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

測試並設定指令做了下述事情:

  • 把 old_ptr 更新為 new 的新值。
  • 返回 old_ptr 的舊值。

當然, 關鍵是這些程式碼是原子執行。因為既可以測試舊值,又可以設定新值,所以我們把這條指令叫作「測試並設定」。

那什麼是原子操作呢? 原子操作就是要麼全部執行,要麼都不執行,不能出現執行到一半的中間狀態。

我們可以運用 Test-and-Set 指令來實現「忙等待鎖」,程式碼如下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

忙等待鎖的實現

我們來確保理解為什麼這個鎖能工作:

  • 第一個場景是,首先假設一個執行緒在執行,呼叫 lock(),沒有其他執行緒持有鎖,所以 flag 是 0。當呼叫 TestAndSet(flag, 1) 方法,返回 0,執行緒會跳出 while 迴圈,獲取鎖。同時也會原子地設定 flag 為1,標誌鎖已經被持有。當執行緒離開臨界區,呼叫 unlock() 將 flag 清理為 0。
  • 第二種場景是,當某一個執行緒已經持有鎖(即 flag 為1)。本執行緒呼叫lock(),然後呼叫 TestAndSet(flag, 1),這一次返回 1。只要另一個執行緒一直持有鎖,TestAndSet() 會重複返回 1,本執行緒會一直 忙等。當 flag 終於被改為 0,本執行緒會呼叫 TestAndSet(),返回 0 並且原子地設定為 1,從而獲得鎖,進入臨界區。

很明顯,當獲取不到鎖時,執行緒就會一直 wile 迴圈,不做任何事情,所以就被稱為「忙等待鎖」,也被稱為 自旋鎖( spin lock

這是最簡單的一種鎖,一直自旋,利用 CPU 週期,直到鎖可用。在單處理器上,需要搶佔式的排程器(即不斷透過時鐘中斷一個執行緒,執行其他執行緒)。否則,自旋鎖在單 CPU 上無法使用,因為一個自旋的執行緒永遠不會放棄 CPU。

再來看看「無等待鎖」的實現

無等待鎖,顧名思義就是獲取不到鎖的時候,不用自旋。

既然不想自旋,那當沒獲取到鎖的時候,就把當前執行緒放入到鎖的等待佇列,然後執行排程程式,把 CPU 讓給其他執行緒執行。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

無等待鎖的實現

本次只是提出了兩種簡單鎖的實現方式。當然,在具體作業系統實現中,會更復雜,但也離不開本例子兩個基本元素。

訊號量

訊號量是作業系統提供的一種協調共享資源訪問的方法。

通常 訊號量表示資源的數量,對應的變數是一個整型(sem)變數。

另外,還有 兩個原子操作的系統呼叫函式來控制訊號量,分別是:

  • P 操作:將 sem 減 1,相減後,如果 sem < 0,則程式/執行緒進入阻塞等待,否則繼續,表明 P 操作可能會阻塞。
  • V 操作:將 sem 加 1,相加後,如果 sem <= 0,喚醒一個等待中的程式/執行緒,表明 V 操作不會阻塞。

P 操作是用在進入臨界區之前,V 操作是用在離開臨界區之後,這兩個操作是必須成對出現的。

舉個類比,2 個資源的訊號量,相當於 2 條火車軌道,PV 操作如下圖過程:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

訊號量與火車軌道

作業系統是如何實現 PV 操作的呢?

訊號量資料結構與 PV 操作的演算法描述如下圖:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

PV 操作的演算法描述

PV 操作的函式是由作業系統管理和實現的,所以作業系統已經使得執行 PV 函式時是具有原子性的。

PV 操作如何使用的呢?

訊號量不僅可以實現臨界區的互斥訪問控制,還可以實現執行緒間的事件同步。

我們先來說說如何使用 訊號量實現臨界區的互斥訪問

為每類共享資源設定一個訊號量 s,其初值為 1,表示該臨界資源未被佔用。

只要把進入臨界區的操作置於 P(s) 和 V(s) 之間,即可實現程式/執行緒互斥:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

此時,任何想進入臨界區的執行緒,必先在互斥訊號量上執行 P 操作,在完成對臨界資源的訪問後再執行 V 操作。由於互斥訊號量的初始值為 1,故在第一個執行緒執行 P 操作後 s 值變為 0,表示臨界資源為空閒,可分配給該執行緒,使之進入臨界區。

若此時又有第二個執行緒想進入臨界區,也應先執行 P 操作,結果使 s 變為負值,這就意味著臨界資源已被佔用,因此,第二個執行緒被阻塞。

並且,直到第一個執行緒執行 V 操作,釋放臨界資源而恢復 s 值為 0 後,才喚醒第二個執行緒,使之進入臨界區,待它完成臨界資源的訪問後,又執行 V 操作,使 s 恢復到初始值 1。

對於兩個併發執行緒,互斥訊號量的值僅取 1、0 和 -1 三個值,分別表示:

  • 如果互斥訊號量為 1,表示沒有執行緒進入臨界區。
  • 如果互斥訊號量為 0,表示有一個執行緒進入臨界區。
  • 如果互斥訊號量為 -1,表示一個執行緒進入臨界區,另一個執行緒等待進入。

透過互斥訊號量的方式,就能保證臨界區任何時刻只有一個執行緒在執行,就達到了互斥的效果。

再來,我們說說如何使用 訊號量實現事件同步

同步的方式是設定一個訊號量,其初值為 0。

我們把前面的「吃飯-做飯」同步的例子,用程式碼的方式實現一下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

媽媽一開始詢問兒子要不要做飯時,執行的是 P(s1) ,相當於詢問兒子需不需要吃飯,由於 s1 初始值為 0,此時 s1 變成 -1,表明兒子不需要吃飯,所以媽媽執行緒就進入等待狀態。

當兒子肚子餓時,執行了 V(s1),使得 s1 訊號量從 -1 變成 0,表明此時兒子需要吃飯了,於是就喚醒了阻塞中的媽媽執行緒,媽媽執行緒就開始做飯。

接著,兒子執行緒執行了 P(s2),相當於詢問媽媽飯做完了嗎,由於 s2 初始值是 0,則此時 s2 變成 -1,說明媽媽還沒做完飯,兒子執行緒就等待狀態。

最後,媽媽終於做完飯了,於是執行 V(s2),s2 訊號量從 -1 變回了 0,於是就喚醒等待中的兒子執行緒,喚醒後,兒子執行緒就可以進行吃飯了。

生產者-消費者問題

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

生產者-消費者模型

生產者-消費者問題描述:

  • 生產者在生成資料後,放在一個緩衝區中。
  • 消費者從緩衝區取出資料處理。
  • 任何時刻, 只能有一個生產者或消費者可以訪問緩衝區。

我們對問題分析可以得出:

  • 任何時刻只能有一個執行緒操作緩衝區,說明操作緩衝區是臨界程式碼, 需要互斥
  • 緩衝區空時,消費者必須等待生產者生成資料;緩衝區滿時,生產者必須等待消費者取出資料。說明生產者和消費者 需要同步

那麼我們需要三個訊號量,分別是:

  • 互斥訊號量 mutex:用於互斥訪問緩衝區,初始化值為 1。
  • 資源訊號量 fullBuffers:用於消費者詢問緩衝區是否有資料,有資料則讀取資料,初始化值為 0(表明緩衝區一開始為空)。
  • 資源訊號量 emptyBuffers:用於生產者詢問緩衝區是否有空位,有空位則生成資料,初始化值為 n (緩衝區大小)。

具體的實現程式碼:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

如果消費者執行緒一開始執行 P(fullBuffers),由於訊號量 fullBuffers 初始值為 0,則此時 fullBuffers 的值從 0 變為 -1,說明緩衝區裡沒有資料,消費者只能等待。

接著,輪到生產者執行 P(emptyBuffers),表示減少 1 個空槽,如果當前沒有其他生產者執行緒在臨界區執行程式碼,那麼該生產者執行緒就可以把資料放到緩衝區,放完後,執行 V(fullBuffers) ,訊號量 fullBuffers 從 -1 變成 0,表明有「消費者」執行緒正在阻塞等待資料,於是阻塞等待的消費者執行緒會被喚醒。

消費者執行緒被喚醒後,如果此時沒有其他消費者執行緒在讀資料,那麼就可以直接進入臨界區,從緩衝區讀取資料。最後,離開臨界區後,把空槽的個數 + 1。


經典同步問題

哲學家就餐問題

當初我在校招的時候,面試官也問過「哲學家就餐」這道題目,我當時聽得一臉懵逼,無論面試官怎麼講述這個問題,我也始終沒聽懂,就莫名其妙地說這個問題會「死鎖」。

當然,我這回答糟透了,所以當場 game over,殘酷又悲慘故事,就不多說了,反正當時菜就是菜。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

時至今日,看我來圖解這道題。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

哲學家就餐的問題

先來看看哲學家就餐的問題描述:

  • 5 個老大哥哲學家,閒著沒事做,圍繞著一張圓桌吃麵。
  • 巧就巧在,這個桌子只有 5 支叉子,每兩個哲學家之間放一支叉子。
  • 哲學家圍在一起先思考,思考中途餓了就會想進餐。
  • 奇葩的是,這些哲學家要兩支叉子才願意吃麵,也就是需要拿到左右兩邊的叉子才進餐。
  • 吃完後,會把兩支叉子放回原處,繼續思考。

那麼問題來了,如何保證哲學家們的動作有序進行,而不會出現有人永遠拿不到叉子呢?

方案一

我們用訊號量的方式,也就是 PV 操作來嘗試解決它,程式碼如下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

上面的程式,好似很自然。拿起叉子用 P 操作,代表有叉子就直接用,沒有叉子時就等待其他哲學家放回叉子。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

方案一的問題

不過,這種解法存在一個極端的問題: 假設五位哲學家同時拿起左邊的叉子,桌面上就沒有叉子了,這樣就沒有人能夠拿到他們右邊的叉子,也就說每一位哲學家都會在P(fork[(i + 1) % N ]) 這條語句阻塞了,很明顯這發生了死鎖的現象

方案二

既然「方案一」會發生同時競爭左邊叉子導致死鎖的現象,那麼我們就在拿叉子前,加個互斥訊號量,程式碼如下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

上面程式中的互斥訊號量的作用就在於, 只要有一個哲學家進入了「臨界區」,也就是準備要拿叉子時,其他哲學家都不能動,只有這位哲學家用完叉子了,才能輪到下一個哲學家進餐。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

方案二的問題

方案二雖然能讓哲學家們按順序吃飯,但是每次進餐只能有一位哲學家,而桌面上是有 5 把叉子,按道理是能可以有兩個哲學家同時進餐的,所以從效率角度上,這不是最好的解決方案。

方案三

那既然方案二使用互斥訊號量,會導致只能允許一個哲學家就餐,那麼我們就不用它。

另外,方案一的問題在於,會出現所有哲學家同時拿左邊刀叉的可能性,那我們就避免哲學家可以同時拿左邊的刀叉,採用分支結構,根據哲學家的編號的不同,而採取不同的動作。

即讓偶數編號的哲學家「先拿左邊的叉子後拿右邊的叉子」,奇數編號的哲學家「先拿右邊的叉子後拿左邊的叉子」。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

上面的程式,在 P 操作時,根據哲學家的編號不同,拿起左右兩邊叉子的順序不同。另外,V 操作是不需要分支的,因為 V 操作是不會阻塞的。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

方案三可解決問題

方案三既不會出現死鎖,也可以兩人同時進餐。

方案四

在這裡再提出另外一種可行的解決方案,我們 用一個陣列 state 來記錄每一位哲學家在程式、思考還是飢餓狀態(正在試圖拿叉子)。

那麼, 一個哲學家只有在兩個鄰居都沒有進餐時,才可以進入進餐狀態。

第 i 個哲學家的左鄰右舍,則由宏 LEFT 和 RIGHT 定義:

  • LEFT : ( i + 5 - 1 ) % 5
  • RIGHT : ( i + 1 ) % 5

比如 i 為 2,則 LEFT 為 1,RIGHT 為 3。

具體程式碼實現如下:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

上面的程式使用了一個訊號量陣列,每個訊號量對應一位哲學家,這樣在所需的叉子被佔用時,想進餐的哲學家就被阻塞。

注意,每個程式/執行緒將 smart_person 函式作為主程式碼執行,而其他take_forks、put_forks 和 test 只是普通的函式,而非單獨的程式/執行緒。

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

方案四也可解決問題

方案四同樣不會出現死鎖,也可以兩人同時進餐。

讀者-寫者問題

前面的「哲學家進餐問題」對於互斥訪問有限的競爭問題(如 I/O 裝置)一類的建模過程十分有用。

另外,還有個著名的問題是「讀者-寫者」,它為資料庫訪問建立了一個模型。

讀者只會讀取資料,不會修改資料,而寫者既可以讀也可以修改資料。

讀者-寫者的問題描述:

  • 「讀-讀」允許:同一時刻,允許多個讀者同時讀。
  • 「讀-寫」互斥:沒有寫者時讀者才能讀,沒有讀者時寫者才能寫。
  • 「寫-寫」互斥:沒有其他寫者時,寫者才能寫。

接下來,提出幾個解決方案來分析一下。

方案一

使用訊號量的方式來嘗試解決:

  • 訊號量 wMutex:控制寫操作的互斥訊號量,初始值為 1。
  • 讀者計數 rCount:正在進行讀操作的讀者個數,初始化為 0。
  • 訊號量 rCountMutex:控制對 rCount 讀者計數器的互斥修改,初始值為 1。

接下來看看程式碼的實現:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

上面的這種實現,是讀者優先的策略,因為只要有讀者正在讀的狀態,後來的讀者都可以直接進入,如果讀者持續不斷進入,則寫者會處於飢餓狀態。

方案二

那既然有讀者優先策略,自然也有寫者優先策略:

  • 只要有寫者準備要寫入,寫者應儘快執行寫操作,後來的讀者就必須阻塞。
  • 如果有寫者持續不斷寫入,則讀者就處於飢餓。

在方案一的基礎上新增如下變數:

  • 訊號量 rMutex:控制讀者進入的互斥訊號量,初始值為 1。
  • 訊號量 wDataMutex:控制寫者寫操作的互斥訊號量,初始值為 1。
  • 寫者計數 wCount:記錄寫者數量,初始值為 0。
  • 訊號量 wCountMutex:控制 wCount 互斥修改,初始值為 1。

具體實現程式碼 如下

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

注意,這裡 rMutex 的作用,開始有多個讀者讀資料,它們全部進入讀者佇列,此時來了一個寫者,執行了 P(rMutex) 之後,後續的讀者由於阻塞在 rMutex 上,都不能再進入讀者佇列,而寫者到來,則可以全部進入寫者佇列,因此保證了寫者優先。

同時,第一個寫者執行了 P(rMutex) 之後,也不能馬上開始寫,必須等到所有進入讀者佇列的讀者都執行完讀操作,透過 V(wDataMutex) 喚醒寫者的寫操作。

方案三

既然讀者優先策略和寫者優先策略都會造成飢餓的現象,那麼我們就來實現一下公平策略。

公平策略:

  • 優先順序相同。
  • 寫者、讀者互斥訪問。
  • 只能一個寫者訪問臨界區。
  • 可以有多個讀者同時訪問臨界資源。

具體程式碼實現:

多個執行緒為了同個資源打起架來了,該如何讓他們安分?

看完程式碼不知你是否有這樣的疑問,為什麼加了一個訊號量 flag,就實現了公平競爭?

對比方案一的讀者優先策略,可以發現,讀者優先中只要後續有讀者到達,讀者就可以進入讀者佇列,而寫者必須等待,直到沒有讀者到達。

沒有讀者到達會導致讀者佇列為空,即 rCount==0,此時寫者才可以進入臨界區執行寫操作。

而這裡 flag 的作用就是阻止讀者的這種特殊許可權(特殊許可權是隻要讀者到達,就可以進入讀者佇列)。

比如:開始來了一些讀者讀資料,它們全部進入讀者佇列,此時來了一個寫者,執行P(falg) 操作,使得後續到來的讀者都阻塞在 flag 上,不能進入讀者佇列,這會使得讀者佇列逐漸為空,即 rCount 減為 0。

這時寫者也不能立馬開始寫(因為此時讀者佇列不為空),會阻塞在訊號量wDataMutex 上,讀者佇列中的讀者全部讀取結束後,最後一個讀者程式執行V(wDataMutex),喚醒剛才的寫者,寫者則繼續開始進行寫操作。


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69923331/viewspace-2727443/,如需轉載,請註明出處,否則將追究法律責任。

相關文章