一、POSIX信號量
1.阻塞隊列實現的生產消費模型代碼不足的地方(無法事前得知臨界資源的就緒狀態)
1.在先前我們的生產消費模型代碼中,一個線程如果想要操作臨界資源,也就是對臨界資源做修改的時候,必須臨界資源是滿足條件的才能修改,否則是無法做出修改的,比如下面的push接口,當隊列滿的時候,此時我們稱臨界資源條件不就緒,無法繼續push,那么線程就應該去cond的隊列中進行wait,如果此時隊列沒滿,也就是臨界資源條件就緒了,那么就可以繼續push,調用_q的push接口。
但是通過代碼你可以看到,如果我們想要判斷臨界資源是否就緒,是不是必須先加鎖然后再判斷?因為本身判斷臨界資源,其實就是在訪問臨界資源,既然要訪問臨界資源,你需不需要加鎖呢?當然是需要的!因為臨界資源需要被保護!
所以我們的代碼就呈現下面這種樣子,由于我們無法事前得知臨界資源的狀態是否就緒,所以我們必須要先加鎖,然后手動判斷臨界資源的就緒狀態,通過狀態進一步判斷是等待,還是直接對臨界資源進行操作。
但如果我們能事前得知,那就不需要加鎖了,因為我們提前已經知道了臨界資源的就緒狀態了,不再需要手動判斷臨界資源的狀態。所以如果我們有一把計數器,這個計數器來表示臨界資源中小塊兒資源的數目,比如隊列中的每個空間就是小塊兒資源,當線程想要對臨界資源做訪問的時候,先去申請這個計數器,如果這個計數器確實大于0,那不就說明當前隊列是有空余的位置嗎?那就可以直接向隊列中push數據。如果這個計數器等于0,那就說明當前隊列沒有空余位置了,你不能向隊列中push數據了,而應該阻塞等待著,等待計數器重新大于0的時候,你才能繼續向隊列中push數據。
2.信號量的理解
1.信號量究竟是什么呢?他其實本質是一把計數器,一把衡量整體的臨界資源中小塊兒臨界資源數目多少的計數器。所以如果有這把計數器的話,我們在重新訪問公共資源之前,就不需要先加鎖,在判斷臨界資源的狀態,再根據狀態對臨界資源進行操作了。而是直接申請信號量,如果信號量申請成功,那就說明臨界資源條件是就緒的,可以進行相應的生產消費活動。
2.而由于信號量是臨界資源中小塊兒臨界資源的數目,每個線程申請到的小塊兒臨界資源是各不相同的,那其實多個線程就可以并發+并行的訪問公共資源的不同區域。
至于并發+并行,實際這兩個是不沖突的,尤其是公司的服務器,他一定是并發+并行運行的,你這個線程在申請到信號量后進行操作,并不影響其他線程也申請信號量進行操作,當然這里說的并發+并行還是對于生產者和消費者之間在對臨界資源進行操作時的關系,因為只有生產者和消費者之間訪問的才是不同的小塊兒資源。
3.所以在有了信號量之后,我們就能提前得知臨界資源的就緒情況,進而能夠決定對臨界資源進行什么操作。
每一個線程想要訪問臨界資源中的小塊兒資源時,都需要先申請信號量,申請信號量成功后,才可以訪問小塊兒資源。那其他線程可不可以申請信號量呢?如果可以的話,信號量是不是共享資源呢?如果想要訪問共享資源,共享資源本身是不是需要被保護呢?
如果信號量只是簡單的++或- -操作來衡量小塊兒臨界資源的數目的話,那肯定是不對的,因為++和- -的操作不是原子的,信號量的申請和釋放就會有安全問題。所以實際信號量的申請和釋放并不是簡單的++或- -,他的申請和釋放操作應該是原子的,信號量- -實際對應的是P操作,信號量++對應的是V操作,所以信號量的核心操作是PV操作,或者叫做PV原語。
3.初步看一下信號量的操作接口
1.信號量的操作接口并不難,PV操作對應的就是sem_wait和sem_post接口,作用分別是申請信號量和釋放信號量,而sem_t和以前接觸的pthread_mutex_t等類型一樣,都是pthread庫給我們維護的一種數據類型。
4.環形隊列實現的生產消費模型
1.上面我們一直在說信號量的原理以及作用,但信號量的應用場景是什么呢?如果用信號量來實現生產消費模型,又該如何實現呢?
在對臨界資源進行操作時,有時并不需要對整個臨界資源進行操作,而是只需要對某一小塊兒資源進行操作,那如果生產線程和消費線程都各自對小塊兒資源操作的話,這一小塊兒資源就只有一個線程在訪問,此時就不會由于多線程訪問臨界資源而產生安全問題了,那生產線程和消費線程就可以并發或并行的去各自訪問自己的小塊兒臨界資源了,互不干擾,臨界資源不會出現安全問題。
2.像這樣使用小塊兒資源的場景,就適合用環形隊列來實現生產消費模型,p向空的位置放數據,c從有數據的空間位置中拿數據,而且我們保證p和c的操作位置不同,也就是說,p一直向前跑,向每個空位置放數據,你c不能超過我p,因為你超過的話沒啥用,前面的位置p還沒有放數據呢,你就算拿數據拿的也是無效的數據。而p也不能套c一個圈,因為你套了的話,就會出現某一個位置上的數據c還沒拿走呢,你p又過來生產數據了,此時就會發生數據覆蓋的問題。
所以大部分情況下,p和c他們操作的都是不同的位置,如果操作的是不同的位置,p和c就可以并發+并行的生產和消費數據,本質原因就是p和c操作的是不同的小塊資源,互相之間并不影響,而原來的阻塞隊列是作為整體被使用的,p和c直接用的就是這個整體資源,你生產的時候,我就不能消費,我消費的時候,你就不能生產,因為一旦同時生產和消費,臨界資源是作為整體被使用,就會出現安全問題,不過今天我們不用擔心,因為p和c操作的是不同的小塊兒資源。
但除大部分情況外,還有小部分情況,比如剛開始環形隊列為空的時候,p和c指向的是同一個隊列位置,此時他們使用的就是同一個小塊兒資源。或者當環形隊列為滿的時候,p和c也會指向同一個位置,他們使用的也是同一個小塊資源。那對于這種情況的話,就不能并發+并行的訪問了,而是只能互斥的訪問。當ringqueue為空時,必須p先生產,c此時阻塞。當ringqueue為滿時,必須c先消費,p此時阻塞。
3.所以想要維護環形隊列的生產消費模型,主要的核心工作就是維護三條規則,一是消費者不能超過生產者,二是消費者不能套生產者一個圈,三是當隊列為空或為滿的時候,我們要保證生產和消費的互斥與同步關系,互斥指的是哪個線程去單獨訪問,同步指的是兩個線程都要去訪問,不能有饑餓問題產生。
我們說過信號量是用來衡量臨界資源中資源數目的計數器,那對于生產者而言,他最看重什么小塊兒資源呢?就是空間資源。對于消費者而言,他最看重的是數據資源。所以我們可以給空間資源定義一個信號量,給數據資源定義一個信號量。
5.環形隊列的代碼編寫(維持生產之間,消費之間,生產消費之間的三種關系)
1.我們寫環形隊列的代碼,實際就是維護上面所說的三條規則,維護前兩條很簡單,因為有信號量管著呢,當信號量為0的時候,P操作就無法滿足,那就會阻塞。對應的其實就是前兩條規則,例如,隊列為空的時候,spaceSem是大于0的,而dataSem是為0的,那么消費者的P操作就無法成功,那他一定是無法消費數據的,所以此時c就不會超過p。反過來也一樣,隊列為滿的情況,大家自己想一下。而維護生產和消費之間的互斥與同步關系靠的是,剛開始信號量的差異,剛開始設定信號量的時候,就把spaceSem設為隊列大小,dataSem設為0,那剛開始的時候,一定是p先走,生產者的P操作會成功,滿的時候,自然dataSem就變成了隊列大小,而spaceSem變為0,所以此時一定就是c先走,消費者的P操作會成功。這樣設定初始信號量的不同就可以在隊列為空和為滿的時候,保證消費者和生產者之間的互斥與同步關系。
2.原本的講解邏輯其實是先給大家搞一個單生產單消費的代碼,也就是大部分是生產和消費之間的并發訪問,小部分是生產和消費在隊列為空和為滿時的互斥與同步。因為上面所說的全部話題都是在單生產單消費的模型下講述的,所以按照321原則來看,現在只有生產和消費的關系,還缺少生產之間和消費之間的關系。
想著是先搞一個存儲int數據的環形隊列,然后搞成單生成單消費的模型,進階一點,我們把int數據換為CalTask任務,也就是讓ringqueue存儲任務對象,但依舊是單生產單消費。最后實現存儲任務對象的多生產多消費模型代碼。
但是上面那樣講解太繁瑣了,畢竟上一篇博客也有了阻塞隊列的生成消費模型的基礎,所以下面我們也就不那么啰嗦了,直接上存儲CalTask任務對象的ringqueue的多生產多消費模型代碼。我會將代碼的細節講述清楚的。
3.我們將底層的代碼一般稱為設計模式,上層調用的代碼稱為業務邏輯,所以設計模式一定要和業務邏輯進行解耦,設計模式一定是基于業務邏輯產生的。所以下面我們先來談上層調用的代碼,假設環形隊列已經寫好了,談完上層調用的代碼后,再根據上層的需求,來回頭實現底層的RingQueue.hpp的代碼。
在上層中,我們創建出一批生成線程和消費線程,讓他們分別執行ProductorRoutine和ConsumerRoutine,生產者構造和獲取計算任務,我們通過生成隨機數來實現計算任務的構造。
而消費者拿任務和執行任務,也是通過輸出型參數的方式來解決。所以其實你可以看到,無論是環形隊列還是阻塞隊列,上層我們測試的邏輯都是相同的,所以上層這里沒什么好說的,關鍵在于底層的設計模式,底層使用的數據結構也就是321原則中交易場所的差別,讓生產消費模型的實現有了差別。
下面是任務類CalTask的類實現,其實也沒什么好說的,這個任務類在阻塞隊列的時候我們就已經見到過了,這里也就不過多贅述了。
4.還是來談談關鍵的地方吧。環形隊列雖然在邏輯結構上是環形的,但實際是通過模運算+數組來實現的環形隊列,所以類成員變量,需要一個vector。為了方便更改vector的大小,也就是存儲任務的上限,我們搞一個_cap也就是容量,來表示vector最大存儲數據的個數。除此之外就是信號量了,生產者或是消費者在生產消費之前都需要申請各自的信號量,如果信號量申請成功,才能繼續向后運行,所以信號量的作用其實也就是掛起等待鎖的作用。所以還需要兩個信號量來分別給生產者和消費者來申請。同時我們前面也說過,生產者和消費者在大部分情況下,訪問的小塊兒資源都是不同的,如何保證訪問的小塊兒資源不同呢?實際就是通過數組下標來完成的,所以我們定義出兩個下標分別對應生產位置和消費位置。再通過321原則看一下生產消費模型,我們還需要維護生產之間和消費之間的互斥關系,所以我們需要兩把鎖,保證進入環形隊列的只能有一個生產者和一個消費者。這就是基本的類成員變量的設計。
可能有人會有疑問,為什么我要搞成兩個信號量呢?一個spaceSem信號量表示空間資源,另一個數據資源,我直接用_cap減去spaceSem不就可以了嗎?干嘛要定義兩個信號量啊!你說的確實沒錯!但存在安全隱患,因為減去的操作就不是原子的了,你用_cap減去spaceSem這個過程是線程不安全的。因為只有信號量原生的PV操作才是原子的,才是安全的,如果我們自己徒增許多操作,極大可能是非原子的,所以既然我們都有信號量了,并且人家信號量的操作本身就是原子的,操作起來是線程安全的,那何不多定義幾個信號量呢?有利無害啊!
5.在初始化信號量的時候,我們剛開始就將spaceSem設置為環形隊列大小,dataSem設置為0,sem_init的第二個參數代表線程間共享,也就是說生產線程之間共享spaceSem信號量,消費線程之間共享dataSem信號量。
最為重要的兩個接口就是Push和Pop,拿Push來說,首先我們進行P操作,申請spaceSem信號量,申請成功之后要進行加鎖操作,因為我們需要保證生產者之間是互斥訪問ringqueue的,然后就是在_productorStep的位置進行任務對象的插入,_productorStep有可能會超過_cap,所以還需要%=_cap,然后就需要釋放鎖,最后V操作的時候,需要注意的是V操作的是dataSem信號量,因為你生產數據之后,數據資源不就有了嗎?那dataSem就應該變多。Pop的操作正好是和Push的操作反過來的,先申請dataSem信號量,最后釋放spaceSem信號量。
下面這個問題是當初實現接口時遇到的問題,圖片中放的代碼已經是優化好之后的代碼了。
6.下面是單生產單消費下的運行情況,可以看到如果是單生產單消費,他的運行結果和條件變量非常非常的相似,當生產者在sleep(1)時,打印出來的結果非常的有順序性,那這是為什么呢?
其實信號量的實現原理和條件變量是一樣的,只不過條件變量是通過wait和signal來實現線程間同步與互斥的,,而信號量是通過wait和post來實現線程間同步與互斥的,wait和post實際就是信號量的PV操作,也是PV原語。
所以信號量其實就是條件變量+手動判斷資源就緒狀態,條件變量解決饑餓問題就是通過喚醒其他線程來實現的,而信號量解決饑餓問題其實也是間接通過喚醒其他線程來實現的,只不過信號量這里不是喚醒,而是釋放其他線程的信號量,也就是V操作其他線程的信號量,一旦V操作了其他線程的信號量,那么只要其他P操作還在阻塞的線程,立馬就不會阻塞了,他們立馬就可以申請信號量成功,然后競爭鎖,進入臨界區。
不過與之前條件變量實現的阻塞隊列不同的是,之前的阻塞隊列用的是一把鎖,所以無論什么時候都只能串行訪問,而今天的環形隊列用的是兩把鎖,生成和消費之間是互不影響的,他們沒有理由同時使用一把鎖,所以他們效率就會高一些,生產和消費之間是可以并發+并行的運行的,也就是消費在競爭到鎖cmutex,進入臨界區拿走數據的同時,生產者也可以競爭到pmutex,進入臨界區生產數據。唯一需要互斥的就只有生產之間和消費之間需要互斥。
7.下面是多生產多消費情況下的打印結果,當然什么也看不出來哈,只能看到一堆消費線程和生產線程在瘋狂打印著自己的生產和消費提示信息。
但我們心里能夠清楚的意識到,生產之間他們被_pmutex鎖住了,所以生產之間是互斥訪問的,消費同樣如此,另外我們通過信號量能夠實現單個生產和單個消費之間的同步與互斥關系,能夠避免出現數據競爭,死鎖等問題。
(其實我自己當時有一些問題產生,例如當生產者之間互相競爭鎖的時候,不會產生饑餓問題嗎?實際是有可能出現的,但出現饑餓問題的概率很小,我們可以不考慮這個饑餓問題,因為我們所寫的代碼并不能完全保證生產者線程之間是公平調度的,因為操作系統的調度策略可能導致某些線程獲得更多的執行時間,但這并不是由這段代碼直接導致的。換句話說,我們所寫的代碼不太可能出現生產者線程的饑餓問題。但是如果你對線程調度的公平性有嚴格的要求,可以使用條件變量或其他更為高級的同步機制來實現,條件變量實際上算是一種很公平的同步機制了,他能讓所有線程都去排隊式的來條件變量中進行等待,直到其他線程將其喚醒,然后被喚醒的線程會去申請鎖,而不會出現饑餓問題。但在我們上面所寫的代碼中暫時不用考慮生產線程之間或者是消費線程之間的饑餓問題。)
8.最后一個話題就是老套路了,和當時阻塞隊列實現的生產消費模型最后提出的問題一樣,我們這里就相當于再回顧一下。那既然進入環形隊列的線程大部分情況下也就只能進入一個生產一個消費,那我們創建多生產多消費的意義是什么呢?其實道理還是類似的,放任務和拿任務并沒有那么耗時,真正在多任務處理的情況中,獲取任務和執行任務才是非常耗時的!而對于計算機來說,多任務處理的場景又非常的常見,所以很需要多線程之間的協調工作。而生產消費模型高效在,獲取任務和執行任務的線程之間在協調處理多任務的時候,不會出現數據競爭,死鎖等安全問題,同時某個線程在消費或生產任務的同時,并不會影響其他線程獲取或執行任務,所以總體來看,多線程之間還是并發+并行的獲取和執行任務,但為了保證多線程的安全性,我們加了一個交易場所,保證共享資源的安全,維持多線程的互斥與同步關系,讓多線程能夠更好的適用于多任務處理的場景。
二、線程池
1.池化技術和線程池模型
1.實際線程池并不難理解,因為大部分時間內,計算機都面臨著多任務處理的難題,而多線程協調處理多任務的場景也就司空見慣了,當任務的數量比較多,并且要求迅速響應任務處理的情況下,如果現去創建多線程,現去處理任務,那就比較晚了,因為創建線程那不就是執行pthread庫的代碼嗎?而在linux中,pthread庫的代碼又是封裝了底層的系統調用,所以還需要將頁表切換為內核級頁表,將代碼跳轉到內核空間執行內核代碼,處理器級別的切換等等工作,這些不都需要花時間嗎?如果客戶對性能要求苛刻,要求你迅速響應的話,那上面那種現創建線程的方式就有點晚了!所以像線程池這樣的技術本質其實就是提前創建好一批線程,讓這些線程持續檢測任務隊列中是否有任務,如果有,那就喚醒某個線程,讓他去拿這個任務并且執行,如果沒有,那就讓線程掛起等待,我操作系統就一直養著你,等到有任務的時候再喚醒你,讓你去執行。
那這樣池化的技術本質還是為了應對未來的某些需求,能夠提升任務處理的效率。
實際生活中也不乏這樣的池化技術,例如疫情期間,大家都屯物資,這是為什么呢?這不也是為了應對將來疫情封控嚴重,大家都出不了門,到時候沒人賣日常的生活用品了,我們能夠拿出來自己屯的物資嗎?那如果我們不屯物資,等到疫情封控最嚴重的時候,再出去買菜買肉什么的,這是不就晚了啊?或者說你去某些飯店吃飯,你和老板說我要吃西紅柿炒雞蛋,老板說沒問題,你先等一會兒,我去村口的菜園里摘點兒西紅柿,然后再去養雞場蹲母雞,等她下出來蛋后,我拿著西紅柿和雞蛋給你做,那要是等老板做完菜,你是不早就餓過頭了啊!所以老板這樣的方式是不也有些晚了啊?正確的做法應該是老板提前屯一些西紅柿和雞蛋,你點菜的時候,老板能夠直接拿出來給你做,這些其實都是我們生活中的池化技術。
2.而內存池也是一種池化技術的體現,當我們在調用malloc或new申請堆空間的時候,實際底層會調用諸如brk,mmap這樣的系統調用,而執行系統調用是要花時間的,所以內存池會預先分配一定數量的內存塊并將其存儲在一個池中,以便程序在需要的時候能夠快速分配和釋放內存,這能夠提高程序的性能和減少內存碎片的產生。
3.線程池模型實際就是生產消費模型,我們會在線程池中預先準備好并創建出一批線程,然后上層將對應的任務push到任務隊列中,休眠的線程如果檢測到任務隊列中有任務,那就直接被操作系統喚醒,然后去消費并處理任務,喚醒一個線程的代價是要比創建一個線程的代價小很多的。
而實際下面線程池的模型不就是我們一直學的生產消費模型嗎?那些任務線程就是生產者,任務隊列就是交易場所,處理線程就是消費者。所以聽起來高大上的線程池本質還是沒有脫離開我們一直所學的生產消費模型,所以實現線程池頂多在技巧和細節上比以前要求高了一些,但在原理上和生產消費模型并無區別。
2.餓漢與懶漢兩種單例模式
1.在IT行業里,大佬們和菜雞的兩極分化比較嚴重,牛逼的是真牛逼,垃圾的是真垃圾,所以大佬們對于一些經典的常見的應用場景,做出解決方案的總結,這樣針對性的解決方案就是設計模式。
而單例模式就是大佬總結出來的一種經典的,常用的,常考的設計模式。
單例模式就是只能有一個實例化對象的類,這個類我們可以稱為單例。而實現單例的方式通常有兩種,分別就是懶漢實現方式和餓漢實現方式。
舉一個形象化的例子,懶漢就是吃完飯,先把碗放下,然后等到下一頓飯的時候再去洗碗,這就是懶漢方式。而餓漢就是吃完飯,立馬把碗洗了,下一頓吃飯的時候,就不用再去洗碗了,而是直接拿起碗來吃飯,這就是餓漢實現方式。
雖然生活中懶漢還是不太好的,因為生活比較亂和邋遢。但在計算機中,懶漢方式還是不錯的,懶漢最核心的思想就是延遲加載,這樣的方式能夠優化服務器的速度,即為你需要的時候我再給你分配,你現在還用不著,那我就先不給你分配,這就是延遲加載。
2.像餓漢這樣的方式,實際是非常常見的,因為延時加載這樣的管理思想對于軟硬件資源管理者OS而言,實際是很優的一種管理手段。就比如平常的malloc和new,操作系統底層在開辟空間的時候,實際并不是以餓漢的方式來給我們開辟的,而是以懶漢的方式來給我們開辟的。等到程序真正訪問和使用要申請的內存空間時,會觸發缺頁中斷,操作系統此時知曉之后才會真正給我們在物理內存上開辟相應申請大小的空間,重新構建虛擬和物理的映射關系,返回對應的虛擬地址。
3.實現餓漢遵循的一個原則就是加載時即為開辟時,什么意思呢?就是在類加載的時候,類的單例對象就已經存在于虛擬地址空間中了,并且物理內存中也有單例對象所占用的內存空間。實現起來也比較簡單,即在類中提前私有化的創建好一個靜態對象,當然這個靜態對象也是這個單例類唯一的對象,要實現對象的唯一還需要私有化構造函數,delete掉拷貝構造和賦值重載成員函數。類外使用單例對象時,即通過類名加靜態方法名的方式得到單例對象的地址,從而訪問其他類成員方法。
實現懶漢遵循的一個原則就是需要時即為開辟時,什么意思呢?就是在類加載的時候,類的單例對象并不會給你創建,而是當你調用GetInstance()接口的時候,才會真正分配單例對象的堆空間,這就是典型的懶漢實現方式。(右邊的懶漢方式實現單例模式是線程不安全的,解決這種不安全的話題放到實現懶漢版本的線程池那里,我會詳細說明線程安全版本的懶漢是如何實現的。)
3.單例模式的線程池代碼(線程安全的懶漢實現版本)
1.下面我們實現的線程池,實際是一個自帶任務隊列的線程池,其內部創建出一大批線程,然后外部可以通過調用Push接口來向線程池中的任務隊列里push任務,線程在沒有任務的時候,會一直在自己的條件變量中進行等待,當上層調用push接口push任務時,線程池所實現的push接口在push任務之后會調用signal喚醒條件變量下等待的線程,當線程被喚醒之后,就會pop出任務隊列中的任務并執行他,這實際就是消費過程。而且由于我們要實現單例版本的線程池,所以還需要提供getInstance接口來獲取單例對象的地址,外部就可以通過對象指針來調用ThreadPool類的push接口,進行任務的push。
我們通過vector來管理創建出的線程,通過queue來作為任務隊列,由于任務隊列是消費者和生產者共同訪問的,所以任務隊列也需要被保護,所以我們通過互斥鎖mutex來保證任務隊列的安全,另外我們再定義出一個變量num表征線程池中線程的個數,線程在沒有任務的時候需要等待,所以還需要一個cond,為了實現線程安全的懶漢單例模式,不僅需要定義出靜態指針tp,還需要一把互斥鎖singleLock來保證靜態指針的安全性,因為可能多個線程同時進入getInstance創建出多個對象的實例。不過這個互斥鎖我們不再使用pthread原生線程庫的互斥鎖,而是用C++11線程庫的mutex來定義互斥鎖。
2.A. 對于構造函數來說,需要初始化好線程個數,以及創建出對應個數的線程,并將每個線程對象的地址push_back到vector當中,除此之外還要初始化好cond和mutex,因為他們是局部的。需要注意的是,我們用的是之前封裝好的RAII風格的線程類來像C++11那樣管理每個線程對象,所以一旦線程池對象被構造,那每個線程對象也就會被構造出來,在構造線程對象的同時,線程就會運行起來,執行對應的線程函數。這就是RAII風格的線程創建,當對象被創建時線程跑起來,當對象銷毀時線程就會被銷毀,即為在對象創建時資源被獲取初始化,在對象銷毀時資源被釋放回收。
B. 對于析構函數來說,當線程池對象被銷毀時,要銷毀destroy cond和mutex,其他成員變量編譯器會調用他們各自的析構函數,我們不用擔心。
C. 所以緊接著我們就應該實現線程函數,因為一旦線程池對象被初始化,線程就會跑起來執行線程函數,我們的線程函數實際就是來執行任務的,所以線程函數命名為handler_task,實現handler_task需要解決的第一個問題其實就是傳參,如果handler_task是類成員函數,那么他的參數列表會隱含一個this指針,所以在調用RAII風格的線程構造函數時,會發生參數不匹配的錯誤,解決方式也很簡單,只要將handler_task設置為static成員函數即可解決傳參的工作。
實現handler_task第一件事實際就是加鎖,因為我們需要保證訪問任務隊列的安全性,所以就需要加鎖,并且為了實現任務線程和處理線程之間的同步我們還需要在條件變量中wait,等到被喚醒時再去拿任務隊列中的任務并執行,但是上面所說的一切操作都需要訪問類成員變量,而handler_task是一個靜態方法,靜態成員無法訪問非靜態成員,線程對象的內部還有返回線程名的接口叫做threadname(),線程在執行任務的時候我還想看到是哪個線程在執行任務,所以在執行任務前我想調用threadname()接口,想要實現上面的操作,我們不得不傳一個結構體threadText到線程函數里面,結構體中包含線程對象指針和線程池對象指針,通過傳遞包含這兩個指針的結構體就能完成上面我們所說的一系列操作。我們要保證臨界區的粒度足夠小,所以執行任務,也就是調用可調用任務對象CalTask的()重載函數,就應該放在臨界區外面,因為臨界區是保護任務隊列的,既然任務已經取出來了,那其實沒必要繼續加鎖保護,所以t()應該放在臨界區外面。至于加鎖的操作,除我們自己在類內封裝一系列接口的使用方式外,還可以直接調用LockGuard.hpp里面同樣是RAII風格的加鎖,即在對象創建時初始化所,對象銷毀時自動釋放鎖。
D. 然后就是Push接口,可以看到在Push接口里面,我便使用了RAII風格的加鎖,當離開代碼塊兒的時候鎖對象lockGuard會被銷毀,此時互斥鎖mutex會自動釋放,將任務push到隊列之后,便可以喚醒處理線程,線程會從cond的等待隊列中醒來并重新被調度去執行生產者生產的任務
E. 最后需要實現的接口就只剩單例模式了,因為getInstance()可能會被多個線程重入,有可能會構建出兩個對象,這樣就不符合單例模式了,并且在析構的時候還有可能產生內存泄露的問題,所以我們要對getInstance()接口進行加鎖,保證只有一個線程能夠進入getInstance實例化出單例對象,當某一個線程實例化出單例對象之后,之后剩余的所有線程進入getInstance時,if條件都不會滿足,但是這樣的效率有點低,因為后面的線程如果進入getInstance時,還需要先申請鎖,然后才能判斷if條件,那我們就直接雙重判斷空指針,提高判斷的效率,后面的線程不用申請鎖也可以直接拿到單例對象的地址,這樣效率是不是就高起來了呢?
除此之外在聲明單例對象的地址時,我們應該用volatile關鍵字修飾,我們直到volatile關鍵字是用來保持內存可見性的,因為在某些編譯器優化的場景下,可能會由于只讀取寄存器的值,不讀取內存的值而造成判斷失誤,從而產生一系列無法預知的問題,所以為了避免這樣的問題產生,我們選擇用volatile關鍵字來修飾單例對象的靜態指針。(假設10個線程都想獲取單例對象的地址,代碼中_tp一直沒有被使用,所以編譯器可能直接將_tp開始為nullptr的值加載到寄存器中,也就是加載到當前CPU線程的上下文中,如果之前某個線程已經new過了單例對象,那么當前CPU在判斷_tp是否為nullptr的時候,他不拿物理內存的值,而是選擇判斷寄存器的值時,就會發生第二次實例化對象,所以我們要用volatile關鍵字來修飾_tp.)
除此之外還要delete掉成員函數,例如拷貝構造和拷貝賦值這兩個成員函數,避免潛在的第二次實例化單例對象發生。
3.下面就是RAII風格的封裝線程create,join,destory的小組件Thread.hpp,在調用pthread_create的時候,也遇到了this指針傳參不匹配的問題,我們依舊是通過static修飾類成員方法來解決的,當然在靜態方法里還是會遇到相同的問題,那就是沒有this指針無法調到其他的類成員函數,所以還是老樣子,定義一個結構體保存this指針和線程函數的參數,將結構體指針傳遞給線程函數,線程函數內實際就是解包一下,拿出this指針,回調包裝器_func包裝的線程池中處理線程執行的handler_task方法。
除了構造和start_routine有點繞之外,其他函數都是簡單的對pthread庫中原生接口的封裝,大家簡單看一下就好,這個RAII風格的線程管理小組件實現起來還是比較簡單的。
4.下面已經是老熟人了,我們實現的阻塞隊列版本和環形隊列版本的生產消費模型一直在用這個任務組件,這個任務組件無非就是構造好一個任務對象,然后在實現一個返回任務名的函數,以及一個可調用對象的()重載,我們不再贅述,大家看一下就行。
5.下面是RAII風格加鎖和解鎖的小組件LockGuard.hpp,由外部傳進來一把鎖,組件負責做加鎖和解鎖的工作,下面實現的時候做了多層封裝,其實沒啥用,只做一層封裝也可以實現加鎖和解鎖的RAII風格的操作。
6.下面就是上層調用邏輯,獲取單例對象地址,然后通過地址來調用Push接口去push任務,沒什么好說的,只不過我們實現了一種用命令行式來構建任務的方式。
三、自旋鎖
1.自旋鎖vs掛起等待鎖
1.除我們之前講的互斥鎖,信號量,條件變量這樣的互斥和同步機制外,還有很多其他的鎖,例如悲觀鎖,樂觀鎖,但這樣的鎖只是對鎖的作用的一種概念性的統稱,是非常籠統的。另外悲觀鎖的實現方式:CAS操作和版本號機制,這些其實稍微知道一下就行,我們主要使用的還是互斥鎖信號量以及條件變量這樣的方式,有這些其實目前已經夠用了。
但還需要深入知道一些的是自旋鎖和讀寫鎖,這樣的鎖平常我們不怎么用,但屬于我們需要掌握的范疇,了解自旋鎖和讀寫鎖之后,基本上就夠用了。
2.以前我們學到的互斥鎖,信號量這些,一旦申請失敗,線程就會被阻塞掛起,我們稱這樣的鎖為掛起等待鎖,因為線程需要去PCB維護的等待隊列中進行wait,直到鎖被釋放。
而自旋鎖如果申請失敗,線程并不會掛起等待,它會選擇自旋,循環檢查鎖的狀態是否被釋放,這種方式可以減少線程上下文切換時所帶來的性能開銷,但同時也會帶來CPU資源的浪費,因為你這個線程一直霸占CPU不斷輪詢鎖的狀態,CPU無法調度其他線程了就。
所以使用掛起等待鎖和自旋鎖,主要依據就是線程需要等待的時間長短,或者說成是申請到鎖的線程在臨界區中待的時間長短,如果時間較長,那么選擇掛起等待鎖來進行加鎖保護臨界資源的方案就比較合適,如果時間較短,那么選擇自旋鎖不斷輪詢鎖的狀態,用自旋鎖來進行臨界資源的保護方案就比較合適。
3.緊接著帶來的問題就是,我們該如何衡量時間的長短呢?又該如何選擇更加合適的加鎖方案呢?
時間長短其實沒有答案,因為時間長短是需要比較才能得出的結論,而選擇什么樣的加鎖方案,實際還是要看具體的場景需求。
一般來說臨界區內部如果要進行復雜計算,IO操作,等待某種軟件條件就緒,大概率我們是要使用掛起等待鎖的。如果只進行了特別簡單的操作,例如搶票邏輯,臨界區的代碼很快就能被執行完,那使用自旋鎖就會更加的合適。
但其實大部分情況下,我們還是用掛起等待鎖,因為別看自旋鎖看起來好像要快一些,一旦時間評估失誤,那申請自旋鎖的線程就會大量的消耗CPU資源,在多任務處理的情景下,效率就會降低。除此之外,自旋鎖出現死鎖的時候,問題要比掛起等待鎖更為嚴重!如果一個線程申請互斥鎖時出現了死鎖,那大不了就是執行流阻塞不再運行了,但CPU沒啥事啊!而自旋鎖出現死鎖時,則會永久性的自旋輪詢鎖的狀態,并且不會從CPU上剝離下去,那么CPU資源就會被一直占用著,無法得到釋放,問題很嚴重!
當然如果你實在不知道選擇哪種方案的話,可以先默認使用掛起等待鎖,然后比較掛起等待鎖和自旋鎖的效率誰高,哪個高就選擇哪個方案即可。
4.自旋鎖的操作也并不難,因為因為這些鎖用的都是POSIX標準,所以使用起來很簡單,直接man手冊即可。
2.智能指針和STL容器是否是線程安全的呢?
四、讀寫鎖
1.讀者寫者模型(321原則)
1.除生產消費模型之外,還有非常經典的一個模型,就是讀者寫者模型,實現讀者寫者模型的本質其實也是維護321原則,即讀者之間,讀者與寫者,寫者之間,以及1個交易場所,這個交易場所一般都是數組,隊列或者是其他的數據結構等等。
讀者就相當于消費者,寫者就相當于生產者,但讀者之間并不是互斥的了,因為他與消費者最根本的區別就是讀者不會拿走數據,也就是不會消費數據,讀者僅僅是對數據做讀取,不會進行任何修改操作,那么共享資源也就不會因為多個讀者來讀的時候出現安全問題,都沒人碰你共享資源,你能出啥子問題嘛!所以讀者之間沒有任何的關系,不想消費者之間是互斥關系,因為每個消費者都要對共享資源做出修改,但我讀者不會這么做,我只讀不改。
而寫者之間肯定是互斥的關系,因為都對共享資源寫了!那他們之間不得互斥啊!要不然共享資源出了問題咋辦!讀者和寫者之間也是互斥與同步的,當讀者讀的時候,你寫者就不要來寫了,要不然讀者讀到的數據都被你寫者給覆蓋掉了!當寫者來寫的時候,你讀者就不要來讀了,你讀到的數據都是不完整的,讀個啥嘛!所以他們之間是互斥的,但當讀者寫完的時候,如果想要數據更新,那就應該讓寫者來寫了,同樣當寫者寫完的時候,那你讀者就應該來讀了。所以讀者和寫者之間是互斥與同步的關系,既要互斥保證臨界資源的安全,又要同步協調完成整個任務的處理!
2.那一般什么場景適合用讀者寫者模型呢?例如一次發布數據,很長時間都不會對數據做修改,大部分時間都是被讀取的場景,例如生活中的寫blog,我寫blog是不是大部分時間都在被別人讀取呢?只有blog出錯的時候,我可能才會重新去修改blog,但大部分時間blog都是被讀取的。又或是媒體發布新聞,當新聞被發布的時候,大部分時間新聞也都是被讀取的,較小部分時間才會對發布的新聞做修改,或者都有可能不做修改。那么對于這樣的場景,使用讀者寫者模型就比較合適了。
3.像實現生產消費模型時,我們一般都會通過cond mutex semaphore這樣的方式實現blockqueue又或是ringqueue的生產消費模型。
那實現讀者寫者模型時,是不是也應該有對應的機制呢?當然是有的,pthread庫為我們實現了讀寫鎖的初始化和銷毀方案,同時也實現了分別用于讀者線程間和寫者線程間的加鎖實現,以及讀者寫者統一的解鎖實現。
—目前所學的mutex cond sem spin rwlock已經能滿足絕大部分需求了
2.讀鎖寫鎖申請的原理(讀鎖共享,寫鎖互斥)
1.下面的表格總結了讀鎖以及寫鎖被請求時,其他線程的行為。
值得注意的是,多個讀者之間可以同時獲取讀鎖,并發+并行的進行讀操作,在設計讀寫鎖語義的時候就是這么設計的,它允許多個讀者之間共享讀寫鎖,并發+并行的進行讀操作。這是讀寫鎖的設計語義。
而對于寫鎖來講,那就是典型的互斥鎖語義。
2.讀寫鎖實現的原理如下,當只要出現一個讀者申請到鎖之后,它會搶寫者的鎖wrlock,所以在多個讀者進行讀取的時候,reader_count這個計數器就會一直增加,并且在讀者讀取數據期間,寫者由于無法申請到wrlock就會一直處于阻塞狀態,寫者無法執行寫入的代碼邏輯,會阻塞在自己的lock(&wrlock)代碼處。但讀者之間是可以共享rdlock的,等到所有的讀者都讀完之后,也就是reader_count變為0的時候,讀者線程才會釋放wrlock,此時寫者才能申請到wrlock進行寫入。
如果是寫者先申請到鎖的話,讀者能進行讀取嗎?當然不能!當寫者申請到鎖執行寫入代碼的時候,第一個來的讀者會阻塞在lock(&wrlock)代碼處,因為此時wrlock已經被寫者拿走了,你讀者想搶的時候,是搶不到的,你只能阻塞。
但是wrlock和rdlock不一樣,wrlock是不共享的,所以如果有寫著想要申請wrlock的話,和讀者下場一樣,都會阻塞!
這就是我們所說的,讀者讀取的時候,寫者不能寫入,讀者可以共享讀鎖。寫者寫入的時候,讀者和其他寫者都無法繼續執行自己的代碼。
3.讀者/寫者優先(默認讀者優先)
1.最后需要談論的就是讀者和寫者優先的話題。我們上面所實現的偽代碼默認其實是讀者優先的。
那有人會問,假設讀者特別多的話,由于第一個讀者執行代碼邏輯的時候,就已經把寫鎖搶走了,那后面無論來多少讀者,我的寫者都無法執行寫入,因為寫者無法申請到rwlock,那就無法進入自己代碼的臨界區,那是不是就有可能造成寫者線程的饑餓問題呢?
當然是有可能的!但出現寫者線程饑餓的問題是很正常的事情,因為讀者寫者模型本身就是大部分時間在讀取小部分時間在寫入,那出現寫者線程饑餓本來就很正常嘛!
所以我們默認讀者優先,如果讀者一直來,那你寫者就一直等!
2.那如果我就想讓寫者優先呢?他其實是可以實現的,比如10個讀者要讀,現在已經5個讀者在執行讀取的臨界區代碼了,那寫者線程此時就阻止后面的5個線程繼續執行讀者臨界區的代碼,等到前面5個讀完,reader_count變為0了,此時我寫者要申請rwlock了,我先寫,等我寫完,你們后面5個讀者再來讀。
原理大概就是上面那樣,但寫者優先的策略比較難寫出來,我們就不寫了,知道有讀者寫者優先這個話題就行!
下面是設置讀寫優先的接口pthread_rwlockattr_setkind_np(),np指的是non-portable不可移植的。
-
接口
+關注
關注
33文章
8575瀏覽量
151019 -
模型
+關注
關注
1文章
3226瀏覽量
48809 -
代碼
+關注
關注
30文章
4779瀏覽量
68524 -
Posix
+關注
關注
0文章
36瀏覽量
9496
發布評論請先 登錄
相關推薦
評論