多個線程為了同個資源打起架來了,該如何讓他們安分?
前言
先來看看虛構(gòu)的小故事
已經(jīng)晚上 11 點了,程序員小明的雙手還在鍵盤上飛舞著,眼神依然注視著的電腦屏幕。
沒辦法這段時間公司業(yè)績增長中,需求自然也多了起來,加班自然也少不了。
天氣變化莫測,這時窗外下起了蓬勃大雨,同時閃電轟鳴。
但這一絲都沒有影響到小明,始料未及,突然一道巨大的雷一閃而過,辦公樓就這么停電了,隨后整棟樓都在回蕩著的小明那一聲撕心裂肺的「臥槽」。
此時,求小明的心里面積有多大?
等小明心里平復(fù)后,突然肚子非常的痛,想上廁所,小明心想肯定是晚上吃的某堡王有問題。
整棟樓都停了電,小明兩眼一抹黑,啥都看不見,只能靠摸墻的方法,一步一步的來到了廁所門口。
到了廁所(共享資源),由于實在太急,小明直接沖入了廁所里,用手摸索著剛好第一個門沒鎖門,便奪門而入。
這就荒唐了,這個門里面正好小紅在上著廁所,正好這個廁所門是壞了的,沒辦法鎖門。
黑暗中,小紅雖然看不見,但靠著聲音,發(fā)現(xiàn)自己面前的這扇門有動靜,覺得不對勁,于是鉚足了力氣,用她穿著高跟鞋腳,用力地一腳踢了過去。
小明很幸運,被踢中了「命根子」,撕心裂肺地喊出了一個字「痛」!
故事說完了,扯了那么多,實際上是為了說明,對于共享資源,如果沒有上鎖,在多線程的環(huán)境里,那么就可能會發(fā)生翻車現(xiàn)場。
接下來,用 30+
張圖,帶大家走進操作系統(tǒng)中避免多線程資源競爭的互斥、同步的方法。
正文
競爭與協(xié)作
在單核 CPU 系統(tǒng)里,為了實現(xiàn)多個程序同時運行的假象,操作系統(tǒng)通常以時間片調(diào)度的方式,讓每個進程執(zhí)行每次執(zhí)行一個時間片,時間片用完了,就切換下一個進程運行,由于這個時間片的時間很短,于是就造成了「并發(fā)」的現(xiàn)象。
另外,操作系統(tǒng)也為每個進程創(chuàng)建巨大、私有的虛擬內(nèi)存的假象,這種地址空間的抽象讓每個程序好像擁有自己的內(nèi)存,而實際上操作系統(tǒng)在背后秘密地讓多個地址空間「復(fù)用」物理內(nèi)存或者磁盤。
如果一個程序只有一個執(zhí)行流程,也代表它是單線程的。當然一個程序可以有多個執(zhí)行流程,也就是所謂的多線程程序,線程是調(diào)度的基本單位,進程則是資源分配的基本單位。
所以,線程之間是可以共享進程的資源,比如代碼段、堆空間、數(shù)據(jù)段、打開的文件等資源,但每個線程都有自己獨立的??臻g。
那么問題就來了,多個線程如果競爭共享資源,如果不采取有效的措施,則會造成共享數(shù)據(jù)的混亂。
我們做個小實驗,創(chuàng)建兩個線程,它們分別對共享變量 i
自增 1
執(zhí)行 10000
次,如下代碼(雖然說是 C++ 代碼,但是沒學(xué)過 C++ 的同學(xué)也是看到懂的):
按理來說,i
變量最后的值應(yīng)該是 20000
,但很不幸,并不是如此。我們對上面的程序執(zhí)行一下:
運行了兩次,發(fā)現(xiàn)出現(xiàn)了 i 值的結(jié)果是 15173
,也會出現(xiàn) 20000
的 i 值結(jié)果。
每次運行不但會產(chǎn)生錯誤,而且得到不同的結(jié)果。在計算機里是不能容忍的,雖然是小概率出現(xiàn)的錯誤,但是小概率事件它一定是會發(fā)生的,「墨菲定律」大家都懂吧。
為什么會發(fā)生這種情況?
為了理解為什么會發(fā)生這種情況,我們必須了解編譯器為更新計數(shù)器 i
變量生成的代碼序列,也就是要了解匯編指令的執(zhí)行順序。
在這個例子中,我們只是想給 i
加上數(shù)字 1,那么它對應(yīng)的匯編指令執(zhí)行過程是這樣的:
可以發(fā)現(xiàn),只是單純給 i
加上數(shù)字 1,在 CPU 運行的時候,實際上要執(zhí)行 3
條指令。
設(shè)想我們的線程 1 進入這個代碼區(qū)域,它將 i 的值(假設(shè)此時是 50 )從內(nèi)存加載到它的寄存器中,然后它向寄存器加 1,此時在寄存器中的 i 值是 51。
現(xiàn)在,一件不幸的事情發(fā)生了:時鐘中斷發(fā)生。因此,操作系統(tǒng)將當前正在運行的線程的狀態(tài)保存到線程的線程控制塊 TCP。
現(xiàn)在更糟的事情發(fā)生了,線程 2 被調(diào)度運行,并進入同一段代碼。它也執(zhí)行了第一條指令,從內(nèi)存獲取 i 值并將其放入到寄存器中,此時內(nèi)存中 i 的值仍為 50,因此線程 2 寄存器中的 i 值也是 50。假設(shè)線程 2 執(zhí)行接下來的兩條指令,將寄存器中的 i 值 + 1,然后將寄存器中的 i 值保存到內(nèi)存中,于是此時全局變量 i 值是 51。
最后,又發(fā)生一次上下文切換,線程 1 恢復(fù)執(zhí)行。還記得它已經(jīng)執(zhí)行了兩條匯編指令,現(xiàn)在準備執(zhí)行最后一條指令。回憶一下, 線程 1 寄存器中的 i 值是51,因此,執(zhí)行最后一條指令后,將值保存到內(nèi)存,全局變量 i 的值再次被設(shè)置為 51。
簡單來說,增加 i (值為 50 )的代碼被運行兩次,按理來說,最后的 i 值應(yīng)該是 52,但是由于不可控的調(diào)度,導(dǎo)致最后 i 值卻是 51。
針對上面線程 1 和線程 2 的執(zhí)行過程,我畫了一張流程圖,會更明確一些:
互斥的概念
上面展示的情況稱為競爭條件(race condition),當多線程相互競爭操作共享變量時,由于運氣不好,即在執(zhí)行過程中發(fā)生了上下文切換,我們得到了錯誤的結(jié)果,事實上,每次運行都可能得到不同的結(jié)果,因此輸出的結(jié)果存在不確定性(indeterminate)。
由于多線程執(zhí)行操作共享變量的這段代碼可能會導(dǎo)致競爭狀態(tài),因此我們將此段代碼稱為臨界區(qū)(critical section),它是訪問共享資源的代碼片段,一定不能給多線程同時執(zhí)行。
我們希望這段代碼是互斥(mutualexclusion)的,也就說保證一個線程在臨界區(qū)執(zhí)行時,其他線程應(yīng)該被阻止進入臨界區(qū),說白了,就是這段代碼執(zhí)行過程中,最多只能出現(xiàn)一個線程。
另外,說一下互斥也并不是只針對多線程。在多進程競爭共享資源的時候,也同樣是可以使用互斥的方式來避免資源競爭造成的資源混亂。
同步的概念
互斥解決了并發(fā)進程/線程對臨界區(qū)的使用問題。這種基于臨界區(qū)控制的交互作用是比較簡單的,只要一個進程/線程進入了臨界區(qū),其他試圖想進入臨界區(qū)的進程/線程都會被阻塞著,直到第一個進程/線程離開了臨界區(qū)。
我們都知道在多線程里,每個線程并一定是順序執(zhí)行的,它們基本是以各自獨立的、不可預(yù)知的速度向前推進,但有時候我們又希望多個線程能密切合作,以實現(xiàn)一個共同的任務(wù)。
例子,線程 1 是負責讀入數(shù)據(jù)的,而線程 2 是負責處理數(shù)據(jù)的,這兩個線程是相互合作、相互依賴的。線程 2 在沒有收到線程 1 的喚醒通知時,就會一直阻塞等待,當線程 1 讀完數(shù)據(jù)需要把數(shù)據(jù)傳給線程 2 時,線程 1 會喚醒線程 2,并把數(shù)據(jù)交給線程 2 處理。
所謂同步,就是并發(fā)進程/線程在一些關(guān)鍵點上可能需要互相等待與互通消息,這種相互制約的等待與互通信息稱為進程/線程同步。
舉個生活的同步例子,你肚子餓了想要吃飯,你叫媽媽早點做菜,媽媽聽到后就開始做菜,但是在媽媽沒有做完飯之前,你必須阻塞等待,等媽媽做完飯后,自然會通知你,接著你吃飯的事情就可以進行了。
注意,同步與互斥是兩種不同的概念:
同步就好比:「操作 A 應(yīng)在操作 B 之前執(zhí)行」,「操作 C 必須在操作 A 和操作 B 都完成之后才能執(zhí)行」等;
互斥就好比:「操作 A 和操作 B 不能在同一時刻執(zhí)行」;
互斥與同步的實現(xiàn)和使用
在進程/線程并發(fā)執(zhí)行的過程中,進程/線程之間存在協(xié)作的關(guān)系,例如有互斥、同步的關(guān)系。
為了實現(xiàn)進程/線程間正確的協(xié)作,操作系統(tǒng)必須提供實現(xiàn)進程協(xié)作的措施和方法,主要的方法有兩種:
鎖:加鎖、解鎖操作;
信號量:P、V 操作;
這兩個都可以方便地實現(xiàn)進程/線程互斥,而信號量比鎖的功能更強一些,它還可以方便地實現(xiàn)進程/線程同步。
鎖
使用加鎖操作和解鎖操作可以解決并發(fā)線程/進程的互斥問題。
任何想進入臨界區(qū)的線程,必須先執(zhí)行加鎖操作。若加鎖操作順利通過,則線程可進入臨界區(qū);在完成對臨界資源的訪問后再執(zhí)行解鎖操作,以釋放該臨界資源。
根據(jù)鎖的實現(xiàn)不同,可以分為「忙等待鎖」和「無忙等待鎖」。
我們先來看看「忙等待鎖」的實現(xiàn)
在說明「忙等待鎖」的實現(xiàn)之前,先介紹現(xiàn)代 CPU 體系結(jié)構(gòu)提供的特殊原子操作指令 —— 測試和置位(Test-and-Set)指令。
如果用 C 代碼表示 Test-and-Set 指令,形式如下:
測試并設(shè)置指令做了下述事情:
把
old_ptr
更新為new
的新值返回
old_ptr
的舊值;
當然,關(guān)鍵是這些代碼是原子執(zhí)行。因為既可以測試舊值,又可以設(shè)置新值,所以我們把這條指令叫作「測試并設(shè)置」。
那什么是原子操作呢?原子操作就是要么全部執(zhí)行,要么都不執(zhí)行,不能出現(xiàn)執(zhí)行到一半的中間狀態(tài)
我們可以運用 Test-and-Set 指令來實現(xiàn)「忙等待鎖」,代碼如下:
我們來確保理解為什么這個鎖能工作:
第一個場景是,首先假設(shè)一個線程在運行,調(diào)用
lock()
,沒有其他線程持有鎖,所以flag
是 0。當調(diào)用TestAndSet(flag, 1)
方法,返回 0,線程會跳出 while 循環(huán),獲取鎖。同時也會原子的設(shè)置 flag 為1,標志鎖已經(jīng)被持有。當線程離開臨界區(qū),調(diào)用unlock()
將flag
清理為 0。第二種場景是,當某一個線程已經(jīng)持有鎖(即
flag
為1)。本線程調(diào)用lock()
,然后調(diào)用TestAndSet(flag, 1)
,這一次返回 1。只要另一個線程一直持有鎖,TestAndSet()
會重復(fù)返回 1,本線程會一直忙等。當flag
終于被改為 0,本線程會調(diào)用TestAndSet()
,返回 0 并且原子地設(shè)置為 1,從而獲得鎖,進入臨界區(qū)。
很明顯,當獲取不到鎖時,線程就會一直 wile 循環(huán),不做任何事情,所以就被稱為「忙等待鎖」,也被稱為自旋鎖(spin lock)。
這是最簡單的一種鎖,一直自旋,利用 CPU 周期,直到鎖可用。在單處理器上,需要搶占式的調(diào)度器(即不斷通過時鐘中斷一個線程,運行其他線程)。否則,自旋鎖在單 CPU 上無法使用,因為一個自旋的線程永遠不會放棄 CPU。
再來看看「無等待鎖」的實現(xiàn)
無等待鎖顧明思議就是獲取不到鎖的時候,不用自旋。
既然不想自旋,那當沒獲取到鎖的時候,就把當前線程放入到鎖的等待隊列,然后執(zhí)行調(diào)度程序,把 CPU 讓給其他線程執(zhí)行。
本次只是提出了兩種簡單鎖的實現(xiàn)方式。當然,在具體操作系統(tǒng)實現(xiàn)中,會更復(fù)雜,但也離不開本例子兩個基本元素。
如果你想要對鎖的更進一步理解,推薦大家可以看《操作系統(tǒng)導(dǎo)論》第 28 章鎖的內(nèi)容,這本書在「微信讀書」就可以免費看。
信號量
信號量是操作系統(tǒng)提供的一種協(xié)調(diào)共享資源訪問的方法。
通常信號量表示資源的數(shù)量,對應(yīng)的變量是一個整型(sem
)變量。
另外,還有兩個原子操作的系統(tǒng)調(diào)用函數(shù)來控制信號量的,分別是:
P 操作:將
sem
減1
,相減后,如果sem < 0
,則進程/線程進入阻塞等待,否則繼續(xù),表明 P 操作可能會阻塞;V 操作:將
sem
加1
,相加后,如果sem <= 0
,喚醒一個等待中的進程/線程,表明 V 操作不會阻塞;
P 操作是用在進入臨界區(qū)之前,V 操作是用在離開臨界區(qū)之后,這兩個操作是必須成對出現(xiàn)的。
舉個類比,2 個資源的信號量,相當于 2 條火車軌道,PV 操作如下圖過程:
操作系統(tǒng)是如何實現(xiàn) PV 操作的呢?
信號量數(shù)據(jù)結(jié)構(gòu)與 PV 操作的算法描述如下圖:
PV 操作的函數(shù)是由操作系統(tǒng)管理和實現(xiàn)的,所以操作系統(tǒng)已經(jīng)使得執(zhí)行 PV 函數(shù)時是具有原子性的。
PV 操作如何使用的呢?
信號量不僅可以實現(xiàn)臨界區(qū)的互斥訪問控制,還可以線程間的事件同步。
我們先來說說如何使用信號量實現(xiàn)臨界區(qū)的互斥訪問。
為每類共享資源設(shè)置一個信號量 s
,其初值為 1
,表示該臨界資源未被占用。
只要把進入臨界區(qū)的操作置于 P(s)
和 V(s)
之間,即可實現(xiàn)進程/線程互斥:
此時,任何想進入臨界區(qū)的線程,必先在互斥信號量上執(zhí)行 P 操作,在完成對臨界資源的訪問后再執(zhí)行 V 操作。由于互斥信號量的初始值為 1,故在第一個線程執(zhí)行 P 操作后 s 值變?yōu)?0,表示臨界資源為空閑,可分配給該線程,使之進入臨界區(qū)。
若此時又有第二個線程想進入臨界區(qū),也應(yīng)先執(zhí)行 P 操作,結(jié)果使 s 變?yōu)樨撝?,這就意味著臨界資源已被占用,因此,第二個線程被阻塞。
并且,直到第一個線程執(zhí)行 V 操作,釋放臨界資源而恢復(fù) s 值為 0 后,才喚醒第二個線程,使之進入臨界區(qū),待它完成臨界資源的訪問后,又執(zhí)行 V 操作,使 s 恢復(fù)到初始值 1。
對于兩個并發(fā)線程,互斥信號量的值僅取 1、0 和 -1 三個值,分別表示:
如果互斥信號量為 1,表示沒有線程進入臨界區(qū);
如果互斥信號量為 0,表示有一個線程進入臨界區(qū);
如果互斥信號量為 -1,表示一個線程進入臨界區(qū),另一個線程等待進入。
通過互斥信號量的方式,就能保證臨界區(qū)任何時刻只有一個線程在執(zhí)行,就達到了互斥的效果。
再來,我們說說如何使用信號量實現(xiàn)事件同步。
同步的方式是設(shè)置一個信號量,其初值為 0
。
我們把前面的「吃飯-做飯」同步的例子,用代碼的方式實現(xiàn)一下:
媽媽一開始詢問兒子要不要做飯時,執(zhí)行的是 P(s1)
,相當于詢問兒子需不需要吃飯,由于 s1
初始值為 0,此時 s1
變成 -1,表明兒子不需要吃飯,所以媽媽線程就進入等待狀態(tài)。
當兒子肚子餓時,執(zhí)行了 V(s1)
,使得 s1
信號量從 -1 變成 0,表明此時兒子需要吃飯了,于是就喚醒了阻塞中的媽媽線程,媽媽線程就開始做飯。
接著,兒子線程執(zhí)行了 P(s2)
,相當于詢問媽媽飯做完了嗎,由于 s2
初始值是 0,則此時 s2
變成 -1,說明媽媽還沒做完飯,兒子線程就等待狀態(tài)。
最后,媽媽終于做完飯了,于是執(zhí)行 V(s2)
,s2
信號量從 -1 變回了 0,于是就喚醒等待中的兒子線程,喚醒后,兒子線程就可以進行吃飯了。
生產(chǎn)者-消費者問題
生產(chǎn)者-消費者問題描述:
生產(chǎn)者在生成數(shù)據(jù)后,放在一個緩沖區(qū)中;
消費者從緩沖區(qū)取出數(shù)據(jù)處理;
任何時刻,只能有一個生產(chǎn)者或消費者可以訪問緩沖區(qū);
我們對問題分析可以得出:
任何時刻只能有一個線程操作緩沖區(qū),說明操作緩沖區(qū)是臨界代碼,需要互斥;
緩沖區(qū)空時,消費者必須等待生產(chǎn)者生成數(shù)據(jù);緩沖區(qū)滿時,生產(chǎn)者必須等待消費者取出數(shù)據(jù)。說明生產(chǎn)者和消費者需要同步。
那么我們需要三個信號量,分別是:
互斥信號量
mutex
:用于互斥訪問緩沖區(qū),初始化值為 1;資源信號量
fullBuffers
:用于消費者詢問緩沖區(qū)是否有數(shù)據(jù),有數(shù)據(jù)則讀取數(shù)據(jù),初始化值為 0(表明緩沖區(qū)一開始為空);資源信號量
emptyBuffers
:用于生產(chǎn)者詢問緩沖區(qū)是否有空位,有空位則生成數(shù)據(jù),初始化值為 n (緩沖區(qū)大?。?;
具體的實現(xiàn)代碼:
如果消費者線程一開始執(zhí)行 P(fullBuffers)
,由于信號量 fullBuffers
初始值為 0,則此時 fullBuffers
的值從 0 變?yōu)?-1,說明緩沖區(qū)里沒有數(shù)據(jù),消費者只能等待。
接著,輪到生產(chǎn)者執(zhí)行 P(emptyBuffers)
,表示減少 1 個空槽,如果當前沒有其他生產(chǎn)者線程在臨界區(qū)執(zhí)行代碼,那么該生產(chǎn)者線程就可以把數(shù)據(jù)放到緩沖區(qū),放完后,執(zhí)行 V(fullBuffers)
,信號量 fullBuffers
從 -1 變成 0,表明有「消費者」線程正在阻塞等待數(shù)據(jù),于是阻塞等待的消費者線程會被喚醒。
消費者線程被喚醒后,如果此時沒有其他消費者線程在讀數(shù)據(jù),那么就可以直接進入臨界區(qū),從緩沖區(qū)讀取數(shù)據(jù)。最后,離開臨界區(qū)后,把空槽的個數(shù) + 1。
經(jīng)典同步問題
哲學(xué)家就餐問題
當初我在校招的時候,面試官也問過「哲學(xué)家就餐」這道題目,我當時聽的一臉懵逼,無論面試官怎么講述這個問題,我也始終沒聽懂,就莫名其妙的說這個問題會「死鎖」。
當然,我這回答槽透了,所以當場 game over,殘酷又悲慘故事,就不多說了,反正當時菜就是菜。
時至今日,看我來圖解這道題。
先來看看哲學(xué)家就餐的問題描述:
5
個老大哥哲學(xué)家,閑著沒事做,圍繞著一張圓桌吃面;巧就巧在,這個桌子只有
5
支叉子,每兩個哲學(xué)家之間放一支叉子;哲學(xué)家圍在一起先思考,思考中途餓了就會想進餐;
奇葩的是,這些哲學(xué)家要兩支叉子才愿意吃面,也就是需要拿到左右兩邊的叉子才進餐;
吃完后,會把兩支叉子放回原處,繼續(xù)思考;
那么問題來了,如何保證哲學(xué)家們的動作有序進行,而不會出現(xiàn)有人永遠拿不到叉子呢?
方案一
我們用信號量的方式,也就是 PV 操作來嘗試解決它,代碼如下:
上面的程序,好似很自然。拿起叉子用 P 操作,代表有叉子就直接用,沒有叉子時就等待其他哲學(xué)家放回叉子。
不過,這種解法存在一個極端的問題:假設(shè)五位哲學(xué)家同時拿起左邊的叉子,桌面上就沒有叉子了, 這樣就沒有人能夠拿到他們右邊的叉子,也就說每一位哲學(xué)家都會在 P(fork[(i + 1) % N ])
這條語句阻塞了,很明顯這發(fā)生了死鎖的現(xiàn)象。
方案二
既然「方案一」會發(fā)生同時競爭左邊叉子導(dǎo)致死鎖的現(xiàn)象,那么我們就在拿叉子前,加個互斥信號量,代碼如下:
上面程序中的互斥信號量的作用就在于,只要有一個哲學(xué)家進入了「臨界區(qū)」,也就是準備要拿叉子時,其他哲學(xué)家都不能動,只有這位哲學(xué)家用完叉子了,才能輪到下一個哲學(xué)家進餐。
方案二雖然能讓哲學(xué)家們按順序吃飯,但是每次進餐只能有一位哲學(xué)家,而桌面上是有 5 把叉子,按道理是能可以有兩個哲學(xué)家同時進餐的,所以從效率角度上,這不是最好的解決方案。
方案三
那既然方案二使用互斥信號量,會導(dǎo)致只能允許一個哲學(xué)家就餐,那么我們就不用它。
另外,方案一的問題在于,會出現(xiàn)所有哲學(xué)家同時拿左邊刀叉的可能性,那我們就避免哲學(xué)家可以同時拿左邊的刀叉,采用分支結(jié)構(gòu),根據(jù)哲學(xué)家的編號的不同,而采取不同的動作。
即讓偶數(shù)編號的哲學(xué)家「先拿左邊的叉子后拿右邊的叉子」,奇數(shù)編號的哲學(xué)家「先拿右邊的叉子后拿左邊的叉子」。
上面的程序,在 P 操作時,根據(jù)哲學(xué)家的編號不同,拿起左右兩邊叉子的順序不同。另外,V 操作是不需要分支的,因為 V 操作是不會阻塞的。
方案三即不會出現(xiàn)死鎖,也可以兩人同時進餐。
方案四
在這里再提出另外一種可行的解決方案,我們用一個數(shù)組 state 來記錄每一位哲學(xué)家在進程、思考還是饑餓狀態(tài)(正在試圖拿叉子)。
那么,一個哲學(xué)家只有在兩個鄰居都沒有進餐時,才可以進入進餐狀態(tài)。
第 i
個哲學(xué)家的左鄰右舍,則由宏 LEFT
和 RIGHT
定義:
LEFT : ( i + 5 - 1 ) % 5
RIGHT : ( i + 1 ) % 5
比如 i 為 2,則 LEFT
為 1,RIGHT
為 3。
具體代碼實現(xiàn)如下:
上面的程序使用了一個信號量數(shù)組,每個信號量對應(yīng)一位哲學(xué)家,這樣在所需的叉子被占用時,想進餐的哲學(xué)家就被阻塞。
注意,每個進程/線程將 smart_person
函數(shù)作為主代碼運行,而其他 take_forks
、put_forks
和 test
只是普通的函數(shù),而非單獨的進程/線程。
方案四同樣不會出現(xiàn)死鎖,也可以兩人同時進餐。
讀者-寫者問題
前面的「哲學(xué)家進餐問題」對于互斥訪問有限的競爭問題(如 I/O 設(shè)備)一類的建模過程十分有用。
另外,還有個著名的問題是「讀者-寫者」,它為數(shù)據(jù)庫訪問建立了一個模型。
讀者只會讀取數(shù)據(jù),不會修改數(shù)據(jù),而寫者即可以讀也可以修改數(shù)據(jù)。
讀者-寫者的問題描述:
「讀-讀」允許:同一時刻,允許多個讀者同時讀
「讀-寫」互斥:沒有寫者時讀者才能讀,沒有讀者時寫者才能寫
「寫-寫」互斥:沒有其他寫者時,寫者才能寫
接下來,提出幾個解決方案來分析分析。
方案一
使用信號量的方式來嘗試解決:
信號量
wMutex
:控制寫操作的互斥信號量,初始值為 1 ;讀者計數(shù)
rCount
:正在進行讀操作的讀者個數(shù),初始化為 0;信號量
rCountMutex
:控制對 rCount 讀者計數(shù)器的互斥修改,初始值為 1;
接下來看看代碼的實現(xiàn):
上面的這種實現(xiàn),是讀者優(yōu)先的策略,因為只要有讀者正在讀的狀態(tài),后來的讀者都可以直接進入,如果讀者持續(xù)不斷進入,則寫者會處于饑餓狀態(tài)。
方案二
那既然有讀者優(yōu)先策略,自然也有寫者優(yōu)先策略:
只要有寫者準備要寫入,寫者應(yīng)盡快執(zhí)行寫操作,后來的讀者就必須阻塞;
如果有寫者持續(xù)不斷寫入,則讀者就處于饑餓;
在方案一的基礎(chǔ)上新增如下變量:
信號量
rMutex
:控制讀者進入的互斥信號量,初始值為 1;信號量
wDataMutex
:控制寫者寫操作的互斥信號量,初始值為 1;寫者計數(shù)
wCount
:記錄寫者數(shù)量,初始值為 0;信號量
wCountMutex
:控制 wCount 互斥修改,初始值為 1;
具體實現(xiàn)如下代碼:
注意,這里 rMutex
的作用,開始有多個讀者讀數(shù)據(jù),它們?nèi)窟M入讀者隊列,此時來了一個寫者,執(zhí)行了 P(rMutex)
之后,后續(xù)的讀者由于阻塞在 rMutex
上,都不能再進入讀者隊列,而寫者到來,則可以全部進入寫者隊列,因此保證了寫者優(yōu)先。
同時,第一個寫者執(zhí)行了 P(rMutex)
之后,也不能馬上開始寫,必須等到所有進入讀者隊列的讀者都執(zhí)行完讀操作,通過 V(wDataMutex)
喚醒寫者的寫操作。
方案三
既然讀者優(yōu)先策略和寫者優(yōu)先策略都會造成饑餓的現(xiàn)象,那么我們就來實現(xiàn)一下公平策略。
公平策略:
優(yōu)先級相同;
寫者、讀者互斥訪問;
只能一個寫者訪問臨界區(qū);
可以有多個讀者同時訪問臨街資源;
具體代碼實現(xiàn):
看完代碼不知你是否有這樣的疑問,為什么加了一個信號量 flag
,就實現(xiàn)了公平競爭?
對比方案一的讀者優(yōu)先策略,可以發(fā)現(xiàn),讀者優(yōu)先中只要后續(xù)有讀者到達,讀者就可以進入讀者隊列, 而寫者必須等待,直到?jīng)]有讀者到達。
沒有讀者到達會導(dǎo)致讀者隊列為空,即 rCount==0
,此時寫者才可以進入臨界區(qū)執(zhí)行寫操作。
而這里 flag
的作用就是阻止讀者的這種特殊權(quán)限(特殊權(quán)限是只要讀者到達,就可以進入讀者隊列)。
比如:開始來了一些讀者讀數(shù)據(jù),它們?nèi)窟M入讀者隊列,此時來了一個寫者,執(zhí)行 P(falg)
操作,使得后續(xù)到來的讀者都阻塞在 flag
上,不能進入讀者隊列,這會使得讀者隊列逐漸為空,即 rCount
減為 0。
這個寫者也不能立馬開始寫(因為此時讀者隊列不為空),會阻塞在信號量 wDataMutex
上,讀者隊列中的讀者全部讀取結(jié)束后,最后一個讀者進程執(zhí)行 V(
,喚醒剛才的寫者,寫者則繼續(xù)開始進行寫操作。wDataMutex
)
免責聲明:本文內(nèi)容由21ic獲得授權(quán)后發(fā)布,版權(quán)歸原作者所有,本平臺僅提供信息存儲服務(wù)。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯(lián)系我們,謝謝!