《深入理解計(jì)算機(jī)系統(tǒng)》并發(fā)編程

目 ?錄

我們在上一章節(jié)中講到的Tiny Web服務(wù)器只能為單個客服端提供訪問,這一章里,我們將通過進(jìn)程、多路復(fù)用和線程技術(shù)研究并發(fā)的服務(wù)器。

1.1 使用進(jìn)程實(shí)現(xiàn)并發(fā)


我們實(shí)現(xiàn)過一個echo服務(wù)器,但是遺憾的是只能為一個客服端服務(wù),這不是我們的初衷,現(xiàn)在我們來更新上一個版本,使得服務(wù)器在接收到連接請求的時候,創(chuàng)建子進(jìn)程為該客戶端提供服務(wù),主進(jìn)程會關(guān)閉已連接的描述符,繼續(xù)監(jiān)聽下一個客服端,這一個過程我畫了一個簡圖:

在這個過程中,客服端1連接上了服務(wù)器,并創(chuàng)建了一個已連接的描述符4,服務(wù)器立即派生子進(jìn)程,子進(jìn)程將繼承原有的已連接描述符4,通過這個子進(jìn)程的描述符為客服端1提供給服務(wù)。這時候,主進(jìn)程必須要關(guān)閉已連接描述符4,使得不至于發(fā)生內(nèi)存泄漏。

客服端2的連接過程和客服端1的過程是一樣的,還是由服務(wù)器創(chuàng)建子進(jìn)程2提供服務(wù),并關(guān)閉服務(wù)器中的已連接描述符5。我們來看看改進(jìn)代碼:只是加入了回收子進(jìn)程,在子進(jìn)程中關(guān)閉監(jiān)聽描述符和主進(jìn)程中關(guān)閉已連接描述符。運(yùn)行的效果如下:

可以同時為多個客服端提供服務(wù),實(shí)現(xiàn)進(jìn)程并發(fā),進(jìn)程級并發(fā)的一個明顯的缺點(diǎn)是,各個進(jìn)程都有獨(dú)立的地址空間,使得共享信息相當(dāng)困難而且慢速需要IPC,原理已經(jīng)講解了,代碼就不難理解了:

1.2 使用IO多路復(fù)用實(shí)現(xiàn)并發(fā)


應(yīng)用程序在一個進(jìn)程的上下文中顯示的調(diào)度它們的邏輯流,邏輯流被模型化為狀態(tài)機(jī),數(shù)據(jù)到達(dá)文件描述后,主程序顯示的從一個狀態(tài)切換到另一個狀態(tài)。

① 響應(yīng)鍵盤輸入和客服端連接

我們使用select函數(shù)創(chuàng)建一個描述符集合,當(dāng)其中之一的描述符做好準(zhǔn)備的時候,將控制權(quán)返回給程序,select函數(shù)原型如下:

int select(int n, fd_set *fdset, NULL, NULL, NULL);

fdset被稱為一個描述符集合,我們將需要處理的描述符添加到fdset結(jié)合中去;第一個參數(shù)n是描述符集合中最大的數(shù)。select函數(shù)會一直阻塞,直到相應(yīng)的集合中的描述符準(zhǔn)備好可以讀;

我們來演示一個例子:

當(dāng)我們打開了監(jiān)聽描述符以后,我們將一個read_set集合清空,并添加上標(biāo)準(zhǔn)輸入和監(jiān)聽描述符3形成集合{0,3},隨后,我們進(jìn)入一個無限循環(huán),每次調(diào)用Select函數(shù)會阻塞,直到描述符0或者3到達(dá)時。

我們啟動以后,隨意輸入內(nèi)容,就會看到服務(wù)器首先響應(yīng)了標(biāo)準(zhǔn)輸入:

我們接下來啟動 已連接描述符,就會發(fā)現(xiàn)一個問題:

不論是服務(wù)端的標(biāo)準(zhǔn)輸入,還是新啟動的客戶端2都被阻塞了。只有當(dāng)已連接描述符客戶端1關(guān)閉的時候才能使用。

一個解決之道是服務(wù)器每次循環(huán)最多回送一個文本行,就不會讓已連接的描述符連續(xù)回送了。

② 多路復(fù)用實(shí)現(xiàn)并發(fā)

服務(wù)器為每一個客戶端創(chuàng)建一個狀態(tài)機(jī),每個狀態(tài)機(jī)三個階段:

【準(zhǔn)備】——【輸入事件】——【寫回】

我們來看看main函數(shù)主要部分:

說明:活動的客戶端是在pool池塘中,通過調(diào)用init_pool完成初始化后進(jìn)入一個while循環(huán),select函數(shù)檢測兩種不同的輸入(新的連接、已經(jīng)連接的描述符準(zhǔn)備好可以讀),當(dāng)新的連接到達(dá)時,accept并add_client。最后使用check_clients函數(shù)將文本行回送。

分析:init_pool函數(shù)

分析:add_client函數(shù)

分析:check_clients函數(shù)

運(yùn)行的效果如圖:

總結(jié):我們這個版本的并發(fā)服務(wù)器,使用的是事件驅(qū)動的形式,它的優(yōu)點(diǎn)就是共享數(shù)據(jù)的效果好很多,因?yàn)槎际峭粋€進(jìn)程上下文。開銷也沒有多進(jìn)程的版本大,缺點(diǎn)就是復(fù)雜度要高些。總之,是優(yōu)秀很多的。

1.3 基于線程的并發(fā)


線程是一個運(yùn)行在進(jìn)程上下文中的邏輯流,由內(nèi)核自動調(diào)度,集成了多進(jìn)程與多路復(fù)用的優(yōu)點(diǎn),每個線程就像在舞臺上跳舞的演員一樣,各自分工和角色不一樣,共享舞臺的地址空間,當(dāng)然也有自己私有的服裝和臺詞。

① 執(zhí)行模型

每個線程在開始的時候都是單一的主線程,這個主線程可以創(chuàng)建對等線程,然后兩個線程并發(fā)執(zhí)行,不斷的切換上下文,分別執(zhí)行一段時間。與進(jìn)程之間不同的是線程的上下文切換要小的多,還有就是線程之間是完全對等的關(guān)系,也就是一個線程可以殺死它的對等線程。

我們來看一個簡單的例子:

主線程main中通過使用Pthread_create創(chuàng)建了一個新的tid線程,成功以后兩個線程同時運(yùn)行,主線程還使用了Pthread_join函數(shù)等待對等線程終止。對等線程只是簡單的打印了一下Hello world。

② 創(chuàng)建線程

原型:int pthread_create(pthread_t *tid, pthread_attr_t *attr, func *f, void *arg);

其中調(diào)用成功后tid是運(yùn)行中的線程ID,attr設(shè)置線程默認(rèn)屬性,f是線程函數(shù),arg是傳遞參數(shù)

可以使用:pthread_t pthread_self(void)函數(shù)獲取當(dāng)前線程的ID;

③ 終止線程

原型:int pthread_cancel(pthread_t tid); 終止當(dāng)前線程

原型:void pthread_exit(void *thread_return);等待所有對等線程終止

④ 回收已經(jīng)終止的線程

原型:int pthread_join(pthread_t tid, void **thread_return);

函數(shù)會阻塞,直到線程tid終止并回收所有存儲器資源。與wait不同的是該函數(shù)只能回收一個特定的線程;

⑤ 分離線程:分離后的線程終止以后由系統(tǒng)自動釋放

原型:int pthread_detach(pthread_t tid);?

⑥ 初始化線程

原型:int pthread_once(pthread_once_t *once_control, void (*init_routine)(void));

⑦ 一個基于線程的并發(fā)服務(wù)器

這個版本同線程的版本沒有多大的變化,有兩個地方需要注意,我們使用了一個connfdp指針指向一個動態(tài)分配的空間來傳遞已連接的描述符,避免出現(xiàn)競爭。同時在每個線程的函數(shù)中使用deatach進(jìn)行分離,每個線程終止后由系統(tǒng)釋放。

運(yùn)行效果:

1.4 多線程中的共享變量


我們前面說過線程集中了多路復(fù)用中的共享的優(yōu)點(diǎn),也舉例說了就像同一個舞臺表演的不同演員一樣,整個舞臺空間是共享的。那么多線程中的共享是如何實(shí)現(xiàn)的,工作原理是什么?

我們看一個簡單的例子,加入一些說明:

① 線程存儲器模型

寄存器是不共享的,虛擬存儲器總是共享的。就像同一個家庭的兩個孩子一樣,可以在一個飯廳吃飯,在客廳看電視,甚至共享同一個廁所,但是各自的房間通常是不一樣的,各自的個人物品也不同。

② 將變量映射到存儲器

全局變量:如ptr,可以使得本地變量msgs變成了共享(有時候兩個孩子要共享一個廁所);

本地自動變量:如myid是不能共享的,每個線程的myid都不一樣;

本地靜態(tài)變量:加入static如cnt,只有一個實(shí)例,兩個對等線程訪問的是同一個地方

③ 共享變量:被1個以上的線程訪問過的變量,如cnt。需要注意的是msgs也變成了共享的。

1.5 用信號量同步線程


智人在進(jìn)化意義上最成功的由于其合作的規(guī)模,單個的智人個體雖然遠(yuǎn)遠(yuǎn)不及同時代的尼安德特人,但是合作的規(guī)模更大,力量也就更大。我們今天探討的就是線程的同步,如果每個線程都各顧各的,勢必會影響到程序的正常運(yùn)行。我們來看一個未經(jīng)同步的線程的運(yùn)行情況:

這個程序的運(yùn)行結(jié)果就不OK了,原因在于每個單獨(dú)的進(jìn)程對共享變量cnt的訪問不是獨(dú)占式的,這種不同步導(dǎo)致了錯誤的結(jié)果。我們來研究一下最核心的代碼的運(yùn)行過程:

這里我們將線程函數(shù)中的for循環(huán)翻譯成匯編代碼,其中:Li是循環(huán)頭,Ti是循環(huán)尾,Li對應(yīng)于加載cnt,Ui對應(yīng)于更新cnt,Si對應(yīng)于存儲cnt。線程的執(zhí)行順序并不一定總是我們所期望的,如果遇到下面這種運(yùn)行順序,就可能會出錯。

上圖中左邊是正確的運(yùn)行順序,(b)就會得到錯誤的結(jié)果,關(guān)鍵點(diǎn)在于線程1更新了eax的值以后并沒有立即寫入到cnt中,就開始運(yùn)行了線程2,線程2由于cnt沒有更新所有eax加載還是為0,當(dāng)線程2完成寫入命令以后cnt就仍然是1,不會得到累加。

為了幫助大家正確理解各個線程的執(zhí)行順序,我們來畫圖

① 進(jìn)度圖

上圖展現(xiàn)了兩個線程,1和2,分別用x軸和y軸表示,其中Hi、Li、Ui、Si、Ti分別代表對共享變成操作的for循環(huán)的關(guān)鍵步驟,其中Li、Ui、Si涉及對cnt臨界區(qū)的操作,所有經(jīng)過這一區(qū)域的執(zhí)行順序都是不安全的。為了使得線程之間的同步變得科學(xué),不跨越臨界區(qū)。我們發(fā)明了信號量這種特殊的變量。

② 信號量:非負(fù)整數(shù)全局變量

信號量s其實(shí)就是一個非負(fù)整數(shù)的全局變量,對這一變量有兩個操作:P(s)使得s減1,而V(s)使得s加1。我們操作信號量s的時候,通常的情況是將其初始化為1,執(zhí)行P操作的時候?yàn)榧渔i,執(zhí)行V操作的時候?yàn)榻怄i。為了限定線程不經(jīng)由不安全區(qū)域,我們將不安全區(qū)域的設(shè)置為-1,如下圖:

我們的信號量s被初始化為1,只能在0和1之間變化:

1>加鎖:執(zhí)行P(s),有兩種情況,如果原有的值為1,那么減至0;如果為0則掛起線程;

2>解鎖:執(zhí)行V(s),也有兩種情況,如果s=0就加1;如果s=1就等待;

③ 更新我們的badcnt程序

這樣以來我們的全局共享變量cnt在運(yùn)行的各個線程中就會經(jīng)由加鎖執(zhí)行++和解鎖,得到正確的結(jié)果了。

④ 信號量調(diào)度共享資源

生產(chǎn)者——消費(fèi)者問題

以小區(qū)的自動售貨機(jī)為例,消費(fèi)者如果直接以下訂單的方式與生產(chǎn)者溝通,這樣的效率就太低下了。我不可能想要喝一瓶可能才讓可口可樂公司給我生產(chǎn)。這時候緩沖區(qū)就是一個很好的發(fā)明,我們發(fā)現(xiàn)在小區(qū)建立幾個自動售貨機(jī),假設(shè)每個自動售貨機(jī)可以裝100瓶飲料。這樣一來只要自動售貨機(jī)不為空生產(chǎn)者就可以將飲料放入到自動售貨機(jī)中去,當(dāng)然只要售貨機(jī)有飲料消費(fèi)者也直接從自動售貨機(jī)購買飲料。這樣一來就方便的多了。

我們前面講過信號量,P操作遇到為0的情況就會等待。但是現(xiàn)實(shí)的生活中,這樣的情況就不很科學(xué)。回到我們上面的自動售貨機(jī)的例子。如果我們的消費(fèi)者發(fā)現(xiàn)了自動售貨機(jī)是空的,我們就開始在原地等待,直到生產(chǎn)者將生產(chǎn)好的飲料送到自動售貨機(jī)上的時候,再購買。這樣以來對個人來說是精力的極大浪費(fèi)。我們有什么好的方法沒有,就像我們滴滴打車一樣,我們下單以后就可以去做其他事情了,一有車子接單以后就會電話聯(lián)系我們。

我們使用一種新的數(shù)據(jù)結(jié)構(gòu)來解決這種問題:

操作函數(shù)

讀者——寫者問題

這個問題類似于上一個,有點(diǎn)兒像我們的購票系統(tǒng),票數(shù)就是我們的共享變量,同一時刻我們允許多個客戶從不同的端口登錄查看票數(shù)在售情況(讀者優(yōu)先),但是當(dāng)有一個購買者(寫者)的買票的時候,寫會獨(dú)占票數(shù)。有一個解答如下:

⑤ 實(shí)現(xiàn)一個預(yù)線程化的并發(fā)服務(wù)器

我們通常所用到的線程并發(fā)服務(wù)器,要求服務(wù)器為每個客戶端單獨(dú)生成一個線程來提供服務(wù),就相當(dāng)于一種下訂單再生產(chǎn)的落后經(jīng)濟(jì)模型,我們學(xué)習(xí)了生產(chǎn)者消費(fèi)者模型以后,嘗試加入新的內(nèi)容:服務(wù)器 由一個主線程和一組工作線程構(gòu)成,主線程接收客戶端的連接請求,并將連接的描述符放入到一個緩沖區(qū)中,每個工作線程反復(fù)的從緩沖區(qū)中取出描述符,提供服務(wù),然后等待下一個描述符。

我們來看看實(shí)現(xiàn)代碼:

1.6 使用線程提高并行性


現(xiàn)代的CPU往往是多核的,如何利用這個特性變得相當(dāng)重要。我們這里所的并行是并發(fā)的一個子集,代表的是在多核處理器上運(yùn)行的并發(fā)程序。

如果我們要計(jì)算1,2,3…… 100各個數(shù)字相加的和,我們知道經(jīng)典的答案是:(1+100)*50=5050,我們使用多線程求一個集合數(shù)字的和的方法,就是將100個數(shù)字分成5個區(qū)域,這樣每個區(qū)域有20個數(shù)字,每個對等線程求出5個區(qū)域20個數(shù)字的和,然后由主線程將不同的和相加,就會得到這100個數(shù)字的和。我們來看一段代碼:

再來看看求和線程函數(shù)sum:

運(yùn)行結(jié)果如下:

1.7 其他并發(fā)問題


我們在實(shí)現(xiàn)程序的并發(fā)操作中,要注意很多問題。包括對共享變量的互斥訪問,使得程序無論何時何系統(tǒng),都能得到正確的返回值。不安全的操作有以下四類:

1> 不保護(hù)共享變量的函數(shù);

2> 保持跨越多個調(diào)用狀態(tài)的函數(shù)(rand、srand);

3> 返回指向靜態(tài)變量指針的函數(shù)(ctime);

4> 調(diào)用線程不安全函數(shù)的函數(shù);

說明:對于第3類函數(shù),我們通常使用的是加鎖——拷貝模式:

① 在庫函數(shù)中使用_r版本

以上我們列出的是線程不安全函數(shù)的_r版本,這些版本不會引用共享的數(shù)據(jù),因而在線程中使用是安全的,我們推薦使用_r版本的這類函數(shù)。

② 競爭

要理解競爭我們最好先來看一個例子:

這是一個很簡單的程序,在主線程中11-12行創(chuàng)建了4個對等線程,分別給每個對等線程傳遞了一個本地變量i,期望在線程函數(shù)中將每個對等線程的id號輸出顯示。

當(dāng)競爭發(fā)生的時候:

如果:先創(chuàng)建了一個線程(1),傳遞了本地變量1到線程函數(shù)thread中,并顯示,這是合理的

如果:創(chuàng)建線程后,thread函數(shù)還未輸出結(jié)果,就切換到主線程又創(chuàng)建新線程就會發(fā)生競爭

在不同的系統(tǒng)上得到了不同的結(jié)果,我們的改進(jìn)方法如下:

③ 死鎖

死鎖是由于我們交替對一對互斥變量(s、t)加鎖,如上圖所示,線程1先對s加鎖,線程2先對t加鎖,然后線程1要求對t加鎖的時候就必須等待,線程2要求對s加鎖的時候也陷入了等待,兩個線程都在等待就死鎖了。解決之道很簡單:

線程按照相同的順序?qū)、t加鎖,也就是說線程1先加鎖s再加鎖t,線程2先加鎖s再加鎖t。







最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

推薦閱讀更多精彩內(nèi)容