日韩黑丝制服一区视频播放|日韩欧美人妻丝袜视频在线观看|九九影院一级蜜桃|亚洲中文在线导航|青草草视频在线观看|婷婷五月色伊人网站|日本一区二区在线|国产AV一二三四区毛片|正在播放久草视频|亚洲色图精品一区

分享

超硬核,要是當(dāng)初這么學(xué)進(jìn)程和線程就好了!

 板橋胡同37號 2020-03-10

來源:Java建設(shè)者

作者:cxuan

下面是本文的結(jié)構(gòu)圖

我們平常說的進(jìn)程和線程更多的是基于編程語言的角度來說的,那么你真的了解什么是線程和進(jìn)程嗎?那么我們就從操作系統(tǒng)的角度來了解一下什么是進(jìn)程和線程。

進(jìn)程

操作系統(tǒng)中最核心的概念就是 進(jìn)程,進(jìn)程是對正在運(yùn)行中的程序的一個抽象。操作系統(tǒng)的其他所有內(nèi)容都是圍繞著進(jìn)程展開的。進(jìn)程是操作系統(tǒng)提供的最古老也是最重要的概念之一。即使可以使用的 CPU 只有一個,它們也支持(偽)并發(fā)操作。它們會將一個單獨(dú)的 CPU 抽象為多個虛擬機(jī)的 CPU??梢哉f:沒有進(jìn)程的抽象,現(xiàn)代操作系統(tǒng)將不復(fù)存在。

所有現(xiàn)代的計算機(jī)會在同一時刻做很多事情,過去使用計算機(jī)的人(單 CPU)可能完全無法理解現(xiàn)在這種變化,舉個例子更能說明這一點(diǎn):首先考慮一個 Web 服務(wù)器,請求都來自于 Web 網(wǎng)頁。當(dāng)一個請求到達(dá)時,服務(wù)器會檢查當(dāng)前頁是否在緩存中,如果是在緩存中,就直接把緩存中的內(nèi)容返回。如果緩存中沒有的話,那么請求就會交給磁盤來處理。但是,從 CPU 的角度來看,磁盤請求需要更長的時間,因?yàn)榇疟P請求會很慢。當(dāng)硬盤請求完成時,更多其他請求才會進(jìn)入。如果有多個磁盤的話,可以在第一個請求完成前就可以連續(xù)的對其他磁盤發(fā)出部分或全部請求。很顯然,這是一種并發(fā)現(xiàn)象,需要有并發(fā)控制條件來控制并發(fā)現(xiàn)象。

現(xiàn)在考慮只有一個用戶的 PC。當(dāng)系統(tǒng)啟動時,許多進(jìn)程也在后臺啟動,用戶通常不知道這些進(jìn)程的啟動,試想一下,當(dāng)你自己的計算機(jī)啟動的時候,你能知道哪些進(jìn)程是需要啟動的么?這些后臺進(jìn)程可能是一個需要輸入電子郵件的電子郵件進(jìn)程,或者是一個計算機(jī)病毒查殺進(jìn)程來周期性的更新病毒庫。某個用戶進(jìn)程可能會在所有用戶上網(wǎng)的時候打印文件以及刻錄 CD-ROM,這些活動都需要管理。于是一個支持多進(jìn)程的多道程序系統(tǒng)就會顯得很有必要了。

在許多多道程序系統(tǒng)中,CPU 會在進(jìn)程間快速切換,使每個程序運(yùn)行幾十或者幾百毫秒。然而,嚴(yán)格意義來說,在某一個瞬間,CPU 只能運(yùn)行一個進(jìn)程,然而我們?nèi)绻褧r間定位為 1 秒內(nèi)的話,它可能運(yùn)行多個進(jìn)程。這樣就會讓我們產(chǎn)生并行的錯覺。有時候人們說的 偽并行(pseudoparallelism) 就是這種情況,以此來區(qū)分多處理器系統(tǒng)(該系統(tǒng)由兩個或多個 CPU 來共享同一個物理內(nèi)存)

再來詳細(xì)解釋一下偽并行:偽并行是指單核或多核處理器同時執(zhí)行多個進(jìn)程,從而使程序更快。通過以非常有限的時間間隔在程序之間快速切換CPU,因此會產(chǎn)生并行感。缺點(diǎn)是 CPU 時間可能分配給下一個進(jìn)程,也可能不分配給下一個進(jìn)程。

因?yàn)?CPU 執(zhí)行速度很快,進(jìn)程間的換進(jìn)換出也非常迅速,因此我們很難對多個并行進(jìn)程進(jìn)行跟蹤,所以,在經(jīng)過多年的努力后,操作系統(tǒng)的設(shè)計者開發(fā)了用于描述并行的一種概念模型(順序進(jìn)程),使得并行更加容易理解和分析,對該模型的探討,也是本篇文章的主題。下面我們就來探討一下進(jìn)程模型

進(jìn)程模型

在進(jìn)程模型中,所有計算機(jī)上運(yùn)行的軟件,通常也包括操作系統(tǒng),被組織為若干順序進(jìn)程(sequential processes),簡稱為 進(jìn)程(process) 。一個進(jìn)程就是一個正在執(zhí)行的程序的實(shí)例,進(jìn)程也包括程序計數(shù)器、寄存器和變量的當(dāng)前值。從概念上來說,每個進(jìn)程都有各自的虛擬 CPU,但是實(shí)際情況是 CPU 會在各個進(jìn)程之間進(jìn)行來回切換。

如上圖所示,這是一個具有 4 個程序的多道處理程序,在進(jìn)程不斷切換的過程中,程序計數(shù)器也在不同的變化。

在上圖中,這 4 道程序被抽象為 4 個擁有各自控制流程(即每個自己的程序計數(shù)器)的進(jìn)程,并且每個程序都獨(dú)立的運(yùn)行。當(dāng)然,實(shí)際上只有一個物理程序計數(shù)器,每個程序要運(yùn)行時,其邏輯程序計數(shù)器會裝載到物理程序計數(shù)器中。當(dāng)程序運(yùn)行結(jié)束后,其物理程序計數(shù)器就會是真正的程序計數(shù)器,然后再把它放回進(jìn)程的邏輯計數(shù)器中。

從下圖我們可以看到,在觀察足夠長的一段時間后,所有的進(jìn)程都運(yùn)行了,但在任何一個給定的瞬間僅有一個進(jìn)程真正運(yùn)行。

因此,當(dāng)我們說一個 CPU 只能真正一次運(yùn)行一個進(jìn)程的時候,即使有 2 個核(或 CPU),每一個核也只能一次運(yùn)行一個線程。

由于 CPU 會在各個進(jìn)程之間來回快速切換,所以每個進(jìn)程在 CPU 中的運(yùn)行時間是無法確定的。并且當(dāng)同一個進(jìn)程再次在 CPU 中運(yùn)行時,其在 CPU 內(nèi)部的運(yùn)行時間往往也是不固定的。進(jìn)程和程序之間的區(qū)別是非常微妙的,但是通過一個例子可以讓你加以區(qū)分:想想一位會做飯的計算機(jī)科學(xué)家正在為他的女兒制作生日蛋糕。他有做生日蛋糕的食譜,廚房里有所需的原諒:面粉、雞蛋、糖、香草汁等。在這個比喻中,做蛋糕的食譜就是程序、計算機(jī)科學(xué)家就是 CPU、而做蛋糕的各種原料都是輸入數(shù)據(jù)。進(jìn)程就是科學(xué)家閱讀食譜、取來各種原料以及烘焙蛋糕等一系列動作的總和。

現(xiàn)在假設(shè)科學(xué)家的兒子跑過來告訴他,說他的頭被蜜蜂蜇了一下,那么此時科學(xué)家會記錄出來他做蛋糕這個過程到了哪一步,然后拿出急救手冊,按照上面的步驟給他兒子實(shí)施救助。這里,會涉及到進(jìn)程之間的切換,科學(xué)家(CPU)會從做蛋糕(進(jìn)程)切換到實(shí)施醫(yī)療救助(另一個進(jìn)程)。等待傷口處理完畢后,科學(xué)家會回到剛剛記錄做蛋糕的那一步,繼續(xù)制作。

這里的關(guān)鍵思想是認(rèn)識到一個進(jìn)程所需的條件,進(jìn)程是某一類特定活動的總和,它有程序、輸入輸出以及狀態(tài)。單個處理器可以被若干進(jìn)程共享,它使用某種調(diào)度算法決定何時停止一個進(jìn)程的工作,并轉(zhuǎn)而為另外一個進(jìn)程提供服務(wù)。另外需要注意的是,如果一個進(jìn)程運(yùn)行了兩遍,則被認(rèn)為是兩個進(jìn)程。那么我們了解到進(jìn)程模型后,那么進(jìn)程是如何創(chuàng)建的呢?

進(jìn)程的創(chuàng)建

操作系統(tǒng)需要一些方式來創(chuàng)建進(jìn)程。下面是一些創(chuàng)建進(jìn)程的方式

  • 系統(tǒng)初始化(init)

  • 正在運(yùn)行的程序執(zhí)行了創(chuàng)建進(jìn)程的系統(tǒng)調(diào)用(比如 fork)

  • 用戶請求創(chuàng)建一個新進(jìn)程

  • 初始化一個批處理工作

系統(tǒng)初始化

啟動操作系統(tǒng)時,通常會創(chuàng)建若干個進(jìn)程。其中有些是前臺進(jìn)程(numerous processes),也就是同用戶進(jìn)行交互并替他們完成工作的進(jìn)程。一些運(yùn)行在后臺,并不與特定的用戶進(jìn)行交互,例如,設(shè)計一個進(jìn)程來接收發(fā)來的電子郵件,這個進(jìn)程大部分的時間都在休眠,但是只要郵件到來后這個進(jìn)程就會被喚醒。還可以設(shè)計一個進(jìn)程來接收對該計算機(jī)上網(wǎng)頁的傳入請求,在請求到達(dá)的進(jìn)程喚醒來處理網(wǎng)頁的傳入請求。進(jìn)程運(yùn)行在后臺用來處理一些活動像是 e-mail,web 網(wǎng)頁,新聞,打印等等被稱為 守護(hù)進(jìn)程(daemons)。大型系統(tǒng)會有很多守護(hù)進(jìn)程。在 UNIX 中,ps 程序可以列出正在運(yùn)行的進(jìn)程, 在 Windows 中,可以使用任務(wù)管理器。

系統(tǒng)調(diào)用創(chuàng)建

除了在啟動階段創(chuàng)建進(jìn)程之外,一些新的進(jìn)程也可以在后面創(chuàng)建。通常,一個正在運(yùn)行的進(jìn)程會發(fā)出系統(tǒng)調(diào)用用來創(chuàng)建一個或多個新進(jìn)程來幫助其完成工作。例如,如果有大量的數(shù)據(jù)需要經(jīng)過網(wǎng)絡(luò)調(diào)取并進(jìn)行順序處理,那么創(chuàng)建一個進(jìn)程讀數(shù)據(jù),并把數(shù)據(jù)放到共享緩沖區(qū)中,而讓第二個進(jìn)程取走并正確處理會比較容易些。在多處理器中,讓每個進(jìn)程運(yùn)行在不同的 CPU 上也可以使工作做的更快。

用戶請求創(chuàng)建

在許多交互式系統(tǒng)中,輸入一個命令或者雙擊圖標(biāo)就可以啟動程序,以上任意一種操作都可以選擇開啟一個新的進(jìn)程,在基本的 UNIX 系統(tǒng)中運(yùn)行 X,新進(jìn)程將接管啟動它的窗口。在 Windows 中啟動進(jìn)程時,它一般沒有窗口,但是它可以創(chuàng)建一個或多個窗口。每個窗口都可以運(yùn)行進(jìn)程。通過鼠標(biāo)或者命令就可以切換窗口并與進(jìn)程進(jìn)行交互。

交互式系統(tǒng)是以人與計算機(jī)之間大量交互為特征的計算機(jī)系統(tǒng),比如游戲、web瀏覽器,IDE 等集成開發(fā)環(huán)境。

批處理創(chuàng)建

最后一種創(chuàng)建進(jìn)程的情形會在大型機(jī)的批處理系統(tǒng)中應(yīng)用。用戶在這種系統(tǒng)中提交批處理作業(yè)。當(dāng)操作系統(tǒng)決定它有資源來運(yùn)行另一個任務(wù)時,它將創(chuàng)建一個新進(jìn)程并從其中的輸入隊(duì)列中運(yùn)行下一個作業(yè)。

從技術(shù)上講,在所有這些情況下,讓現(xiàn)有流程執(zhí)行流程是通過創(chuàng)建系統(tǒng)調(diào)用來創(chuàng)建新流程的。該進(jìn)程可能是正在運(yùn)行的用戶進(jìn)程,是從鍵盤或鼠標(biāo)調(diào)用的系統(tǒng)進(jìn)程或批處理程序。這些就是系統(tǒng)調(diào)用創(chuàng)建新進(jìn)程的過程。該系統(tǒng)調(diào)用告訴操作系統(tǒng)創(chuàng)建一個新進(jìn)程,并直接或間接指示在其中運(yùn)行哪個程序。

在 UNIX 中,僅有一個系統(tǒng)調(diào)用來創(chuàng)建一個新的進(jìn)程,這個系統(tǒng)調(diào)用就是 fork。這個調(diào)用會創(chuàng)建一個與調(diào)用進(jìn)程相關(guān)的副本。在 fork 后,一個父進(jìn)程和子進(jìn)程會有相同的內(nèi)存映像,相同的環(huán)境字符串和相同的打開文件。通常,子進(jìn)程會執(zhí)行 execve 或者一個簡單的系統(tǒng)調(diào)用來改變內(nèi)存映像并運(yùn)行一個新的程序。例如,當(dāng)一個用戶在 shell 中輸出 sort 命令時,shell 會 fork 一個子進(jìn)程然后子進(jìn)程去執(zhí)行 sort 命令。這兩步過程的原因是允許子進(jìn)程在 fork 之后但在 execve 之前操作其文件描述符,以完成標(biāo)準(zhǔn)輸入,標(biāo)準(zhǔn)輸出和標(biāo)準(zhǔn)錯誤的重定向。

在 Windows 中,情況正相反,一個簡單的 Win32 功能調(diào)用 CreateProcess,會處理流程創(chuàng)建并將正確的程序加載到新的進(jìn)程中。這個調(diào)用會有 10 個參數(shù),包括了需要執(zhí)行的程序、輸入給程序的命令行參數(shù)、各種安全屬性、有關(guān)打開的文件是否繼承控制位、優(yōu)先級信息、進(jìn)程所需要創(chuàng)建的窗口規(guī)格以及指向一個結(jié)構(gòu)的指針,在該結(jié)構(gòu)中新創(chuàng)建進(jìn)程的信息被返回給調(diào)用者。除了 CreateProcess Win 32 中大概有 100 個其他的函數(shù)用于處理進(jìn)程的管理,同步以及相關(guān)的事務(wù)。下面是 UNIX 操作系統(tǒng)和 Windows 操作系統(tǒng)系統(tǒng)調(diào)用的對比

在 UNIX 和 Windows 中,進(jìn)程創(chuàng)建之后,父進(jìn)程和子進(jìn)程有各自不同的地址空間。如果其中某個進(jìn)程在其地址空間中修改了一個詞,這個修改將對另一個進(jìn)程不可見。在 UNIX 中,子進(jìn)程的地址空間是父進(jìn)程的一個拷貝,但是卻是兩個不同的地址空間;不可寫的內(nèi)存區(qū)域是共享的。某些 UNIX 實(shí)現(xiàn)是正是在兩者之間共享,因?yàn)樗荒鼙恍薷??;蛘?,子進(jìn)程共享父進(jìn)程的所有內(nèi)存,但是這種情況下內(nèi)存通過 寫時復(fù)制(copy-on-write) 共享,這意味著一旦兩者之一想要修改部分內(nèi)存,則這塊內(nèi)存首先被明確的復(fù)制,以確保修改發(fā)生在私有內(nèi)存區(qū)域。再次強(qiáng)調(diào),可寫的內(nèi)存是不能被共享的。但是,對于一個新創(chuàng)建的進(jìn)程來說,確實(shí)有可能共享創(chuàng)建者的資源,比如可以共享打開的文件。在 Windows 中,從一開始父進(jìn)程的地址空間和子進(jìn)程的地址空間就是不同的

進(jìn)程的終止

進(jìn)程在創(chuàng)建之后,它就開始運(yùn)行并做完成任務(wù)。然而,沒有什么事兒是永不停歇的,包括進(jìn)程也一樣。進(jìn)程早晚會發(fā)生終止,但是通常是由于以下情況觸發(fā)的

  • 正常退出(自愿的)

  • 錯誤退出(自愿的)

  • 嚴(yán)重錯誤(非自愿的)

  • 被其他進(jìn)程殺死(非自愿的)

正常退出

多數(shù)進(jìn)程是由于完成了工作而終止。當(dāng)編譯器完成了所給定程序的編譯之后,編譯器會執(zhí)行一個系統(tǒng)調(diào)用告訴操作系統(tǒng)它完成了工作。這個調(diào)用在 UNIX 中是 exit ,在 Windows 中是 ExitProcess。面向屏幕中的軟件也支持自愿終止操作。字處理軟件、Internet 瀏覽器和類似的程序中總有一個供用戶點(diǎn)擊的圖標(biāo)或菜單項(xiàng),用來通知進(jìn)程刪除它所打開的任何臨時文件,然后終止。

錯誤退出

進(jìn)程發(fā)生終止的第二個原因是發(fā)現(xiàn)嚴(yán)重錯誤,例如,如果用戶執(zhí)行如下命令

cc foo.c    

為了能夠編譯 foo.c 但是該文件不存在,于是編譯器就會發(fā)出聲明并退出。在給出了錯誤參數(shù)時,面向屏幕的交互式進(jìn)程通常并不會直接退出,因?yàn)檫@從用戶的角度來說并不合理,用戶需要知道發(fā)生了什么并想要進(jìn)行重試,所以這時候應(yīng)用程序通常會彈出一個對話框告知用戶發(fā)生了系統(tǒng)錯誤,是需要重試還是退出。

嚴(yán)重錯誤

進(jìn)程終止的第三個原因是由進(jìn)程引起的錯誤,通常是由于程序中的錯誤所導(dǎo)致的。例如,執(zhí)行了一條非法指令,引用不存在的內(nèi)存,或者除數(shù)是 0 等。在有些系統(tǒng)比如 UNIX 中,進(jìn)程可以通知操作系統(tǒng),它希望自行處理某種類型的錯誤,在這類錯誤中,進(jìn)程會收到信號(中斷),而不是在這類錯誤出現(xiàn)時直接終止進(jìn)程。

被其他進(jìn)程殺死

第四個終止進(jìn)程的原因是,某個進(jìn)程執(zhí)行系統(tǒng)調(diào)用告訴操作系統(tǒng)殺死某個進(jìn)程。在 UNIX 中,這個系統(tǒng)調(diào)用是 kill。在 Win32 中對應(yīng)的函數(shù)是 TerminateProcess(注意不是系統(tǒng)調(diào)用)。

進(jìn)程的層次結(jié)構(gòu)

在一些系統(tǒng)中,當(dāng)一個進(jìn)程創(chuàng)建了其他進(jìn)程后,父進(jìn)程和子進(jìn)程就會以某種方式進(jìn)行關(guān)聯(lián)。子進(jìn)程它自己就會創(chuàng)建更多進(jìn)程,從而形成一個進(jìn)程層次結(jié)構(gòu)。

UNIX 進(jìn)程體系

在 UNIX 中,進(jìn)程和它的所有子進(jìn)程以及子進(jìn)程的子進(jìn)程共同組成一個進(jìn)程組。當(dāng)用戶從鍵盤中發(fā)出一個信號后,該信號被發(fā)送給當(dāng)前與鍵盤相關(guān)的進(jìn)程組中的所有成員(它們通常是在當(dāng)前窗口創(chuàng)建的所有活動進(jìn)程)。每個進(jìn)程可以分別捕獲該信號、忽略該信號或采取默認(rèn)的動作,即被信號 kill 掉。

這里有另一個例子,可以用來說明層次的作用,考慮 UNIX 在啟動時如何初始化自己。一個稱為 init 的特殊進(jìn)程出現(xiàn)在啟動映像中 。當(dāng) init 進(jìn)程開始運(yùn)行時,它會讀取一個文件,文件會告訴它有多少個終端。然后為每個終端創(chuàng)建一個新進(jìn)程。這些進(jìn)程等待用戶登錄。如果登錄成功,該登錄進(jìn)程就執(zhí)行一個 shell 來等待接收用戶輸入指令,這些命令可能會啟動更多的進(jìn)程,以此類推。因此,整個操作系統(tǒng)中所有的進(jìn)程都隸屬于一個單個以 init 為根的進(jìn)程樹。

Windows 進(jìn)程體系

相反,Windows 中沒有進(jìn)程層次的概念,Windows 中所有進(jìn)程都是平等的,唯一類似于層次結(jié)構(gòu)的是在創(chuàng)建進(jìn)程的時候,父進(jìn)程得到一個特別的令牌(稱為句柄),該句柄可以用來控制子進(jìn)程。然而,這個令牌可能也會移交給別的操作系統(tǒng),這樣就不存在層次結(jié)構(gòu)了。而在 UNIX 中,進(jìn)程不能剝奪其子進(jìn)程的 進(jìn)程權(quán)。(這樣看來,還是 Windows 比較)。

進(jìn)程狀態(tài)

盡管每個進(jìn)程是一個獨(dú)立的實(shí)體,有其自己的程序計數(shù)器和內(nèi)部狀態(tài),但是,進(jìn)程之間仍然需要相互幫助。例如,一個進(jìn)程的結(jié)果可以作為另一個進(jìn)程的輸入,在 shell 命令中

cat chapter1 chapter2 chapter3 | grep tree

第一個進(jìn)程是 cat,將三個文件級聯(lián)并輸出。第二個進(jìn)程是 grep,它從輸入中選擇具有包含關(guān)鍵字 tree 的內(nèi)容,根據(jù)這兩個進(jìn)程的相對速度(這取決于兩個程序的相對復(fù)雜度和各自所分配到的 CPU 時間片),可能會發(fā)生下面這種情況,grep 準(zhǔn)備就緒開始運(yùn)行,但是輸入進(jìn)程還沒有完成,于是必須阻塞 grep 進(jìn)程,直到輸入完畢。

當(dāng)一個進(jìn)程開始運(yùn)行時,它可能會經(jīng)歷下面這幾種狀態(tài)

圖中會涉及三種狀態(tài)

  1. 運(yùn)行態(tài),運(yùn)行態(tài)指的就是進(jìn)程實(shí)際占用 CPU 時間片運(yùn)行時

  2. 就緒態(tài),就緒態(tài)指的是可運(yùn)行,但因?yàn)槠渌M(jìn)程正在運(yùn)行而處于就緒狀態(tài)

  3. 阻塞態(tài),除非某種外部事件發(fā)生,否則進(jìn)程不能運(yùn)行

邏輯上來說,運(yùn)行態(tài)和就緒態(tài)是很相似的。這兩種情況下都表示進(jìn)程可運(yùn)行,但是第二種情況沒有獲得 CPU 時間分片。第三種狀態(tài)與前兩種狀態(tài)不同的原因是這個進(jìn)程不能運(yùn)行,CPU 空閑時也不能運(yùn)行。

三種狀態(tài)會涉及四種狀態(tài)間的切換,在操作系統(tǒng)發(fā)現(xiàn)進(jìn)程不能繼續(xù)執(zhí)行時會發(fā)生狀態(tài)1的輪轉(zhuǎn),在某些系統(tǒng)中進(jìn)程執(zhí)行系統(tǒng)調(diào)用,例如 pause,來獲取一個阻塞的狀態(tài)。在其他系統(tǒng)中包括 UNIX,當(dāng)進(jìn)程從管道或特殊文件(例如終端)中讀取沒有可用的輸入時,該進(jìn)程會被自動終止。

轉(zhuǎn)換 2 和轉(zhuǎn)換 3 都是由進(jìn)程調(diào)度程序(操作系統(tǒng)的一部分)引起的,進(jìn)程本身不知道調(diào)度程序的存在。轉(zhuǎn)換 2 的出現(xiàn)說明進(jìn)程調(diào)度器認(rèn)定當(dāng)前進(jìn)程已經(jīng)運(yùn)行了足夠長的時間,是時候讓其他進(jìn)程運(yùn)行 CPU 時間片了。當(dāng)所有其他進(jìn)程都運(yùn)行過后,這時候該是讓第一個進(jìn)程重新獲得 CPU 時間片的時候了,就會發(fā)生轉(zhuǎn)換 3。

程序調(diào)度指的是,決定哪個進(jìn)程優(yōu)先被運(yùn)行和運(yùn)行多久,這是很重要的一點(diǎn)。已經(jīng)設(shè)計出許多算法來嘗試平衡系統(tǒng)整體效率與各個流程之間的競爭需求。

當(dāng)進(jìn)程等待的一個外部事件發(fā)生時(如從外部輸入一些數(shù)據(jù)后),則發(fā)生轉(zhuǎn)換 4。如果此時沒有其他進(jìn)程在運(yùn)行,則立刻觸發(fā)轉(zhuǎn)換 3,該進(jìn)程便開始運(yùn)行,否則該進(jìn)程會處于就緒階段,等待 CPU 空閑后再輪到它運(yùn)行。

從上面的觀點(diǎn)引入了下面的模型

操作系統(tǒng)最底層的就是調(diào)度程序,在它上面有許多進(jìn)程。所有關(guān)于中斷處理、啟動進(jìn)程和停止進(jìn)程的具體細(xì)節(jié)都隱藏在調(diào)度程序中。事實(shí)上,調(diào)度程序只是一段非常小的程序。

進(jìn)程的實(shí)現(xiàn)

操作系統(tǒng)為了執(zhí)行進(jìn)程間的切換,會維護(hù)著一張表格,這張表就是 進(jìn)程表(process table)。每個進(jìn)程占用一個進(jìn)程表項(xiàng)。該表項(xiàng)包含了進(jìn)程狀態(tài)的重要信息,包括程序計數(shù)器、堆棧指針、內(nèi)存分配狀況、所打開文件的狀態(tài)、賬號和調(diào)度信息,以及其他在進(jìn)程由運(yùn)行態(tài)轉(zhuǎn)換到就緒態(tài)或阻塞態(tài)時所必須保存的信息,從而保證該進(jìn)程隨后能再次啟動,就像從未被中斷過一樣。

下面展示了一個典型系統(tǒng)中的關(guān)鍵字段

第一列內(nèi)容與進(jìn)程管理有關(guān),第二列內(nèi)容與 存儲管理有關(guān),第三列內(nèi)容與文件管理有關(guān)。

存儲管理的 text segment 、 data segment、stack segment 更多了解見下面這篇文章

程序員需要了解的硬核知識之匯編語言(全)

現(xiàn)在我們應(yīng)該對進(jìn)程表有個大致的了解了,就可以在對單個 CPU 上如何運(yùn)行多個順序進(jìn)程的錯覺做更多的解釋。與每一 I/O 類相關(guān)聯(lián)的是一個稱作 中斷向量(interrupt vector) 的位置(靠近內(nèi)存底部的固定區(qū)域)。它包含中斷服務(wù)程序的入口地址。假設(shè)當(dāng)一個磁盤中斷發(fā)生時,用戶進(jìn)程 3 正在運(yùn)行,則中斷硬件將程序計數(shù)器、程序狀態(tài)字、有時還有一個或多個寄存器壓入堆棧,計算機(jī)隨即跳轉(zhuǎn)到中斷向量所指示的地址。這就是硬件所做的事情。然后軟件就隨即接管一切剩余的工作。

當(dāng)中斷結(jié)束后,操作系統(tǒng)會調(diào)用一個 C 程序來處理中斷剩下的工作。在完成剩下的工作后,會使某些進(jìn)程就緒,接著調(diào)用調(diào)度程序,決定隨后運(yùn)行哪個進(jìn)程。然后將控制權(quán)轉(zhuǎn)移給一段匯編語言代碼,為當(dāng)前的進(jìn)程裝入寄存器值以及內(nèi)存映射并啟動該進(jìn)程運(yùn)行,下面顯示了中斷處理和調(diào)度的過程。

  1. 硬件壓入堆棧程序計數(shù)器等

  2. 硬件從中斷向量裝入新的程序計數(shù)器

  3. 匯編語言過程保存寄存器的值

  4. 匯編語言過程設(shè)置新的堆棧

  5. C 中斷服務(wù)器運(yùn)行(典型的讀和緩存寫入)

  6. 調(diào)度器決定下面哪個程序先運(yùn)行

  7. C 過程返回至匯編代碼

  8. 匯編語言過程開始運(yùn)行新的當(dāng)前進(jìn)程

一個進(jìn)程在執(zhí)行過程中可能被中斷數(shù)千次,但關(guān)鍵每次中斷后,被中斷的進(jìn)程都返回到與中斷發(fā)生前完全相同的狀態(tài)。

線程

在傳統(tǒng)的操作系統(tǒng)中,每個進(jìn)程都有一個地址空間和一個控制線程。事實(shí)上,這是大部分進(jìn)程的定義。不過,在許多情況下,經(jīng)常存在同一地址空間中運(yùn)行多個控制線程的情形,這些線程就像是分離的進(jìn)程。下面我們就著重探討一下什么是線程

線程的使用

或許這個疑問也是你的疑問,為什么要在進(jìn)程的基礎(chǔ)上再創(chuàng)建一個線程的概念,準(zhǔn)確的說,這其實(shí)是進(jìn)程模型和線程模型的討論,回答這個問題,可能需要分三步來回答

  • 多線程之間會共享同一塊地址空間和所有可用數(shù)據(jù)的能力,這是進(jìn)程所不具備的

  • 線程要比進(jìn)程更輕量級,由于線程更輕,所以它比進(jìn)程更容易創(chuàng)建,也更容易撤銷。在許多系統(tǒng)中,創(chuàng)建一個線程要比創(chuàng)建一個進(jìn)程快 10 - 100 倍。

  • 第三個原因可能是性能方面的探討,如果多個線程都是 CPU 密集型的,那么并不能獲得性能上的增強(qiáng),但是如果存在著大量的計算和大量的 I/O 處理,擁有多個線程能在這些活動中彼此重疊進(jìn)行,從而會加快應(yīng)用程序的執(zhí)行速度

多線程解決方案

現(xiàn)在考慮一個線程使用的例子:一個萬維網(wǎng)服務(wù)器,對頁面的請求發(fā)送給服務(wù)器,而所請求的頁面發(fā)送回客戶端。在多數(shù) web 站點(diǎn)上,某些頁面較其他頁面相比有更多的訪問。例如,索尼的主頁比任何一個照相機(jī)詳情介紹頁面具有更多的訪問,Web 服務(wù)器可以把獲得大量訪問的頁面集合保存在內(nèi)存中,避免到磁盤去調(diào)入這些頁面,從而改善性能。這種頁面的集合稱為 高速緩存(cache),高速緩存也應(yīng)用在許多場合中,比如說 CPU 緩存。

上面是一個 web 服務(wù)器的組織方式,一個叫做 調(diào)度線程(dispatcher thread) 的線程從網(wǎng)絡(luò)中讀入工作請求,在調(diào)度線程檢查完請求后,它會選擇一個空閑的(阻塞的)工作線程來處理請求,通常是將消息的指針寫入到每個線程關(guān)聯(lián)的特殊字中。然后調(diào)度線程會喚醒正在睡眠中的工作線程,把工作線程的狀態(tài)從阻塞態(tài)變?yōu)榫途w態(tài)。

當(dāng)工作線程啟動后,它會檢查請求是否在 web 頁面的高速緩存中存在,這個高速緩存是所有線程都可以訪問的。如果高速緩存不存在這個 web 頁面的話,它會調(diào)用一個 read 操作從磁盤中獲取頁面并且阻塞線程直到磁盤操作完成。當(dāng)線程阻塞在硬盤操作的期間,為了完成更多的工作,調(diào)度線程可能挑選另一個線程運(yùn)行,也可能把另一個當(dāng)前就緒的工作線程投入運(yùn)行。

這種模型允許將服務(wù)器編寫為順序線程的集合,在分派線程的程序中包含一個死循環(huán),該循環(huán)用來獲得工作請求并且把請求派給工作線程。每個工作線程的代碼包含一個從調(diào)度線程接收的請求,并且檢查 web 高速緩存中是否存在所需頁面,如果有,直接把該頁面返回給客戶,接著工作線程阻塞,等待一個新請求的到達(dá)。如果沒有,工作線程就從磁盤調(diào)入該頁面,將該頁面返回給客戶機(jī),然后工作線程阻塞,等待一個新請求。

下面是調(diào)度線程和工作線程的代碼,這里假設(shè) TRUE 為常數(shù) 1 ,buf 和 page 分別是保存工作請求和 Web 頁面的相應(yīng)結(jié)構(gòu)。

調(diào)度線程的大致邏輯

while(TRUE){
  get_next_request(&buf);
  handoff_work(&buf);
}

工作線程的大致邏輯

while(TRUE){
  wait_for_work(&buf);
  look_for_page_in_cache(&buf,&page);
  if(page_not_in_cache(&page)){
    read_page_from_disk(&buf,&page);
  }
  return _page(&page);
}

單線程解決方案

現(xiàn)在考慮沒有多線程的情況下,如何編寫 Web 服務(wù)器。我們很容易的就想象為單個線程了,Web 服務(wù)器的主循環(huán)獲取請求并檢查請求,并爭取在下一個請求之前完成工作。在等待磁盤操作時,服務(wù)器空轉(zhuǎn),并且不處理任何到來的其他請求。結(jié)果會導(dǎo)致每秒中只有很少的請求被處理,所以這個例子能夠說明多線程提高了程序的并行性并提高了程序的性能。

狀態(tài)機(jī)解決方案

到現(xiàn)在為止,我們已經(jīng)有了兩種解決方案,單線程解決方案和多線程解決方案,其實(shí)還有一種解決方案就是 狀態(tài)機(jī)解決方案,它的流程如下

如果目前只有一個非阻塞版本的 read 系統(tǒng)調(diào)用可以使用,那么當(dāng)請求到達(dá)服務(wù)器時,這個唯一的 read 調(diào)用的線程會進(jìn)行檢查,如果能夠從高速緩存中得到響應(yīng),那么直接返回,如果不能,則啟動一個非阻塞的磁盤操作

服務(wù)器在表中記錄當(dāng)前請求的狀態(tài),然后進(jìn)入并獲取下一個事件,緊接著下一個事件可能就是一個新工作的請求或是磁盤對先前操作的回答。如果是新工作的請求,那么就開始處理請求。如果是磁盤的響應(yīng),就從表中取出對應(yīng)的狀態(tài)信息進(jìn)行處理。對于非阻塞式磁盤 I/O 而言,這種響應(yīng)一般都是信號中斷響應(yīng)。

每次服務(wù)器從某個請求工作的狀態(tài)切換到另一個狀態(tài)時,都必須顯示的保存或者重新裝入相應(yīng)的計算狀態(tài)。這里,每個計算都有一個被保存的狀態(tài),存在一個會發(fā)生且使得相關(guān)狀態(tài)發(fā)生改變的事件集合,我們把這類設(shè)計稱為有限狀態(tài)機(jī)(finite-state machine),有限狀態(tài)機(jī)被廣泛的應(yīng)用在計算機(jī)科學(xué)中。

這三種解決方案各有各的特性,多線程使得順序進(jìn)程的思想得以保留下來,并且實(shí)現(xiàn)了并行性,但是順序進(jìn)程會阻塞系統(tǒng)調(diào)用;單線程服務(wù)器保留了阻塞系統(tǒng)的簡易性,但是卻放棄了性能。有限狀態(tài)機(jī)的處理方法運(yùn)用了非阻塞調(diào)用和中斷,通過并行實(shí)現(xiàn)了高性能,但是給編程增加了困難。

模型特性
單線程無并行性,性能較差,阻塞系統(tǒng)調(diào)用
多線程有并行性,阻塞系統(tǒng)調(diào)用
有限狀態(tài)機(jī)并行性,非阻塞系統(tǒng)調(diào)用、中斷

經(jīng)典的線程模型

理解進(jìn)程的另一個角度是,用某種方法把相關(guān)的資源集中在一起。進(jìn)程有存放程序正文和數(shù)據(jù)以及其他資源的地址空間。這些資源包括打開的文件、子進(jìn)程、即將發(fā)生的定時器、信號處理程序、賬號信息等。把這些信息放在進(jìn)程中會比較容易管理。

另一個概念是,進(jìn)程中擁有一個執(zhí)行的線程,通常簡寫為 線程(thread)。線程會有程序計數(shù)器,用來記錄接著要執(zhí)行哪一條指令;線程還擁有寄存器,用來保存線程當(dāng)前正在使用的變量;線程還會有堆棧,用來記錄程序的執(zhí)行路徑。盡管線程必須在某個進(jìn)程中執(zhí)行,但是進(jìn)程和線程完完全全是兩個不同的概念,并且他們可以分開處理。進(jìn)程用于把資源集中在一起,而線程則是 CPU 上調(diào)度執(zhí)行的實(shí)體。

線程給進(jìn)程模型增加了一項(xiàng)內(nèi)容,即在同一個進(jìn)程中,允許彼此之間有較大的獨(dú)立性且互不干擾。在一個進(jìn)程中并行運(yùn)行多個線程類似于在一臺計算機(jī)上運(yùn)行多個進(jìn)程。在多個線程中,各個線程共享同一地址空間和其他資源。在多個進(jìn)程中,進(jìn)程共享物理內(nèi)存、磁盤、打印機(jī)和其他資源。因?yàn)榫€程會包含有一些進(jìn)程的屬性,所以線程被稱為輕量的進(jìn)程(lightweight processes)。多線程(multithreading)一詞還用于描述在同一進(jìn)程中多個線程的情況。

下圖我們可以看到三個傳統(tǒng)的進(jìn)程,每個進(jìn)程有自己的地址空間和單個控制線程。每個線程都在不同的地址空間中運(yùn)行

下圖中,我們可以看到有一個進(jìn)程三個線程的情況。每個線程都在相同的地址空間中運(yùn)行。

線程不像是進(jìn)程那樣具備較強(qiáng)的獨(dú)立性。同一個進(jìn)程中的所有線程都會有完全一樣的地址空間,這意味著它們也共享同樣的全局變量。由于每個線程都可以訪問進(jìn)程地址空間內(nèi)每個內(nèi)存地址,因此一個線程可以讀取、寫入甚至擦除另一個線程的堆棧。線程之間除了共享同一內(nèi)存空間外,還具有如下不同的內(nèi)容

上圖左邊的是同一個進(jìn)程中每個線程共享的內(nèi)容,上圖右邊是每個線程中的內(nèi)容。也就是說左邊的列表是進(jìn)程的屬性,右邊的列表是線程的屬性。

和進(jìn)程一樣,線程可以處于下面這幾種狀態(tài):運(yùn)行中、阻塞、就緒和終止(進(jìn)程圖中沒有畫)。正在運(yùn)行的線程擁有 CPU 時間片并且狀態(tài)是運(yùn)行中。一個被阻塞的線程會等待某個釋放它的事件。例如,當(dāng)一個線程執(zhí)行從鍵盤讀入數(shù)據(jù)的系統(tǒng)調(diào)用時,該線程就被阻塞直到有輸入為止。線程通常會被阻塞,直到它等待某個外部事件的發(fā)生或者有其他線程來釋放它。線程之間的狀態(tài)轉(zhuǎn)換和進(jìn)程之間的狀態(tài)轉(zhuǎn)換是一樣的。

每個線程都會有自己的堆棧,如下圖所示

線程系統(tǒng)調(diào)用

進(jìn)程通常會從當(dāng)前的某個單線程開始,然后這個線程通過調(diào)用一個庫函數(shù)(比如 thread_create)創(chuàng)建新的線程。線程創(chuàng)建的函數(shù)會要求指定新創(chuàng)建線程的名稱。創(chuàng)建的線程通常都返回一個線程標(biāo)識符,該標(biāo)識符就是新線程的名字。

當(dāng)一個線程完成工作后,可以通過調(diào)用一個函數(shù)(比如 thread_exit)來退出。緊接著線程消失,狀態(tài)變?yōu)榻K止,不能再進(jìn)行調(diào)度。在某些線程的運(yùn)行過程中,可以通過調(diào)用函數(shù)例如 thread_join ,表示一個線程可以等待另一個線程退出。這個過程阻塞調(diào)用線程直到等待特定的線程退出。在這種情況下,線程的創(chuàng)建和終止非常類似于進(jìn)程的創(chuàng)建和終止。

另一個常見的線程是調(diào)用 thread_yield,它允許線程自動放棄 CPU 從而讓另一個線程運(yùn)行。這樣一個調(diào)用還是很重要的,因?yàn)椴煌谶M(jìn)程,線程是無法利用時鐘中斷強(qiáng)制讓線程讓出 CPU 的。

POSIX 線程

為了使編寫可移植線程程序成為可能,IEEE 在 IEEE 標(biāo)準(zhǔn) 1003.1c 中定義了線程標(biāo)準(zhǔn)。線程包被定義為 Pthreads。大部分的 UNIX 系統(tǒng)支持它。這個標(biāo)準(zhǔn)定義了 60 多種功能調(diào)用,一一列舉不太現(xiàn)實(shí),下面為你列舉了一些常用的系統(tǒng)調(diào)用。

POSIX線程(通常稱為pthreads)是一種獨(dú)立于語言而存在的執(zhí)行模型,以及并行執(zhí)行模型。它允許程序控制時間上重疊的多個不同的工作流程。每個工作流程都稱為一個線程,可以通過調(diào)用POSIX Threads API來實(shí)現(xiàn)對這些流程的創(chuàng)建和控制??梢园阉斫鉃榫€程的標(biāo)準(zhǔn)。

POSIX Threads 的實(shí)現(xiàn)在許多類似且符合POSIX的操作系統(tǒng)上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在現(xiàn)有 Windows API 之上實(shí)現(xiàn)了pthread

IEEE 是世界上最大的技術(shù)專業(yè)組織,致力于為人類的利益而發(fā)展技術(shù)。

線程調(diào)用描述
pthread_create創(chuàng)建一個新線程
pthread_exit結(jié)束調(diào)用的線程
pthread_join等待一個特定的線程退出
pthread_yield釋放 CPU 來運(yùn)行另外一個線程
pthread_attr_init創(chuàng)建并初始化一個線程的屬性結(jié)構(gòu)
pthread_attr_destory刪除一個線程的屬性結(jié)構(gòu)

所有的 Pthreads 都有特定的屬性,每一個都含有標(biāo)識符、一組寄存器(包括程序計數(shù)器)和一組存儲在結(jié)構(gòu)中的屬性。這個屬性包括堆棧大小、調(diào)度參數(shù)以及其他線程需要的項(xiàng)目。

新的線程會通過 pthread_create 創(chuàng)建,新創(chuàng)建的線程的標(biāo)識符會作為函數(shù)值返回。這個調(diào)用非常像是 UNIX 中的 fork 系統(tǒng)調(diào)用(除了參數(shù)之外),其中線程標(biāo)識符起著 PID 的作用,這么做的目的是為了和其他線程進(jìn)行區(qū)分。

當(dāng)線程完成指派給他的工作后,會通過 pthread_exit 來終止。這個調(diào)用會停止線程并釋放堆棧。

一般一個線程在繼續(xù)運(yùn)行前需要等待另一個線程完成它的工作并退出??梢酝ㄟ^ pthread_join 線程調(diào)用來等待別的特定線程的終止。而要等待線程的線程標(biāo)識符作為一個參數(shù)給出。

有時會出現(xiàn)這種情況:一個線程邏輯上沒有阻塞,但感覺上它已經(jīng)運(yùn)行了足夠長的時間并且希望給另外一個線程機(jī)會去運(yùn)行。這時候可以通過 pthread_yield 來完成。

下面兩個線程調(diào)用是處理屬性的。pthread_attr_init 建立關(guān)聯(lián)一個線程的屬性結(jié)構(gòu)并初始化成默認(rèn)值,這些值(例如優(yōu)先級)可以通過修改屬性結(jié)構(gòu)的值來改變。

最后,pthread_attr_destroy 刪除一個線程的結(jié)構(gòu),釋放它占用的內(nèi)存。它不會影響調(diào)用它的線程,這些線程會一直存在。

為了更好的理解 pthread 是如何工作的,考慮下面這個例子

#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>

#define NUMBER_OF_THREADS 10

void *print_hello_world(vvoid *tid){
  /* 輸出線程的標(biāo)識符,然后退出 */
  printf(Hello World. Greetings from thread %d\n,tid);
  pthread_exit(NULL);
}

int main(int argc,char *argv[]){
  /* 主程序創(chuàng)建 10 個線程,然后退出 */
  pthread_t threads[NUMBER_OF_THREADS];
  int status,i;

  for(int i = 0;i < NUMBER_OF_THREADS;i++){
    printf(Main here. Creating thread %d\n,i);
    status = pthread_create(&threads[i], NULL, print_hello_world, (void *)i);

    if(status != 0){
      printf(Oops. pthread_create returned error code %d\n,status);
      exit(-1);
    }
  }
  exit(NULL);
}

主線程在宣布它的指責(zé)之后,循環(huán) NUMBER_OF_THREADS 次,每次創(chuàng)建一個新的線程。如果線程創(chuàng)建失敗,會打印出一條信息后退出。在創(chuàng)建完成所有的工作后,主程序退出。

線程實(shí)現(xiàn)

主要有三種實(shí)現(xiàn)方式

  • 在用戶空間中實(shí)現(xiàn)線程;

  • 在內(nèi)核空間中實(shí)現(xiàn)線程;

  • 在用戶和內(nèi)核空間中混合實(shí)現(xiàn)線程。

下面我們分開討論一下

在用戶空間中實(shí)現(xiàn)線程

第一種方法是把整個線程包放在用戶空間中,內(nèi)核對線程一無所知,它不知道線程的存在。所有的這類實(shí)現(xiàn)都有同樣的通用結(jié)構(gòu)

線程在運(yùn)行時系統(tǒng)之上運(yùn)行,運(yùn)行時系統(tǒng)是管理線程過程的集合,包括前面提到的四個過程:pthread_create, pthread_exit, pthread_join 和 pthread_yield。

運(yùn)行時系統(tǒng)(Runtime System) 也叫做運(yùn)行時環(huán)境,該運(yùn)行時系統(tǒng)提供了程序在其中運(yùn)行的環(huán)境。此環(huán)境可能會解決許多問題,包括應(yīng)用程序內(nèi)存的布局,程序如何訪問變量,在過程之間傳遞參數(shù)的機(jī)制,與操作系統(tǒng)的接口等等。編譯器根據(jù)特定的運(yùn)行時系統(tǒng)進(jìn)行假設(shè)以生成正確的代碼。通常,運(yùn)行時系統(tǒng)將負(fù)責(zé)設(shè)置和管理堆棧,并且會包含諸如垃圾收集,線程或語言內(nèi)置的其他動態(tài)的功能。

在用戶空間管理線程時,每個進(jìn)程需要有其專用的線程表(thread table),用來跟蹤該進(jìn)程中的線程。這些表和內(nèi)核中的進(jìn)程表類似,不過它僅僅記錄各個線程的屬性,如每個線程的程序計數(shù)器、堆棧指針、寄存器和狀態(tài)。該線程表由運(yùn)行時系統(tǒng)統(tǒng)一管理。當(dāng)一個線程轉(zhuǎn)換到就緒狀態(tài)或阻塞狀態(tài)時,在該線程表中存放重新啟動該線程的所有信息,與內(nèi)核在進(jìn)程表中存放的信息完全一樣。

在用戶空間實(shí)現(xiàn)線程的優(yōu)勢

在用戶空間中實(shí)現(xiàn)線程要比在內(nèi)核空間中實(shí)現(xiàn)線程具有這些方面的優(yōu)勢:考慮如果在線程完成時或者是在調(diào)用 pthread_yield 時,必要時會進(jìn)程線程切換,然后線程的信息會被保存在運(yùn)行時環(huán)境所提供的線程表中,然后,線程調(diào)度程序來選擇另外一個需要運(yùn)行的線程。保存線程的狀態(tài)和調(diào)度程序都是本地過程,所以啟動他們比進(jìn)行內(nèi)核調(diào)用效率更高。因而不需要切換到內(nèi)核,也就不需要上下文切換,也不需要對內(nèi)存高速緩存進(jìn)行刷新,因?yàn)榫€程調(diào)度非常便捷,因此效率比較高。

在用戶空間實(shí)現(xiàn)線程還有一個優(yōu)勢就是它允許每個進(jìn)程有自己定制的調(diào)度算法。例如在某些應(yīng)用程序中,那些具有垃圾收集線程的應(yīng)用程序(知道是誰了吧)就不用擔(dān)心自己線程會不會在不合適的時候停止,這是一個優(yōu)勢。用戶線程還具有較好的可擴(kuò)展性,因?yàn)閮?nèi)核空間中的內(nèi)核線程需要一些表空間和堆棧空間,如果內(nèi)核線程數(shù)量比較大,容易造成問題。

在用戶空間實(shí)現(xiàn)線程的劣勢

盡管在用戶空間實(shí)現(xiàn)線程會具有一定的性能優(yōu)勢,但是劣勢還是很明顯的,你如何實(shí)現(xiàn)阻塞系統(tǒng)調(diào)用呢?假設(shè)在還沒有任何鍵盤輸入之前,一個線程讀取鍵盤,讓線程進(jìn)行系統(tǒng)調(diào)用是不可能的,因?yàn)檫@會停止所有的線程。所以,使用線程的一個目標(biāo)是能夠讓線程進(jìn)行阻塞調(diào)用,并且要避免被阻塞的線程影響其他線程

與阻塞調(diào)用類似的問題是缺頁中斷問題,實(shí)際上,計算機(jī)并不會把所有的程序都一次性的放入內(nèi)存中,如果某個程序發(fā)生函數(shù)調(diào)用或者跳轉(zhuǎn)指令到了一條不在內(nèi)存的指令上,就會發(fā)生頁面故障,而操作系統(tǒng)將到磁盤上取回這個丟失的指令,這就稱為缺頁故障。而在對所需的指令進(jìn)行讀入和執(zhí)行時,相關(guān)的進(jìn)程就會被阻塞。如果只有一個線程引起頁面故障,內(nèi)核由于甚至不知道有線程存在,通常會把整個進(jìn)程阻塞直到磁盤 I/O 完成為止,盡管其他的線程是可以運(yùn)行的。

另外一個問題是,如果一個線程開始運(yùn)行,該線程所在進(jìn)程中的其他線程都不能運(yùn)行,除非第一個線程自愿的放棄 CPU,在一個單進(jìn)程內(nèi)部,沒有時鐘中斷,所以不可能使用輪轉(zhuǎn)調(diào)度的方式調(diào)度線程。除非其他線程能夠以自己的意愿進(jìn)入運(yùn)行時環(huán)境,否則調(diào)度程序沒有可以調(diào)度線程的機(jī)會。

在內(nèi)核中實(shí)現(xiàn)線程

現(xiàn)在我們考慮使用內(nèi)核來實(shí)現(xiàn)線程的情況,此時不再需要運(yùn)行時環(huán)境了。另外,每個進(jìn)程中也沒有線程表。相反,在內(nèi)核中會有用來記錄系統(tǒng)中所有線程的線程表。當(dāng)某個線程希望創(chuàng)建一個新線程或撤銷一個已有線程時,它會進(jìn)行一個系統(tǒng)調(diào)用,這個系統(tǒng)調(diào)用通過對線程表的更新來完成線程創(chuàng)建或銷毀工作。

內(nèi)核中的線程表持有每個線程的寄存器、狀態(tài)和其他信息。這些信息和用戶空間中的線程信息相同,但是位置卻被放在了內(nèi)核中而不是用戶空間中。另外,內(nèi)核還維護(hù)了一張進(jìn)程表用來跟蹤系統(tǒng)狀態(tài)。

所有能夠阻塞的調(diào)用都會通過系統(tǒng)調(diào)用的方式來實(shí)現(xiàn),當(dāng)一個線程阻塞時,內(nèi)核可以進(jìn)行選擇,是運(yùn)行在同一個進(jìn)程中的另一個線程(如果有就緒線程的話)還是運(yùn)行一個另一個進(jìn)程中的線程。但是在用戶實(shí)現(xiàn)中,運(yùn)行時系統(tǒng)始終運(yùn)行自己的線程,直到內(nèi)核剝奪它的 CPU 時間片(或者沒有可運(yùn)行的線程存在了)為止。

由于在內(nèi)核中創(chuàng)建或者銷毀線程的開銷比較大,所以某些系統(tǒng)會采用可循環(huán)利用的方式來回收線程。當(dāng)某個線程被銷毀時,就把它標(biāo)志為不可運(yùn)行的狀態(tài),但是其內(nèi)部結(jié)構(gòu)沒有受到影響。稍后,在必須創(chuàng)建一個新線程時,就會重新啟用舊線程,把它標(biāo)志為可用狀態(tài)。

如果某個進(jìn)程中的線程造成缺頁故障后,內(nèi)核很容易的就能檢查出來是否有其他可運(yùn)行的線程,如果有的話,在等待所需要的頁面從磁盤讀入時,就選擇一個可運(yùn)行的線程運(yùn)行。這樣做的缺點(diǎn)是系統(tǒng)調(diào)用的代價比較大,所以如果線程的操作(創(chuàng)建、終止)比較多,就會帶來很大的開銷。

混合實(shí)現(xiàn)

結(jié)合用戶空間和內(nèi)核空間的優(yōu)點(diǎn),設(shè)計人員采用了一種內(nèi)核級線程的方式,然后將用戶級線程與某些或者全部內(nèi)核線程多路復(fù)用起來

在這種模型中,編程人員可以自由控制用戶線程和內(nèi)核線程的數(shù)量,具有很大的靈活度。采用這種方法,內(nèi)核只識別內(nèi)核級線程,并對其進(jìn)行調(diào)度。其中一些內(nèi)核級線程會被多個用戶級線程多路復(fù)用。

進(jìn)程間通信

進(jìn)程是需要頻繁的和其他進(jìn)程進(jìn)行交流的。例如,在一個 shell 管道中,第一個進(jìn)程的輸出必須傳遞給第二個進(jìn)程,這樣沿著管道進(jìn)行下去。因此,進(jìn)程之間如果需要通信的話,必須要使用一種良好的數(shù)據(jù)結(jié)構(gòu)以至于不能被中斷。下面我們會一起討論有關(guān) 進(jìn)程間通信(Inter Process Communication, IPC) 的問題。

關(guān)于進(jìn)程間的通信,這里有三個問題

  • 上面提到了第一個問題,那就是一個進(jìn)程如何傳遞消息給其他進(jìn)程。

  • 第二個問題是如何確保兩個或多個線程之間不會相互干擾。例如,兩個航空公司都試圖為不同的顧客搶購飛機(jī)上的最后一個座位。

  • 第三個問題是數(shù)據(jù)的先后順序的問題,如果進(jìn)程 A 產(chǎn)生數(shù)據(jù)并且進(jìn)程 B 打印數(shù)據(jù)。則進(jìn)程 B 打印數(shù)據(jù)之前需要先等 A 產(chǎn)生數(shù)據(jù)后才能夠進(jìn)行打印。

需要注意的是,這三個問題中的后面兩個問題同樣也適用于線程

第一個問題在線程間比較好解決,因?yàn)樗鼈児蚕硪粋€地址空間,它們具有相同的運(yùn)行時環(huán)境,可以想象你在用高級語言編寫多線程代碼的過程中,線程通信問題是不是比較容易解決?

另外兩個問題也同樣適用于線程,同樣的問題可用同樣的方法來解決。我們后面會慢慢討論這三個問題,你現(xiàn)在腦子中大致有個印象即可。

競態(tài)條件

在一些操作系統(tǒng)中,協(xié)作的進(jìn)程可能共享一些彼此都能讀寫的公共資源。公共資源可能在內(nèi)存中也可能在一個共享文件。為了講清楚進(jìn)程間是如何通信的,這里我們舉一個例子:一個后臺打印程序。當(dāng)一個進(jìn)程需要打印某個文件時,它會將文件名放在一個特殊的后臺目錄(spooler directory)中。另一個進(jìn)程 打印后臺進(jìn)程(printer daemon) 會定期的檢查是否需要文件被打印,如果有的話,就打印并將該文件名從目錄下刪除。

假設(shè)我們的后臺目錄有非常多的 槽位(slot),編號依次為 0,1,2,…,每個槽位存放一個文件名。同時假設(shè)有兩個共享變量:out,指向下一個需要打印的文件;in,指向目錄中下個空閑的槽位??梢园堰@兩個文件保存在一個所有進(jìn)程都能訪問的文件中,該文件的長度為兩個字。在某一時刻,0 至 3 號槽位空,4 號至 6 號槽位被占用。在同一時刻,進(jìn)程 A 和 進(jìn)程 B 都決定將一個文件排隊(duì)打印,情況如下

墨菲法則(Murphy) 中說過,任何可能出錯的地方終將出錯,這句話生效時,可能發(fā)生如下情況。

進(jìn)程 A 讀到 in 的值為 7,將 7 存在一個局部變量 next_free_slot 中。此時發(fā)生一次時鐘中斷,CPU 認(rèn)為進(jìn)程 A 已經(jīng)運(yùn)行了足夠長的時間,決定切換到進(jìn)程 B 。進(jìn)程 B 也讀取 in 的值,發(fā)現(xiàn)是 7,然后進(jìn)程 B 將 7 寫入到自己的局部變量 next_free_slot 中,在這一時刻兩個進(jìn)程都認(rèn)為下一個可用槽位是 7 。

進(jìn)程 B 現(xiàn)在繼續(xù)運(yùn)行,它會將打印文件名寫入到 slot 7 中,然后把 in 的指針更改為 8 ,然后進(jìn)程 B 離開去做其他的事情

現(xiàn)在進(jìn)程 A 開始恢復(fù)運(yùn)行,由于進(jìn)程 A 通過檢查 next_free_slot也發(fā)現(xiàn) slot 7 的槽位是空的,于是將打印文件名存入 slot 7 中,然后把 in 的值更新為 8 ,由于 slot 7 這個槽位中已經(jīng)有進(jìn)程 B 寫入的值,所以進(jìn)程 A 的打印文件名會把進(jìn)程 B 的文件覆蓋,由于打印機(jī)內(nèi)部是無法發(fā)現(xiàn)是哪個進(jìn)程更新的,它的功能比較局限,所以這時候進(jìn)程 B 永遠(yuǎn)無法打印輸出,類似這種情況,即兩個或多個線程同時對一共享數(shù)據(jù)進(jìn)行修改,從而影響程序運(yùn)行的正確性時,這種就被稱為競態(tài)條件(race condition)。調(diào)試競態(tài)條件是一種非常困難的工作,因?yàn)榻^大多數(shù)情況下程序運(yùn)行良好,但在極少數(shù)的情況下會發(fā)生一些無法解釋的奇怪現(xiàn)象。

臨界區(qū)

不僅共享資源會造成競態(tài)條件,事實(shí)上共享文件、共享內(nèi)存也會造成競態(tài)條件、那么該如何避免呢?或許一句話可以概括說明:禁止一個或多個進(jìn)程在同一時刻對共享資源(包括共享內(nèi)存、共享文件等)進(jìn)行讀寫。換句話說,我們需要一種 互斥(mutual exclusion) 條件,這也就是說,如果一個進(jìn)程在某種方式下使用共享變量和文件的話,除該進(jìn)程之外的其他進(jìn)程就禁止做這種事(訪問統(tǒng)一資源)。上面問題的糾結(jié)點(diǎn)在于,在進(jìn)程 A 對共享變量的使用未結(jié)束之前進(jìn)程 B 就使用它。在任何操作系統(tǒng)中,為了實(shí)現(xiàn)互斥操作而選用適當(dāng)?shù)脑Z是一個主要的設(shè)計問題,接下來我們會著重探討一下。

避免競爭問題的條件可以用一種抽象的方式去描述。大部分時間,進(jìn)程都會忙于內(nèi)部計算和其他不會導(dǎo)致競爭條件的計算。然而,有時候進(jìn)程會訪問共享內(nèi)存或文件,或者做一些能夠?qū)е赂倯B(tài)條件的操作。我們把對共享內(nèi)存進(jìn)行訪問的程序片段稱作 臨界區(qū)域(critical region) 或 臨界區(qū)(critical section)。如果我們能夠正確的操作,使兩個不同進(jìn)程不可能同時處于臨界區(qū),就能避免競爭條件,這也是從操作系統(tǒng)設(shè)計角度來進(jìn)行的。

盡管上面這種設(shè)計避免了競爭條件,但是不能確保并發(fā)線程同時訪問共享數(shù)據(jù)的正確性和高效性。一個好的解決方案,應(yīng)該包含下面四種條件

  1. 任何時候兩個進(jìn)程不能同時處于臨界區(qū)

  2. 不應(yīng)對 CPU 的速度和數(shù)量做任何假設(shè)

  3. 位于臨界區(qū)外的進(jìn)程不得阻塞其他進(jìn)程

  4. 不能使任何進(jìn)程無限等待進(jìn)入臨界區(qū)

從抽象的角度來看,我們通常希望進(jìn)程的行為如上圖所示,在 t1 時刻,進(jìn)程 A 進(jìn)入臨界區(qū),在 t2 的時刻,進(jìn)程 B 嘗試進(jìn)入臨界區(qū),因?yàn)榇藭r進(jìn)程 A 正在處于臨界區(qū)中,所以進(jìn)程 B 會阻塞直到 t3 時刻進(jìn)程 A 離開臨界區(qū),此時進(jìn)程 B 能夠允許進(jìn)入臨界區(qū)。最后,在 t4 時刻,進(jìn)程 B 離開臨界區(qū),系統(tǒng)恢復(fù)到?jīng)]有進(jìn)程的原始狀態(tài)。

忙等互斥

下面我們會繼續(xù)探討實(shí)現(xiàn)互斥的各種設(shè)計,在這些方案中,當(dāng)一個進(jìn)程正忙于更新其關(guān)鍵區(qū)域的共享內(nèi)存時,沒有其他進(jìn)程會進(jìn)入其關(guān)鍵區(qū)域,也不會造成影響。

屏蔽中斷

在單處理器系統(tǒng)上,最簡單的解決方案是讓每個進(jìn)程在進(jìn)入臨界區(qū)后立即屏蔽所有中斷,并在離開臨界區(qū)之前重新啟用它們。屏蔽中斷后,時鐘中斷也會被屏蔽。CPU 只有發(fā)生時鐘中斷或其他中斷時才會進(jìn)行進(jìn)程切換。這樣,在屏蔽中斷后 CPU 不會切換到其他進(jìn)程。所以,一旦某個進(jìn)程屏蔽中斷之后,它就可以檢查和修改共享內(nèi)存,而不用擔(dān)心其他進(jìn)程介入訪問共享數(shù)據(jù)。

這個方案可行嗎?進(jìn)程進(jìn)入臨界區(qū)域是由誰決定的呢?不是用戶進(jìn)程嗎?當(dāng)進(jìn)程進(jìn)入臨界區(qū)域后,用戶進(jìn)程關(guān)閉中斷,如果經(jīng)過一段較長時間后進(jìn)程沒有離開,那么中斷不就一直啟用不了,結(jié)果會如何?可能會造成整個系統(tǒng)的終止。而且如果是多處理器的話,屏蔽中斷僅僅對執(zhí)行 disable 指令的 CPU 有效。其他 CPU 仍將繼續(xù)運(yùn)行,并可以訪問共享內(nèi)存。

另一方面,對內(nèi)核來說,當(dāng)它在執(zhí)行更新變量或列表的幾條指令期間將中斷屏蔽是很方便的。例如,如果多個進(jìn)程處理就緒列表中的時候發(fā)生中斷,則可能會發(fā)生競態(tài)條件的出現(xiàn)。所以,屏蔽中斷對于操作系統(tǒng)本身來說是一項(xiàng)很有用的技術(shù),但是對于用戶線程來說,屏蔽中斷卻不是一項(xiàng)通用的互斥機(jī)制。

鎖變量

作為第二種嘗試,可以尋找一種軟件層面解決方案。考慮有單個共享的(鎖)變量,初始為值為 0 。當(dāng)一個線程想要進(jìn)入關(guān)鍵區(qū)域時,它首先會查看鎖的值是否為 0 ,如果鎖的值是 0 ,進(jìn)程會把它設(shè)置為 1 并讓進(jìn)程進(jìn)入關(guān)鍵區(qū)域。如果鎖的狀態(tài)是 1,進(jìn)程會等待直到鎖變量的值變?yōu)?0 。因此,鎖變量的值是 0 則意味著沒有線程進(jìn)入關(guān)鍵區(qū)域。如果是 1 則意味著有進(jìn)程在關(guān)鍵區(qū)域內(nèi)。我們對上圖修改后,如下所示

這種設(shè)計方式是否正確呢?是否存在紕漏呢?假設(shè)一個進(jìn)程讀出鎖變量的值并發(fā)現(xiàn)它為 0 ,而恰好在它將其設(shè)置為 1 之前,另一個進(jìn)程調(diào)度運(yùn)行,讀出鎖的變量為0 ,并將鎖的變量設(shè)置為 1 。然后第一個線程運(yùn)行,把鎖變量的值再次設(shè)置為 1,此時,臨界區(qū)域就會有兩個進(jìn)程在同時運(yùn)行。

也許有的讀者可以這么認(rèn)為,在進(jìn)入前檢查一次,在要離開的關(guān)鍵區(qū)域再檢查一次不就解決了嗎?實(shí)際上這種情況也是于事無補(bǔ),因?yàn)樵诘诙螜z查期間其他線程仍有可能修改鎖變量的值,換句話說,這種 set-before-check 不是一種 原子性 操作,所以同樣還會發(fā)生競爭條件。

嚴(yán)格輪詢法

第三種互斥的方式先拋出來一段代碼,這里的程序是用 C 語言編寫,之所以采用 C 是因?yàn)椴僮飨到y(tǒng)普遍是用 C 來編寫的(偶爾會用 C++),而基本不會使用 Java 、Modula3 或 Pascal 這樣的語言,Java 中的 native 關(guān)鍵字底層也是 C 或 C++ 編寫的源碼。對于編寫操作系統(tǒng)而言,需要使用 C 語言這種強(qiáng)大、高效、可預(yù)知和有特性的語言,而對于 Java ,它是不可預(yù)知的,因?yàn)樗陉P(guān)鍵時刻會用完存儲器,而在不合適的時候會調(diào)用垃圾回收機(jī)制回收內(nèi)存。在 C 語言中,這種情況不會發(fā)生,C 語言中不會主動調(diào)用垃圾回收回收內(nèi)存。有關(guān) C 、C++ 、Java 和其他四種語言的比較可以參考 鏈接

進(jìn)程 0 的代碼

while(TRUE){
  while(turn != 0){
    /* 進(jìn)入關(guān)鍵區(qū)域 */
    critical_region();
    turn = 1;
    /* 離開關(guān)鍵區(qū)域 */
    noncritical_region();
  }
}

進(jìn)程 1 的代碼

while(TRUE){
  while(turn != 1){
    critical_region();
    turn = 0;
    noncritical_region();
  }
}

在上面代碼中,變量 turn,初始值為 0 ,用于記錄輪到那個進(jìn)程進(jìn)入臨界區(qū),并檢查或更新共享內(nèi)存。開始時,進(jìn)程 0 檢查 turn,發(fā)現(xiàn)其值為 0 ,于是進(jìn)入臨界區(qū)。進(jìn)程 1 也發(fā)現(xiàn)其值為 0 ,所以在一個等待循環(huán)中不停的測試 turn,看其值何時變?yōu)?1。連續(xù)檢查一個變量直到某個值出現(xiàn)為止,這種方法稱為 忙等待(busywaiting)。由于這種方式浪費(fèi) CPU 時間,所以這種方式通常應(yīng)該要避免。只有在有理由認(rèn)為等待時間是非常短的情況下,才能夠使用忙等待。用于忙等待的鎖,稱為 自旋鎖(spinlock)

進(jìn)程 0 離開臨界區(qū)時,它將 turn 的值設(shè)置為 1,以便允許進(jìn)程 1 進(jìn)入其臨界區(qū)。假設(shè)進(jìn)程 1 很快便離開了臨界區(qū),則此時兩個進(jìn)程都處于臨界區(qū)之外,turn 的值又被設(shè)置為 0 ?,F(xiàn)在進(jìn)程 0 很快就執(zhí)行完了整個循環(huán),它退出臨界區(qū),并將 turn 的值設(shè)置為 1。此時,turn 的值為 1,兩個進(jìn)程都在其臨界區(qū)外執(zhí)行。

突然,進(jìn)程 0 結(jié)束了非臨界區(qū)的操作并返回到循環(huán)的開始。但是,這時它不能進(jìn)入臨界區(qū),因?yàn)?turn 的當(dāng)前值為 1,此時進(jìn)程 1 還忙于非臨界區(qū)的操作,進(jìn)程 0 只能繼續(xù) while 循環(huán),直到進(jìn)程 1 把 turn 的值改為 0 。這說明,在一個進(jìn)程比另一個進(jìn)程執(zhí)行速度慢了很多的情況下,輪流進(jìn)入臨界區(qū)并不是一個好的方法。

這種情況違反了前面的敘述 3 ,即 位于臨界區(qū)外的進(jìn)程不得阻塞其他進(jìn)程,進(jìn)程 0 被一個臨界區(qū)外的進(jìn)程阻塞。由于違反了第三條,所以也不能作為一個好的方案。

Peterson 解法

荷蘭數(shù)學(xué)家 T.Dekker 通過將鎖變量與警告變量相結(jié)合,最早提出了一個不需要嚴(yán)格輪換的軟件互斥算法,關(guān)于 Dekker 的算法,參考 鏈接

后來, G.L.Peterson 發(fā)現(xiàn)了一種簡單很多的互斥算法,它的算法如下

#define FALSE 0
#define TRUE  1
/* 進(jìn)程數(shù)量 */
#define N     2                                                    

/* 現(xiàn)在輪到誰 */
int turn;                    

/* 所有值初始化為 0 (FALSE) */
int interested[N];                                            

/* 進(jìn)程是 0 或 1 */
void enter_region(int process){                    

  /* 另一個進(jìn)程號 */
  int other;                                                        

  /* 另一個進(jìn)程 */
  other = 1 - process;                

  /* 表示愿意進(jìn)入臨界區(qū) */
  interested[process] = TRUE;                        
  turn = process;

  /* 空循環(huán) */
  while(turn == process 
        && interested[other] == true){} 

}

void leave_region(int process){

  /* 表示離開臨界區(qū) */
  interested[process] == FALSE;                 
}

在使用共享變量時(即進(jìn)入其臨界區(qū))之前,各個進(jìn)程使用各自的進(jìn)程號 0 或 1 作為參數(shù)來調(diào)用 enter_region,這個函數(shù)調(diào)用在需要時將使進(jìn)程等待,直到能夠安全的臨界區(qū)。在完成對共享變量的操作之后,進(jìn)程將調(diào)用 leave_region 表示操作完成,并且允許其他進(jìn)程進(jìn)入。

現(xiàn)在來看看這個辦法是如何工作的。一開始,沒有任何進(jìn)程處于臨界區(qū)中,現(xiàn)在進(jìn)程 0 調(diào)用 enter_region。它通過設(shè)置數(shù)組元素和將 turn 置為 0 來表示它希望進(jìn)入臨界區(qū)。由于進(jìn)程 1 并不想進(jìn)入臨界區(qū),所以 enter_region 很快便返回。如果進(jìn)程現(xiàn)在調(diào)用 enter_region,進(jìn)程 1 將在此處掛起直到 interested[0] 變?yōu)?FALSE,這種情況只有在進(jìn)程 0 調(diào)用 leave_region 退出臨界區(qū)時才會發(fā)生。

那么上面討論的是順序進(jìn)入的情況,現(xiàn)在來考慮一種兩個進(jìn)程同時調(diào)用 enter_region 的情況。它們都將自己的進(jìn)程存入 turn,但只有最后保存進(jìn)去的進(jìn)程號才有效,前一個進(jìn)程的進(jìn)程號因?yàn)橹貙懚鴣G失。假如進(jìn)程 1 是最后存入的,則 turn 為 1 。當(dāng)兩個進(jìn)程都運(yùn)行到 while 的時候,進(jìn)程 0 將不會循環(huán)并進(jìn)入臨界區(qū),而進(jìn)程 1 將會無限循環(huán)且不會進(jìn)入臨界區(qū),直到進(jìn)程 0 退出位置。

TSL 指令

現(xiàn)在來看一種需要硬件幫助的方案。一些計算機(jī),特別是那些設(shè)計為多處理器的計算機(jī),都會有下面這條指令

TSL RX,LOCK    

稱為 測試并加鎖(test and set lock),它將一個內(nèi)存字 lock 讀到寄存器 RX 中,然后在該內(nèi)存地址上存儲一個非零值。讀寫指令能保證是一體的,不可分割的,一同執(zhí)行的。在這個指令結(jié)束之前其他處理器均不允許訪問內(nèi)存。執(zhí)行 TSL 指令的 CPU 將會鎖住內(nèi)存總線,用來禁止其他 CPU 在這個指令結(jié)束之前訪問內(nèi)存。

很重要的一點(diǎn)是鎖住內(nèi)存總線和禁用中斷不一樣。禁用中斷并不能保證一個處理器在讀寫操作之間另一個處理器對內(nèi)存的讀寫。也就是說,在處理器 1 上屏蔽中斷對處理器 2 沒有影響。讓處理器 2 遠(yuǎn)離內(nèi)存直到處理器 1 完成讀寫的最好的方式就是鎖住總線。這需要一個特殊的硬件(基本上,一根總線就可以確??偩€由鎖住它的處理器使用,而其他的處理器不能使用)

為了使用 TSL 指令,要使用一個共享變量 lock 來協(xié)調(diào)對共享內(nèi)存的訪問。當(dāng) lock 為 0 時,任何進(jìn)程都可以使用 TSL 指令將其設(shè)置為 1,并讀寫共享內(nèi)存。當(dāng)操作結(jié)束時,進(jìn)程使用 move 指令將 lock 的值重新設(shè)置為 0 。

這條指令如何防止兩個進(jìn)程同時進(jìn)入臨界區(qū)呢?下面是解決方案

enter_region:
            | 復(fù)制鎖到寄存器并將鎖設(shè)為1
            TSL REGISTER,LOCK              
            | 鎖是 0 嗎?
          CMP REGISTER,#0                             
          | 若不是零,說明鎖已被設(shè)置,所以循環(huán)
          JNE enter_region                            
          | 返回調(diào)用者,進(jìn)入臨界區(qū)
          RET                                              

leave_region:

            | 在鎖中存入 0
            MOVE LOCK,#0                  
      | 返回調(diào)用者
          RET                                              

我們可以看到這個解決方案的思想和 Peterson 的思想很相似。假設(shè)存在如下共 4 指令的匯編語言程序。第一條指令將 lock 原來的值復(fù)制到寄存器中并將 lock 設(shè)置為 1 ,隨后這個原來的值和 0 做對比。如果它不是零,說明之前已經(jīng)被加過鎖,則程序返回到開始并再次測試。經(jīng)過一段時間后(可長可短),該值變?yōu)?0 (當(dāng)前處于臨界區(qū)中的進(jìn)程退出臨界區(qū)時),于是過程返回,此時已加鎖。要清除這個鎖也比較簡單,程序只需要將 0 存入 lock 即可,不需要特殊的同步指令。

現(xiàn)在有了一種很明確的做法,那就是進(jìn)程在進(jìn)入臨界區(qū)之前會先調(diào)用 enter_region,判斷是否進(jìn)行循環(huán),如果lock 的值是 1 ,進(jìn)行無限循環(huán),如果 lock 是 0,不進(jìn)入循環(huán)并進(jìn)入臨界區(qū)。在進(jìn)程從臨界區(qū)返回時它調(diào)用 leave_region,這會把 lock 設(shè)置為 0 。與基于臨界區(qū)問題的所有解法一樣,進(jìn)程必須在正確的時間調(diào)用 enter_region 和 leave_region ,解法才能奏效。

還有一個可以替換 TSL 的指令是 XCHG,它原子性的交換了兩個位置的內(nèi)容,例如,一個寄存器與一個內(nèi)存字,代碼如下

enter_region:
        | 把 1 放在內(nèi)存器中
        MOVE REGISTER,#1    
    | 交換寄存器和鎖變量的內(nèi)容
        XCHG REGISTER,LOCK          
    | 鎖是 0 嗎?
        CMP REGISTER,#0     
    | 若不是 0 ,鎖已被設(shè)置,進(jìn)行循環(huán)
        JNE enter_region                    
    | 返回調(diào)用者,進(jìn)入臨界區(qū)
        RET                                                     

leave_region:                
        | 在鎖中存入 0 
        MOVE LOCK,#0    
    | 返回調(diào)用者
        RET                                                     

XCHG 的本質(zhì)上與 TSL 的解決辦法一樣。所有的 Intel x86 CPU 在底層同步中使用 XCHG 指令。

睡眠與喚醒

上面解法中的 Peterson 、TSL 和 XCHG 解法都是正確的,但是它們都有忙等待的缺點(diǎn)。這些解法的本質(zhì)上都是一樣的,先檢查是否能夠進(jìn)入臨界區(qū),若不允許,則該進(jìn)程將原地等待,直到允許為止。

這種方式不但浪費(fèi)了 CPU 時間,而且還可能引起意想不到的結(jié)果。考慮一臺計算機(jī)上有兩個進(jìn)程,這兩個進(jìn)程具有不同的優(yōu)先級,H 是屬于優(yōu)先級比較高的進(jìn)程,L 是屬于優(yōu)先級比較低的進(jìn)程。進(jìn)程調(diào)度的規(guī)則是不論何時只要 H 進(jìn)程處于就緒態(tài) H 就開始運(yùn)行。在某一時刻,L 處于臨界區(qū)中,此時 H 變?yōu)榫途w態(tài),準(zhǔn)備運(yùn)行(例如,一條 I/O 操作結(jié)束)?,F(xiàn)在 H 要開始忙等,但由于當(dāng) H 就緒時 L 就不會被調(diào)度,L 從來不會有機(jī)會離開關(guān)鍵區(qū)域,所以 H 會變成死循環(huán),有時將這種情況稱為優(yōu)先級反轉(zhuǎn)問題(priority inversion problem)

現(xiàn)在讓我們看一下進(jìn)程間的通信原語,這些原語在不允許它們進(jìn)入關(guān)鍵區(qū)域之前會阻塞而不是浪費(fèi) CPU 時間,最簡單的是 sleep 和 wakeup。Sleep 是一個能夠造成調(diào)用者阻塞的系統(tǒng)調(diào)用,也就是說,這個系統(tǒng)調(diào)用會暫停直到其他進(jìn)程喚醒它。wakeup 調(diào)用有一個參數(shù),即要喚醒的進(jìn)程。還有一種方式是 wakeup 和 sleep 都有一個參數(shù),即 sleep 和 wakeup 需要匹配的內(nèi)存地址。

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

作為這些私有原語的例子,讓我們考慮生產(chǎn)者-消費(fèi)者(producer-consumer) 問題,也稱作 有界緩沖區(qū)(bounded-buffer) 問題。兩個進(jìn)程共享一個公共的固定大小的緩沖區(qū)。其中一個是生產(chǎn)者(producer),將信息放入緩沖區(qū), 另一個是消費(fèi)者(consumer),會從緩沖區(qū)中取出。也可以把這個問題一般化為 m 個生產(chǎn)者和 n 個消費(fèi)者的問題,但是我們這里只討論一個生產(chǎn)者和一個消費(fèi)者的情況,這樣可以簡化實(shí)現(xiàn)方案。

如果緩沖隊(duì)列已滿,那么當(dāng)生產(chǎn)者仍想要將數(shù)據(jù)寫入緩沖區(qū)的時候,會出現(xiàn)問題。它的解決辦法是讓生產(chǎn)者睡眠,也就是阻塞生產(chǎn)者。等到消費(fèi)者從緩沖區(qū)中取出一個或多個數(shù)據(jù)項(xiàng)時再喚醒它。同樣的,當(dāng)消費(fèi)者試圖從緩沖區(qū)中取數(shù)據(jù),但是發(fā)現(xiàn)緩沖區(qū)為空時,消費(fèi)者也會睡眠,阻塞。直到生產(chǎn)者向其中放入一個新的數(shù)據(jù)。

這個邏輯聽起來比較簡單,而且這種方式也需要一種稱作 監(jiān)聽 的變量,這個變量用于監(jiān)視緩沖區(qū)的數(shù)據(jù),我們暫定為 count,如果緩沖區(qū)最多存放 N 個數(shù)據(jù)項(xiàng),生產(chǎn)者會每次判斷 count 是否達(dá)到 N,否則生產(chǎn)者向緩沖區(qū)放入一個數(shù)據(jù)項(xiàng)并增量 count 的值。消費(fèi)者的邏輯也很相似:首先測試 count 的值是否為 0 ,如果為 0 則消費(fèi)者睡眠、阻塞,否則會從緩沖區(qū)取出數(shù)據(jù)并使 count 數(shù)量遞減。每個進(jìn)程也會檢查檢查是否其他線程是否應(yīng)該被喚醒,如果應(yīng)該被喚醒,那么就喚醒該線程。下面是生產(chǎn)者消費(fèi)者的代碼

/* 緩沖區(qū) slot 槽的數(shù)量 */
#define N 100                        
/* 緩沖區(qū)數(shù)據(jù)的數(shù)量 */
int count = 0                                        

// 生產(chǎn)者
void producer(void){
  int item;

  /* 無限循環(huán) */
  while(TRUE){                
    /* 生成下一項(xiàng)數(shù)據(jù) */
    item = produce_item()                
    /* 如果緩存區(qū)是滿的,就會阻塞 */
    if(count == N){
      sleep();                                    
    }

    /* 把當(dāng)前數(shù)據(jù)放在緩沖區(qū)中 */
    insert_item(item);
    /* 增加緩沖區(qū) count 的數(shù)量 */
    count = count + 1;                    
    if(count == 1){
      /* 緩沖區(qū)是否為空? */
      wakeup(consumer);                    
    }
  }
}

// 消費(fèi)者
void consumer(void){

  int item;

  /* 無限循環(huán) */
  while(TRUE){
    /* 如果緩沖區(qū)是空的,就會進(jìn)行阻塞 */
      if(count == 0){                         
      sleep();
    }
    /* 從緩沖區(qū)中取出一個數(shù)據(jù) */
       item = remove_item();           
    /* 將緩沖區(qū)的 count 數(shù)量減一 */
    count = count - 1
    /* 緩沖區(qū)滿嘛? */
    if(count == N - 1){                    
      wakeup(producer);        
    }
    /* 打印數(shù)據(jù)項(xiàng) */
    consumer_item(item);                
  }

}

為了在 C 語言中描述像是 sleep 和 wakeup 的系統(tǒng)調(diào)用,我們將以庫函數(shù)調(diào)用的形式來表示。它們不是 C 標(biāo)準(zhǔn)庫的一部分,但可以在實(shí)際具有這些系統(tǒng)調(diào)用的任何系統(tǒng)上使用。代碼中未實(shí)現(xiàn)的 insert_item 和 remove_item 用來記錄將數(shù)據(jù)項(xiàng)放入緩沖區(qū)和從緩沖區(qū)取出數(shù)據(jù)等。

現(xiàn)在讓我們回到生產(chǎn)者-消費(fèi)者問題上來,上面代碼中會產(chǎn)生競爭條件,因?yàn)?count 這個變量是暴露在大眾視野下的。有可能出現(xiàn)下面這種情況:緩沖區(qū)為空,此時消費(fèi)者剛好讀取 count 的值發(fā)現(xiàn)它為 0 。此時調(diào)度程序決定暫停消費(fèi)者并啟動運(yùn)行生產(chǎn)者。生產(chǎn)者生產(chǎn)了一條數(shù)據(jù)并把它放在緩沖區(qū)中,然后增加 count 的值,并注意到它的值是 1 。由于 count 為 0,消費(fèi)者必須處于睡眠狀態(tài),因此生產(chǎn)者調(diào)用 wakeup 來喚醒消費(fèi)者。但是,消費(fèi)者此時在邏輯上并沒有睡眠,所以 wakeup 信號會丟失。當(dāng)消費(fèi)者下次啟動后,它會查看之前讀取的 count 值,發(fā)現(xiàn)它的值是 0 ,然后在此進(jìn)行睡眠。不久之后生產(chǎn)者會填滿整個緩沖區(qū),在這之后會阻塞,這樣一來兩個進(jìn)程將永遠(yuǎn)睡眠下去。

引起上面問題的本質(zhì)是 喚醒尚未進(jìn)行睡眠狀態(tài)的進(jìn)程會導(dǎo)致喚醒丟失。如果它沒有丟失,則一切都很正常。一種快速解決上面問題的方式是增加一個喚醒等待位(wakeup waiting bit)。當(dāng)一個 wakeup 信號發(fā)送給仍在清醒的進(jìn)程后,該位置為 1 。之后,當(dāng)進(jìn)程嘗試睡眠的時候,如果喚醒等待位為 1 ,則該位清除,而進(jìn)程仍然保持清醒。

然而,當(dāng)進(jìn)程數(shù)量有許多的時候,這時你可以說通過增加喚醒等待位的數(shù)量來喚醒等待位,于是就有了 2、4、6、8 個喚醒等待位,但是并沒有從根本上解決問題。

信號量

信號量是 E.W.Dijkstra 在 1965 年提出的一種方法,它使用一個整形變量來累計喚醒次數(shù),以供之后使用。在他的觀點(diǎn)中,有一個新的變量類型稱作 信號量(semaphore)。一個信號量的取值可以是 0 ,或任意正數(shù)。0 表示的是不需要任何喚醒,任意的正數(shù)表示的就是喚醒次數(shù)。

Dijkstra 提出了信號量有兩個操作,現(xiàn)在通常使用 down 和 up(分別可以用 sleep 和 wakeup 來表示)。down 這個指令的操作會檢查值是否大于 0 。如果大于 0 ,則將其值減 1 ;若該值為 0 ,則進(jìn)程將睡眠,而且此時 down 操作將會繼續(xù)執(zhí)行。檢查數(shù)值、修改變量值以及可能發(fā)生的睡眠操作均為一個單一的、不可分割的 原子操作(atomic action) 完成。這會保證一旦信號量操作開始,沒有其他的進(jìn)程能夠訪問信號量,直到操作完成或者阻塞。這種原子性對于解決同步問題和避免競爭絕對必不可少。

原子性操作指的是在計算機(jī)科學(xué)的許多其他領(lǐng)域中,一組相關(guān)操作全部執(zhí)行而沒有中斷或根本不執(zhí)行。

up 操作會使信號量的值 + 1。如果一個或者多個進(jìn)程在信號量上睡眠,無法完成一個先前的 down 操作,則由系統(tǒng)選擇其中一個并允許該程完成 down 操作。因此,對一個進(jìn)程在其上睡眠的信號量執(zhí)行一次 up 操作之后,該信號量的值仍然是 0 ,但在其上睡眠的進(jìn)程卻少了一個。信號量的值增 1 和喚醒一個進(jìn)程同樣也是不可分割的。不會有某個進(jìn)程因執(zhí)行 up 而阻塞,正如在前面的模型中不會有進(jìn)程因執(zhí)行 wakeup 而阻塞是一樣的道理。

用信號量解決生產(chǎn)者 - 消費(fèi)者問題

用信號量解決丟失的 wakeup 問題,代碼如下

/* 定義緩沖區(qū)槽的數(shù)量 */
#define N 100
/* 信號量是一種特殊的 int */
typedef int semaphore;
/* 控制關(guān)鍵區(qū)域的訪問 */
semaphore mutex = 1;
/* 統(tǒng)計 buffer 空槽的數(shù)量 */
semaphore empty = N;
/* 統(tǒng)計 buffer 滿槽的數(shù)量 */
semaphore full = 0;                                                

void producer(void){ 

  int item;  

  /* TRUE 的常量是 1 */
  while(TRUE){            
    /* 產(chǎn)生放在緩沖區(qū)的一些數(shù)據(jù) */
    item = producer_item();        
    /* 將空槽數(shù)量減 1  */
    down(&empty);    
    /* 進(jìn)入關(guān)鍵區(qū)域  */
    down(&mutex);    
    /* 把數(shù)據(jù)放入緩沖區(qū)中 */
    insert_item(item);
    /* 離開臨界區(qū) */
    up(&mutex);    
    /* 將 buffer 滿槽數(shù)量 + 1 */
    up(&full);                                                        
  }
}

void consumer(void){

  int item;

  /* 無限循環(huán) */
  while(TRUE){
    /* 緩存區(qū)滿槽數(shù)量 - 1 */
    down(&full);
    /* 進(jìn)入緩沖區(qū) */    
    down(&mutex);
    /* 從緩沖區(qū)取出數(shù)據(jù) */
    item = remove_item();    
    /* 離開臨界區(qū) */
    up(&mutex);    
    /* 將空槽數(shù)目 + 1 */
    up(&empty);    
    /* 處理數(shù)據(jù) */
    consume_item(item);                                            
  }

}

為了確保信號量能正確工作,最重要的是要采用一種不可分割的方式來實(shí)現(xiàn)它。通常是將 up 和 down 作為系統(tǒng)調(diào)用來實(shí)現(xiàn)。而且操作系統(tǒng)只需在執(zhí)行以下操作時暫時屏蔽全部中斷:檢查信號量、更新、必要時使進(jìn)程睡眠。由于這些操作僅需要非常少的指令,因此中斷不會造成影響。如果使用多個 CPU,那么信號量應(yīng)該被鎖進(jìn)行保護(hù)。使用 TSL 或者 XCHG 指令用來確保同一時刻只有一個 CPU 對信號量進(jìn)行操作。

使用 TSL 或者 XCHG 來防止幾個 CPU 同時訪問一個信號量,與生產(chǎn)者或消費(fèi)者使用忙等待來等待其他騰出或填充緩沖區(qū)是完全不一樣的。前者的操作僅需要幾個毫秒,而生產(chǎn)者或消費(fèi)者可能需要任意長的時間。

上面這個解決方案使用了三種信號量:一個稱為 full,用來記錄充滿的緩沖槽數(shù)目;一個稱為 empty,記錄空的緩沖槽數(shù)目;一個稱為 mutex,用來確保生產(chǎn)者和消費(fèi)者不會同時進(jìn)入緩沖區(qū)。Full 被初始化為 0 ,empty 初始化為緩沖區(qū)中插槽數(shù),mutex 初始化為 1。信號量初始化為 1 并且由兩個或多個進(jìn)程使用,以確保它們中同時只有一個可以進(jìn)入關(guān)鍵區(qū)域的信號被稱為 二進(jìn)制信號量(binary semaphores)。如果每個進(jìn)程都在進(jìn)入關(guān)鍵區(qū)域之前執(zhí)行 down 操作,而在離開關(guān)鍵區(qū)域之后執(zhí)行 up 操作,則可以確保相互互斥。

現(xiàn)在我們有了一個好的進(jìn)程間原語的保證。然后我們再來看一下中斷的順序保證

  1. 硬件壓入堆棧程序計數(shù)器等

  2. 硬件從中斷向量裝入新的程序計數(shù)器

  3. 匯編語言過程保存寄存器的值

  4. 匯編語言過程設(shè)置新的堆棧

  5. C 中斷服務(wù)器運(yùn)行(典型的讀和緩存寫入)

  6. 調(diào)度器決定下面哪個程序先運(yùn)行

  7. C 過程返回至匯編代碼

  8. 匯編語言過程開始運(yùn)行新的當(dāng)前進(jìn)程

在使用信號量的系統(tǒng)中,隱藏中斷的自然方法是讓每個 I/O 設(shè)備都配備一個信號量,該信號量最初設(shè)置為0。在 I/O 設(shè)備啟動后,中斷處理程序立刻對相關(guān)聯(lián)的信號執(zhí)行一個 down 操作,于是進(jìn)程立即被阻塞。當(dāng)中斷進(jìn)入時,中斷處理程序隨后對相關(guān)的信號量執(zhí)行一個 up操作,能夠使已經(jīng)阻止的進(jìn)程恢復(fù)運(yùn)行。在上面的中斷處理步驟中,其中的第 5 步 C 中斷服務(wù)器運(yùn)行 就是中斷處理程序在信號量上執(zhí)行的一個 up 操作,所以在第 6 步中,操作系統(tǒng)能夠執(zhí)行設(shè)備驅(qū)動程序。當(dāng)然,如果有幾個進(jìn)程已經(jīng)處于就緒狀態(tài),調(diào)度程序可能會選擇接下來運(yùn)行一個更重要的進(jìn)程,我們會在后面討論調(diào)度的算法。

上面的代碼實(shí)際上是通過兩種不同的方式來使用信號量的,而這兩種信號量之間的區(qū)別也是很重要的。mutex 信號量用于互斥。它用于確保任意時刻只有一個進(jìn)程能夠?qū)彌_區(qū)和相關(guān)變量進(jìn)行讀寫?;コ馐怯糜诒苊膺M(jìn)程混亂所必須的一種操作。

另外一個信號量是關(guān)于同步(synchronization)的。full 和 empty 信號量用于確保事件的發(fā)生或者不發(fā)生。在這個事例中,它們確保了緩沖區(qū)滿時生產(chǎn)者停止運(yùn)行;緩沖區(qū)為空時消費(fèi)者停止運(yùn)行。這兩個信號量的使用與 mutex 不同。

互斥量

如果不需要信號量的計數(shù)能力時,可以使用信號量的一個簡單版本,稱為 mutex(互斥量)?;コ饬康膬?yōu)勢就在于在一些共享資源和一段代碼中保持互斥。由于互斥的實(shí)現(xiàn)既簡單又有效,這使得互斥量在實(shí)現(xiàn)用戶空間線程包時非常有用。

互斥量是一個處于兩種狀態(tài)之一的共享變量:解鎖(unlocked) 和 加鎖(locked)。這樣,只需要一個二進(jìn)制位來表示它,不過一般情況下,通常會用一個 整形(integer) 來表示。0 表示解鎖,其他所有的值表示加鎖,比 1 大的值表示加鎖的次數(shù)。

mutex 使用兩個過程,當(dāng)一個線程(或者進(jìn)程)需要訪問關(guān)鍵區(qū)域時,會調(diào)用 mutex_lock 進(jìn)行加鎖。如果互斥鎖當(dāng)前處于解鎖狀態(tài)(表示關(guān)鍵區(qū)域可用),則調(diào)用成功,并且調(diào)用線程可以自由進(jìn)入關(guān)鍵區(qū)域。

另一方面,如果 mutex 互斥量已經(jīng)鎖定的話,調(diào)用線程會阻塞直到關(guān)鍵區(qū)域內(nèi)的線程執(zhí)行完畢并且調(diào)用了 mutex_unlock 。如果多個線程在 mutex 互斥量上阻塞,將隨機(jī)選擇一個線程并允許它獲得鎖。

由于 mutex 互斥量非常簡單,所以只要有 TSL 或者是 XCHG 指令,就可以很容易地在用戶空間實(shí)現(xiàn)它們。用于用戶級線程包的 mutex_lock 和 mutex_unlock 代碼如下,XCHG 的本質(zhì)也一樣。

mutex_lock:
            | 將互斥信號量復(fù)制到寄存器,并將互斥信號量置為1
            TSL REGISTER,MUTEX
      | 互斥信號量是 0 嗎?
            CMP REGISTER,#0 
      | 如果互斥信號量為0,它被解鎖,所以返回
            JZE ok  
      | 互斥信號正在使用;調(diào)度其他線程
            CALL thread_yield   
      | 再試一次
            JMP mutex_lock  
      | 返回調(diào)用者,進(jìn)入臨界區(qū)
ok:     RET                                                     

mutex_unlcok:
            | 將 mutex 置為 0 
            MOVE MUTEX,#0   
      | 返回調(diào)用者
            RET                                                     

mutex_lock 的代碼和上面 enter_region 的代碼很相似,我們可以對比著看一下

上面代碼最大的區(qū)別你看出來了嗎?

  • 根據(jù)上面我們對 TSL 的分析,我們知道,如果 TSL 判斷沒有進(jìn)入臨界區(qū)的進(jìn)程會進(jìn)行無限循環(huán)獲取鎖,而在 TSL 的處理中,如果 mutex 正在使用,那么就調(diào)度其他線程進(jìn)行處理。所以上面最大的區(qū)別其實(shí)就是在判斷 mutex/TSL 之后的處理。

  • 在(用戶)線程中,情況有所不同,因?yàn)闆]有時鐘來停止運(yùn)行時間過長的線程。結(jié)果是通過忙等待的方式來試圖獲得鎖的線程將永遠(yuǎn)循環(huán)下去,決不會得到鎖,因?yàn)檫@個運(yùn)行的線程不會讓其他線程運(yùn)行從而釋放鎖,其他線程根本沒有獲得鎖的機(jī)會。在后者獲取鎖失敗時,它會調(diào)用 thread_yield 將 CPU 放棄給另外一個線程。結(jié)果就不會進(jìn)行忙等待。在該線程下次運(yùn)行時,它再一次對鎖進(jìn)行測試。

上面就是 enter_region 和 mutex_lock 的差別所在。由于 thread_yield 僅僅是一個用戶空間的線程調(diào)度,所以它的運(yùn)行非常快捷。這樣,mutex_lock 和 mutex_unlock 都不需要任何內(nèi)核調(diào)用。通過使用這些過程,用戶線程完全可以實(shí)現(xiàn)在用戶空間中的同步,這個過程僅僅需要少量的同步。

我們上面描述的互斥量其實(shí)是一套調(diào)用框架中的指令。從軟件角度來說,總是需要更多的特性和同步原語。例如,有時線程包提供一個調(diào)用 mutex_trylock,這個調(diào)用嘗試獲取鎖或者返回錯誤碼,但是不會進(jìn)行加鎖操作。這就給了調(diào)用線程一個靈活性,以決定下一步做什么,是使用替代方法還是等候下去。

Futexes

隨著并行的增加,有效的同步(synchronization)鎖定(locking) 對于性能來說是非常重要的。如果進(jìn)程等待時間很短,那么自旋鎖(Spin lock) 是非常有效;但是如果等待時間比較長,那么這會浪費(fèi) CPU 周期。如果進(jìn)程很多,那么阻塞此進(jìn)程,并僅當(dāng)鎖被釋放的時候讓內(nèi)核解除阻塞是更有效的方式。不幸的是,這種方式也會導(dǎo)致另外的問題:它可以在進(jìn)程競爭頻繁的時候運(yùn)行良好,但是在競爭不是很激烈的情況下內(nèi)核切換的消耗會非常大,而且更困難的是,預(yù)測鎖的競爭數(shù)量更不容易。

有一種有趣的解決方案是把兩者的優(yōu)點(diǎn)結(jié)合起來,提出一種新的思想,稱為 futex,或者是 快速用戶空間互斥(fast user space mutex),是不是聽起來很有意思?

futex 是 Linux 中的特性實(shí)現(xiàn)了基本的鎖定(很像是互斥鎖)而且避免了陷入內(nèi)核中,因?yàn)閮?nèi)核的切換的開銷非常大,這樣做可以大大提高性能。futex 由兩部分組成:內(nèi)核服務(wù)和用戶庫。內(nèi)核服務(wù)提供了了一個 等待隊(duì)列(wait queue) 允許多個進(jìn)程在鎖上排隊(duì)等待。除非內(nèi)核明確的對他們解除阻塞,否則它們不會運(yùn)行。

對于一個進(jìn)程來說,把它放到等待隊(duì)列需要昂貴的系統(tǒng)調(diào)用,這種方式應(yīng)該被避免。在沒有競爭的情況下,futex 可以直接在用戶空間中工作。這些進(jìn)程共享一個 32 位整數(shù)(integer) 作為公共鎖變量。假設(shè)鎖的初始化為 1,我們認(rèn)為這時鎖已經(jīng)被釋放了。線程通過執(zhí)行原子性的操作減少并測試(decrement and test) 來搶占鎖。decrement and set 是 Linux 中的原子功能,由包裹在 C 函數(shù)中的內(nèi)聯(lián)匯編組成,并在頭文件中進(jìn)行定義。下一步,線程會檢查結(jié)果來查看鎖是否已經(jīng)被釋放。如果鎖現(xiàn)在不是鎖定狀態(tài),那么剛好我們的線程可以成功搶占該鎖。然而,如果鎖被其他線程持有,搶占鎖的線程不得不等待。在這種情況下,futex 庫不會自旋,但是會使用一個系統(tǒng)調(diào)用來把線程放在內(nèi)核中的等待隊(duì)列中。這樣一來,切換到內(nèi)核的開銷已經(jīng)是合情合理的了,因?yàn)榫€程可以在任何時候阻塞。當(dāng)線程完成了鎖的工作時,它會使用原子性的 增加并測試(increment and test) 釋放鎖,并檢查結(jié)果以查看內(nèi)核等待隊(duì)列上是否仍阻止任何進(jìn)程。如果有的話,它會通知內(nèi)核可以對等待隊(duì)列中的一個或多個進(jìn)程解除阻塞。如果沒有鎖競爭,內(nèi)核則不需要參與競爭。

Pthreads 中的互斥量

Pthreads 提供了一些功能用來同步線程。最基本的機(jī)制是使用互斥量變量,可以鎖定和解鎖,用來保護(hù)每個關(guān)鍵區(qū)域。希望進(jìn)入關(guān)鍵區(qū)域的線程首先要嘗試獲取 mutex。如果 mutex 沒有加鎖,線程能夠馬上進(jìn)入并且互斥量能夠自動鎖定,從而阻止其他線程進(jìn)入。如果 mutex 已經(jīng)加鎖,調(diào)用線程會阻塞,直到 mutex 解鎖。如果多個線程在相同的互斥量上等待,當(dāng)互斥量解鎖時,只有一個線程能夠進(jìn)入并且重新加鎖。這些鎖并不是必須的,程序員需要正確使用它們。

下面是與互斥量有關(guān)的函數(shù)調(diào)用

像我們想象中的一樣,mutex 能夠被創(chuàng)建和銷毀,扮演這兩個角色的分別是 Phread_mutex_init 和 Pthread_mutex_destroy。mutex 也可以通過 Pthread_mutex_lock 來進(jìn)行加鎖,如果互斥量已經(jīng)加鎖,則會阻塞調(diào)用者。還有一個調(diào)用Pthread_mutex_trylock 用來嘗試對線程加鎖,當(dāng) mutex 已經(jīng)被加鎖時,會返回一個錯誤代碼而不是阻塞調(diào)用者。這個調(diào)用允許線程有效的進(jìn)行忙等。最后,Pthread_mutex_unlock 會對 mutex 解鎖并且釋放一個正在等待的線程。

除了互斥量以外,Pthreads 還提供了第二種同步機(jī)制: 條件變量(condition variables) 。mutex 可以很好的允許或阻止對關(guān)鍵區(qū)域的訪問。條件變量允許線程由于未滿足某些條件而阻塞。絕大多數(shù)情況下這兩種方法是一起使用的。下面我們進(jìn)一步來研究線程、互斥量、條件變量之間的關(guān)聯(lián)。

下面再來重新認(rèn)識一下生產(chǎn)者和消費(fèi)者問題:一個線程將東西放在一個緩沖區(qū)內(nèi),由另一個線程將它們?nèi)〕?。如果生產(chǎn)者發(fā)現(xiàn)緩沖區(qū)沒有空槽可以使用了,生產(chǎn)者線程會阻塞起來直到有一個線程可以使用。生產(chǎn)者使用 mutex 來進(jìn)行原子性檢查從而不受其他線程干擾。但是當(dāng)發(fā)現(xiàn)緩沖區(qū)已經(jīng)滿了以后,生產(chǎn)者需要一種方法來阻塞自己并在以后被喚醒。這便是條件變量做的工作。

下面是一些與條件變量有關(guān)的最重要的 pthread 調(diào)用

上表中給出了一些調(diào)用用來創(chuàng)建和銷毀條件變量。條件變量上的主要屬性是 Pthread_cond_wait 和 Pthread_cond_signal。前者阻塞調(diào)用線程,直到其他線程發(fā)出信號為止(使用后者調(diào)用)。阻塞的線程通常需要等待喚醒的信號以此來釋放資源或者執(zhí)行某些其他活動。只有這樣阻塞的線程才能繼續(xù)工作。條件變量允許等待與阻塞原子性的進(jìn)程。Pthread_cond_broadcast 用來喚醒多個阻塞的、需要等待信號喚醒的線程。

需要注意的是,條件變量(不像是信號量)不會存在于內(nèi)存中。如果將一個信號量傳遞給一個沒有線程等待的條件變量,那么這個信號就會丟失,這個需要注意

下面是一個使用互斥量和條件變量的例子

#include <stdio.h>
#include <pthread.h>

/* 需要生產(chǎn)的數(shù)量 */
#define MAX 1000000000                                        
pthread_mutex_t the_mutex;
/* 使用信號量 */
pthread_cond_t condc,condp;                                
int buffer = 0;

/* 生產(chǎn)數(shù)據(jù) */
void *producer(void *ptr){                                

  int i;

  for(int i = 0;i <= MAX;i++){
    /* 緩沖區(qū)獨(dú)占訪問,也就是使用 mutex 獲取鎖 */
    pthread_mutex_lock(&the_mutex);                
    while(buffer != 0){
      pthread_cond_wait(&condp,&the_mutex);
    }
    /* 把他們放在緩沖區(qū)中 */
    buffer = i;            
    /* 喚醒消費(fèi)者 */
    pthread_cond_signal(&condc);    
    /* 釋放緩沖區(qū) */
    pthread_mutex_unlock(&the_mutex);            
  }
  pthread_exit(0);

}

/* 消費(fèi)數(shù)據(jù) */
void *consumer(void *ptr){                                

  int i;

  for(int i = 0;i <= MAX;i++){
    /* 緩沖區(qū)獨(dú)占訪問,也就是使用 mutex 獲取鎖 */
    pthread_mutex_lock(&the_mutex);                
    while(buffer == 0){
      pthread_cond_wait(&condc,&the_mutex);
    }
    /* 把他們從緩沖區(qū)中取出 */
    buffer = 0;    
    /* 喚醒生產(chǎn)者 */
    pthread_cond_signal(&condp);
    /* 釋放緩沖區(qū) */
    pthread_mutex_unlock(&the_mutex);            
  }
  pthread_exit(0);

}                              

管程

為了能夠編寫更加準(zhǔn)確無誤的程序,Brinch Hansen 和 Hoare 提出了一個更高級的同步原語叫做 管程(monitor)。他們兩個人的提案略有不同,通過下面的描述你就可以知道。管程是程序、變量和數(shù)據(jù)結(jié)構(gòu)等組成的一個集合,它們組成一個特殊的模塊或者包。進(jìn)程可以在任何需要的時候調(diào)用管程中的程序,但是它們不能從管程外部訪問數(shù)據(jù)結(jié)構(gòu)和程序。下面展示了一種抽象的,類似 Pascal 語言展示的簡潔的管程。不能用 C 語言進(jìn)行描述,因?yàn)楣艹淌钦Z言概念而 C 語言并不支持管程。

monitor example
    integer i;
    condition c;

    procedure producer();
  ...
    end;    

    procedure consumer();
    .
    end;
end monitor;

管程有一個很重要的特性,即在任何時候管程中只能有一個活躍的進(jìn)程,這一特性使管程能夠很方便的實(shí)現(xiàn)互斥操作。管程是編程語言的特性,所以編譯器知道它們的特殊性,因此可以采用與其他過程調(diào)用不同的方法來處理對管程的調(diào)用。通常情況下,當(dāng)進(jìn)程調(diào)用管程中的程序時,該程序的前幾條指令會檢查管程中是否有其他活躍的進(jìn)程。如果有的話,調(diào)用進(jìn)程將被掛起,直到另一個進(jìn)程離開管程才將其喚醒。如果沒有活躍進(jìn)程在使用管程,那么該調(diào)用進(jìn)程才可以進(jìn)入。

進(jìn)入管程中的互斥由編譯器負(fù)責(zé),但是一種通用做法是使用 互斥量(mutex) 和 二進(jìn)制信號量(binary semaphore)。由于編譯器而不是程序員在操作,因此出錯的幾率會大大降低。在任何時候,編寫管程的程序員都無需關(guān)心編譯器是如何處理的。他只需要知道將所有的臨界區(qū)轉(zhuǎn)換成為管程過程即可。絕不會有兩個進(jìn)程同時執(zhí)行臨界區(qū)中的代碼。

即使管程提供了一種簡單的方式來實(shí)現(xiàn)互斥,但在我們看來,這還不夠。因?yàn)槲覀冞€需要一種在進(jìn)程無法執(zhí)行被阻塞。在生產(chǎn)者-消費(fèi)者問題中,很容易將針對緩沖區(qū)滿和緩沖區(qū)空的測試放在管程程序中,但是生產(chǎn)者在發(fā)現(xiàn)緩沖區(qū)滿的時候該如何阻塞呢?

解決的辦法是引入條件變量(condition variables) 以及相關(guān)的兩個操作 wait 和 signal。當(dāng)一個管程程序發(fā)現(xiàn)它不能運(yùn)行時(例如,生產(chǎn)者發(fā)現(xiàn)緩沖區(qū)已滿),它會在某個條件變量(如 full)上執(zhí)行 wait 操作。這個操作造成調(diào)用進(jìn)程阻塞,并且還將另一個以前等在管程之外的進(jìn)程調(diào)入管程。在前面的 pthread 中我們已經(jīng)探討過條件變量的實(shí)現(xiàn)細(xì)節(jié)了。另一個進(jìn)程,比如消費(fèi)者可以通過執(zhí)行 signal 來喚醒阻塞的調(diào)用進(jìn)程。

Brinch Hansen 和 Hoare 在對進(jìn)程喚醒上有所不同,Hoare 建議讓新喚醒的進(jìn)程繼續(xù)運(yùn)行;而掛起另外的進(jìn)程。而 Brinch Hansen 建議讓執(zhí)行 signal 的進(jìn)程必須退出管程,這里我們采用 Brinch Hansen 的建議,因?yàn)樗诟拍钌细唵?,并且更容易?shí)現(xiàn)。

如果在一個條件變量上有若干進(jìn)程都在等待,則在對該條件執(zhí)行 signal 操作后,系統(tǒng)調(diào)度程序只能選擇其中一個進(jìn)程恢復(fù)運(yùn)行。

順便提一下,這里還有上面兩位教授沒有提出的第三種方式,它的理論是讓執(zhí)行 signal 的進(jìn)程繼續(xù)運(yùn)行,等待這個進(jìn)程退出管程時,其他進(jìn)程才能進(jìn)入管程。

條件變量不是計數(shù)器。條件變量也不能像信號量那樣積累信號以便以后使用。所以,如果向一個條件變量發(fā)送信號,但是該條件變量上沒有等待進(jìn)程,那么信號將會丟失。也就是說,wait 操作必須在 signal 之前執(zhí)行。

下面是一個使用 Pascal 語言通過管程實(shí)現(xiàn)的生產(chǎn)者-消費(fèi)者問題的解法

monitor ProducerConsumer
        condition full,empty;
        integer count;

        procedure insert(item:integer);
        begin
                if count = N then wait(full);
                insert_item(item);
                count := count + 1;
                if count = 1 then signal(empty);
        end;

        function remove:integer;
        begin
                if count = 0 then wait(empty);
                remove = remove_item;
                count := count - 1;
                if count = N - 1 then signal(full);
        end;

        count := 0;
end monitor;

procedure producer;
begin
            while true do
      begin 
                  item = produce_item;
                  ProducerConsumer.insert(item);
      end
end;

procedure consumer;
begin 
            while true do
            begin
                        item = ProducerConsumer.remove;
                        consume_item(item);
            end
end;

讀者可能覺得 wait 和 signal 操作看起來像是前面提到的 sleep 和 wakeup ,而且后者存在嚴(yán)重的競爭條件。它們確實(shí)很像,但是有個關(guān)鍵的區(qū)別:sleep 和 wakeup 之所以會失敗是因?yàn)楫?dāng)一個進(jìn)程想睡眠時,另一個進(jìn)程試圖去喚醒它。使用管程則不會發(fā)生這種情況。管程程序的自動互斥保證了這一點(diǎn),如果管程過程中的生產(chǎn)者發(fā)現(xiàn)緩沖區(qū)已滿,它將能夠完成 wait 操作而不用擔(dān)心調(diào)度程序可能會在 wait 完成之前切換到消費(fèi)者。甚至,在 wait 執(zhí)行完成并且把生產(chǎn)者標(biāo)志為不可運(yùn)行之前,是不會允許消費(fèi)者進(jìn)入管程的。

盡管類 Pascal 是一種想象的語言,但還是有一些真正的編程語言支持,比如 Java (終于輪到大 Java 出場了),Java 是能夠支持管程的,它是一種 面向?qū)ο?/code>的語言,支持用戶級線程,還允許將方法劃分為類。只要將關(guān)鍵字 synchronized 關(guān)鍵字加到方法中即可。Java 能夠保證一旦某個線程執(zhí)行該方法,就不允許其他線程執(zhí)行該對象中的任何 synchronized 方法。沒有關(guān)鍵字 synchronized ,就不能保證沒有交叉執(zhí)行。

下面是 Java 使用管程解決的生產(chǎn)者-消費(fèi)者問題

public class ProducerConsumer {
  // 定義緩沖區(qū)大小的長度
  static final int N = 100;
  // 初始化一個新的生產(chǎn)者線程
  static Producer p = new Producer();
  // 初始化一個新的消費(fèi)者線程
  static Consumer c = new Consumer();        
  // 初始化一個管程
  static Our_monitor mon = new Our_monitor(); 

  // run 包含了線程代碼
  static class Producer extends Thread{
    public void run(){                                                
      int item;
      // 生產(chǎn)者循環(huán)
      while(true){                                                        
        item = produce_item();
        mon.insert(item);
      }
    }
    // 生產(chǎn)代碼
    private int produce_item(){...}                        
  }

  // run 包含了線程代碼
  static class consumer extends Thread {
    public void run( ) {                                            
           int item;
      while(true){
        item = mon.remove();
                consume_item(item);
      }
    }
    // 消費(fèi)代碼
    private int produce_item(){...}                        
  }

  // 這是管程
  static class Our_monitor {                                    
    private int buffer[] = new int[N];
    // 計數(shù)器和索引
    private int count = 0,lo = 0,hi = 0;            

    private synchronized void insert(int val){
      if(count == N){
        // 如果緩沖區(qū)是滿的,則進(jìn)入休眠
        go_to_sleep();                                                
      }
      // 向緩沖區(qū)插入內(nèi)容
            buffer[hi] = val;                   
      // 找到下一個槽的為止
      hi = (hi + 1) % N;                 
      // 緩沖區(qū)中的數(shù)目自增 1 
      count = count + 1;                                            
      if(count == 1){
        // 如果消費(fèi)者睡眠,則喚醒
        notify();                                                            
      }
    }

    private synchronized void remove(int val){
      int val;
      if(count == 0){
        // 緩沖區(qū)是空的,進(jìn)入休眠
        go_to_sleep();                                                
      }
      // 從緩沖區(qū)取出數(shù)據(jù)
      val = buffer[lo];                
      // 設(shè)置待取出數(shù)據(jù)項(xiàng)的槽
      lo = (lo + 1) % N;                    
      // 緩沖區(qū)中的數(shù)據(jù)項(xiàng)數(shù)目減 1 
      count = count - 1;                                            
      if(count = N - 1){
        // 如果生產(chǎn)者睡眠,喚醒它
        notify();                                                            
      }
      return val;
    }

    private void go_to_sleep() {
      try{
        wait( );
      }catch(Interr uptedExceptionexc) {};
    }
  }

}

上面的代碼中主要設(shè)計四個類,外部類(outer class) ProducerConsumer 創(chuàng)建并啟動兩個線程,p 和 c。第二個類和第三個類 Producer 和 Consumer 分別包含生產(chǎn)者和消費(fèi)者代碼。最后,Our_monitor 是管程,它有兩個同步線程,用于在共享緩沖區(qū)中插入和取出數(shù)據(jù)。

在前面的所有例子中,生產(chǎn)者和消費(fèi)者線程在功能上與它們是相同的。生產(chǎn)者有一個無限循環(huán),該無限循環(huán)產(chǎn)生數(shù)據(jù)并將數(shù)據(jù)放入公共緩沖區(qū)中;消費(fèi)者也有一個等價的無限循環(huán),該無限循環(huán)用于從緩沖區(qū)取出數(shù)據(jù)并完成一系列工作。

程序中比較耐人尋味的就是 Our_monitor 了,它包含緩沖區(qū)、管理變量以及兩個同步方法。當(dāng)生產(chǎn)者在 insert 內(nèi)活動時,它保證消費(fèi)者不能在 remove 方法中運(yùn)行,從而保證更新變量以及緩沖區(qū)的安全性,并且不用擔(dān)心競爭條件。變量 count 記錄在緩沖區(qū)中數(shù)據(jù)的數(shù)量。變量 lo 是緩沖區(qū)槽的序號,指出將要取出的下一個數(shù)據(jù)項(xiàng)。類似地,hi 是緩沖區(qū)中下一個要放入的數(shù)據(jù)項(xiàng)序號。允許 lo = hi,含義是在緩沖區(qū)中有 0 個或 N 個數(shù)據(jù)。

Java 中的同步方法與其他經(jīng)典管程有本質(zhì)差別:Java 沒有內(nèi)嵌的條件變量。然而,Java 提供了 wait 和 notify 分別與 sleep 和 wakeup 等價。

通過臨界區(qū)自動的互斥,管程比信號量更容易保證并行編程的正確性。但是管程也有缺點(diǎn),我們前面說到過管程是一個編程語言的概念,編譯器必須要識別管程并用某種方式對其互斥作出保證。C、Pascal 以及大多數(shù)其他編程語言都沒有管程,所以不能依靠編譯器來遵守互斥規(guī)則。

與管程和信號量有關(guān)的另一個問題是,這些機(jī)制都是設(shè)計用來解決訪問共享內(nèi)存的一個或多個 CPU 上的互斥問題的。通過將信號量放在共享內(nèi)存中并用 TSL 或 XCHG 指令來保護(hù)它們,可以避免競爭。但是如果是在分布式系統(tǒng)中,可能同時具有多個 CPU 的情況,并且每個 CPU 都有自己的私有內(nèi)存呢,它們通過網(wǎng)絡(luò)相連,那么這些原語將會失效。因?yàn)樾盘柫刻图壛耍艹淘谏贁?shù)幾種編程語言之外無法使用,所以還需要其他方法。

消息傳遞

上面提到的其他方法就是 消息傳遞(messaage passing)。這種進(jìn)程間通信的方法使用兩個原語 send 和 receive ,它們像信號量而不像管程,是系統(tǒng)調(diào)用而不是語言級別。示例如下

send(destination, &message);

receive(source, &message);

send 方法用于向一個給定的目標(biāo)發(fā)送一條消息,receive 從一個給定的源接受一條消息。如果沒有消息,接受者可能被阻塞,直到接受一條消息或者帶著錯誤碼返回。

消息傳遞系統(tǒng)的設(shè)計要點(diǎn)

消息傳遞系統(tǒng)現(xiàn)在面臨著許多信號量和管程所未涉及的問題和設(shè)計難點(diǎn),尤其對那些在網(wǎng)絡(luò)中不同機(jī)器上的通信狀況。例如,消息有可能被網(wǎng)絡(luò)丟失。為了防止消息丟失,發(fā)送方和接收方可以達(dá)成一致:一旦接受到消息后,接收方馬上回送一條特殊的 確認(rèn)(acknowledgement) 消息。如果發(fā)送方在一段時間間隔內(nèi)未收到確認(rèn),則重發(fā)消息。

現(xiàn)在考慮消息本身被正確接收,而返回給發(fā)送著的確認(rèn)消息丟失的情況。發(fā)送者將重發(fā)消息,這樣接受者將收到兩次相同的消息。

對于接收者來說,如何區(qū)分新的消息和一條重發(fā)的老消息是非常重要的。通常采用在每條原始消息中嵌入一個連續(xù)的序號來解決此問題。如果接受者收到一條消息,它具有與前面某一條消息一樣的序號,就知道這條消息是重復(fù)的,可以忽略。

消息系統(tǒng)還必須處理如何命名進(jìn)程的問題,以便在發(fā)送或接收調(diào)用中清晰的指明進(jìn)程。身份驗(yàn)證(authentication) 也是一個問題,比如客戶端怎么知道它是在與一個真正的文件服務(wù)器通信,從發(fā)送方到接收方的信息有可能被中間人所篡改。

用消息傳遞解決生產(chǎn)者-消費(fèi)者問題

現(xiàn)在我們考慮如何使用消息傳遞來解決生產(chǎn)者-消費(fèi)者問題,而不是共享緩存。下面是一種解決方式

/* buffer 中槽的數(shù)量 */
#define N 100                                                    

void producer(void){

  int item;
  /* buffer 中槽的數(shù)量 */
  message m;                                                    

  while(TRUE){
    /* 生成放入緩沖區(qū)的數(shù)據(jù) */
    item = produce_item();                        
    /* 等待消費(fèi)者發(fā)送空緩沖區(qū) */
    receive(consumer,&m);                            
    /* 建立一個待發(fā)送的消息 */
    build_message(&m,item);                        
    /* 發(fā)送給消費(fèi)者 */
    send(consumer,&m);                                
  }

}

void consumer(void){

  int item,i;
  message m;

  /* 循環(huán)N次 */
  for(int i = 0;i < N;i++){                        
    /* 發(fā)送N個緩沖區(qū) */
    send(producer,&m);                                
  }
  while(TRUE){
    /* 接受包含數(shù)據(jù)的消息 */
    receive(producer,&m);                            
    /* 將數(shù)據(jù)從消息中提取出來 */
      item = extract_item(&m);                    
    /* 將空緩沖區(qū)發(fā)送回生產(chǎn)者 */
    send(producer,&m);                                
    /* 處理數(shù)據(jù) */
    consume_item(item);                                
  }

}

假設(shè)所有的消息都有相同的大小,并且在尚未接受到發(fā)出的消息時,由操作系統(tǒng)自動進(jìn)行緩沖。在該解決方案中共使用 N 條消息,這就類似于一塊共享內(nèi)存緩沖區(qū)的 N 個槽。消費(fèi)者首先將 N 條空消息發(fā)送給生產(chǎn)者。當(dāng)生產(chǎn)者向消費(fèi)者傳遞一個數(shù)據(jù)項(xiàng)時,它取走一條空消息并返回一條填充了內(nèi)容的消息。通過這種方式,系統(tǒng)中總的消息數(shù)量保持不變,所以消息都可以存放在事先確定數(shù)量的內(nèi)存中。

如果生產(chǎn)者的速度要比消費(fèi)者快,則所有的消息最終都將被填滿,等待消費(fèi)者,生產(chǎn)者將被阻塞,等待返回一條空消息。如果消費(fèi)者速度快,那么情況將正相反:所有的消息均為空,等待生產(chǎn)者來填充,消費(fèi)者將被阻塞,以等待一條填充過的消息。

消息傳遞的方式有許多變體,下面先介紹如何對消息進(jìn)行 編址

  • 一種方法是為每個進(jìn)程分配一個唯一的地址,讓消息按進(jìn)程的地址編址。

  • 另一種方式是引入一個新的數(shù)據(jù)結(jié)構(gòu),稱為 信箱(mailbox),信箱是一個用來對一定的數(shù)據(jù)進(jìn)行緩沖的數(shù)據(jù)結(jié)構(gòu),信箱中消息的設(shè)置方法也有多種,典型的方法是在信箱創(chuàng)建時確定消息的數(shù)量。在使用信箱時,在 send 和 receive 調(diào)用的地址參數(shù)就是信箱的地址,而不是進(jìn)程的地址。當(dāng)一個進(jìn)程試圖向一個滿的信箱發(fā)送消息時,它將被掛起,直到信箱中有消息被取走,從而為新的消息騰出地址空間。

屏障

最后一個同步機(jī)制是準(zhǔn)備用于進(jìn)程組而不是進(jìn)程間的生產(chǎn)者-消費(fèi)者情況的。在某些應(yīng)用中劃分了若干階段,并且規(guī)定,除非所有的進(jìn)程都就緒準(zhǔn)備著手下一個階段,否則任何進(jìn)程都不能進(jìn)入下一個階段,可以通過在每個階段的結(jié)尾安裝一個 屏障(barrier) 來實(shí)現(xiàn)這種行為。當(dāng)一個進(jìn)程到達(dá)屏障時,它會被屏障所攔截,直到所有的屏障都到達(dá)為止。屏障可用于一組進(jìn)程同步,如下圖所示

在上圖中我們可以看到,有四個進(jìn)程接近屏障,這意味著每個進(jìn)程都在進(jìn)行運(yùn)算,但是還沒有到達(dá)每個階段的結(jié)尾。過了一段時間后,A、B、D 三個進(jìn)程都到達(dá)了屏障,各自的進(jìn)程被掛起,但此時還不能進(jìn)入下一個階段呢,因?yàn)檫M(jìn)程 B 還沒有執(zhí)行完畢。結(jié)果,當(dāng)最后一個 C 到達(dá)屏障后,這個進(jìn)程組才能夠進(jìn)入下一個階段。

避免鎖:讀-復(fù)制-更新

最快的鎖是根本沒有鎖。問題在于沒有鎖的情況下,我們是否允許對共享數(shù)據(jù)結(jié)構(gòu)的并發(fā)讀寫進(jìn)行訪問。答案當(dāng)然是不可以。假設(shè)進(jìn)程 A 正在對一個數(shù)字?jǐn)?shù)組進(jìn)行排序,而進(jìn)程 B 正在計算其平均值,而此時你進(jìn)行 A 的移動,會導(dǎo)致 B 會多次讀到重復(fù)值,而某些值根本沒有遇到過。

然而,在某些情況下,我們可以允許寫操作來更新數(shù)據(jù)結(jié)構(gòu),即便還有其他的進(jìn)程正在使用。竅門在于確保每個讀操作要么讀取舊的版本,要么讀取新的版本,例如下面的樹

上面的樹中,讀操作從根部到葉子遍歷整個樹。加入一個新節(jié)點(diǎn) X 后,為了實(shí)現(xiàn)這一操作,我們要讓這個節(jié)點(diǎn)在樹中可見之前使它恰好正確:我們對節(jié)點(diǎn) X 中的所有值進(jìn)行初始化,包括它的子節(jié)點(diǎn)指針。然后通過原子寫操作,使 X 稱為 A 的子節(jié)點(diǎn)。所有的讀操作都不會讀到前后不一致的版本

在上面的圖中,我們接著移除 B 和 D。首先,將 A 的左子節(jié)點(diǎn)指針指向 C 。所有原本在 A 中的讀操作將會后續(xù)讀到節(jié)點(diǎn) C ,而永遠(yuǎn)不會讀到 B 和 D。也就是說,它們將只會讀取到新版數(shù)據(jù)。同樣,所有當(dāng)前在 B 和 D 中的讀操作將繼續(xù)按照原始的數(shù)據(jù)結(jié)構(gòu)指針并且讀取舊版數(shù)據(jù)。所有操作均能正確運(yùn)行,我們不需要鎖住任何東西。而不需要鎖住數(shù)據(jù)就能夠移除 B 和 D 的主要原因就是 讀-復(fù)制-更新(Ready-Copy-Update,RCU),將更新過程中的移除和再分配過程分離開。

調(diào)度

當(dāng)一個計算機(jī)是多道程序設(shè)計系統(tǒng)時,會頻繁的有很多進(jìn)程或者線程來同時競爭 CPU 時間片。當(dāng)兩個或兩個以上的進(jìn)程/線程處于就緒狀態(tài)時,就會發(fā)生這種情況。如果只有一個 CPU 可用,那么必須選擇接下來哪個進(jìn)程/線程可以運(yùn)行。操作系統(tǒng)中有一個叫做 調(diào)度程序(scheduler) 的角色存在,它就是做這件事兒的,該程序使用的算法叫做 調(diào)度算法(scheduling algorithm) 。

盡管有一些不同,但許多適用于進(jìn)程調(diào)度的處理方法同樣也適用于線程調(diào)度。當(dāng)內(nèi)核管理線程的時候,調(diào)度通常會以線程級別發(fā)生,很少或者根本不會考慮線程屬于哪個進(jìn)程。下面我們會首先專注于進(jìn)程和線程的調(diào)度問題,然后會明確的介紹線程調(diào)度以及它產(chǎn)生的問題。

調(diào)度介紹

讓我們回到早期以磁帶上的卡片作為輸入的批處理系統(tǒng)的時代,那時候的調(diào)度算法非常簡單:依次運(yùn)行磁帶上的每一個作業(yè)。對于多道程序設(shè)計系統(tǒng),會復(fù)雜一些,因?yàn)橥ǔ卸鄠€用戶在等待服務(wù)。一些大型機(jī)仍然將 批處理和 分時服務(wù)結(jié)合使用,需要調(diào)度程序決定下一個運(yùn)行的是一個批處理作業(yè)還是終端上的用戶。由于在這些機(jī)器中 CPU 是稀缺資源,所以好的調(diào)度程序可以在提高性能和用戶的滿意度方面取得很大的成果。

進(jìn)程行為

幾乎所有的進(jìn)程(磁盤或網(wǎng)絡(luò))I/O 請求和計算都是交替運(yùn)行的

如上圖所示,CPU 不停頓的運(yùn)行一段時間,然后發(fā)出一個系統(tǒng)調(diào)用等待 I/O 讀寫文件。完成系統(tǒng)調(diào)用后,CPU 又開始計算,直到它需要讀更多的數(shù)據(jù)或者寫入更多的數(shù)據(jù)為止。當(dāng)一個進(jìn)程等待外部設(shè)備完成工作而被阻塞時,才是 I/O 活動。

上面 a 是 CPU 密集型進(jìn)程;b 是 I/O 密集型進(jìn)程進(jìn)程,a 因?yàn)樵谟嬎愕臅r間上花費(fèi)時間更長,因此稱為計算密集型(compute-bound) 或者 CPU 密集型(CPU-bound),b 因?yàn)镮/O 發(fā)生頻率比較快因此稱為 I/O 密集型(I/O-bound)。計算密集型進(jìn)程有較長的 CPU 集中使用和較小頻度的 I/O 等待。I/O 密集型進(jìn)程有較短的 CPU 使用時間和較頻繁的 I/O 等待。注意到上面兩種進(jìn)程的區(qū)分關(guān)鍵在于 CPU 的時間占用而不是 I/O 的時間占用。I/O 密集型的原因是因?yàn)樗鼈儧]有在 I/O 之間花費(fèi)更多的計算、而不是 I/O 請求時間特別長。無論數(shù)據(jù)到達(dá)后需要花費(fèi)多少時間,它們都需要花費(fèi)相同的時間來發(fā)出讀取磁盤塊的硬件請求。

值得注意的是,隨著 CPU 的速度越來越快,更多的進(jìn)程傾向于 I/O 密集型。這種情況出現(xiàn)的原因是 CPU 速度的提升要遠(yuǎn)遠(yuǎn)高于硬盤。這種情況導(dǎo)致的結(jié)果是,未來對 I/O 密集型進(jìn)程的調(diào)度處理似乎更為重要。這里的基本思想是,如果需要運(yùn)行 I/O 密集型進(jìn)程,那么就應(yīng)該讓它盡快得到機(jī)會,以便發(fā)出磁盤請求并保持磁盤始終忙碌。

何時調(diào)度

第一個和調(diào)度有關(guān)的問題是何時進(jìn)行調(diào)度決策。存在著需要調(diào)度處理的各種情形。首先,在創(chuàng)建一個新進(jìn)程后,需要決定是運(yùn)行父進(jìn)程還是子進(jìn)程。因?yàn)槎叩倪M(jìn)程都處于就緒態(tài)下,這是正常的調(diào)度決策,可以任意選擇,也就是說,調(diào)度程序可以任意的選擇子進(jìn)程或父進(jìn)程開始運(yùn)行。

第二,在進(jìn)程退出時需要作出調(diào)度決定。因?yàn)榇诉M(jìn)程不再運(yùn)行(因?yàn)樗鼘⒉辉俅嬖冢?,因此必須從就緒進(jìn)程中選擇其他進(jìn)程運(yùn)行。如果沒有進(jìn)程處于就緒態(tài),系統(tǒng)提供的空閑進(jìn)程通常會運(yùn)行

什么是空閑進(jìn)程

空閑進(jìn)程(system-supplied idle process) 是 Microsoft 公司 windows 操作系統(tǒng)帶有的系統(tǒng)進(jìn)程,該進(jìn)程是在各個處理器上運(yùn)行的單個線程,它唯一的任務(wù)是在系統(tǒng)沒有處理其他線程時占用處理器時間。System Idle Process 并不是一個真正的進(jìn)程,它是核心虛擬出來的,多任務(wù)操作系統(tǒng)都存在。在沒有可用的進(jìn)程時,系統(tǒng)處于空運(yùn)行狀態(tài),此時就是System Idle Process 在正在運(yùn)行。你可以簡單的理解成,它代表的是 CPU 的空閑狀態(tài),數(shù)值越大代表處理器越空閑,可以通過 Windows 任務(wù)管理器查看 Windows 中的 CPU 利用率

第三種情況是,當(dāng)進(jìn)程阻塞在 I/O 、信號量或其他原因時,必須選擇另外一個進(jìn)程來運(yùn)行。有時,阻塞的原因會成為選擇進(jìn)程運(yùn)行的關(guān)鍵因素。例如,如果 A 是一個重要進(jìn)程,并且它正在等待 B 退出關(guān)鍵區(qū)域,讓 B 退出關(guān)鍵區(qū)域從而使 A 得以運(yùn)行。但是調(diào)度程序一般不會對這種情況進(jìn)行考量。

第四點(diǎn),當(dāng) I/O 中斷發(fā)生時,可以做出調(diào)度決策。如果中斷來自 I/O 設(shè)備,而 I/O 設(shè)備已經(jīng)完成了其工作,那么那些等待 I/O 的進(jìn)程現(xiàn)在可以繼續(xù)運(yùn)行。由調(diào)度程序來決定是否準(zhǔn)備運(yùn)行新的進(jìn)程還是重新運(yùn)行已經(jīng)中斷的進(jìn)程。

如果硬件時鐘以 50 或 60 Hz 或其他頻率提供周期性中斷,可以在每個時鐘中斷或第 k 個時鐘中斷處做出調(diào)度決策。根據(jù)如何處理時鐘中斷可以把調(diào)度算法可以分為兩類。非搶占式(nonpreemptive) 調(diào)度算法挑選一個進(jìn)程,讓該進(jìn)程運(yùn)行直到被阻塞(阻塞在 I/O 上或等待另一個進(jìn)程),或者直到該進(jìn)程自動釋放 CPU。即使該進(jìn)程運(yùn)行了若干個小時后,它也不會被強(qiáng)制掛起。這樣會在時鐘中斷發(fā)生時不會進(jìn)行調(diào)度。在處理完時鐘中斷后,如果沒有更高優(yōu)先級的進(jìn)程等待,則被中斷的進(jìn)程會繼續(xù)執(zhí)行。

另外一種情況是 搶占式 調(diào)度算法,它會選擇一個進(jìn)程,并使其在最大固定時間內(nèi)運(yùn)行。如果在時間間隔結(jié)束后仍在運(yùn)行,這個進(jìn)程會被掛起,調(diào)度程序會選擇其他進(jìn)程來運(yùn)行(前提是存在就緒進(jìn)程)。進(jìn)行搶占式調(diào)度需要在時間間隔結(jié)束時發(fā)生時鐘中斷,以將 CPU 的控制權(quán)交還給調(diào)度程序。如果沒有可用的時鐘,那么非搶占式就是唯一的選擇。

調(diào)度算法的分類

毫無疑問,不同的環(huán)境下需要不同的調(diào)度算法。之所以出現(xiàn)這種情況,是因?yàn)椴煌膽?yīng)用程序和不同的操作系統(tǒng)有不同的目標(biāo)。也就是說,在不同的系統(tǒng)中,調(diào)度程序的優(yōu)化也是不同的。這里有必要劃分出三種環(huán)境

  • 批處理(Batch)

  • 交互式(Interactive)

  • 實(shí)時(Real time)

批處理系統(tǒng)廣泛應(yīng)用于商業(yè)領(lǐng)域,比如用來處理工資單、存貨清單、賬目收入、賬目支出、利息計算、索賠處理和其他周期性作業(yè)。在批處理系統(tǒng)中,一般會選擇使用非搶占式算法或者周期性比較長的搶占式算法。這種方法可以減少線程切換因此能夠提升性能。

在交互式用戶環(huán)境中,為了避免一個進(jìn)程霸占 CPU 拒絕為其他進(jìn)程服務(wù),所以需要搶占式算法。即使沒有進(jìn)程有意要一直運(yùn)行下去,但是,由于某個進(jìn)程出現(xiàn)錯誤也有可能無限期的排斥其他所有進(jìn)程。為了避免這種情況,搶占式也是必須的。服務(wù)器也屬于此類別,因?yàn)樗鼈兺ǔ槎鄠€(遠(yuǎn)程)用戶提供服務(wù),而這些用戶都非常著急。計算機(jī)用戶總是很忙。

在實(shí)時系統(tǒng)中,搶占有時是不需要的,因?yàn)檫M(jìn)程知道自己可能運(yùn)行不了很長時間,通常很快的做完自己的工作并阻塞。實(shí)時系統(tǒng)與交互式系統(tǒng)的差別是,實(shí)時系統(tǒng)只運(yùn)行那些用來推進(jìn)現(xiàn)有應(yīng)用的程序,而交互式系統(tǒng)是通用的,它可以運(yùn)行任意的非協(xié)作甚至是有惡意的程序。

調(diào)度算法的目標(biāo)

為了設(shè)計調(diào)度算法,有必要考慮一下什么是好的調(diào)度算法。有一些目標(biāo)取決于環(huán)境(批處理、交互式或者實(shí)時)蛋大部分是適用于所有情況的,下面是一些需要考量的因素,我們會在下面一起討論。

所有系統(tǒng)

在所有的情況中,公平是很重要的。對一個進(jìn)程給予相較于其他等價的進(jìn)程更多的 CPU 時間片對其他進(jìn)程來說是不公平的。當(dāng)然,不同類型的進(jìn)程可以采用不同的處理方式。

與公平有關(guān)的是系統(tǒng)的強(qiáng)制執(zhí)行,什么意思呢?如果某公司的薪資發(fā)放系統(tǒng)計劃在本月的15號,那么碰上了疫情大家生活都很拮據(jù),此時老板說要在14號晚上發(fā)放薪資,那么調(diào)度程序必須強(qiáng)制使進(jìn)程執(zhí)行 14 號晚上發(fā)放薪資的策略。

另一個共同的目標(biāo)是保持系統(tǒng)的所有部分盡可能的忙碌。如果 CPU 和所有的 I/O 設(shè)備能夠一直運(yùn)行,那么相對于讓某些部件空轉(zhuǎn)而言,每秒鐘就可以完成更多的工作。例如,在批處理系統(tǒng)中,調(diào)度程序控制哪個作業(yè)調(diào)入內(nèi)存運(yùn)行。在內(nèi)存中既有一些 CPU 密集型進(jìn)程又有一些 I/O 密集型進(jìn)程是一個比較好的想法,好于先調(diào)入和運(yùn)行所有的 CPU 密集型作業(yè),然后在它們完成之后再調(diào)入和運(yùn)行所有 I/O 密集型作業(yè)的做法。使用后者這種方式會在 CPU 密集型進(jìn)程啟動后,爭奪 CPU ,而磁盤卻在空轉(zhuǎn),而當(dāng) I/O 密集型進(jìn)程啟動后,它們又要為磁盤而競爭,CPU 卻又在空轉(zhuǎn)。。。。。。顯然,通過結(jié)合 I/O 密集型和 CPU 密集型,能夠使整個系統(tǒng)運(yùn)行更流暢,效率更高。

批處理系統(tǒng)

通常有三個指標(biāo)來衡量系統(tǒng)工作狀態(tài):吞吐量、周轉(zhuǎn)時間和 CPU 利用率,吞吐量(throughout) 是系統(tǒng)每小時完成的作業(yè)數(shù)量。綜合考慮,每小時完成 50 個工作要比每小時完成 40 個工作好。周轉(zhuǎn)時間(Turnaround time) 是一種平均時間,它指的是從一個批處理提交開始直到作業(yè)完成時刻為止平均時間。該數(shù)據(jù)度量了用戶要得到輸出所需的平均等待時間。周轉(zhuǎn)時間越小越好。

CPU 利用率(CPU utilization) 通常作為批處理系統(tǒng)上的指標(biāo)。即使如此, CPU 利用率也不是一個好的度量指標(biāo),真正有價值的衡量指標(biāo)是系統(tǒng)每小時可以完成多少作業(yè)(吞吐量),以及完成作業(yè)需要多長時間(周轉(zhuǎn)時間)。把 CPU 利用率作為度量指標(biāo),就像是引擎每小時轉(zhuǎn)動了多少次來比較汽車的性能一樣。而且知道 CPU 的利用率什么時候接近 100% 要比什么什么時候要求得到更多的計算能力要有用。

交互式系統(tǒng)

對于交互式系統(tǒng),則有不同的指標(biāo)。最重要的是盡量減少響應(yīng)時間。這個時間說的是從執(zhí)行指令開始到得到結(jié)果的時間。再有后臺進(jìn)程運(yùn)行(例如,從網(wǎng)絡(luò)上讀取和保存 E-mail 文件)的個人計算機(jī)上,用戶請求啟動一個程序或打開一個文件應(yīng)該優(yōu)先于后臺的工作。能夠讓所有的交互式請求首先運(yùn)行的就是一個好的服務(wù)。

一個相關(guān)的問題是 均衡性(proportionality),用戶對做一件事情需要多長時間總是有一種固定(不過通常不正確)的看法。當(dāng)認(rèn)為一個請求很復(fù)雜需要較多時間時,用戶會認(rèn)為很正常并且可以接受,但是一個很簡單的程序卻花費(fèi)了很長的運(yùn)行時間,用戶就會很惱怒。可以拿彩印和復(fù)印來舉出一個簡單的例子,彩印可能需要1分鐘的時間,但是用戶覺得復(fù)雜并且愿意等待一分鐘,相反,復(fù)印很簡單只需要 5 秒鐘,但是復(fù)印機(jī)花費(fèi) 1 分鐘卻沒有完成復(fù)印操作,用戶就會很焦躁。

實(shí)時系統(tǒng)

實(shí)時系統(tǒng)則有著和交互式系統(tǒng)不同的考量因素,因此也就有不同的調(diào)度目標(biāo)。實(shí)時系統(tǒng)的特點(diǎn)是必須滿足最后的截止時間。例如,如果計算機(jī)控制著以固定速率產(chǎn)生數(shù)據(jù)的設(shè)備,未能按時運(yùn)行的話可能會導(dǎo)致數(shù)據(jù)丟失。因此,實(shí)時系統(tǒng)中最重要的需求是滿足所有(或大多數(shù))時間期限。

在一些實(shí)事系統(tǒng)中,特別是涉及到多媒體的,可預(yù)測性很重要。偶爾不能滿足最后的截止時間不重要,但是如果音頻多媒體運(yùn)行不穩(wěn)定,聲音質(zhì)量會持續(xù)惡化。視頻也會造成問題,但是耳朵要比眼睛敏感很多。為了避免這些問題,進(jìn)程調(diào)度必須能夠高度可預(yù)測的而且是有規(guī)律的。

批處理中的調(diào)度

現(xiàn)在讓我們把目光從一般性的調(diào)度轉(zhuǎn)換為特定的調(diào)度算法。下面我們會探討在批處理中的調(diào)度。

先來先服務(wù)

很像是先到先得。。。可能最簡單的非搶占式調(diào)度算法的設(shè)計就是 先來先服務(wù)(first-come,first-serverd)。使用此算法,將按照請求順序?yàn)檫M(jìn)程分配 CPU。最基本的,會有一個就緒進(jìn)程的等待隊(duì)列。當(dāng)?shù)谝粋€任務(wù)從外部進(jìn)入系統(tǒng)時,將會立即啟動并允許運(yùn)行任意長的時間。它不會因?yàn)檫\(yùn)行時間太長而中斷。當(dāng)其他作業(yè)進(jìn)入時,它們排到就緒隊(duì)列尾部。當(dāng)正在運(yùn)行的進(jìn)程阻塞,處于等待隊(duì)列的第一個進(jìn)程就開始運(yùn)行。當(dāng)一個阻塞的進(jìn)程重新處于就緒態(tài)時,它會像一個新到達(dá)的任務(wù),會排在隊(duì)列的末尾,即排在所有進(jìn)程最后。

這個算法的強(qiáng)大之處在于易于理解和編程,在這個算法中,一個單鏈表記錄了所有就緒進(jìn)程。要選取一個進(jìn)程運(yùn)行,只要從該隊(duì)列的頭部移走一個進(jìn)程即可;要添加一個新的作業(yè)或者阻塞一個進(jìn)程,只要把這個作業(yè)或進(jìn)程附加在隊(duì)列的末尾即可。這是很簡單的一種實(shí)現(xiàn)。

不過,先來先服務(wù)也是有缺點(diǎn)的,那就是沒有優(yōu)先級的關(guān)系,試想一下,如果有 100 個 I/O 進(jìn)程正在排隊(duì),第 101 個是一個 CPU 密集型進(jìn)程,那豈不是需要等 100 個 I/O 進(jìn)程運(yùn)行完畢才會等到一個 CPU 密集型進(jìn)程運(yùn)行,這在實(shí)際情況下根本不可能,所以需要優(yōu)先級或者搶占式進(jìn)程的出現(xiàn)來優(yōu)先選擇重要的進(jìn)程運(yùn)行。

最短作業(yè)優(yōu)先

批處理中,第二種調(diào)度算法是 最短作業(yè)優(yōu)先(Shortest Job First),我們假設(shè)運(yùn)行時間已知。例如,一家保險公司,因?yàn)槊刻煲鲱愃频墓ぷ鳎匀藗兛梢韵喈?dāng)精確地預(yù)測處理 1000 個索賠的一批作業(yè)需要多長時間。當(dāng)輸入隊(duì)列中有若干個同等重要的作業(yè)被啟動時,調(diào)度程序應(yīng)使用最短優(yōu)先作業(yè)算法

如上圖 a 所示,這里有 4 個作業(yè) A、B、C、D ,運(yùn)行時間分別為 8、4、4、4 分鐘。若按圖中的次序運(yùn)行,則 A 的周轉(zhuǎn)時間為 8 分鐘,B 為 12 分鐘,C 為 16 分鐘,D 為 20 分鐘,平均時間內(nèi)為 14 分鐘。

現(xiàn)在考慮使用最短作業(yè)優(yōu)先算法運(yùn)行 4 個作業(yè),如上圖 b 所示,目前的周轉(zhuǎn)時間分別為 4、8、12、20,平均為 11 分鐘,可以證明最短作業(yè)優(yōu)先是最優(yōu)的。考慮有 4 個作業(yè)的情況,其運(yùn)行時間分別為 a、b、c、d。第一個作業(yè)在時間 a 結(jié)束,第二個在時間 a + b 結(jié)束,以此類推。平均周轉(zhuǎn)時間為 (4a + 3b + 2c + d) / 4 。顯然 a 對平均值的影響最大,所以 a 應(yīng)該是最短優(yōu)先作業(yè),其次是 b,然后是 c ,最后是 d 它就只能影響自己的周轉(zhuǎn)時間了。

需要注意的是,在所有的進(jìn)程都可以運(yùn)行的情況下,最短作業(yè)優(yōu)先的算法才是最優(yōu)的。

最短剩余時間優(yōu)先

最短作業(yè)優(yōu)先的搶占式版本被稱作為 最短剩余時間優(yōu)先(Shortest Remaining Time Next) 算法。使用這個算法,調(diào)度程序總是選擇剩余運(yùn)行時間最短的那個進(jìn)程運(yùn)行。當(dāng)一個新作業(yè)到達(dá)時,其整個時間同當(dāng)前進(jìn)程的剩余時間做比較。如果新的進(jìn)程比當(dāng)前運(yùn)行進(jìn)程需要更少的時間,當(dāng)前進(jìn)程就被掛起,而運(yùn)行新的進(jìn)程。這種方式能夠使短期作業(yè)獲得良好的服務(wù)。

交互式系統(tǒng)中的調(diào)度

交互式系統(tǒng)中在個人計算機(jī)、服務(wù)器和其他系統(tǒng)中都是很常用的,所以有必要來探討一下交互式調(diào)度

輪詢調(diào)度

一種最古老、最簡單、最公平并且最廣泛使用的算法就是 輪詢算法(round-robin)。每個進(jìn)程都會被分配一個時間段,稱為時間片(quantum),在這個時間片內(nèi)允許進(jìn)程運(yùn)行。如果時間片結(jié)束時進(jìn)程還在運(yùn)行的話,則搶占一個 CPU 并將其分配給另一個進(jìn)程。如果進(jìn)程在時間片結(jié)束前阻塞或結(jié)束,則 CPU 立即進(jìn)行切換。輪詢算法比較容易實(shí)現(xiàn)。調(diào)度程序所做的就是維護(hù)一個可運(yùn)行進(jìn)程的列表,就像下圖中的 a,當(dāng)一個進(jìn)程用完時間片后就被移到隊(duì)列的末尾,就像下圖的 b。

時間片輪詢調(diào)度中唯一有意思的一點(diǎn)就是時間片的長度。從一個進(jìn)程切換到另一個進(jìn)程需要一定的時間進(jìn)行管理處理,包括保存寄存器的值和內(nèi)存映射、更新不同的表格和列表、清除和重新調(diào)入內(nèi)存高速緩存等。這種切換稱作 進(jìn)程間切換(process switch) 和 上下文切換(context switch)。如果進(jìn)程間的切換時間需要 1ms,其中包括內(nèi)存映射、清除和重新調(diào)入高速緩存等,再假設(shè)時間片設(shè)為 4 ms,那么 CPU 在做完 4 ms 有用的工作之后,CPU 將花費(fèi) 1 ms 來進(jìn)行進(jìn)程間的切換。因此,CPU 的時間片會浪費(fèi) 20% 的時間在管理開銷上。耗費(fèi)巨大。

為了提高 CPU 的效率,我們把時間片設(shè)置為 100 ms?,F(xiàn)在時間的浪費(fèi)只有 1%。但是考慮會發(fā)現(xiàn)下面的情況,如果在一個非常短的時間內(nèi)到達(dá) 50 個請求,并且對 CPU 有不同的需求,此時會發(fā)生什么?50 個進(jìn)程都被放在可運(yùn)行進(jìn)程列表中。如果 CP畫U 是空閑的,第一個進(jìn)程會立即開始執(zhí)行,第二個直到 100 ms 以后才會啟動,以此類推。不幸的是最后一個進(jìn)程需要等待 5 秒才能獲得執(zhí)行機(jī)會。大部分用戶都會覺得對于一個簡短的指令運(yùn)行 5 秒中是很慢的。如果隊(duì)列末尾的某些請求只需要幾號秒鐘的運(yùn)行時間的話,這種設(shè)計就非常糟糕了。

另外一個因素是如果時間片設(shè)置長度要大于 CPU 使用長度,那么搶占就不會經(jīng)常發(fā)生。相反,在時間片用完之前,大多數(shù)進(jìn)程都已經(jīng)阻塞了,那么就會引起進(jìn)程間的切換。消除搶占可提高性能,因?yàn)檫M(jìn)程切換僅在邏輯上必要時才發(fā)生,即流程阻塞且無法繼續(xù)時才發(fā)生。

結(jié)論可以表述如下:將上下文切換時間設(shè)置得太短會導(dǎo)致過多的進(jìn)程切換并降低 CPU 效率,但設(shè)置時間太長會導(dǎo)致一個短請求很長時間得不到響應(yīng)。最好的切換時間是在 20 - 50 毫秒之間設(shè)置。

優(yōu)先級調(diào)度

輪詢調(diào)度假設(shè)了所有的進(jìn)程是同等重要的。但事實(shí)情況可能不是這樣。例如,在一所大學(xué)中的等級制度,首先是院長,然后是教授、秘書、后勤人員,最后是學(xué)生。這種將外部情況考慮在內(nèi)就實(shí)現(xiàn)了優(yōu)先級調(diào)度(priority scheduling)

它的基本思想很明確,每個進(jìn)程都被賦予一個優(yōu)先級,優(yōu)先級高的進(jìn)程優(yōu)先運(yùn)行。

但是也不意味著高優(yōu)先級的進(jìn)程能夠永遠(yuǎn)一直運(yùn)行下去,調(diào)度程序會在每個時鐘中斷期間降低當(dāng)前運(yùn)行進(jìn)程的優(yōu)先級。如果此操作導(dǎo)致其優(yōu)先級降低到下一個最高進(jìn)程的優(yōu)先級以下,則會發(fā)生進(jìn)程切換。或者,可以為每個進(jìn)程分配允許運(yùn)行的最大時間間隔。當(dāng)時間間隔用完后,下一個高優(yōu)先級的進(jìn)程會得到運(yùn)行的機(jī)會。

可以靜態(tài)或者動態(tài)的為進(jìn)程分配優(yōu)先級。在一臺軍用計算機(jī)上,可以把將軍所啟動的進(jìn)程設(shè)為優(yōu)先級 100,上校為 90 ,少校為 80,上尉為 70,中尉為 60,以此類推。UNIX 中有一條命令為 nice ,它允許用戶為了照顧他人而自愿降低自己進(jìn)程的優(yōu)先級,但是一般沒人用。

優(yōu)先級也可以由系統(tǒng)動態(tài)分配,用于實(shí)現(xiàn)某種目的。例如,有些進(jìn)程為 I/O 密集型,其多數(shù)時間用來等待 I/O 結(jié)束。當(dāng)這樣的進(jìn)程需要 CPU 時,應(yīng)立即分配 CPU,用來啟動下一個 I/O 請求,這樣就可以在另一個進(jìn)程進(jìn)行計算的同時執(zhí)行 I/O 操作。這類 I/O 密集型進(jìn)程長時間的等待 CPU 只會造成它長時間占用內(nèi)存。使 I/O 密集型進(jìn)程獲得較好的服務(wù)的一種簡單算法是,將其優(yōu)先級設(shè)為 1/f,f 為該進(jìn)程在上一時間片中所占的部分。一個在 50 ms 的時間片中只使用 1 ms 的進(jìn)程將獲得優(yōu)先級 50 ,而在阻塞之前用掉 25 ms 的進(jìn)程將具有優(yōu)先級 2,而使用掉全部時間片的進(jìn)程將得到優(yōu)先級 1。

可以很方便的將一組進(jìn)程按優(yōu)先級分成若干類,并且在各個類之間采用優(yōu)先級調(diào)度,而在各類進(jìn)程的內(nèi)部采用輪轉(zhuǎn)調(diào)度。下面展示了一個四個優(yōu)先級類的系統(tǒng)

它的調(diào)度算法主要描述如下:上面存在優(yōu)先級為 4 類的可運(yùn)行進(jìn)程,首先會按照輪轉(zhuǎn)法為每個進(jìn)程運(yùn)行一個時間片,此時不理會較低優(yōu)先級的進(jìn)程。若第 4 類進(jìn)程為空,則按照輪詢的方式運(yùn)行第三類進(jìn)程。若第 4 類和第 3 類進(jìn)程都為空,則按照輪轉(zhuǎn)法運(yùn)行第 2 類進(jìn)程。如果不對優(yōu)先級進(jìn)行調(diào)整,則低優(yōu)先級的進(jìn)程很容易產(chǎn)生饑餓現(xiàn)象。

多級隊(duì)列

最早使用優(yōu)先級調(diào)度的系統(tǒng)是 CTSS(Compatible TimeSharing System)。CTSS 是一種兼容分時系統(tǒng),它有一個問題就是進(jìn)程切換太慢,其原因是 IBM 7094 內(nèi)存只能放進(jìn)一個進(jìn)程。

IBM 是哥倫比亞大學(xué)計算機(jī)中心在 1964 - 1968 年的計算機(jī)

CTSS 在每次切換前都需要將當(dāng)前進(jìn)程換出到磁盤,并從磁盤上讀入一個新進(jìn)程。CTSS 的設(shè)計者很快就認(rèn)識到,為 CPU 密集型進(jìn)程設(shè)置較長的時間片比頻繁地分給他們很短的時間要更有效(減少交換次數(shù))。另一方面,如前所述,長時間片的進(jìn)程又會影響到響應(yīng)時間,解決辦法是設(shè)置優(yōu)先級類。屬于最高優(yōu)先級的進(jìn)程運(yùn)行一個時間片,次高優(yōu)先級進(jìn)程運(yùn)行 2 個時間片,再下面一級運(yùn)行 4 個時間片,以此類推。當(dāng)一個進(jìn)程用完分配的時間片后,它被移到下一類。

最短進(jìn)程優(yōu)先

對于批處理系統(tǒng)而言,由于最短作業(yè)優(yōu)先常常伴隨著最短響應(yīng)時間,所以如果能夠把它用于交互式進(jìn)程,那將是非常好的。在某種程度上,的確可以做到這一點(diǎn)。交互式進(jìn)程通常遵循下列模式:等待命令、執(zhí)行命令、等待命令、執(zhí)行命令。。。如果我們把每個命令的執(zhí)行都看作一個分離的作業(yè),那么我們可以通過首先運(yùn)行最短的作業(yè)來使響應(yīng)時間最短。這里唯一的問題是如何從當(dāng)前可運(yùn)行進(jìn)程中找出最短的那一個進(jìn)程。

一種方式是根據(jù)進(jìn)程過去的行為進(jìn)行推測,并執(zhí)行估計運(yùn)行時間最短的那一個。假設(shè)每個終端上每條命令的預(yù)估運(yùn)行時間為 T0,現(xiàn)在假設(shè)測量到其下一次運(yùn)行時間為 T1,可以用兩個值的加權(quán)來改進(jìn)估計時間,即aT0+ (1- 1)T1。通過選擇 a 的值,可以決定是盡快忘掉老的運(yùn)行時間,還是在一段長時間內(nèi)始終記住它們。當(dāng) a = 1/2 時,可以得到下面這個序列

可以看到,在三輪過后,T0 在新的估計值中所占比重下降至 1/8。

有時把這種通過當(dāng)前測量值和先前估計值進(jìn)行加權(quán)平均從而得到下一個估計值的技術(shù)稱作 老化(aging)。這種方法會使用很多預(yù)測值基于當(dāng)前值的情況。

保證調(diào)度

一種完全不同的調(diào)度方法是對用戶做出明確的性能保證。一種實(shí)際而且容易實(shí)現(xiàn)的保證是:若用戶工作時有 n 個用戶登錄,則每個用戶將獲得 CPU 處理能力的 1/n。類似地,在一個有 n 個進(jìn)程運(yùn)行的單用戶系統(tǒng)中,若所有的進(jìn)程都等價,則每個進(jìn)程將獲得 1/n 的 CPU 時間。

彩票調(diào)度

對用戶進(jìn)行承諾并在隨后兌現(xiàn)承諾是一件好事,不過很難實(shí)現(xiàn)。但是存在著一種簡單的方式,有一種既可以給出預(yù)測結(jié)果而又有一種比較簡單的實(shí)現(xiàn)方式的算法,就是 彩票調(diào)度(lottery scheduling)算法。

其基本思想是為進(jìn)程提供各種系統(tǒng)資源(例如 CPU 時間)的彩票。當(dāng)做出一個調(diào)度決策的時候,就隨機(jī)抽出一張彩票,擁有彩票的進(jìn)程將獲得該資源。在應(yīng)用到 CPU 調(diào)度時,系統(tǒng)可以每秒持有 50 次抽獎,每個中獎?wù)邔@得比如 20 毫秒的 CPU 時間作為獎勵。

George Orwell 關(guān)于 所有的進(jìn)程是平等的,但是某些進(jìn)程能夠更平等一些。一些重要的進(jìn)程可以給它們額外的彩票,以便增加他們贏得的機(jī)會。如果出售了 100 張彩票,而且有一個進(jìn)程持有了它們中的 20 張,它就會有 20% 的機(jī)會去贏得彩票中獎。在長時間的運(yùn)行中,它就會獲得 20% 的CPU。相反,對于優(yōu)先級調(diào)度程序,很難說明擁有優(yōu)先級 40 究竟是什么意思,這里的規(guī)則很清楚,擁有彩票 f 份額的進(jìn)程大約得到系統(tǒng)資源的 f 份額。

如果希望進(jìn)程之間協(xié)作的話可以交換它們之間的票據(jù)。例如,客戶端進(jìn)程給服務(wù)器進(jìn)程發(fā)送了一條消息后阻塞,客戶端進(jìn)程可能會把自己所有的票據(jù)都交給服務(wù)器,來增加下一次服務(wù)器運(yùn)行的機(jī)會。當(dāng)服務(wù)完成后,它會把彩票還給客戶端讓其有機(jī)會再次運(yùn)行。事實(shí)上,如果沒有客戶機(jī),服務(wù)器也根本不需要彩票。

可以把彩票理解為 buff,這個 buff 有 15% 的幾率能讓你產(chǎn)生 速度之靴 的效果。

公平分享調(diào)度

到目前為止,我們假設(shè)被調(diào)度的都是各個進(jìn)程自身,而不用考慮該進(jìn)程的擁有者是誰。結(jié)果是,如果用戶 1 啟動了 9 個進(jìn)程,而用戶 2 啟動了一個進(jìn)程,使用輪轉(zhuǎn)或相同優(yōu)先級調(diào)度算法,那么用戶 1 將得到 90 % 的 CPU 時間,而用戶 2 將之得到 10 % 的 CPU 時間。

為了阻止這種情況的出現(xiàn),一些系統(tǒng)在調(diào)度前會把進(jìn)程的擁有者考慮在內(nèi)。在這種模型下,每個用戶都會分配一些CPU 時間,而調(diào)度程序會選擇進(jìn)程并強(qiáng)制執(zhí)行。因此如果兩個用戶每個都會有 50% 的 CPU 時間片保證,那么無論一個用戶有多少個進(jìn)程,都將獲得相同的 CPU 份額。

實(shí)時系統(tǒng)中的調(diào)度

實(shí)時系統(tǒng)(real-time) 是一個時間扮演了重要作用的系統(tǒng)。典型的,一種或多種外部物理設(shè)備發(fā)給計算機(jī)一個服務(wù)請求,而計算機(jī)必須在一個確定的時間范圍內(nèi)恰當(dāng)?shù)淖龀龇磻?yīng)。例如,在 CD 播放器中的計算機(jī)會獲得從驅(qū)動器過來的位流,然后必須在非常短的時間內(nèi)將位流轉(zhuǎn)換為音樂播放出來。如果計算時間過長,那么音樂就會聽起來有異常。再比如說醫(yī)院特別護(hù)理部門的病人監(jiān)護(hù)裝置、飛機(jī)中的自動駕駛系統(tǒng)、列車中的煙霧警告裝置等,在這些例子中,正確但是卻緩慢的響應(yīng)要比沒有響應(yīng)甚至還糟糕。

實(shí)時系統(tǒng)可以分為兩類,硬實(shí)時(hard real time) 和 軟實(shí)時(soft real time) 系統(tǒng),前者意味著必須要滿足絕對的截止時間;后者的含義是雖然不希望偶爾錯失截止時間,但是可以容忍。在這兩種情形中,實(shí)時都是通過把程序劃分為一組進(jìn)程而實(shí)現(xiàn)的,其中每個進(jìn)程的行為是可預(yù)測和提前可知的。這些進(jìn)程一般壽命較短,并且極快的運(yùn)行完成。在檢測到一個外部信號時,調(diào)度程序的任務(wù)就是按照滿足所有截止時間的要求調(diào)度進(jìn)程。

實(shí)時系統(tǒng)中的事件可以按照響應(yīng)方式進(jìn)一步分類為周期性(以規(guī)則的時間間隔發(fā)生)事件或 非周期性(發(fā)生時間不可預(yù)知)事件。一個系統(tǒng)可能要響應(yīng)多個周期性事件流,根據(jù)每個事件處理所需的時間,可能甚至無法處理所有事件。例如,如果有 m 個周期事件,事件 i 以周期 Pi 發(fā)生,并需要 Ci 秒 CPU 時間處理一個事件,那么可以處理負(fù)載的條件是

只有滿足這個條件的實(shí)時系統(tǒng)稱為可調(diào)度的,這意味著它實(shí)際上能夠被實(shí)現(xiàn)。一個不滿足此檢驗(yàn)標(biāo)準(zhǔn)的進(jìn)程不能被調(diào)度,因?yàn)檫@些進(jìn)程共同需要的 CPU 時間總和大于 CPU 能提供的時間。

舉一個例子,考慮一個有三個周期性事件的軟實(shí)時系統(tǒng),其周期分別是 100 ms、200 m 和 500 ms。如果這些事件分別需要 50 ms、30 ms 和 100 ms 的 CPU 時間,那么該系統(tǒng)時可調(diào)度的,因?yàn)?0.5 + 0.15 + 0.2 < 1。如果此時有第四個事件加入,其周期為 1 秒,那么此時這個事件如果不超過 150 ms,那么仍然是可以調(diào)度的。忽略上下文切換的時間。

實(shí)時系統(tǒng)的調(diào)度算法可以是靜態(tài)的或動態(tài)的。前者在系統(tǒng)開始運(yùn)行之前做出調(diào)度決策;后者在運(yùn)行過程中進(jìn)行調(diào)度決策。只有在可以提前掌握所完成的工作以及必須滿足的截止時間等信息時,靜態(tài)調(diào)度才能工作,而動態(tài)調(diào)度不需要這些限制。

調(diào)度策略和機(jī)制

到目前為止,我們隱含的假設(shè)系統(tǒng)中所有進(jìn)程屬于不同的分組用戶并且進(jìn)程間存在相互競爭 CPU 的情況。通常情況下確實(shí)如此,但有時也會發(fā)生一個進(jìn)程會有很多子進(jìn)程并在其控制下運(yùn)行的情況。例如,一個數(shù)據(jù)庫管理系統(tǒng)進(jìn)程會有很多子進(jìn)程。每一個子進(jìn)程可能處理不同的請求,或者每個子進(jìn)程實(shí)現(xiàn)不同的功能(如請求分析、磁盤訪問等)。主進(jìn)程完全可能掌握哪一個子進(jìn)程最重要(或最緊迫),而哪一個最不重要。但是,以上討論的調(diào)度算法中沒有一個算法從用戶進(jìn)程接收有關(guān)的調(diào)度決策信息,這就導(dǎo)致了調(diào)度程序很少能夠做出最優(yōu)的選擇。

解決問題的辦法是將 調(diào)度機(jī)制(scheduling mechanism) 和 調(diào)度策略(scheduling policy) 分開,這是長期一貫的原則。這也就意味著調(diào)度算法在某種方式下被參數(shù)化了,但是參數(shù)可以被用戶進(jìn)程填寫。讓我們首先考慮數(shù)據(jù)庫的例子。假設(shè)內(nèi)核使用優(yōu)先級調(diào)度算法,并提供了一條可供進(jìn)程設(shè)置優(yōu)先級的系統(tǒng)調(diào)用。這樣,盡管父進(jìn)程本身并不參與調(diào)度,但它可以控制如何調(diào)度子進(jìn)程的細(xì)節(jié)。調(diào)度機(jī)制位于內(nèi)核,而調(diào)度策略由用戶進(jìn)程決定,調(diào)度策略和機(jī)制分離是一種關(guān)鍵性思路。

線程調(diào)度

當(dāng)若干進(jìn)程都有多個線程時,就存在兩個層次的并行:進(jìn)程和線程。在這樣的系統(tǒng)中調(diào)度處理有本質(zhì)的差別,這取決于所支持的是用戶級線程還是內(nèi)核級線程(或兩者都支持)。

首先考慮用戶級線程,由于內(nèi)核并不知道有線程存在,所以內(nèi)核還是和以前一樣地操作,選取一個進(jìn)程,假設(shè)為 A,并給予 A 以時間片控制。A 中的線程調(diào)度程序決定哪個線程運(yùn)行。假設(shè)為 A1。由于多道線程并不存在時鐘中斷,所以這個線程可以按其意愿任意運(yùn)行多長時間。如果該線程用完了進(jìn)程的全部時間片,內(nèi)核就會選擇另一個進(jìn)程繼續(xù)運(yùn)行。

在進(jìn)程 A 終于又一次運(yùn)行時,線程 A1 會接著運(yùn)行。該線程會繼續(xù)耗費(fèi) A 進(jìn)程的所有時間,直到它完成工作。不過,線程運(yùn)行不會影響到其他進(jìn)程。其他進(jìn)程會得到調(diào)度程序所分配的合適份額,不會考慮進(jìn)程 A 內(nèi)部發(fā)生的事情。

現(xiàn)在考慮 A 線程每次 CPU 計算的工作比較少的情況,例如:在 50 ms 的時間片中有 5 ms 的計算工作。于是,每個線程運(yùn)行一會兒,然后把 CPU 交回給線程調(diào)度程序。這樣在內(nèi)核切換到進(jìn)程 B 之前,就會有序列 A1,A2,A3,A1,A2,A3,A1,A2,A3,A1 。如下所示

運(yùn)行時系統(tǒng)使用的調(diào)度算法可以是上面介紹算法的任意一種。從實(shí)用方面考慮,輪轉(zhuǎn)調(diào)度和優(yōu)先級調(diào)度更為常用。唯一的局限是,缺乏一個時鐘中斷運(yùn)行過長的線程。但由于線程之間的合作關(guān)系,這通常也不是問題。

現(xiàn)在考慮使用內(nèi)核線程的情況,內(nèi)核選擇一個特定的線程運(yùn)行。它不用考慮線程屬于哪個進(jìn)程,不過如果有必要的話,也可以這么做。對被選擇的線程賦予一個時間片,而且如果超過了時間片,就會強(qiáng)制掛起該線程。一個線程在 50 ms 的時間片內(nèi),5 ms 之后被阻塞,在 30 ms 的時間片中,線程的順序會是 A1,B1,A2,B2,A3,B3。如下圖所示

用戶級線程和內(nèi)核級線程之間的主要差別在于性能。用戶級線程的切換需要少量的機(jī)器指令(想象一下Java程序的線程切換),而內(nèi)核線程需要完整的上下文切換,修改內(nèi)存映像,使高速緩存失效,這會導(dǎo)致了若干數(shù)量級的延遲。另一方面,在使用內(nèi)核級線程時,一旦線程阻塞在 I/O 上就不需要在用戶級線程中那樣將整個進(jìn)程掛起。

從進(jìn)程 A 的一個線程切換到進(jìn)程 B 的一個線程,其消耗要遠(yuǎn)高于運(yùn)行進(jìn)程 A 的兩個線程(涉及修改內(nèi)存映像,修改高速緩存),內(nèi)核對這種切換的消耗是了解到,可以通過這些信息作出決定。

文章參考:

《現(xiàn)代操作系統(tǒng)》

《Modern Operating System》forth edition

https://www./computing/news-wires-white-papers-and-books/interactive-systems

https://j00ru./syscalls/nt/32/

https://www./process_hierarchy.xhtml

https://en./wiki/Runtime_system

https://en./wiki/Execution_model

https://zhidao.baidu.com/question/113227654.html

https://baike.baidu.com/item/等待隊(duì)列/9223804?fr=aladdin

http://www./cu/computinghistory/7094.html

https://baike.baidu.com/item/中斷向量/4947039?fr=aladdin

    本站是提供個人知識管理的網(wǎng)絡(luò)存儲空間,所有內(nèi)容均由用戶發(fā)布,不代表本站觀點(diǎn)。請注意甄別內(nèi)容中的聯(lián)系方式、誘導(dǎo)購買等信息,謹(jǐn)防詐騙。如發(fā)現(xiàn)有害或侵權(quán)內(nèi)容,請點(diǎn)擊一鍵舉報。
    轉(zhuǎn)藏 分享 獻(xiàn)花(0

    0條評論

    發(fā)表

    請遵守用戶 評論公約

    類似文章 更多