咱們日常說的進程和線程更多的是基於編程語言的角度來講的,那麼你真的瞭解什麼是線程和進程嗎?那麼咱們就從操做系統的角度來了解一下什麼是進程和線程。html
操做系統中最核心的概念就是 進程
,進程是對正在運行中的程序的一個抽象。操做系統的其餘全部內容都是圍繞着進程展開的。進程是操做系統提供的最古老也是最重要的概念之一。即便能夠使用的 CPU 只有一個,它們也支持(僞)併發
操做。它們會將一個單獨的 CPU 抽象爲多個虛擬機的 CPU。能夠說:沒有進程的抽象,現代操做系統將不復存在。java
全部現代的計算機會在同一時刻作不少事情,過去使用計算機的人(單 CPU)可能徹底沒法理解如今這種變化,舉個例子更能說明這一點:首先考慮一個 Web 服務器,請求都來自於 Web 網頁。當一個請求到達時,服務器會檢查當前頁是否在緩存中,若是是在緩存中,就直接把緩存中的內容返回。若是緩存中沒有的話,那麼請求就會交給磁盤來處理。可是,從 CPU 的角度來看,磁盤請求須要更長的時間,由於磁盤請求會很慢。當硬盤請求完成時,更多其餘請求才會進入。若是有多個磁盤的話,能夠在第一個請求完成前就能夠連續的對其餘磁盤發出部分或所有請求。很顯然,這是一種並發現象,須要有併發控制條件來控制並發現象。程序員
如今考慮只有一個用戶的 PC。當系統啓動時,許多進程也在後臺啓動,用戶一般不知道這些進程的啓動,試想一下,當你本身的計算機啓動的時候,你能知道哪些進程是須要啓動的麼?這些後臺進程多是一個須要輸入電子郵件的電子郵件進程,或者是一個計算機病毒查殺進程來週期性的更新病毒庫。某個用戶進程可能會在全部用戶上網的時候打印文件以及刻錄 CD-ROM,這些活動都須要管理。因而一個支持多進程的多道程序系統就會顯得頗有必要了。web
在許多多道程序系統中,CPU 會在進程
間快速切換,使每一個程序運行幾十或者幾百毫秒。然而,嚴格意義來講,在某一個瞬間,CPU 只能運行一個進程,然而咱們若是把時間定位爲 1 秒內的話,它可能運行多個進程。這樣就會讓咱們產生並行
的錯覺。有時候人們說的 僞並行(pseudoparallelism)
就是這種狀況,以此來區分多處理器系統(該系統由兩個或多個 CPU 來共享同一個物理內存)算法
再來詳細解釋一下僞並行:
僞並行
是指單核或多核處理器同時執行多個進程,從而使程序更快。 經過以很是有限的時間間隔在程序之間快速切換CPU,所以會產生並行感。 缺點是 CPU 時間可能分配給下一個進程,也可能不分配給下一個進程。shell
由於 CPU 執行速度很快,進程間的換進換出也很是迅速,所以咱們很難對多個並行進程進行跟蹤,因此,在通過多年的努力後,操做系統的設計者開發了用於描述並行的一種概念模型(順序進程),使得並行更加容易理解和分析,對該模型的探討,也是本篇文章的主題。下面咱們就來探討一下進程模型數據庫
在進程模型中,全部計算機上運行的軟件,一般也包括操做系統,被組織爲若干順序進程(sequential processes)
,簡稱爲 進程(process)
。一個進程就是一個正在執行的程序的實例,進程也包括程序計數器、寄存器和變量的當前值。從概念上來講,每一個進程都有各自的虛擬 CPU,可是實際狀況是 CPU 會在各個進程之間進行來回切換。編程
如上圖所示,這是一個具備 4 個程序的多道處理程序,在進程不斷切換的過程當中,程序計數器也在不一樣的變化。windows
在上圖中,這 4 道程序被抽象爲 4 個擁有各自控制流程(即每一個本身的程序計數器)的進程,而且每一個程序都獨立的運行。固然,實際上只有一個物理程序計數器,每一個程序要運行時,其邏輯程序計數器會裝載到物理程序計數器中。當程序運行結束後,其物理程序計數器就會是真正的程序計數器,而後再把它放回進程的邏輯計數器中。數組
從下圖咱們能夠看到,在觀察足夠長的一段時間後,全部的進程都運行了,但在任何一個給定的瞬間僅有一個進程真正運行。
所以,當咱們說一個 CPU 只能真正一次運行一個進程的時候,即便有 2 個核(或 CPU),每個核也只能一次運行一個線程。
因爲 CPU 會在各個進程之間來回快速切換,因此每一個進程在 CPU 中的運行時間是沒法肯定的。而且當同一個進程再次在 CPU 中運行時,其在 CPU 內部的運行時間每每也是不固定的。進程和程序之間的區別是很是微妙的,可是經過一個例子可讓你加以區分:想一想一位會作飯的計算機科學家正在爲他的女兒製做生日蛋糕。他有作生日蛋糕的食譜,廚房裏有所需的原諒:麪粉、雞蛋、糖、香草汁等。在這個比喻中,作蛋糕的食譜就是程序、計算機科學家就是 CPU、而作蛋糕的各類原諒都是輸入數據。進程就是科學家閱讀食譜、取來各類原料以及烘焙蛋糕等一系例了動做的總和。
如今假設科學家的兒子跑過來告訴他,說他的頭被蜜蜂蜇了一下,那麼此時科學家會記錄出來他作蛋糕這個過程到了哪一步,而後拿出急救手冊,按照上面的步驟給他兒子實施救助。這裏,會涉及到進程之間的切換,科學家(CPU)會從作蛋糕(進程)切換到實施醫療救助(另外一個進程)。等待傷口處理完畢後,科學家會回到剛剛記錄作蛋糕的那一步,繼續製做。
這裏的關鍵思想是認識到一個進程所需的條件
,進程是某一類特定活動的總和,它有程序、輸入輸出以及狀態。單個處理器能夠被若干進程共享,它使用某種調度算法決定什麼時候中止一個進程的工做,並轉而爲另一個進程提供服務。另外須要注意的是,若是一個進程運行了兩遍,則被認爲是兩個進程。那麼咱們瞭解到進程模型後,那麼進程是如何建立的呢?
操做系統須要一些方式來建立進程。下面是一些建立進程的方式
啓動操做系統時,一般會建立若干個進程。其中有些是前臺進程(numerous processes)
,也就是同用戶進行交互並替他們完成工做的進程。一些運行在後臺,並不與特定的用戶進行交互,例如,設計一個進程來接收發來的電子郵件,這個進程大部分的時間都在休眠,可是隻要郵件到來後這個進程就會被喚醒。還能夠設計一個進程來接收對該計算機上網頁的傳入請求,在請求到達的進程喚醒來處理網頁的傳入請求。進程運行在後臺用來處理一些活動像是 e-mail,web 網頁,新聞,打印等等被稱爲 守護進程(daemons)
。大型系統會有不少守護進程。在 UNIX 中,ps
程序能夠列出正在運行的進程, 在 Windows 中,能夠使用任務管理器。
除了在啓動階段建立進程以外,一些新的進程也能夠在後面建立。一般,一個正在運行的進程會發出系統調用
用來建立一個或多個新進程來幫助其完成工做。例如,若是有大量的數據須要通過網絡調取並進行順序處理,那麼建立一個進程讀數據,並把數據放到共享緩衝區中,而讓第二個進程取走並正確處理會比較容易些。在多處理器中,讓每一個進程運行在不一樣的 CPU 上也能夠使工做作的更快。
在許多交互式系統中,輸入一個命令或者雙擊圖標就能夠啓動程序,以上任意一種操做均可以選擇開啓一個新的進程,在基本的 UNIX 系統中運行 X,新進程將接管啓動它的窗口。在 Windows 中啓動進程時,它通常沒有窗口,可是它能夠建立一個或多個窗口。每一個窗口均可以運行進程。經過鼠標或者命令就能夠切換窗口並與進程進行交互。
交互式系統是以人與計算機之間大量交互爲特徵的計算機系統,好比遊戲、web瀏覽器,IDE 等集成開發環境。
最後一種建立進程的情形會在大型機的批處理系統
中應用。用戶在這種系統中提交批處理做業。當操做系統決定它有資源來運行另外一個任務時,它將建立一個新進程並從其中的輸入隊列中運行下一個做業。
從技術上講,在全部這些狀況下,讓現有流程執行流程是經過建立系統調用來建立新流程的。該進程多是正在運行的用戶進程,是從鍵盤或鼠標調用的系統進程或批處理程序。這些就是系統調用建立新進程的過程。該系統調用告訴操做系統建立一個新進程,並直接或間接指示在其中運行哪一個程序。
在 UNIX 中,僅有一個系統調用來建立一個新的進程,這個系統調用就是 fork
。這個調用會建立一個與調用進程相關的副本。在 fork 後,一個父進程和子進程會有相同的內存映像,相同的環境字符串和相同的打開文件。一般,子進程會執行 execve
或者一個簡單的系統調用來改變內存映像並運行一個新的程序。例如,當一個用戶在 shell 中輸出 sort 命令時,shell 會 fork 一個子進程而後子進程去執行 sort 命令。這兩步過程的緣由是容許子進程在 fork 以後但在 execve 以前操做其文件描述符,以完成標準輸入,標準輸出和標準錯誤的重定向。
在 Windows 中,狀況正相反,一個簡單的 Win32 功能調用 CreateProcess
,會處理流程建立並將正確的程序加載到新的進程中。這個調用會有 10 個參數,包括了須要執行的程序、輸入給程序的命令行參數、各類安全屬性、有關打開的文件是否繼承控制位、優先級信息、進程所須要建立的窗口規格以及指向一個結構的指針,在該結構中新建立進程的信息被返回給調用者。除了 CreateProcess
Win 32 中大概有 100 個其餘的函數用於處理進程的管理,同步以及相關的事務。下面是 UNIX 操做系統和 Windows 操做系統系統調用的對比
UNIX | Win32 | 說明 |
---|---|---|
fork | CreateProcess | 建立一個新進程 |
waitpid | WaitForSingleObject | 等待一個進程退出 |
execve | none | CraeteProcess = fork + servvice |
exit | ExitProcess | 終止執行 |
open | CreateFile | 建立一個文件或打開一個已有的文件 |
close | CloseHandle | 關閉文件 |
read | ReadFile | 從單個文件中讀取數據 |
write | WriteFile | 向單個文件寫數據 |
lseek | SetFilePointer | 移動文件指針 |
stat | GetFileAttributesEx | 得到不一樣的文件屬性 |
mkdir | CreateDirectory | 建立一個新的目錄 |
rmdir | RemoveDirectory | 移除一個空的目錄 |
link | none | Win32 不支持 link |
unlink | DeleteFile | 銷燬一個已有的文件 |
mount | none | Win32 不支持 mount |
umount | none | Win32 不支持 mount,因此也不支持mount |
chdir | SetCurrentDirectory | 切換當前工做目錄 |
chmod | none | Win32 不支持安全 |
kill | none | Win32 不支持信號 |
time | GetLocalTime | 獲取當前時間 |
在 UNIX 和 Windows 中,進程建立以後,父進程和子進程有各自不一樣的地址空間。若是其中某個進程在其地址空間中修改了一個詞,這個修改將對另外一個進程不可見。在 UNIX 中,子進程的地址空間是父進程的一個拷貝,可是確是兩個不一樣的地址空間;不可寫的內存區域是共享的。某些 UNIX 實現是正是在二者之間共享,由於它不能被修改。或者,子進程共享父進程的全部內存,可是這種狀況下內存經過 寫時複製(copy-on-write)
共享,這意味着一旦二者之一想要修改部份內存,則這塊內存首先被明確的複製,以確保修改發生在私有內存區域。再次強調,可寫的內存是不能被共享的。可是,對於一個新建立的進程來講,確實有可能共享建立者的資源,好比能夠共享打開的文件。在 Windows 中,從一開始父進程的地址空間和子進程的地址空間就是不一樣的。
進程在建立以後,它就開始運行並作完成任務。然而,沒有什麼事兒是永不停歇的,包括進程也同樣。進程遲早會發生終止,可是一般是因爲如下狀況觸發的
正常退出(自願的)
錯誤退出(自願的)
嚴重錯誤(非自願的)
被其餘進程殺死(非自願的)
多數進程是因爲完成了工做而終止。當編譯器完成了所給定程序的編譯以後,編譯器會執行一個系統調用告訴操做系統它完成了工做。這個調用在 UNIX 中是 exit
,在 Windows 中是 ExitProcess
。面向屏幕中的軟件也支持自願終止操做。字處理軟件、Internet 瀏覽器和相似的程序中總有一個供用戶點擊的圖標或菜單項,用來通知進程刪除它鎖打開的任何臨時文件,而後終止。
進程發生終止的第二個緣由是發現嚴重錯誤,例如,若是用戶執行以下命令
cc foo.c
爲了可以編譯 foo.c 可是該文件不存在,因而編譯器就會發出聲明並退出。在給出了錯誤參數時,面向屏幕的交互式進程一般並不會直接退出,由於這從用戶的角度來講並不合理,用戶須要知道發生了什麼並想要進行重試,因此這時候應用程序一般會彈出一個對話框告知用戶發生了系統錯誤,是須要重試仍是退出。
進程終止的第三個緣由是由進程引發的錯誤,一般是因爲程序中的錯誤所致使的。例如,執行了一條非法指令,引用不存在的內存,或者除數是 0 等。在有些系統好比 UNIX 中,進程能夠通知操做系統,它但願自行處理某種類型的錯誤,在這類錯誤中,進程會收到信號(中斷),而不是在這類錯誤出現時直接終止進程。
第四個終止進程的緣由是,某個進程執行系統調用告訴操做系統殺死某個進程。在 UNIX 中,這個系統調用是 kill。在 Win32 中對應的函數是 TerminateProcess
(注意不是系統調用)。
在一些系統中,當一個進程建立了其餘進程後,父進程和子進程就會以某種方式進行關聯。子進程它本身就會建立更多進程,從而造成一個進程層次結構。
在 UNIX 中,進程和它的全部子進程以及子進程的子進程共同組成一個進程組。當用戶從鍵盤中發出一個信號後,該信號被髮送給當前與鍵盤相關的進程組中的全部成員(它們一般是在當前窗口建立的全部活動進程)。每一個進程能夠分別捕獲該信號、忽略該信號或採起默認的動做,即被信號 kill 掉。
這裏有另外一個例子,能夠用來講明層次的做用,考慮 UNIX
在啓動時如何初始化本身。一個稱爲 init
的特殊進程出如今啓動映像中 。當 init 進程開始運行時,它會讀取一個文件,文件會告訴它有多少個終端。而後爲每一個終端建立一個新進程。這些進程等待用戶登陸。若是登陸成功,該登陸進程就執行一個 shell 來等待接收用戶輸入指令,這些命令可能會啓動更多的進程,以此類推。所以,整個操做系統中全部的進程都隸屬於一個單個以 init 爲根的進程樹。
相反,Windows 中沒有進程層次的概念,Windows 中全部進程都是平等的,惟一相似於層次結構的是在建立進程的時候,父進程獲得一個特別的令牌(稱爲句柄),該句柄能夠用來控制子進程。然而,這個令牌可能也會移交給別的操做系統,這樣就不存在層次結構了。而在 UNIX 中,進程不能剝奪其子進程的 進程權
。(這樣看來,仍是 Windows 比較渣
)。
儘管每一個進程是一個獨立的實體,有其本身的程序計數器和內部狀態,可是,進程之間仍然須要相互幫助。例如,一個進程的結果能夠做爲另外一個進程的輸入,在 shell 命令中
cat chapter1 chapter2 chapter3 | grep tree
第一個進程是 cat
,將三個文件級聯並輸出。第二個進程是 grep
,它從輸入中選擇具備包含關鍵字 tree
的內容,根據這兩個進程的相對速度(這取決於兩個程序的相對複雜度和各自所分配到的 CPU 時間片),可能會發生下面這種狀況,grep
準備就緒開始運行,可是輸入進程尚未完成,因而必須阻塞 grep 進程,直到輸入完畢。
當一個進程開始運行時,它可能會經歷下面這幾種狀態
圖中會涉及三種狀態
運行態
,運行態指的就是進程實際佔用 CPU 時間片運行時就緒態
,就緒態指的是可運行,但由於其餘進程正在運行而處於就緒狀態阻塞態
,除非某種外部事件發生,不然進程不能運行邏輯上來講,運行態和就緒態是很類似的。這兩種狀況下都表示進程可運行
,可是第二種狀況沒有得到 CPU 時間分片。第三種狀態與前兩種狀態不一樣的緣由是這個進程不能運行,CPU 空閒時也不能運行。
三種狀態會涉及四種狀態間的切換,在操做系統發現進程不能繼續執行時會發生狀態1
的輪轉,在某些系統中進程執行系統調用,例如 pause
,來獲取一個阻塞的狀態。在其餘系統中包括 UNIX,當進程從管道或特殊文件(例如終端)中讀取沒有可用的輸入時,該進程會被自動終止。
轉換 2 和轉換 3 都是由進程調度程序(操做系統的一部分)引發的,進程自己不知道調度程序的存在。轉換 2 的出現說明進程調度器認定當前進程已經運行了足夠長的時間,是時候讓其餘進程運行 CPU 時間片了。當全部其餘進程都運行事後,這時候該是讓第一個進程從新得到 CPU 時間片的時候了,就會發生轉換 3。
程序調度指的是,決定哪一個進程優先被運行和運行多久,這是很重要的一點。已經設計出許多算法來嘗試平衡系統總體效率與各個流程之間的競爭需求。
當進程等待的一個外部事件發生時(如從外部輸入一些數據後),則發生轉換 4。若是此時沒有其餘進程在運行,則馬上觸發轉換 3,該進程便開始運行,不然該進程會處於就緒階段,等待 CPU 空閒後再輪到它運行。
從上面的觀點引入了下面的模型
操做系統最底層的就是調度程序,在它上面有許多進程。全部關於中斷處理、啓動進程和中止進程的具體細節都隱藏在調度程序中。事實上,調度程序只是一段很是小的程序。
操做系統爲了執行進程間的切換,會維護着一張表格,這張表就是 進程表(process table)
。每一個進程佔用一個進程表項。該表項包含了進程狀態的重要信息,包括程序計數器、堆棧指針、內存分配情況、所打開文件的狀態、帳號和調度信息,以及其餘在進程由運行態轉換到就緒態或阻塞態時所必須保存的信息,從而保證該進程隨後能再次啓動,就像從未被中斷過同樣。
下面展現了一個典型系統中的關鍵字段
第一列內容與進程管理
有關,第二列內容與 存儲管理
有關,第三列內容與文件管理
有關。
存儲管理的 text segment 、 data segment、stack segment 更多瞭解見下面這篇文章
如今咱們應該對進程表有個大體的瞭解了,就能夠在對單個 CPU 上如何運行多個順序進程的錯覺作更多的解釋。與每一 I/O 類相關聯的是一個稱做 中斷向量(interrupt vector)
的位置(靠近內存底部的固定區域)。它包含中斷服務程序的入口地址。假設當一個磁盤中斷髮生時,用戶進程 3 正在運行,則中斷硬件將程序計數器、程序狀態字、有時還有一個或多個寄存器壓入堆棧,計算機隨即跳轉到中斷向量所指示的地址。這就是硬件所作的事情。而後軟件就隨即接管一切剩餘的工做。
當中斷結束後,操做系統會調用一個 C 程序來處理中斷剩下的工做。在完成剩下的工做後,會使某些進程就緒,接着調用調度程序,決定隨後運行哪一個進程。而後將控制權轉移給一段彙編語言代碼,爲當前的進程裝入寄存器值以及內存映射並啓動該進程運行,下面顯示了中斷處理和調度的過程。
硬件壓入堆棧程序計數器等
硬件從中斷向量裝入新的程序計數器
彙編語言過程保存寄存器的值
彙編語言過程設置新的堆棧
C 中斷服務器運行(典型的讀和緩存寫入)
調度器決定下面哪一個程序先運行
C 過程返回至彙編代碼
彙編語言過程開始運行新的當前進程
一個進程在執行過程當中可能被中斷數千次,但關鍵每次中斷後,被中斷的進程都返回到與中斷髮生前徹底相同的狀態。
在傳統的操做系統中,每一個進程都有一個地址空間和一個控制線程。事實上,這是大部分進程的定義。不過,在許多狀況下,常常存在同一地址空間中運行多個控制線程的情形,這些線程就像是分離的進程。下面咱們就着重探討一下什麼是線程
或許這個疑問也是你的疑問,爲何要在進程的基礎上再建立一個線程的概念,準確的說,這實際上是進程模型和線程模型的討論,回答這個問題,可能須要分三步來回答
更輕量級
,因爲線程更輕,因此它比進程更容易建立,也更容易撤銷。在許多系統中,建立一個線程要比建立一個進程快 10 - 100 倍。如今考慮一個線程使用的例子:一個萬維網服務器,對頁面的請求發送給服務器,而所請求的頁面發送回客戶端。在多數 web 站點上,某些頁面較其餘頁面相比有更多的訪問。例如,索尼的主頁比任何一個照相機詳情介紹頁面具備更多的訪問,Web 服務器能夠把得到大量訪問的頁面集合保存在內存中,避免到磁盤去調入這些頁面,從而改善性能。這種頁面的集合稱爲 高速緩存(cache)
,高速緩存也應用在許多場合中,好比說 CPU 緩存。
上面是一個 web 服務器的組織方式,一個叫作 調度線程(dispatcher thread)
的線程從網絡中讀入工做請求,在調度線程檢查完請求後,它會選擇一個空閒的(阻塞的)工做線程來處理請求,一般是將消息的指針寫入到每一個線程關聯的特殊字中。而後調度線程會喚醒正在睡眠中的工做線程,把工做線程的狀態從阻塞態變爲就緒態。
當工做線程啓動後,它會檢查請求是否在 web 頁面的高速緩存中存在,這個高速緩存是全部線程均可以訪問的。若是高速緩存不存在這個 web 頁面的話,它會調用一個 read
操做從磁盤中獲取頁面而且阻塞線程直到磁盤操做完成。當線程阻塞在硬盤操做的期間,爲了完成更多的工做,調度線程可能挑選另外一個線程運行,也可能把另外一個當前就緒的工做線程投入運行。
這種模型容許將服務器編寫爲順序線程的集合,在分派線程的程序中包含一個死循環,該循環用來得到工做請求而且把請求派給工做線程。每一個工做線程的代碼包含一個從調度線程接收的請求,而且檢查 web 高速緩存中是否存在所需頁面,若是有,直接把該頁面返回給客戶,接着工做線程阻塞,等待一個新請求的到達。若是沒有,工做線程就從磁盤調入該頁面,將該頁面返回給客戶機,而後工做線程阻塞,等待一個新請求。
下面是調度線程和工做線程的代碼,這裏假設 TRUE 爲常數 1 ,buf 和 page 分別是保存工做請求和 Web 頁面的相應結構。
調度線程的大體邏輯
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); }
如今考慮沒有多線程的狀況下,如何編寫 Web 服務器。咱們很容易的就想象爲單個線程了,Web 服務器的主循環獲取請求並檢查請求,並爭取在下一個請求以前完成工做。在等待磁盤操做時,服務器空轉,而且不處理任何到來的其餘請求。結果會致使每秒中只有不多的請求被處理,因此這個例子可以說明多線程提升了程序的並行性並提升了程序的性能。
到如今爲止,咱們已經有了兩種解決方案,單線程解決方案和多線程解決方案,其實還有一種解決方案就是 狀態機解決方案
,它的流程以下
若是目前只有一個非阻塞版本的 read 系統調用能夠使用,那麼當請求到達服務器時,這個惟一的 read 調用的線程會進行檢查,若是可以從高速緩存中獲得響應,那麼直接返回,若是不能,則啓動一個非阻塞的磁盤操做
服務器在表中記錄當前請求的狀態,而後進入並獲取下一個事件,緊接着下一個事件可能就是一個新工做的請求或是磁盤對先前操做的回答。若是是新工做的請求,那麼就開始處理請求。若是是磁盤的響應,就從表中取出對應的狀態信息進行處理。對於非阻塞式磁盤 I/O 而言,這種響應通常都是信號中斷響應。
每次服務器從某個請求工做的狀態切換到另外一個狀態時,都必須顯示的保存或者從新裝入相應的計算狀態。這裏,每一個計算都有一個被保存的狀態,存在一個會發生且使得相關狀態發生改變的事件集合,咱們把這類設計稱爲有限狀態機(finite-state machine)
,有限狀態機杯普遍的應用在計算機科學中。
這三種解決方案各有各的特性,多線程使得順序進程的思想得以保留下來,而且實現了並行性,可是順序進程會阻塞系統調用;單線程服務器保留了阻塞系統的簡易性,可是卻放棄了性能。有限狀態機的處理方法運用了非阻塞調用和中斷,經過並行實現了高性能,可是給編程增長了困難。
模型 | 特性 |
---|---|
單線程 | 無並行性,性能較差,阻塞系統調用 |
多線程 | 有並行性,阻塞系統調用 |
有限狀態機 | 並行性,非阻塞系統調用、中斷 |
理解進程的另外一個角度是,用某種方法把相關的資源集中在一塊兒。進程有存放程序正文和數據以及其餘資源的地址空間。這些資源包括打開的文件、子進程、即將發生的定時器、信號處理程序、帳號信息等。把這些信息放在進程中會比較容易管理。
另外一個概念是,進程中擁有一個執行的線程,一般簡寫爲 線程(thread)
。線程會有程序計數器,用來記錄接着要執行哪一條指令;線程還擁有寄存器,用來保存線程當前正在使用的變量;線程還會有堆棧,用來記錄程序的執行路徑。儘管線程必須在某個進程中執行,可是進程和線程完徹底全是兩個不一樣的概念,而且他們能夠分開處理。進程用於把資源集中在一塊兒,而線程則是 CPU 上調度執行的實體。
線程給進程模型增長了一項內容,即在同一個進程中,容許彼此之間有較大的獨立性且互不干擾。在一個進程中並行運行多個線程相似於在一臺計算機上運行多個進程。在多個線程中,各個線程共享同一地址空間和其餘資源。在多個進程中,進程共享物理內存、磁盤、打印機和其餘資源。由於線程會包含有一些進程的屬性,因此線程被稱爲輕量的進程(lightweight processes)
。多線程(multithreading)
一詞還用於描述在同一進程中多個線程的狀況。
下圖咱們能夠看到三個傳統的進程,每一個進程有本身的地址空間和單個控制線程。每一個線程都在不一樣的地址空間中運行
下圖中,咱們能夠看到有一個進程三個線程的狀況。每一個線程都在相同的地址空間中運行。
線程不像是進程那樣具有較強的獨立性。同一個進程中的全部線程都會有徹底同樣的地址空間,這意味着它們也共享一樣的全局變量。因爲每一個線程均可以訪問進程地址空間內每一個內存地址,所以一個線程能夠讀取、寫入甚至擦除另外一個線程的堆棧。線程之間除了共享同一內存空間外,還具備以下不一樣的內容
上圖左邊的是同一個進程中每一個線程共享
的內容,上圖右邊是每一個線程
中的內容。也就是說左邊的列表是進程的屬性,右邊的列表是線程的屬性。
和進程同樣,線程能夠處於下面這幾種狀態:運行中、阻塞、就緒和終止(進程圖中沒有畫)。正在運行的線程擁有 CPU 時間片而且狀態是運行中。一個被阻塞的線程會等待某個釋放它的事件。例如,當一個線程執行從鍵盤讀入數據的系統調用時,該線程就被阻塞直到有輸入爲止。線程一般會被阻塞,直到它等待某個外部事件的發生或者有其餘線程來釋放它。線程之間的狀態轉換和進程之間的狀態轉換是同樣的。
每一個線程都會有本身的堆棧,以下圖所示
進程一般會從當前的某個單線程開始,而後這個線程經過調用一個庫函數(好比 thread_create
)建立新的線程。線程建立的函數會要求指定新建立線程的名稱。建立的線程一般都返回一個線程標識符,該標識符就是新線程的名字。
當一個線程完成工做後,能夠經過調用一個函數(好比 thread_exit
)來退出。緊接着線程消失,狀態變爲終止,不能再進行調度。在某些線程的運行過程當中,能夠經過調用函數例如 thread_join
,表示一個線程能夠等待另外一個線程退出。這個過程阻塞調用線程直到等待特定的線程退出。在這種狀況下,線程的建立和終止很是相似於進程的建立和終止。
另外一個常見的線程是調用 thread_yield
,它容許線程自動放棄 CPU 從而讓另外一個線程運行。這樣一個調用仍是很重要的,由於不一樣於進程,線程是沒法利用時鐘中斷強制讓線程讓出 CPU 的。
爲了使編寫可移植線程程序成爲可能,IEEE 在 IEEE 標準 1003.1c 中定義了線程標準。線程包被定義爲 Pthreads
。大部分的 UNIX 系統支持它。這個標準定義了 60 多種功能調用,一一列舉不太現實,下面爲你列舉了一些經常使用的系統調用。
POSIX線程(一般稱爲pthreads)是一種獨立於語言而存在的執行模型,以及並行執行模型。它容許程序控制時間上重疊的多個不一樣的工做流程。每一個工做流程都稱爲一個線程,能夠經過調用POSIX Threads API來實現對這些流程的建立和控制。能夠把它理解爲線程的標準。
POSIX Threads 的實如今許多相似且符合POSIX的操做系統上可用,例如 FreeBSD、NetBSD、OpenBSD、Linux、macOS、Android、Solaris,它在現有 Windows API 之上實現了pthread。
IEEE 是世界上最大的技術專業組織,致力於爲人類的利益而發展技術。
線程調用 | 描述 |
---|---|
pthread_create | 建立一個新線程 |
pthread_exit | 結束調用的線程 |
pthread_join | 等待一個特定的線程退出 |
pthread_yield | 釋放 CPU 來運行另一個線程 |
pthread_attr_init | 建立並初始化一個線程的屬性結構 |
pthread_attr_destory | 刪除一個線程的屬性結構 |
全部的 Pthreads 都有特定的屬性,每個都含有標識符、一組寄存器(包括程序計數器)和一組存儲在結構中的屬性。這個屬性包括堆棧大小、調度參數以及其餘線程須要的項目。
新的線程會經過 pthread_create
建立,新建立的線程的標識符會做爲函數值返回。這個調用很是像是 UNIX 中的 fork
系統調用(除了參數以外),其中線程標識符起着 PID
的做用,這麼作的目的是爲了和其餘線程進行區分。
當線程完成指派給他的工做後,會經過 pthread_exit
來終止。這個調用會中止線程並釋放堆棧。
通常一個線程在繼續運行前須要等待另外一個線程完成它的工做並退出。能夠經過 pthread_join
線程調用來等待別的特定線程的終止。而要等待線程的線程標識符做爲一個參數給出。
有時會出現這種狀況:一個線程邏輯上沒有阻塞,但感受上它已經運行了足夠長的時間而且但願給另一個線程機會去運行。這時候能夠經過 pthread_yield
來完成。
下面兩個線程調用是處理屬性的。pthread_attr_init
創建關聯一個線程的屬性結構並初始化成默認值,這些值(例如優先級)能夠經過修改屬性結構的值來改變。
最後,pthread_attr_destroy
刪除一個線程的結構,釋放它佔用的內存。它不會影響調用它的線程,這些線程會一直存在。
爲了更好的理解 pthread 是如何工做的,考慮下面這個例子
#include <pthread.h> #include <stdio.h> #include <stdlib.h> #define NUMBER_OF_THREADS 10 void *print_hello_world(vvoid *tid){ /* 輸出線程的標識符,而後退出 */ printf("Hello World. Greetings from thread %d\n",tid); pthread_exit(NULL); } int main(int argc,char *argv[]){ /* 主程序建立 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); }
主線程在宣佈它的指責以後,循環 NUMBER_OF_THREADS
次,每次建立一個新的線程。若是線程建立失敗,會打印出一條信息後退出。在建立完成全部的工做後,主程序退出。
主要有三種實現方式
下面咱們分開討論一下
第一種方法是把整個線程包放在用戶空間中,內核對線程一無所知,它不知道線程的存在。全部的這類實現都有一樣的通用結構
線程在運行時系統之上運行,運行時系統是管理線程過程的集合,包括前面提到的四個過程: pthread_create, pthread_exit, pthread_join 和 pthread_yield。
運行時系統(Runtime System)
也叫作運行時環境,該運行時系統提供了程序在其中運行的環境。此環境可能會解決許多問題,包括應用程序內存的佈局,程序如何訪問變量,在過程之間傳遞參數的機制,與操做系統的接口等等。編譯器根據特定的運行時系統進行假設以生成正確的代碼。一般,運行時系統將負責設置和管理堆棧,而且會包含諸如垃圾收集,線程或語言內置的其餘動態的功能。
在用戶空間管理線程時,每一個進程須要有其專用的線程表(thread table)
,用來跟蹤該進程中的線程。這些表和內核中的進程表相似,不過它僅僅記錄各個線程的屬性,如每一個線程的程序計數器、堆棧指針、寄存器和狀態。該線程標由運行時系通通一管理。當一個線程轉換到就緒狀態或阻塞狀態時,在該線程表中存放從新啓動該線程的全部信息,與內核在進程表中存放的信息徹底同樣。
在用戶空間中實現線程要比在內核空間中實現線程具備這些方面的優點:考慮若是在線程完成時或者是在調用 pthread_yield
時,必要時會進程線程切換,而後線程的信息會被保存在運行時環境所提供的線程表中,而後,線程調度程序來選擇另一個須要運行的線程。保存線程的狀態和調度程序都是本地過程
,因此啓動他們比進行內核調用效率更高。於是不須要切換到內核,也就不須要上下文切換,也不須要對內存高速緩存進行刷新,由於線程調度很是便捷,所以效率比較高。
在用戶空間實現線程還有一個優點就是它容許每一個進程有本身定製的調度算法。例如在某些應用程序中,那些具備垃圾收集線程的應用程序(知道是誰了吧)就不用擔憂本身線程會不會在不合適的時候中止,這是一個優點。用戶線程還具備較好的可擴展性,由於內核空間中的內核線程須要一些表空間和堆棧空間,若是內核線程數量比較大,容易形成問題。
儘管在用戶空間實現線程會具備必定的性能優點,可是劣勢仍是很明顯的,你如何實現阻塞系統調用
呢?假設在尚未任何鍵盤輸入以前,一個線程讀取鍵盤,讓線程進行系統調用是不可能的,由於這會中止全部的線程。因此,使用線程的一個目標是可以讓線程進行阻塞調用,而且要避免被阻塞的線程影響其餘線程。
與阻塞調用相似的問題是缺頁中斷
問題,實際上,計算機並不會把全部的程序都一次性的放入內存中,若是某個程序發生函數調用或者跳轉指令到了一條不在內存的指令上,就會發生頁面故障,而操做系統將到磁盤上取回這個丟失的指令,這就稱爲缺頁故障
。而在對所需的指令進行讀入和執行時,相關的進程就會被阻塞。若是隻有一個線程引發頁面故障,內核因爲甚至不知道有線程存在,一般會吧整個進程阻塞直到磁盤 I/O 完成爲止,儘管其餘的線程是能夠運行的。
另一個問題是,若是一個線程開始運行,該線程所在進程中的其餘線程都不能運行,除非第一個線程自願的放棄 CPU,在一個單進程內部,沒有時鐘中斷,因此不可能使用輪轉調度的方式調度線程。除非其餘線程可以以本身的意願進入運行時環境,不然調度程序沒有能夠調度線程的機會。
如今咱們考慮使用內核來實現線程的狀況,此時再也不須要運行時環境了。另外,每一個進程中也沒有線程表。相反,在內核中會有用來記錄系統中全部線程的線程表。當某個線程但願建立一個新線程或撤銷一個已有線程時,它會進行一個系統調用,這個系統調用經過對線程表的更新來完成線程建立或銷燬工做。
內核中的線程表持有每一個線程的寄存器、狀態和其餘信息。這些信息和用戶空間中的線程信息相同,可是位置卻被放在了內核中而不是用戶空間中。另外,內核還維護了一張進程表用來跟蹤系統狀態。
全部可以阻塞的調用都會經過系統調用的方式來實現,當一個線程阻塞時,內核能夠進行選擇,是運行在同一個進程中的另外一個線程(若是有就緒線程的話)仍是運行一個另外一個進程中的線程。可是在用戶實現中,運行時系統始終運行本身的線程,直到內核剝奪它的 CPU 時間片(或者沒有可運行的線程存在了)爲止。
因爲在內核中建立或者銷燬線程的開銷比較大,因此某些系統會採用可循環利用的方式來回收線程。當某個線程被銷燬時,就把它標誌爲不可運行的狀態,可是其內部結構沒有受到影響。稍後,在必須建立一個新線程時,就會從新啓用舊線程,把它標誌爲可用狀態。
若是某個進程中的線程形成缺頁故障後,內核很容易的就能檢查出來是否有其餘可運行的線程,若是有的話,在等待所須要的頁面從磁盤讀入時,就選擇一個可運行的線程運行。這樣作的缺點是系統調用的代價比較大,因此若是線程的操做(建立、終止)比較多,就會帶來很大的開銷。
結合用戶空間和內核空間的優勢,設計人員採用了一種內核級線程
的方式,而後將用戶級線程與某些或者所有內核線程多路複用起來
在這種模型中,編程人員能夠自由控制用戶線程和內核線程的數量,具備很大的靈活度。採用這種方法,內核只識別內核級線程,並對其進行調度。其中一些內核級線程會被多個用戶級線程多路複用。
進程是須要頻繁的和其餘進程進行交流的。例如,在一個 shell 管道中,第一個進程的輸出必須傳遞給第二個進程,這樣沿着管道進行下去。所以,進程之間若是須要通訊的話,必需要使用一種良好的數據結構以致於不能被中斷。下面咱們會一塊兒討論有關 進程間通訊(Inter Process Communication, IPC)
的問題。
關於進程間的通訊,這裏有三個問題
須要注意的是,這三個問題中的後面兩個問題一樣也適用於線程
第一個問題在線程間比較好解決,由於它們共享一個地址空間,它們具備相同的運行時環境,能夠想象你在用高級語言編寫多線程代碼的過程當中,線程通訊問題是否是比較容易解決?
另外兩個問題也一樣適用於線程,一樣的問題可用一樣的方法來解決。咱們後面會慢慢討論這三個問題,你如今腦子中大體有個印象便可。
在一些操做系統中,協做的進程可能共享一些彼此都能讀寫的公共資源。公共資源可能在內存中也可能在一個共享文件。爲了講清楚進程間是如何通訊的,這裏咱們舉一個例子:一個後臺打印程序。當一個進程須要打印某個文件時,它會將文件名放在一個特殊的後臺目錄(spooler directory)
中。另外一個進程 打印後臺進程(printer daemon)
會按期的檢查是否須要文件被打印,若是有的話,就打印並將該文件名從目錄下刪除。
假設咱們的後臺目錄有很是多的 槽位(slot)
,編號依次爲 0,1,2,...,每一個槽位存放一個文件名。同時假設有兩個共享變量:out
,指向下一個須要打印的文件;in
,指向目錄中下個空閒的槽位。能夠把這兩個文件保存在一個全部進程都能訪問的文件中,該文件的長度爲兩個字。在某一時刻,0 至 3 號槽位空,4 號至 6 號槽位被佔用。在同一時刻,進程 A 和 進程 B 都決定將一個文件排隊打印,狀況以下
墨菲法則(Murphy)
中說過,任何可能出錯的地方終將出錯,這句話生效時,可能發生以下狀況。
進程 A 讀到 in 的值爲 7,將 7 存在一個局部變量 next_free_slot
中。此時發生一次時鐘中斷,CPU 認爲進程 A 已經運行了足夠長的時間,決定切換到進程 B 。進程 B 也讀取 in 的值,發現是 7,而後進程 B 將 7 寫入到本身的局部變量 next_free_slot
中,在這一時刻兩個進程都認爲下一個可用槽位是 7 。
進程 B 如今繼續運行,它會將打印文件名寫入到 slot 7 中,而後把 in 的指針更改成 8 ,而後進程 B 離開去作其餘的事情
如今進程 A 開始恢復運行,因爲進程 A 經過檢查 next_free_slot
也發現 slot 7 的槽位是空的,因而將打印文件名存入 slot 7 中,而後把 in 的值更新爲 8 ,因爲 slot 7 這個槽位中已經有進程 B 寫入的值,因此進程 A 的打印文件名會把進程 B 的文件覆蓋,因爲打印機內部是沒法發現是哪一個進程更新的,它的功能比較侷限,因此這時候進程 B 永遠沒法打印輸出,相似這種狀況,即兩個或多個線程同時對一共享數據進行修改,從而影響程序運行的正確性時,這種就被稱爲競態條件(race condition)。調試競態條件是一種很是困難的工做,由於絕大多數狀況下程序運行良好,但在極少數的狀況下會發生一些沒法解釋的奇怪現象。
不只共享資源會形成競態條件,事實上共享文件、共享內存也會形成競態條件、那麼該如何避免呢?或許一句話能夠歸納說明:禁止一個或多個進程在同一時刻對共享資源(包括共享內存、共享文件等)進行讀寫。換句話說,咱們須要一種 互斥(mutual exclusion)
條件,這也就是說,若是一個進程在某種方式下使用共享變量和文件的話,除該進程以外的其餘進程就禁止作這種事(訪問統一資源)。上面問題的糾結點在於,在進程 A 對共享變量的使用未結束以前進程 B 就使用它。在任何操做系統中,爲了實現互斥操做而選用適當的原語是一個主要的設計問題,接下來咱們會着重探討一下。
避免競爭問題的條件能夠用一種抽象的方式去描述。大部分時間,進程都會忙於內部計算和其餘不會致使競爭條件的計算。然而,有時候進程會訪問共享內存或文件,或者作一些可以致使競態條件的操做。咱們把對共享內存進行訪問的程序片斷稱做 臨界區域(critical region)
或 臨界區(critical section)
。若是咱們可以正確的操做,使兩個不一樣進程不可能同時處於臨界區,就能避免競爭條件,這也是從操做系統設計角度來進行的。
儘管上面這種設計避免了競爭條件,可是不能確保併發線程同時訪問共享數據的正確性和高效性。一個好的解決方案,應該包含下面四種條件
從抽象的角度來看,咱們一般但願進程的行爲如上圖所示,在 t1 時刻,進程 A 進入臨界區,在 t2 的時刻,進程 B 嘗試進入臨界區,由於此時進程 A 正在處於臨界區中,因此進程 B 會阻塞直到 t3 時刻進程 A 離開臨界區,此時進程 B 可以容許進入臨界區。最後,在 t4 時刻,進程 B 離開臨界區,系統恢復到沒有進程的原始狀態。
下面咱們會繼續探討實現互斥的各類設計,在這些方案中,當一個進程正忙於更新其關鍵區域的共享內存時,沒有其餘進程會進入其關鍵區域,也不會形成影響。
在單處理器系統上,最簡單的解決方案是讓每一個進程在進入臨界區後當即屏蔽全部中斷
,並在離開臨界區以前從新啓用它們。屏蔽中斷後,時鐘中斷也會被屏蔽。CPU 只有發生時鐘中斷或其餘中斷時纔會進行進程切換。這樣,在屏蔽中斷後 CPU 不會切換到其餘進程。因此,一旦某個進程屏蔽中斷以後,它就能夠檢查和修改共享內存,而不用擔憂其餘進程介入訪問共享數據。
這個方案可行嗎?進程進入臨界區域是由誰決定的呢?不是用戶進程嗎?當進程進入臨界區域後,用戶進程關閉中斷,若是通過一段較長時間後進程沒有離開,那麼中斷不就一直啓用不了,結果會如何?可能會形成整個系統的終止。並且若是是多處理器的話,屏蔽中斷僅僅對執行 disable
指令的 CPU 有效。其餘 CPU 仍將繼續運行,並能夠訪問共享內存。
另外一方面,對內核來講,當它在執行更新變量或列表的幾條指令期間將中斷屏蔽是很方便的。例如,若是多個進程處理就緒列表中的時候發生中斷,則可能會發生競態條件的出現。因此,屏蔽中斷對於操做系統自己來講是一項頗有用的技術,可是對於用戶線程來講,屏蔽中斷卻不是一項通用的互斥機制。
做爲第二種嘗試,能夠尋找一種軟件層面解決方案。考慮有單個共享的(鎖)變量,初始爲值爲 0 。當一個線程想要進入關鍵區域時,它首先會查看鎖的值是否爲 0 ,若是鎖的值是 0 ,進程會把它設置爲 1 並讓進程進入關鍵區域。若是鎖的狀態是 1,進程會等待直到鎖變量的值變爲 0 。所以,鎖變量的值是 0 則意味着沒有線程進入關鍵區域。若是是 1 則意味着有進程在關鍵區域內。咱們對上圖修改後,以下所示
這種設計方式是否正確呢?是否存在紕漏呢?假設一個進程讀出鎖變量的值並發現它爲 0 ,而剛好在它將其設置爲 1 以前,另外一個進程調度運行,讀出鎖的變量爲0 ,並將鎖的變量設置爲 1 。而後第一個線程運行,把鎖變量的值再次設置爲 1,此時,臨界區域就會有兩個進程在同時運行。
也許有的讀者能夠這麼認爲,在進入前檢查一次,在要離開的關鍵區域再檢查一次不就解決了嗎?實際上這種狀況也是於事無補,由於在第二次檢查期間其餘線程仍有可能修改鎖變量的值,換句話說,這種 set-before-check
不是一種 原子性
操做,因此一樣還會發生競爭條件。
第三種互斥的方式先拋出來一段代碼,這裏的程序是用 C 語言編寫,之因此採用 C 是由於操做系統廣泛是用 C 來編寫的(偶爾會用 C++),而基本不會使用 Java 、Modula3 或 Pascal 這樣的語言,Java 中的 native 關鍵字底層也是 C 或 C++ 編寫的源碼。對於編寫操做系統而言,須要使用 C 語言這種強大、高效、可預知和有特性的語言,而對於 Java ,它是不可預知的,由於它在關鍵時刻會用完存儲器,而在不合適的時候會調用垃圾回收機制回收內存。在 C 語言中,這種狀況不會發生,C 語言中不會主動調用垃圾回收回收內存。有關 C 、C++ 、Java 和其餘四種語言的比較能夠參考 連接
進程 0 的代碼
while(TRUE){ while(turn != 0){ /* 進入關鍵區域 */ critical_region(); turn = 1; /* 離開關鍵區域 */ noncritical_region(); } }
進程 1 的代碼
while(TRUE){ while(turn != 1){ critical_region(); turn = 0; noncritical_region(); } }
在上面代碼中,變量 turn
,初始值爲 0 ,用於記錄輪到那個進程進入臨界區,並檢查或更新共享內存。開始時,進程 0 檢查 turn,發現其值爲 0 ,因而進入臨界區。進程 1 也發現其值爲 0 ,因此在一個等待循環中不停的測試 turn,看其值什麼時候變爲 1。連續檢查一個變量直到某個值出現爲止,這種方法稱爲 忙等待(busywaiting)
。因爲這種方式浪費 CPU 時間,因此這種方式一般應該要避免。只有在有理由認爲等待時間是很是短的狀況下,纔可以使用忙等待。用於忙等待的鎖,稱爲 自旋鎖(spinlock)
。
進程 0 離開臨界區時,它將 turn 的值設置爲 1,以便容許進程 1 進入其臨界區。假設進程 1 很快便離開了臨界區,則此時兩個進程都處於臨界區以外,turn 的值又被設置爲 0 。如今進程 0 很快就執行完了整個循環,它退出臨界區,並將 turn 的值設置爲 1。此時,turn 的值爲 1,兩個進程都在其臨界區外執行。
忽然,進程 0 結束了非臨界區的操做並返回到循環的開始。可是,這時它不能進入臨界區,由於 turn 的當前值爲 1,此時進程 1 還忙於非臨界區的操做,進程 0 只能繼續 while 循環,直到進程 1 把 turn 的值改成 0 。這說明,在一個進程比另外一個進程執行速度慢了不少的狀況下,輪流進入臨界區並非一個好的方法。
這種狀況違反了前面的敘述 3 ,即 位於臨界區外的進程不得阻塞其餘進程,進程 0 被一個臨界區外的進程阻塞。因爲違反了第三條,因此也不能做爲一個好的方案。
荷蘭數學家 T.Dekker 經過將鎖變量與警告變量相結合,最先提出了一個不須要嚴格輪換的軟件互斥算法,關於 Dekker 的算法,參考 連接
後來, G.L.Peterson 發現了一種簡單不少的互斥算法,它的算法以下
#define FALSE 0 #define TRUE 1 /* 進程數量 */ #define N 2 /* 如今輪到誰 */ int turn; /* 全部值初始化爲 0 (FALSE) */ int interested[N]; /* 進程是 0 或 1 */ void enter_region(int process){ /* 另外一個進程號 */ int other; /* 另外一個進程 */ other = 1 - process; /* 表示願意進入臨界區 */ interested[process] = TRUE; turn = process; /* 空循環 */ while(turn == process && interested[other] == true){} } void leave_region(int process){ /* 表示離開臨界區 */ interested[process] == FALSE; }
在使用共享變量時(即進入其臨界區)以前,各個進程使用各自的進程號 0 或 1 做爲參數來調用 enter_region
,這個函數調用在須要時將使進程等待,直到可以安全的臨界區。在完成對共享變量的操做以後,進程將調用 leave_region
表示操做完成,而且容許其餘進程進入。
如今來看看這個辦法是如何工做的。一開始,沒有任何進程處於臨界區中,如今進程 0 調用 enter_region
。它經過設置數組元素和將 turn 置爲 0 來表示它但願進入臨界區。因爲進程 1 並不想進入臨界區,因此 enter_region 很快便返回。若是進程如今調用 enter_region,進程 1 將在此處掛起直到 interested[0]
變爲 FALSE,這種狀況只有在進程 0 調用 leave_region
退出臨界區時纔會發生。
那麼上面討論的是順序進入的狀況,如今來考慮一種兩個進程同時調用 enter_region
的狀況。它們都將本身的進程存入 turn,但只有最後保存進去的進程號纔有效,前一個進程的進程號由於重寫而丟失。假如進程 1 是最後存入的,則 turn 爲 1 。當兩個進程都運行到 while
的時候,進程 0 將不會循環並進入臨界區,而進程 1 將會無限循環且不會進入臨界區,直到進程 0 退出位置。
如今來看一種須要硬件幫助的方案。一些計算機,特別是那些設計爲多處理器的計算機,都會有下面這條指令
TSL RX,LOCK
稱爲 測試並加鎖(test and set lock)
,它將一個內存字 lock 讀到寄存器 RX
中,而後在該內存地址上存儲一個非零值。讀寫指令能保證是一體的,不可分割的,一同執行的。在這個指令結束以前其餘處理器均不容許訪問內存。執行 TSL 指令的 CPU 將會鎖住內存總線,用來禁止其餘 CPU 在這個指令結束以前訪問內存。
很重要的一點是鎖住內存總線和禁用中斷不同。禁用中斷並不能保證一個處理器在讀寫操做之間另外一個處理器對內存的讀寫。也就是說,在處理器 1 上屏蔽中斷對處理器 2 沒有影響。讓處理器 2 遠離內存直處處理器 1 完成讀寫的最好的方式就是鎖住總線。這須要一個特殊的硬件(基本上,一根總線就能夠確保總線由鎖住它的處理器使用,而其餘的處理器不能使用)
爲了使用 TSL 指令,要使用一個共享變量 lock 來協調對共享內存的訪問。當 lock 爲 0 時,任何進程均可以使用 TSL 指令將其設置爲 1,並讀寫共享內存。當操做結束時,進程使用 move
指令將 lock 的值從新設置爲 0 。
這條指令如何防止兩個進程同時進入臨界區呢?下面是解決方案
enter_region: | 複製鎖到寄存器並將鎖設爲1 TSL REGISTER,LOCK | 鎖是 0 嗎? CMP REGISTER,#0 | 若不是零,說明鎖已被設置,因此循環 JNE enter_region | 返回調用者,進入臨界區 RET leave_region: | 在鎖中存入 0 MOVE LOCK,#0 | 返回調用者 RET
咱們能夠看到這個解決方案的思想和 Peterson 的思想很類似。假設存在以下共 4 指令的彙編語言程序。第一條指令將 lock 原來的值複製到寄存器中並將 lock 設置爲 1 ,隨後這個原來的值和 0 作對比。若是它不是零,說明以前已經被加過鎖,則程序返回到開始並再次測試。通過一段時間後(可長可短),該值變爲 0 (當前處於臨界區中的進程退出臨界區時),因而過程返回,此時已加鎖。要清除這個鎖也比較簡單,程序只須要將 0 存入 lock 便可,不須要特殊的同步指令。
如今有了一種很明確的作法,那就是進程在進入臨界區以前會先調用 enter_region
,判斷是否進行循環,若是lock 的值是 1 ,進行無限循環,若是 lock 是 0,不進入循環並進入臨界區。在進程從臨界區返回時它調用 leave_region
,這會把 lock 設置爲 0 。與基於臨界區問題的全部解法同樣,進程必須在正確的時間調用 enter_region 和 leave_region ,解法才能奏效。
還有一個能夠替換 TSL 的指令是 XCHG
,它原子性的交換了兩個位置的內容,例如,一個寄存器與一個內存字,代碼以下
enter_region: | 把 1 放在內存器中 MOVE REGISTER,#1 | 交換寄存器和鎖變量的內容 XCHG REGISTER,LOCK | 鎖是 0 嗎? CMP REGISTER,#0 | 若不是 0 ,鎖已被設置,進行循環 JNE enter_region | 返回調用者,進入臨界區 RET leave_region: | 在鎖中存入 0 MOVE LOCK,#0 | 返回調用者 RET
XCHG 的本質上與 TSL 的解決辦法同樣。全部的 Intel x86 CPU 在底層同步中使用 XCHG 指令。
上面解法中的 Peterson 、TSL 和 XCHG 解法都是正確的,可是它們都有忙等待的缺點。這些解法的本質上都是同樣的,先檢查是否可以進入臨界區,若不容許,則該進程將原地等待,直到容許爲止。
這種方式不但浪費了 CPU 時間,並且還可能引發意想不到的結果。考慮一臺計算機上有兩個進程,這兩個進程具備不一樣的優先級,H
是屬於優先級比較高的進程,L
是屬於優先級比較低的進程。進程調度的規則是不論什麼時候只要 H 進程處於就緒態 H 就開始運行。在某一時刻,L 處於臨界區中,此時 H 變爲就緒態,準備運行(例如,一條 I/O 操做結束)。如今 H 要開始忙等,但因爲當 H 就緒時 L 就不會被調度,L 歷來不會有機會離開關鍵區域,因此 H 會變成死循環,有時將這種狀況稱爲優先級反轉問題(priority inversion problem)
。
如今讓咱們看一下進程間的通訊原語,這些原語在不容許它們進入關鍵區域以前會阻塞而不是浪費 CPU 時間,最簡單的是 sleep
和 wakeup
。Sleep 是一個可以形成調用者阻塞的系統調用,也就是說,這個系統調用會暫停直到其餘進程喚醒它。wakeup 調用有一個參數,即要喚醒的進程。還有一種方式是 wakeup 和 sleep 都有一個參數,即 sleep 和 wakeup 須要匹配的內存地址。
做爲這些私有原語的例子,讓咱們考慮生產者-消費者(producer-consumer)
問題,也稱做 有界緩衝區(bounded-buffer)
問題。兩個進程共享一個公共的固定大小的緩衝區。其中一個是生產者(producer)
,將信息放入緩衝區, 另外一個是消費者(consumer)
,會從緩衝區中取出。也能夠把這個問題通常化爲 m 個生產者和 n 個消費者的問題,可是咱們這裏只討論一個生產者和一個消費者的狀況,這樣能夠簡化實現方案。
若是緩衝隊列已滿,那麼當生產者仍想要將數據寫入緩衝區的時候,會出現問題。它的解決辦法是讓生產者睡眠,也就是阻塞生產者。等到消費者從緩衝區中取出一個或多個數據項時再喚醒它。一樣的,當消費者試圖從緩衝區中取數據,可是發現緩衝區爲空時,消費者也會睡眠,阻塞。直到生產者向其中放入一個新的數據。
這個邏輯聽起來比較簡單,並且這種方式也須要一種稱做 監聽
的變量,這個變量用於監視緩衝區的數據,咱們暫定爲 count,若是緩衝區最多存放 N 個數據項,生產者會每次判斷 count 是否達到 N,不然生產者向緩衝區放入一個數據項並增量 count 的值。消費者的邏輯也很類似:首先測試 count 的值是否爲 0 ,若是爲 0 則消費者睡眠、阻塞,不然會從緩衝區取出數據並使 count 數量遞減。每一個進程也會檢查檢查是否其餘線程是否應該被喚醒,若是應該被喚醒,那麼就喚醒該線程。下面是生產者消費者的代碼
/* 緩衝區 slot 槽的數量 */ #define N 100 /* 緩衝區數據的數量 */ int count = 0 // 生產者 void producer(void){ int item; /* 無限循環 */ while(TRUE){ /* 生成下一項數據 */ item = produce_item() /* 若是緩存區是滿的,就會阻塞 */ if(count == N){ sleep(); } /* 把當前數據放在緩衝區中 */ insert_item(item); /* 增長緩衝區 count 的數量 */ count = count + 1; if(count == 1){ /* 緩衝區是否爲空? */ wakeup(consumer); } } } // 消費者 void consumer(void){ int item; /* 無限循環 */ while(TRUE){ /* 若是緩衝區是空的,就會進行阻塞 */ if(count == 0){ sleep(); } /* 從緩衝區中取出一個數據 */ item = remove_item(); /* 將緩衝區的 count 數量減一 */ count = count - 1 /* 緩衝區滿嘛? */ if(count == N - 1){ wakeup(producer); } /* 打印數據項 */ consumer_item(item); } }
爲了在 C 語言中描述像是 sleep
和 wakeup
的系統調用,咱們將以庫函數調用的形式來表示。它們不是 C 標準庫的一部分,但能夠在實際具備這些系統調用的任何系統上使用。代碼中未實現的 insert_item
和 remove_item
用來記錄將數據項放入緩衝區和從緩衝區取出數據等。
如今讓咱們回到生產者-消費者問題上來,上面代碼中會產生競爭條件,由於 count 這個變量是暴露在大衆視野下的。有可能出現下面這種狀況:緩衝區爲空,此時消費者恰好讀取 count 的值發現它爲 0 。此時調度程序決定暫停消費者並啓動運行生產者。生產者生產了一條數據並把它放在緩衝區中,而後增長 count 的值,並注意到它的值是 1 。因爲 count 爲 0,消費者必須處於睡眠狀態,所以生產者調用 wakeup
來喚醒消費者。可是,消費者此時在邏輯上並無睡眠,因此 wakeup 信號會丟失。當消費者下次啓動後,它會查看以前讀取的 count 值,發現它的值是 0 ,而後在此進行睡眠。不久以後生產者會填滿整個緩衝區,在這以後會阻塞,這樣一來兩個進程將永遠睡眠下去。
引發上面問題的本質是 喚醒還沒有進行睡眠狀態的進程會致使喚醒丟失。若是它沒有丟失,則一切都很正常。一種快速解決上面問題的方式是增長一個喚醒等待位(wakeup waiting bit)
。當一個 wakeup 信號發送給仍在清醒的進程後,該位置爲 1 。以後,當進程嘗試睡眠的時候,若是喚醒等待位爲 1 ,則該位清除,而進程仍然保持清醒。
然而,當進程數量有許多的時候,這時你能夠說經過增長喚醒等待位的數量來喚醒等待位,因而就有了 二、四、六、8 個喚醒等待位,可是並無從根本上解決問題。
信號量是 E.W.Dijkstra 在 1965 年提出的一種方法,它使用一個整形變量來累計喚醒次數,以供以後使用。在他的觀點中,有一個新的變量類型稱做 信號量(semaphore)
。一個信號量的取值能夠是 0 ,或任意正數。0 表示的是不須要任何喚醒,任意的正數表示的就是喚醒次數。
Dijkstra 提出了信號量有兩個操做,如今一般使用 down
和 up
(分別能夠用 sleep 和 wakeup 來表示)。down 這個指令的操做會檢查值是否大於 0 。若是大於 0 ,則將其值減 1 ;若該值爲 0 ,則進程將睡眠,並且此時 down 操做將會繼續執行。檢查數值、修改變量值以及可能發生的睡眠操做均爲一個單一的、不可分割的 原子操做(atomic action)
完成。這會保證一旦信號量操做開始,沒有其餘的進程可以訪問信號量,直到操做完成或者阻塞。這種原子性對於解決同步問題和避免競爭絕對必不可少。
原子性操做指的是在計算機科學的許多其餘領域中,一組相關操做所有執行而沒有中斷或根本不執行。
up 操做會使信號量的值 + 1。若是一個或者多個進程在信號量上睡眠,沒法完成一個先前的 down 操做,則由系統選擇其中一個並容許該程完成 down 操做。所以,對一個進程在其上睡眠的信號量執行一次 up 操做以後,該信號量的值仍然是 0 ,但在其上睡眠的進程卻少了一個。信號量的值增 1 和喚醒一個進程一樣也是不可分割的。不會有某個進程因執行 up 而阻塞,正如在前面的模型中不會有進程因執行 wakeup 而阻塞是同樣的道理。
用信號量解決丟失的 wakeup 問題,代碼以下
/* 定義緩衝區槽的數量 */ #define N 100 /* 信號量是一種特殊的 int */ typedef int semaphore; /* 控制關鍵區域的訪問 */ semaphore mutex = 1; /* 統計 buffer 空槽的數量 */ semaphore empty = N; /* 統計 buffer 滿槽的數量 */ semaphore full = 0; void producer(void){ int item; /* TRUE 的常量是 1 */ while(TRUE){ /* 產生放在緩衝區的一些數據 */ item = producer_item(); /* 將空槽數量減 1 */ down(&empty); /* 進入關鍵區域 */ down(&mutex); /* 把數據放入緩衝區中 */ insert_item(item); /* 離開臨界區 */ up(&mutex); /* 將 buffer 滿槽數量 + 1 */ up(&full); } } void consumer(void){ int item; /* 無限循環 */ while(TRUE){ /* 緩存區滿槽數量 - 1 */ down(&full); /* 進入緩衝區 */ down(&mutex); /* 從緩衝區取出數據 */ item = remove_item(); /* 離開臨界區 */ up(&mutex); /* 將空槽數目 + 1 */ up(&empty); /* 處理數據 */ consume_item(item); } }
爲了確保信號量能正確工做,最重要的是要採用一種不可分割的方式來實現它。一般是將 up 和 down 做爲系統調用來實現。並且操做系統只需在執行如下操做時暫時屏蔽所有中斷:檢查信號量、更新、必要時使進程睡眠。因爲這些操做僅須要很是少的指令,所以中斷不會形成影響。若是使用多個 CPU,那麼信號量應該被鎖進行保護。使用 TSL 或者 XCHG 指令用來確保同一時刻只有一個 CPU 對信號量進行操做。
使用 TSL 或者 XCHG 來防止幾個 CPU 同時訪問一個信號量,與生產者或消費者使用忙等待來等待其餘騰出或填充緩衝區是徹底不同的。前者的操做僅須要幾個毫秒,而生產者或消費者可能須要任意長的時間。
上面這個解決方案使用了三種信號量:一個稱爲 full,用來記錄充滿的緩衝槽數目;一個稱爲 empty,記錄空的緩衝槽數目;一個稱爲 mutex,用來確保生產者和消費者不會同時進入緩衝區。Full
被初始化爲 0 ,empty 初始化爲緩衝區中插槽數,mutex 初始化爲 1。信號量初始化爲 1 而且由兩個或多個進程使用,以確保它們中同時只有一個能夠進入關鍵區域的信號被稱爲 二進制信號量(binary semaphores)
。若是每一個進程都在進入關鍵區域以前執行 down 操做,而在離開關鍵區域以後執行 up 操做,則能夠確保相互互斥。
如今咱們有了一個好的進程間原語的保證。而後咱們再來看一下中斷的順序保證
硬件壓入堆棧程序計數器等
硬件從中斷向量裝入新的程序計數器
彙編語言過程保存寄存器的值
彙編語言過程設置新的堆棧
C 中斷服務器運行(典型的讀和緩存寫入)
調度器決定下面哪一個程序先運行
C 過程返回至彙編代碼
彙編語言過程開始運行新的當前進程
在使用信號量
的系統中,隱藏中斷的天然方法是讓每一個 I/O 設備都配備一個信號量,該信號量最初設置爲0。在 I/O 設備啓動後,中斷處理程序馬上對相關聯的信號執行一個 down
操做,因而進程當即被阻塞。當中斷進入時,中斷處理程序隨後對相關的信號量執行一個 up
操做,可以使已經阻止的進程恢復運行。在上面的中斷處理步驟中,其中的第 5 步 C 中斷服務器運行
就是中斷處理程序在信號量上執行的一個 up 操做,因此在第 6 步中,操做系統可以執行設備驅動程序。固然,若是有幾個進程已經處於就緒狀態,調度程序可能會選擇接下來運行一個更重要的進程,咱們會在後面討論調度的算法。
上面的代碼其實是經過兩種不一樣的方式來使用信號量的,而這兩種信號量之間的區別也是很重要的。mutex
信號量用於互斥。它用於確保任意時刻只有一個進程可以對緩衝區和相關變量進行讀寫。互斥是用於避免進程混亂所必須的一種操做。
另一個信號量是關於同步(synchronization)
的。full
和 empty
信號量用於確保事件的發生或者不發生。在這個事例中,它們確保了緩衝區滿時生產者中止運行;緩衝區爲空時消費者中止運行。這兩個信號量的使用與 mutex 不一樣。
若是不須要信號量的計數能力時,能夠使用信號量的一個簡單版本,稱爲 mutex(互斥量)
。互斥量的優點就在於在一些共享資源和一段代碼中保持互斥。因爲互斥的實現既簡單又有效,這使得互斥量在實現用戶空間線程包時很是有用。
互斥量是一個處於兩種狀態之一的共享變量:解鎖(unlocked)
和 加鎖(locked)
。這樣,只須要一個二進制位來表示它,不過通常狀況下,一般會用一個 整形(integer)
來表示。0 表示解鎖,其餘全部的值表示加鎖,比 1 大的值表示加鎖的次數。
mutex 使用兩個過程,當一個線程(或者進程)須要訪問關鍵區域時,會調用 mutex_lock
進行加鎖。若是互斥鎖當前處於解鎖狀態(表示關鍵區域可用),則調用成功,而且調用線程能夠自由進入關鍵區域。
另外一方面,若是 mutex 互斥量已經鎖定的話,調用線程會阻塞直到關鍵區域內的線程執行完畢而且調用了 mutex_unlock
。若是多個線程在 mutex 互斥量上阻塞,將隨機選擇一個線程並容許它得到鎖。
因爲 mutex 互斥量很是簡單,因此只要有 TSL 或者是 XCHG 指令,就能夠很容易地在用戶空間實現它們。用於用戶級線程包的 mutex_lock
和 mutex_unlock
代碼以下,XCHG 的本質也同樣。
mutex_lock: | 將互斥信號量複製到寄存器,並將互斥信號量置爲1 TSL REGISTER,MUTEX | 互斥信號量是 0 嗎? CMP REGISTER,#0 | 若是互斥信號量爲0,它被解鎖,因此返回 JZE ok | 互斥信號正在使用;調度其餘線程 CALL thread_yield | 再試一次 JMP mutex_lock | 返回調用者,進入臨界區 ok: RET mutex_unlcok: | 將 mutex 置爲 0 MOVE MUTEX,#0 | 返回調用者 RET
mutex_lock 的代碼和上面 enter_region 的代碼很類似,咱們能夠對比着看一下
上面代碼最大的區別你看出來了嗎?
根據上面咱們對 TSL 的分析,咱們知道,若是 TSL 判斷沒有進入臨界區的進程會進行無限循環獲取鎖,而在 TSL 的處理中,若是 mutex 正在使用,那麼就調度其餘線程進行處理。因此上面最大的區別其實就是在判斷 mutex/TSL 以後的處理。
在(用戶)線程中,狀況有所不一樣,由於沒有時鐘來中止運行時間過長的線程。結果是經過忙等待的方式來試圖得到鎖的線程將永遠循環下去,決不會獲得鎖,由於這個運行的線程不會讓其餘線程運行從而釋放鎖,其餘線程根本沒有得到鎖的機會。在後者獲取鎖失敗時,它會調用 thread_yield
將 CPU 放棄給另一個線程。結果就不會進行忙等待。在該線程下次運行時,它再一次對鎖進行測試。
上面就是 enter_region 和 mutex_lock 的差異所在。因爲 thread_yield 僅僅是一個用戶空間的線程調度,因此它的運行很是快捷。這樣,mutex_lock
和 mutex_unlock
都不須要任何內核調用。經過使用這些過程,用戶線程徹底能夠實如今用戶空間中的同步,這個過程僅僅須要少許的同步。
咱們上面描述的互斥量實際上是一套調用框架中的指令。從軟件角度來講,老是須要更多的特性和同步原語。例如,有時線程包提供一個調用 mutex_trylock
,這個調用嘗試獲取鎖或者返回錯誤碼,可是不會進行加鎖操做。這就給了調用線程一個靈活性,以決定下一步作什麼,是使用替代方法仍是等候下去。
隨着並行的增長,有效的同步(synchronization)
和鎖定(locking)
對於性能來講是很是重要的。若是進程等待時間很短,那麼自旋鎖(Spin lock)
是很是有效;可是若是等待時間比較長,那麼這會浪費 CPU 週期。若是進程不少,那麼阻塞此進程,並僅當鎖被釋放的時候讓內核解除阻塞是更有效的方式。不幸的是,這種方式也會致使另外的問題:它能夠在進程競爭頻繁的時候運行良好,可是在競爭不是很激烈的狀況下內核切換的消耗會很是大,並且更困難的是,預測鎖的競爭數量更不容易。
有一種有趣的解決方案是把二者的優勢結合起來,提出一種新的思想,稱爲 futex
,或者是 快速用戶空間互斥(fast user space mutex)
,是否是聽起來頗有意思?
futex 是 Linux
中的特性實現了基本的鎖定(很像是互斥鎖)並且避免了陷入內核中,由於內核的切換的開銷很是大,這樣作能夠大大提升性能。futex 由兩部分組成:內核服務和用戶庫。內核服務提供了了一個 等待隊列(wait queue)
容許多個進程在鎖上排隊等待。除非內核明確的對他們解除阻塞,不然它們不會運行。
對於一個進程來講,把它放到等待隊列須要昂貴的系統調用,這種方式應該被避免。在沒有競爭的狀況下,futex 能夠直接在用戶空間中工做。這些進程共享一個 32 位整數(integer)
做爲公共鎖變量。假設鎖的初始化爲 1,咱們認爲這時鎖已經被釋放了。線程經過執行原子性的操做減小並測試(decrement and test)
來搶佔鎖。decrement and set 是 Linux 中的原子功能,由包裹在 C 函數中的內聯彙編組成,並在頭文件中進行定義。下一步,線程會檢查結果來查看鎖是否已經被釋放。若是鎖如今不是鎖定狀態,那麼恰好咱們的線程能夠成功搶佔該鎖。然而,若是鎖被其餘線程持有,搶佔鎖的線程不得不等待。在這種狀況下,futex 庫不會自旋
,可是會使用一個系統調用來把線程放在內核中的等待隊列中。這樣一來,切換到內核的開銷已是合情合理的了,由於線程能夠在任什麼時候候阻塞。當線程完成了鎖的工做時,它會使用原子性的 增長並測試(increment and test)
釋放鎖,並檢查結果以查看內核等待隊列上是否仍阻止任何進程。若是有的話,它會通知內核能夠對等待隊列中的一個或多個進程解除阻塞。若是沒有鎖競爭,內核則不須要參與競爭。
Pthreads 提供了一些功能用來同步線程。最基本的機制是使用互斥量變量,能夠鎖定和解鎖,用來保護每一個關鍵區域。但願進入關鍵區域的線程首先要嘗試獲取 mutex。若是 mutex 沒有加鎖,線程可以立刻進入而且互斥量可以自動鎖定,從而阻止其餘線程進入。若是 mutex 已經加鎖,調用線程會阻塞,直到 mutex 解鎖。若是多個線程在相同的互斥量上等待,當互斥量解鎖時,只有一個線程可以進入而且從新加鎖。這些鎖並非必須的,程序員須要正確使用它們。
下面是與互斥量有關的函數調用
向咱們想象中的同樣,mutex 可以被建立和銷燬,扮演這兩個角色的分別是 Phread_mutex_init
和 Pthread_mutex_destroy
。mutex 也能夠經過 Pthread_mutex_lock
來進行加鎖,若是互斥量已經加鎖,則會阻塞調用者。還有一個調用Pthread_mutex_trylock
用來嘗試對線程加鎖,當 mutex 已經被加鎖時,會返回一個錯誤代碼而不是阻塞調用者。這個調用容許線程有效的進行忙等。最後,Pthread_mutex_unlock
會對 mutex 解鎖而且釋放一個正在等待的線程。
除了互斥量之外,Pthreads
還提供了第二種同步機制: 條件變量(condition variables)
。mutex 能夠很好的容許或阻止對關鍵區域的訪問。條件變量容許線程因爲未知足某些條件而阻塞。絕大多數狀況下這兩種方法是一塊兒使用的。下面咱們進一步來研究線程、互斥量、條件變量之間的關聯。
下面再來從新認識一下生產者和消費者問題:一個線程將東西放在一個緩衝區內,由另外一個線程將它們取出。若是生產者發現緩衝區沒有空槽能夠使用了,生產者線程會阻塞起來直到有一個線程能夠使用。生產者使用 mutex 來進行原子性檢查從而不受其餘線程干擾。可是當發現緩衝區已經滿了之後,生產者須要一種方法來阻塞本身並在之後被喚醒。這即是條件變量作的工做。
下面是一些與條件變量有關的最重要的 pthread 調用
上表中給出了一些調用用來建立和銷燬條件變量。條件變量上的主要屬性是 Pthread_cond_wait
和 Pthread_cond_signal
。前者阻塞調用線程,直到其餘線程發出信號爲止(使用後者調用)。阻塞的線程一般須要等待喚醒的信號以此來釋放資源或者執行某些其餘活動。只有這樣阻塞的線程才能繼續工做。條件變量容許等待與阻塞原子性的進程。Pthread_cond_broadcast
用來喚醒多個阻塞的、須要等待信號喚醒的線程。
須要注意的是,條件變量(不像是信號量)不會存在於內存中。若是將一個信號量傳遞給一個沒有線程等待的條件變量,那麼這個信號就會丟失,這個須要注意
下面是一個使用互斥量和條件變量的例子
#include <stdio.h> #include <pthread.h> /* 須要生產的數量 */ #define MAX 1000000000 pthread_mutex_t the_mutex; /* 使用信號量 */ pthread_cond_t condc,condp; int buffer = 0; /* 生產數據 */ void *producer(void *ptr){ int i; for(int i = 0;i <= MAX;i++){ /* 緩衝區獨佔訪問,也就是使用 mutex 獲取鎖 */ pthread_mutex_lock(&the_mutex); while(buffer != 0){ pthread_cond_wait(&condp,&the_mutex); } /* 把他們放在緩衝區中 */ buffer = i; /* 喚醒消費者 */ pthread_cond_signal(&condc); /* 釋放緩衝區 */ pthread_mutex_unlock(&the_mutex); } pthread_exit(0); } /* 消費數據 */ void *consumer(void *ptr){ int i; for(int i = 0;i <= MAX;i++){ /* 緩衝區獨佔訪問,也就是使用 mutex 獲取鎖 */ pthread_mutex_lock(&the_mutex); while(buffer == 0){ pthread_cond_wait(&condc,&the_mutex); } /* 把他們從緩衝區中取出 */ buffer = 0; /* 喚醒生產者 */ pthread_cond_signal(&condp); /* 釋放緩衝區 */ pthread_mutex_unlock(&the_mutex); } pthread_exit(0); }
爲了可以編寫更加準確無誤的程序,Brinch Hansen 和 Hoare 提出了一個更高級的同步原語叫作 管程(monitor)
。他們兩我的的提案略有不一樣,經過下面的描述你就能夠知道。管程是程序、變量和數據結構等組成的一個集合,它們組成一個特殊的模塊或者包。進程能夠在任何須要的時候調用管程中的程序,可是它們不能從管程外部訪問數據結構和程序。下面展現了一種抽象的,相似 Pascal 語言展現的簡潔的管程。不能用 C 語言進行描述,由於管程是語言概念而 C 語言並不支持管程。
monitor example integer i; condition c; procedure producer(); ... end; procedure consumer(); . end; end monitor;
管程有一個很重要的特性,即在任什麼時候候管程中只能有一個活躍的進程,這一特性使管程可以很方便的實現互斥操做。管程是編程語言的特性,因此編譯器知道它們的特殊性,所以能夠採用與其餘過程調用不一樣的方法來處理對管程的調用。一般狀況下,當進程調用管程中的程序時,該程序的前幾條指令會檢查管程中是否有其餘活躍的進程。若是有的話,調用進程將被掛起,直到另外一個進程離開管程纔將其喚醒。若是沒有活躍進程在使用管程,那麼該調用進程才能夠進入。
進入管程中的互斥由編譯器負責,可是一種通用作法是使用 互斥量(mutex)
和 二進制信號量(binary semaphore)
。因爲編譯器而不是程序員在操做,所以出錯的概率會大大下降。在任什麼時候候,編寫管程的程序員都無需關心編譯器是如何處理的。他只須要知道將全部的臨界區轉換成爲管程過程便可。毫不會有兩個進程同時執行臨界區中的代碼。
即便管程提供了一種簡單的方式來實現互斥,但在咱們看來,這還不夠。由於咱們還須要一種在進程沒法執行被阻塞。在生產者-消費者問題中,很容易將針對緩衝區滿和緩衝區空的測試放在管程程序中,可是生產者在發現緩衝區滿的時候該如何阻塞呢?
解決的辦法是引入條件變量(condition variables)
以及相關的兩個操做 wait
和 signal
。當一個管程程序發現它不能運行時(例如,生產者發現緩衝區已滿),它會在某個條件變量(如 full)上執行 wait
操做。這個操做形成調用進程阻塞,而且還將另外一個之前等在管程以外的進程調入管程。在前面的 pthread 中咱們已經探討過條件變量的實現細節了。另外一個進程,好比消費者能夠經過執行 signal
來喚醒阻塞的調用進程。
Brinch Hansen 和 Hoare 在對進程喚醒上有所不一樣,Hoare 建議讓新喚醒的進程繼續運行;而掛起另外的進程。而 Brinch Hansen 建議讓執行 signal 的進程必須退出管程,這裏咱們採用 Brinch Hansen 的建議,由於它在概念上更簡單,而且更容易實現。
若是在一個條件變量上有若干進程都在等待,則在對該條件執行 signal 操做後,系統調度程序只能選擇其中一個進程恢復運行。
順便提一下,這裏還有上面兩位教授沒有提出的第三種方式,它的理論是讓執行 signal 的進程繼續運行,等待這個進程退出管程時,其餘進程才能進入管程。
條件變量不是計數器。條件變量也不能像信號量那樣積累信號以便之後使用。因此,若是向一個條件變量發送信號,可是該條件變量上沒有等待進程,那麼信號將會丟失。也就是說,wait 操做必須在 signal 以前執行。
下面是一個使用 Pascal
語言經過管程實現的生產者-消費者問題的解法
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 ,並且後者存在嚴重的競爭條件。它們確實很像,可是有個關鍵的區別:sleep 和 wakeup 之因此會失敗是由於當一個進程想睡眠時,另外一個進程試圖去喚醒它。使用管程則不會發生這種狀況。管程程序的自動互斥保證了這一點,若是管程過程當中的生產者發現緩衝區已滿,它將可以完成 wait 操做而不用擔憂調度程序可能會在 wait 完成以前切換到消費者。甚至,在 wait 執行完成而且把生產者標誌爲不可運行以前,是不會容許消費者進入管程的。
儘管類 Pascal 是一種想象的語言,但仍是有一些真正的編程語言支持,好比 Java (終於輪到大 Java 出場了),Java 是可以支持管程的,它是一種 面向對象
的語言,支持用戶級線程,還容許將方法劃分爲類。只要將關鍵字 synchronized
關鍵字加到方法中便可。Java 可以保證一旦某個線程執行該方法,就不容許其餘線程執行該對象中的任何 synchronized 方法。沒有關鍵字 synchronized ,就不能保證沒有交叉執行。
下面是 Java 使用管程解決的生產者-消費者問題
public class ProducerConsumer { // 定義緩衝區大小的長度 static final int N = 100; // 初始化一個新的生產者線程 static Producer p = new Producer(); // 初始化一個新的消費者線程 static Consumer c = new Consumer(); // 初始化一個管程 static Our_monitor mon = new Our_monitor(); // run 包含了線程代碼 static class Producer extends Thread{ public void run(){ int item; // 生產者循環 while(true){ item = produce_item(); mon.insert(item); } } // 生產代碼 private int produce_item(){...} } // run 包含了線程代碼 static class consumer extends Thread { public void run( ) { int item; while(true){ item = mon.remove(); consume_item(item); } } // 消費代碼 private int produce_item(){...} } // 這是管程 static class Our_monitor { private int buffer[] = new int[N]; // 計數器和索引 private int count = 0,lo = 0,hi = 0; private synchronized void insert(int val){ if(count == N){ // 若是緩衝區是滿的,則進入休眠 go_to_sleep(); } // 向緩衝區插入內容 buffer[hi] = val; // 找到下一個槽的爲止 hi = (hi + 1) % N; // 緩衝區中的數目自增 1 count = count + 1; if(count == 1){ // 若是消費者睡眠,則喚醒 notify(); } } private synchronized void remove(int val){ int val; if(count == 0){ // 緩衝區是空的,進入休眠 go_to_sleep(); } // 從緩衝區取出數據 val = buffer[lo]; // 設置待取出數據項的槽 lo = (lo + 1) % N; // 緩衝區中的數據項數目減 1 count = count - 1; if(count = N - 1){ // 若是生產者睡眠,喚醒它 notify(); } return val; } private void go_to_sleep() { try{ wait( ); }catch(Interr uptedExceptionexc) {}; } } }
上面的代碼中主要設計四個類,外部類(outer class)
ProducerConsumer 建立並啓動兩個線程,p 和 c。第二個類和第三個類 Producer
和 Consumer
分別包含生產者和消費者代碼。最後,Our_monitor
是管程,它有兩個同步線程,用於在共享緩衝區中插入和取出數據。
在前面的全部例子中,生產者和消費者線程在功能上與它們是相同的。生產者有一個無限循環,該無限循環產生數據並將數據放入公共緩衝區中;消費者也有一個等價的無限循環,該無限循環用於從緩衝區取出數據並完成一系列工做。
程序中比較回味無窮的就是 Our_monitor
了,它包含緩衝區、管理變量以及兩個同步方法。當生產者在 insert 內活動時,它保證消費者不能在 remove 方法中運行,從而保證更新變量以及緩衝區的安全性,而且不用擔憂競爭條件。變量 count 記錄在緩衝區中數據的數量。變量 lo
是緩衝區槽的序號,指出將要取出的下一個數據項。相似地,hi
是緩衝區中下一個要放入的數據項序號。容許 lo = hi,含義是在緩衝區中有 0 個或 N 個數據。
Java 中的同步方法與其餘經典管程有本質差異:Java 沒有內嵌的條件變量。然而,Java 提供了 wait 和 notify 分別與 sleep 和 wakeup 等價。
經過臨界區自動的互斥,管程比信號量更容易保證並行編程的正確性。可是管程也有缺點,咱們前面說到過管程是一個編程語言的概念,編譯器必需要識別管程並用某種方式對其互斥做出保證。C、Pascal 以及大多數其餘編程語言都沒有管程,因此不能依靠編譯器來遵照互斥規則。
與管程和信號量有關的另外一個問題是,這些機制都是設計用來解決訪問共享內存的一個或多個 CPU 上的互斥問題的。經過將信號量放在共享內存中並用 TSL
或 XCHG
指令來保護它們,能夠避免競爭。可是若是是在分佈式系統中,可能同時具備多個 CPU 的狀況,而且每一個 CPU 都有本身的私有內存呢,它們經過網絡相連,那麼這些原語將會失效。由於信號量過低級了,而管程在少數幾種編程語言以外沒法使用,因此還須要其餘方法。
上面提到的其餘方法就是 消息傳遞(messaage passing)
。這種進程間通訊的方法使用兩個原語 send
和 receive
,它們像信號量而不像管程,是系統調用而不是語言級別。示例以下
send(destination, &message); receive(source, &message);
send 方法用於向一個給定的目標發送一條消息,receive 從一個給定的源接受一條消息。若是沒有消息,接受者可能被阻塞,直到接受一條消息或者帶着錯誤碼返回。
消息傳遞系統如今面臨着許多信號量和管程所未涉及的問題和設計難點,尤爲對那些在網絡中不一樣機器上的通訊情況。例如,消息有可能被網絡丟失。爲了防止消息丟失,發送方和接收方能夠達成一致:一旦接受到消息後,接收方立刻回送一條特殊的 確認(acknowledgement)
消息。若是發送方在一段時間間隔內未收到確認,則重發消息。
如今考慮消息自己被正確接收,而返回給發送着的確認消息丟失的狀況。發送者將重發消息,這樣接受者將收到兩次相同的消息。
對於接收者來講,如何區分新的消息和一條重發的老消息是很是重要的。一般採用在每條原始消息中嵌入一個連續的序號來解決此問題。若是接受者收到一條消息,它具備與前面某一條消息同樣的序號,就知道這條消息是重複的,能夠忽略。
消息系統還必須處理如何命名進程的問題,以便在發送或接收調用中清晰的指明進程。身份驗證(authentication)
也是一個問題,好比客戶端怎麼知道它是在與一個真正的文件服務器通訊,從發送方到接收方的信息有可能被中間人所篡改。
如今咱們考慮如何使用消息傳遞來解決生產者-消費者問題,而不是共享緩存。下面是一種解決方式
/* buffer 中槽的數量 */ #define N 100 void producer(void){ int item; /* buffer 中槽的數量 */ message m; while(TRUE){ /* 生成放入緩衝區的數據 */ item = produce_item(); /* 等待消費者發送空緩衝區 */ receive(consumer,&m); /* 創建一個待發送的消息 */ build_message(&m,item); /* 發送給消費者 */ send(consumer,&m); } } void consumer(void){ int item,i; message m; /* 循環N次 */ for(int i = 0;i < N;i++){ /* 發送N個緩衝區 */ send(producer,&m); } while(TRUE){ /* 接受包含數據的消息 */ receive(producer,&m); /* 將數據從消息中提取出來 */ item = extract_item(&m); /* 將空緩衝區發送回生產者 */ send(producer,&m); /* 處理數據 */ consume_item(item); } }
假設全部的消息都有相同的大小,而且在還沒有接受到發出的消息時,由操做系統自動進行緩衝。在該解決方案中共使用 N 條消息,這就相似於一塊共享內存緩衝區的 N 個槽。消費者首先將 N 條空消息發送給生產者。當生產者向消費者傳遞一個數據項時,它取走一條空消息並返回一條填充了內容的消息。經過這種方式,系統中總的消息數量保持不變,因此消息均可以存放在事先肯定數量的內存中。
若是生產者的速度要比消費者快,則全部的消息最終都將被填滿,等待消費者,生產者將被阻塞,等待返回一條空消息。若是消費者速度快,那麼狀況將正相反:全部的消息均爲空,等待生產者來填充,消費者將被阻塞,以等待一條填充過的消息。
消息傳遞的方式有許多變體,下面先介紹如何對消息進行 編址
。
信箱(mailbox)
,信箱是一個用來對必定的數據進行緩衝的數據結構,信箱中消息的設置方法也有多種,典型的方法是在信箱建立時肯定消息的數量。在使用信箱時,在 send 和 receive 調用的地址參數就是信箱的地址,而不是進程的地址。當一個進程試圖向一個滿的信箱發送消息時,它將被掛起,直到信箱中有消息被取走,從而爲新的消息騰出地址空間。最後一個同步機制是準備用於進程組而不是進程間的生產者-消費者狀況的。在某些應用中劃分了若干階段,而且規定,除非全部的進程都就緒準備着手下一個階段,不然任何進程都不能進入下一個階段,能夠經過在每一個階段的結尾安裝一個 屏障(barrier)
來實現這種行爲。當一個進程到達屏障時,它會被屏障所攔截,直到全部的屏障都到達爲止。屏障可用於一組進程同步,以下圖所示
在上圖中咱們能夠看到,有四個進程接近屏障,這意味着每一個進程都在進行運算,可是尚未到達每一個階段的結尾。過了一段時間後,A、B、D 三個進程都到達了屏障,各自的進程被掛起,但此時還不能進入下一個階段呢,由於進程 B 尚未執行完畢。結果,當最後一個 C 到達屏障後,這個進程組纔可以進入下一個階段。
最快的鎖是根本沒有鎖。問題在於沒有鎖的狀況下,咱們是否容許對共享數據結構的併發讀寫進行訪問。答案固然是不能夠。假設進程 A 正在對一個數字數組進行排序,而進程 B 正在計算其平均值,而此時你進行 A 的移動,會致使 B 會屢次讀到重複值,而某些值根本沒有遇到過。
然而,在某些狀況下,咱們能夠容許寫操做來更新數據結構,即使還有其餘的進程正在使用。竅門在於確保每一個讀操做要麼讀取舊的版本,要麼讀取新的版本,例以下面的樹
上面的樹中,讀操做從根部到葉子遍歷整個樹。加入一個新節點 X 後,爲了實現這一操做,咱們要讓這個節點在樹中可見以前使它"剛好正確":咱們對節點 X 中的全部值進行初始化,包括它的子節點指針。而後經過原子寫操做,使 X 稱爲 A 的子節點。全部的讀操做都不會讀到先後不一致的版本
在上面的圖中,咱們接着移除 B 和 D。首先,將 A 的左子節點指針指向 C 。全部本來在 A 中的讀操做將會後續讀到節點 C ,而永遠不會讀到 B 和 D。也就是說,它們將只會讀取到新版數據。一樣,全部當前在 B 和 D 中的讀操做將繼續按照原始的數據結構指針而且讀取舊版數據。全部操做均能正確運行,咱們不須要鎖住任何東西。而不須要鎖住數據就可以移除 B 和 D 的主要緣由就是 讀-複製-更新(Ready-Copy-Update,RCU)
,將更新過程當中的移除和再分配過程分離開。
當一個計算機是多道程序設計系統時,會頻繁的有不少進程或者線程來同時競爭 CPU 時間片。當兩個或兩個以上的進程/線程處於就緒狀態時,就會發生這種狀況。若是隻有一個 CPU 可用,那麼必須選擇接下來哪一個進程/線程能夠運行。操做系統中有一個叫作 調度程序(scheduler)
的角色存在,它就是作這件事兒的,該程序使用的算法叫作 調度算法(scheduling algorithm)
。
儘管有一些不一樣,但許多適用於進程調度的處理方法一樣也適用於線程調度。當內核管理線程的時候,調度一般會以線程級別發生,不多或者根本不會考慮線程屬於哪一個進程。下面咱們會首先專一於進程和線程的調度問題,而後會明確的介紹線程調度以及它產生的問題。
讓咱們回到早期以磁帶上的卡片做爲輸入的批處理系統的時代,那時候的調度算法很是簡單:依次運行磁帶上的每個做業。對於多道程序設計系統,會複雜一些,由於一般會有多個用戶在等待服務。一些大型機仍然將 批處理
和 分時服務
結合使用,須要調度程序決定下一個運行的是一個批處理做業仍是終端上的用戶。因爲在這些機器中 CPU 是稀缺資源,因此好的調度程序能夠在提升性能和用戶的滿意度方面取得很大的成果。
幾乎全部的進程(磁盤或網絡)I/O 請求和計算都是交替運行的
如上圖所示,CPU 不停頓的運行一段時間,而後發出一個系統調用等待 I/O 讀寫文件。完成系統調用後,CPU 又開始計算,直到它須要讀更多的數據或者寫入更多的數據爲止。當一個進程等待外部設備完成工做而被阻塞時,纔是 I/O 活動。
上面 a 是 CPU 密集型進程;b 是 I/O 密集型進程進程,a 由於在計算的時間上花費時間更長,所以稱爲計算密集型(compute-bound)
或者 CPU 密集型(CPU-bound)
,b 由於I/O 發生頻率比較快所以稱爲 I/O 密集型(I/O-bound)
。計算密集型進程有較長的 CPU 集中使用和較小頻度的 I/O 等待。I/O 密集型進程有較短的 CPU 使用時間和較頻繁的 I/O 等待。注意到上面兩種進程的區分關鍵在於 CPU 的時間佔用而不是 I/O 的時間佔用。I/O 密集型的緣由是由於它們沒有在 I/O 之間花費更多的計算、而不是 I/O 請求時間特別長。不管數據到達後須要花費多少時間,它們都須要花費相同的時間來發出讀取磁盤塊的硬件請求。
值得注意的是,隨着 CPU 的速度愈來愈快,更多的進程傾向於 I/O 密集型。這種狀況出現的緣由是 CPU 速度的提高要遠遠高於硬盤。這種狀況致使的結果是,將來對 I/O 密集型進程的調度處理彷佛更爲重要。這裏的基本思想是,若是須要運行 I/O 密集型進程,那麼就應該讓它儘快獲得機會,以便發出磁盤請求並保持磁盤始終忙碌。
第一個和調度有關的問題是什麼時候進行調度決策
。存在着須要調度處理的各類情形。首先,在建立一個新進程後,須要決定是運行父進程仍是子進程。由於兩者的進程都處於就緒態下,這是正常的調度決策,能夠任意選擇,也就是說,調度程序能夠任意的選擇子進程或父進程開始運行。
第二,在進程退出時須要做出調度決定。由於此進程再也不運行(由於它將再也不存在),所以必須從就緒進程中選擇其餘進程運行。若是沒有進程處於就緒態,系統提供的空閒進程
一般會運行
什麼是空閒進程
空閒進程(system-supplied idle process)
是 Microsoft 公司 windows 操做系統帶有的系統進程,該進程是在各個處理器上運行的單個線程,它惟一的任務是在系統沒有處理其餘線程時佔用處理器時間。System Idle Process 並非一個真正的進程,它是核心虛擬
出來的,多任務操做系統都存在。在沒有可用的進程時,系統處於空運行狀態,此時就是System Idle Process 在正在運行。你能夠簡單的理解成,它表明的是 CPU 的空閒狀態,數值越大表明處理器越空閒,能夠經過 Windows 任務管理器查看 Windows 中的 CPU 利用率
第三種狀況是,當進程阻塞在 I/O 、信號量或其餘緣由時,必須選擇另一個進程來運行。有時,阻塞的緣由會成爲選擇進程運行的關鍵因素。例如,若是 A 是一個重要進程,而且它正在等待 B 退出關鍵區域,讓 B 退出關鍵區域從而使 A 得以運行。可是調度程序通常不會對這種狀況進行考量。
第四點,當 I/O 中斷髮生時,能夠作出調度決策。若是中斷來自 I/O 設備,而 I/O 設備已經完成了其工做,那麼那些等待 I/O 的進程如今能夠繼續運行。由調度程序來決定是否準備運行新的進程仍是從新運行已經中斷的進程。
若是硬件時鐘以 50 或 60 Hz 或其餘頻率提供週期性中斷,能夠在每一個時鐘中斷或第 k 個時鐘中斷處作出調度決策。根據如何處理時鐘中斷能夠把調度算法能夠分爲兩類。非搶佔式(nonpreemptive)
調度算法挑選一個進程,讓該進程運行直到被阻塞(阻塞在 I/O 上或等待另外一個進程),或者直到該進程自動釋放 CPU。即便該進程運行了若干個小時後,它也不會被強制掛起。這樣會在時鐘中斷髮生時不會進行調度。在處理完時鐘中斷後,若是沒有更高優先級的進程等待,則被中斷的進程會繼續執行。
另一種狀況是 搶佔式
調度算法,它會選擇一個進程,並使其在最大固定時間內運行。若是在時間間隔結束後仍在運行,這個進程會被掛起,調度程序會選擇其餘進程來運行(前提是存在就緒進程)。進行搶佔式調度須要在時間間隔結束時發生時鐘中斷,以將 CPU 的控制權交還給調度程序。若是沒有可用的時鐘,那麼非搶佔式就是惟一的選擇。
毫無疑問,不一樣的環境下須要不一樣的調度算法。之因此出現這種狀況,是由於不一樣的應用程序和不一樣的操做系統有不一樣的目標。也就是說,在不一樣的系統中,調度程序的優化也是不一樣的。這裏有必要劃分出三種環境
批處理(Batch)
交互式(Interactive)
實時(Real time)
批處理系統普遍應用於商業領域,好比用來處理工資單、存貨清單、帳目收入、帳目支出、利息計算、索賠處理和其餘週期性做業。在批處理系統中,通常會選擇使用非搶佔式算法或者週期性比較長的搶佔式算法。這種方法能夠減小線程切換所以可以提高性能。
在交互式用戶環境中,爲了不一個進程霸佔 CPU 拒絕爲其餘進程服務,因此須要搶佔式算法。即便沒有進程有意要一直運行下去,可是,因爲某個進程出現錯誤也有可能無限期的排斥其餘全部進程。爲了不這種狀況,搶佔式也是必須的。服務器也屬於此類別,由於它們一般爲多個(遠程)用戶提供服務,而這些用戶都很是着急。計算機用戶老是很忙。
在實時系統中,搶佔有時是不須要的,由於進程知道本身可能運行不了很長時間,一般很快的作完本身的工做並阻塞。實時系統與交互式系統的差異是,實時系統只運行那些用來推動現有應用的程序,而交互式系統是通用的,它能夠運行任意的非協做甚至是有惡意的程序。
爲了設計調度算法,有必要考慮一下什麼是好的調度算法。有一些目標取決於環境(批處理、交互式或者實時)蛋大部分是適用於全部狀況的,下面是一些須要考量的因素,咱們會在下面一塊兒討論。
全部系統
在全部的狀況中,公平
是很重要的。對一個進程給予相較於其餘等價的進程更多的 CPU 時間片對其餘進程來講是不公平的。固然,不一樣類型的進程能夠採用不一樣的處理方式。
與公平有關的是系統的強制執行
,什麼意思呢?若是某公司的薪資發放系統計劃在本月的15號,那麼碰上了疫情你們生活都很拮据,此時老闆說要在14號晚上發放薪資,那麼調度程序必須強制使進程執行 14 號晚上發放薪資的策略。
另外一個共同的目標是保持系統的全部部分儘量的忙碌
。若是 CPU 和全部的 I/O 設備可以一直運行,那麼相對於讓某些部件空轉而言,每秒鐘就能夠完成更多的工做。例如,在批處理系統中,調度程序控制哪一個做業調入內存運行。在內存中既有一些 CPU 密集型進程又有一些 I/O 密集型進程是一個比較好的想法,好於先調入和運行全部的 CPU 密集型做業,而後在它們完成以後再調入和運行全部 I/O 密集型做業的作法。使用後者這種方式會在 CPU 密集型進程啓動後,爭奪 CPU ,而磁盤卻在空轉,而當 I/O 密集型進程啓動後,它們又要爲磁盤而競爭,CPU 卻又在空轉。。。。。。顯然,經過結合 I/O 密集型和 CPU 密集型,可以使整個系統運行更流暢,效率更高。
批處理系統
一般有三個指標來衡量系統工做狀態:吞吐量、週轉時間和 CPU 利用率,吞吐量(throughout)
是系統每小時完成的做業數量。綜合考慮,每小時完成 50 個工做要比每小時完成 40 個工做好。週轉時間(Turnaround time)
是一種平均時間,它指的是從一個批處理提交開始直到做業完成時刻爲止平均時間。該數據度量了用戶要獲得輸出所需的平均等待時間。週轉時間越小越好。
CPU 利用率(CPU utilization)
一般做爲批處理系統上的指標。即便如此, CPU 利用率也不是一個好的度量指標,真正有價值的衡量指標是系統每小時能夠完成多少做業(吞吐量),以及完成做業須要多長時間(週轉時間)。把 CPU 利用率做爲度量指標,就像是引擎每小時轉動了多少次來比較汽車的性能同樣。並且知道 CPU 的利用率何時接近 100% 要比什麼何時要求獲得更多的計算能力要有用。
交互式系統
對於交互式系統,則有不一樣的指標。最重要的是儘可能減小響應時間
。這個時間說的是從執行指令開始到獲得結果的時間。再有後臺進程運行(例如,從網絡上讀取和保存 E-mail 文件)的我的計算機上,用戶請求啓動一個程序或打開一個文件應該優先於後臺的工做。可以讓全部的交互式請求首先運行的就是一個好的服務。
一個相關的問題是 均衡性(proportionality)
,用戶對作一件事情須要多長時間老是有一種固定(不過一般不正確)的見解。當認爲一個請求很複雜須要較多時間時,用戶會認爲很正常而且能夠接受,可是一個很簡單的程序卻花費了很長的運行時間,用戶就會很惱怒。能夠拿彩印和複印來舉出一個簡單的例子,彩印可能須要1分鐘的時間,可是用戶以爲複雜而且願意等待一分鐘,相反,複印很簡單隻須要 5 秒鐘,可是複印機花費 1 分鐘卻沒有完成複印操做,用戶就會很焦躁。
實時系統
實時系統則有着和交互式系統不一樣的考量因素,所以也就有不一樣的調度目標。實時系統的特色是必須知足最後的截止時間
。例如,若是計算機控制着以固定速率產生數據的設備,未能按時運行的話可能會致使數據丟失。所以,實時系統中最重要的需求是知足全部(或大多數)時間期限。
在一些實事系統中,特別是涉及到多媒體的,可預測性很重要
。偶爾不能知足最後的截止時間不重要,可是若是音頻多媒體運行不穩定,聲音質量會持續惡化。視頻也會形成問題,可是耳朵要比眼睛敏感不少。爲了不這些問題,進程調度必須可以高度可預測的並且是有規律的。
如今讓咱們把目光從通常性的調度轉換爲特定的調度算法。下面咱們會探討在批處理中的調度。
很像是先到先得。。。可能最簡單的非搶佔式調度算法的設計就是 先來先服務(first-come,first-serverd)
。使用此算法,將按照請求順序爲進程分配 CPU。最基本的,會有一個就緒進程的等待隊列。當第一個任務從外部進入系統時,將會當即啓動並容許運行任意長的時間。它不會由於運行時間太長而中斷。當其餘做業進入時,它們排到就緒隊列尾部。當正在運行的進程阻塞,處於等待隊列的第一個進程就開始運行。當一個阻塞的進程從新處於就緒態時,它會像一個新到達的任務,會排在隊列的末尾,即排在全部進程最後。
這個算法的強大之處在於易於理解和編程,在這個算法中,一個單鏈表記錄了全部就緒進程。要選取一個進程運行,只要從該隊列的頭部移走一個進程便可;要添加一個新的做業或者阻塞一個進程,只要把這個做業或進程附加在隊列的末尾便可。這是很簡單的一種實現。
不過,先來先服務也是有缺點的,那就是沒有優先級的關係,試想一下,若是有 100 個 I/O 進程正在排隊,第 101 個是一個 CPU 密集型進程,那豈不是須要等 100 個 I/O 進程運行完畢纔會等到一個 CPU 密集型進程運行,這在實際狀況下根本不可能,因此須要優先級或者搶佔式進程的出現來優先選擇重要的進程運行。
批處理中,第二種調度算法是 最短做業優先(Shortest Job First)
,咱們假設運行時間已知。例如,一家保險公司,由於天天要作相似的工做,因此人們能夠至關精確地預測處理 1000 個索賠的一批做業須要多長時間。當輸入隊列中有若干個同等重要的做業被啓動時,調度程序應使用最短優先做業算法
如上圖 a 所示,這裏有 4 個做業 A、B、C、D ,運行時間分別爲 八、四、四、4 分鐘。若按圖中的次序運行,則 A 的週轉時間爲 8 分鐘,B 爲 12 分鐘,C 爲 16 分鐘,D 爲 20 分鐘,平均時間內爲 14 分鐘。
如今考慮使用最短做業優先算法運行 4 個做業,如上圖 b 所示,目前的週轉時間分別爲 四、八、十二、20,平均爲 11 分鐘,能夠證實最短做業優先是最優的。考慮有 4 個做業的狀況,其運行時間分別爲 a、b、c、d。第一個做業在時間 a 結束,第二個在時間 a + b 結束,以此類推。平均週轉時間爲 (4a + 3b + 2c + d) / 4 。顯然 a 對平均值的影響最大,因此 a 應該是最短優先做業,其次是 b,而後是 c ,最後是 d 它就只能影響本身的週轉時間了。
須要注意的是,在全部的進程均可以運行的狀況下,最短做業優先的算法纔是最優的。
最短做業優先的搶佔式版本被稱做爲 最短剩餘時間優先(Shortest Remaining Time Next)
算法。使用這個算法,調度程序老是選擇剩餘運行時間最短的那個進程運行。當一個新做業到達時,其整個時間同當前進程的剩餘時間作比較。若是新的進程比當前運行進程須要更少的時間,當前進程就被掛起,而運行新的進程。這種方式可以使短時間做業得到良好的服務。
交互式系統中在我的計算機、服務器和其餘系統中都是很經常使用的,因此有必要來探討一下交互式調度
一種最古老、最簡單、最公平而且最普遍使用的算法就是 輪詢算法(round-robin)
。每一個進程都會被分配一個時間段,稱爲時間片(quantum)
,在這個時間片內容許進程運行。若是時間片結束時進程還在運行的話,則搶佔一個 CPU 並將其分配給另外一個進程。若是進程在時間片結束前阻塞或結束,則 CPU 當即進行切換。輪詢算法比較容易實現。調度程序所作的就是維護一個可運行進程的列表,就像下圖中的 a,當一個進程用完時間片後就被移到隊列的末尾,就像下圖的 b。
時間片輪詢調度中惟一有意思的一點就是時間片的長度。從一個進程切換到另外一個進程須要必定的時間進行管理處理,包括保存寄存器的值和內存映射、更新不一樣的表格和列表、清除和從新調入內存高速緩存等。這種切換稱做 進程間切換(process switch)
和 上下文切換(context switch)
。若是進程間的切換時間須要 1ms,其中包括內存映射、清除和從新調入高速緩存等,再假設時間片設爲 4 ms,那麼 CPU 在作完 4 ms 有用的工做以後,CPU 將花費 1 ms 來進行進程間的切換。所以,CPU 的時間片會浪費 20% 的時間在管理開銷上。耗費巨大。
爲了提升 CPU 的效率,咱們把時間片設置爲 100 ms。如今時間的浪費只有 1%。可是考慮會發現下面的狀況,若是在一個很是短的時間內到達 50 個請求,而且對 CPU 有不一樣的需求,此時會發生什麼?50 個進程都被放在可運行進程列表中。若是 CP畫U 是空閒的,第一個進程會當即開始執行,第二個直到 100 ms 之後纔會啓動,以此類推。不幸的是最後一個進程須要等待 5 秒才能得到執行機會。大部分用戶都會以爲對於一個簡短的指令運行 5 秒中是很慢的。若是隊列末尾的某些請求只須要幾號秒鐘的運行時間的話,這種設計就很是糟糕了。
另一個因素是若是時間片設置長度要大於 CPU 使用長度,那麼搶佔就不會常常發生。相反,在時間片用完以前,大多數進程都已經阻塞了,那麼就會引發進程間的切換。消除搶佔可提升性能,由於進程切換僅在邏輯上必要時才發生,即流程阻塞且沒法繼續時才發生。
結論能夠表述以下:將上下文切換時間設置得過短會致使過多的進程切換並下降 CPU 效率,但設置時間太長會致使一個短請求很長時間得不到響應。最好的切換時間是在 20 - 50 毫秒之間設置。
輪詢調度假設了全部的進程是同等重要的。但事實狀況可能不是這樣。例如,在一所大學中的等級制度,首先是院長,而後是教授、祕書、後勤人員,最後是學生。這種將外部狀況考慮在內就實現了優先級調度(priority scheduling)
它的基本思想很明確,每一個進程都被賦予一個優先級,優先級高的進程優先運行。
可是也不意味着高優先級的進程可以永遠一直運行下去,調度程序會在每一個時鐘中斷期間下降當前運行進程的優先級。若是此操做致使其優先級下降到下一個最高進程的優先級如下,則會發生進程切換。或者,能夠爲每一個進程分配容許運行的最大時間間隔。當時間間隔用完後,下一個高優先級的進程會獲得運行的機會。
能夠靜態或者動態的爲進程分配優先級。在一臺軍用計算機上,能夠把將軍所啓動的進程設爲優先級 100,上校爲 90 ,少校爲 80,上尉爲 70,中尉爲 60,以此類推。UNIX 中有一條命令爲 nice
,它容許用戶爲了照顧他人而自願下降本身進程的優先級,可是通常沒人用。
優先級也能夠由系統動態分配,用於實現某種目的。例如,有些進程爲 I/O 密集型,其多數時間用來等待 I/O 結束。當這樣的進程須要 CPU 時,應當即分配 CPU,用來啓動下一個 I/O 請求,這樣就能夠在另外一個進程進行計算的同時執行 I/O 操做。這類 I/O 密集型進程長時間的等待 CPU 只會形成它長時間佔用內存。使 I/O 密集型進程得到較好的服務的一種簡單算法是,將其優先級設爲 1/f
,f 爲該進程在上一時間片中所佔的部分。一個在 50 ms 的時間片中只使用 1 ms 的進程將得到優先級 50 ,而在阻塞以前用掉 25 ms 的進程將具備優先級 2,而使用掉所有時間片的進程將獲得優先級 1。
能夠很方便的將一組進程按優先級分紅若干類,而且在各個類之間採用優先級調度,而在各種進程的內部採用輪轉調度。下面展現了一個四個優先級類的系統
它的調度算法主要描述以下:上面存在優先級爲 4 類的可運行進程,首先會按照輪轉法爲每一個進程運行一個時間片,此時不理會較低優先級的進程。若第 4 類進程爲空,則按照輪詢的方式運行第三類進程。若第 4 類和第 3 類進程都爲空,則按照輪轉法運行第 2 類進程。若是不對優先級進行調整,則低優先級的進程很容易產生飢餓現象。
最先使用優先級調度的系統是 CTSS(Compatible TimeSharing System)
。CTSS 是一種兼容分時系統,它有一個問題就是進程切換太慢,其緣由是 IBM 7094 內存只能放進一個進程。
IBM 是哥倫比亞大學計算機中心在 1964 - 1968 年的計算機
CTSS 在每次切換前都須要將當前進程換出到磁盤,並從磁盤上讀入一個新進程。CTSS 的設計者很快就認識到,爲 CPU 密集型進程設置較長的時間片比頻繁地分給他們很短的時間要更有效(減小交換次數)。另外一方面,如前所述,長時間片的進程又會影響到響應時間,解決辦法是設置優先級類。屬於最高優先級的進程運行一個時間片,次高優先級進程運行 2 個時間片,再下面一級運行 4 個時間片,以此類推。當一個進程用完分配的時間片後,它被移到下一類。
對於批處理系統而言,因爲最短做業優先經常伴隨着最短響應時間,因此若是可以把它用於交互式進程,那將是很是好的。在某種程度上,的確能夠作到這一點。交互式進程一般遵循下列模式:等待命令、執行命令、等待命令、執行命令。。。若是咱們把每一個命令的執行都看做一個分離的做業,那麼咱們能夠經過首先運行最短的做業來使響應時間最短。這裏惟一的問題是如何從當前可運行進程中找出最短的那一個進程。
一種方式是根據進程過去的行爲進行推測,並執行估計運行時間最短的那一個。假設每一個終端上每條命令的預估運行時間爲 T0
,如今假設測量到其下一次運行時間爲 T1
,能夠用兩個值的加權來改進估計時間,即aT0+ (1- 1)T1
。經過選擇 a 的值,能夠決定是儘快忘掉老的運行時間,仍是在一段長時間內始終記住它們。當 a = 1/2 時,能夠獲得下面這個序列
能夠看到,在三輪事後,T0 在新的估計值中所佔比重降低至 1/8。
有時把這種經過當前測量值和先前估計值進行加權平均從而獲得下一個估計值的技術稱做 老化(aging)
。這種方法會使用不少預測值基於當前值的狀況。
一種徹底不一樣的調度方法是對用戶作出明確的性能保證。一種實際並且容易實現的保證是:若用戶工做時有 n 個用戶登陸,則每一個用戶將得到 CPU 處理能力的 1/n。相似地,在一個有 n 個進程運行的單用戶系統中,若全部的進程都等價,則每一個進程將得到 1/n 的 CPU 時間。
對用戶進行承諾並在隨後兌現承諾是一件好事,不過很難實現。可是存在着一種簡單的方式,有一種既能夠給出預測結果而又有一種比較簡單的實現方式的算法,就是 彩票調度(lottery scheduling)
算法。
其基本思想是爲進程提供各類系統資源(例如 CPU 時間)的彩票。當作出一個調度決策的時候,就隨機抽出一張彩票,擁有彩票的進程將得到該資源。在應用到 CPU 調度時,系統能夠每秒持有 50 次抽獎,每一箇中獎者將得到好比 20 毫秒的 CPU 時間做爲獎勵。
George Orwell
關於 全部的進程是平等的,可是某些進程可以更平等一些。一些重要的進程能夠給它們額外的彩票,以便增長他們贏得的機會。若是出售了 100 張彩票,並且有一個進程持有了它們中的 20 張,它就會有 20% 的機會去贏得彩票中獎。在長時間的運行中,它就會得到 20% 的CPU。相反,對於優先級調度程序,很難說明擁有優先級 40 到底是什麼意思,這裏的規則很清楚,擁有彩票 f 份額的進程大約獲得系統資源的 f 份額。
若是但願進程之間協做的話能夠交換它們之間的票據。例如,客戶端進程給服務器進程發送了一條消息後阻塞,客戶端進程可能會把本身全部的票據都交給服務器,來增長下一次服務器運行的機會。當服務完成後,它會把彩票還給客戶端讓其有機會再次運行。事實上,若是沒有客戶機,服務器也根本不須要彩票。
能夠把彩票理解爲 buff,這個 buff 有 15% 的概率能讓你產生
速度之靴
的效果。
到目前爲止,咱們假設被調度的都是各個進程自身,而不用考慮該進程的擁有者是誰。結果是,若是用戶 1 啓動了 9 個進程,而用戶 2 啓動了一個進程,使用輪轉或相同優先級調度算法,那麼用戶 1 將獲得 90 % 的 CPU 時間,而用戶 2 將之獲得 10 % 的 CPU 時間。
爲了阻止這種狀況的出現,一些系統在調度前會把進程的擁有者考慮在內。在這種模型下,每一個用戶都會分配一些CPU 時間,而調度程序會選擇進程並強制執行。所以若是兩個用戶每一個都會有 50% 的 CPU 時間片保證,那麼不管一個用戶有多少個進程,都將得到相同的 CPU 份額。
實時系統(real-time)
是一個時間扮演了重要做用的系統。典型的,一種或多種外部物理設備發給計算機一個服務請求,而計算機必須在一個肯定的時間範圍內恰當的作出反應。例如,在 CD 播放器中的計算機會得到從驅動器過來的位流,而後必須在很是短的時間內將位流轉換爲音樂播放出來。若是計算時間過長,那麼音樂就會聽起來有異常。再好比說醫院特別護理部門的病人監護裝置、飛機中的自動駕駛系統、列車中的煙霧警告裝置等,在這些例子中,正確可是卻緩慢的響應要比沒有響應甚至還糟糕。
實時系統能夠分爲兩類,硬實時(hard real time)
和 軟實時(soft real time)
系統,前者意味着必需要知足絕對的截止時間;後者的含義是雖然不但願偶爾錯失截止時間,可是能夠容忍。在這兩種情形中,實時都是經過把程序劃分爲一組進程而實現的,其中每一個進程的行爲是可預測和提早可知的。這些進程通常壽命較短,而且極快的運行完成。在檢測到一個外部信號時,調度程序的任務就是按照知足全部截止時間的要求調度進程。
實時系統中的事件能夠按照響應方式進一步分類爲週期性(以規則的時間間隔發生)
事件或 非週期性(發生時間不可預知)
事件。一個系統可能要響應多個週期性事件流,根據每一個事件處理所需的時間,可能甚至沒法處理全部事件。例如,若是有 m 個週期事件,事件 i 以週期 Pi 發生,並須要 Ci 秒 CPU 時間處理一個事件,那麼能夠處理負載的條件是
只有知足這個條件的實時系統稱爲可調度的
,這意味着它實際上可以被實現。一個不知足此檢驗標準的進程不能被調度,由於這些進程共同須要的 CPU 時間總和大於 CPU 能提供的時間。
舉一個例子,考慮一個有三個週期性事件的軟實時系統,其週期分別是 100 ms、200 m 和 500 ms。若是這些事件分別須要 50 ms、30 ms 和 100 ms 的 CPU 時間,那麼該系統時可調度的,由於 0.5 + 0.15 + 0.2 < 1。若是此時有第四個事件加入,其週期爲 1 秒,那麼此時這個事件若是不超過 150 ms,那麼仍然是能夠調度的。忽略上下文切換的時間。
實時系統的調度算法能夠是靜態的或動態的。前者在系統開始運行以前作出調度決策;後者在運行過程當中進行調度決策。只有在能夠提早掌握所完成的工做以及必須知足的截止時間等信息時,靜態調度才能工做,而動態調度不須要這些限制。
到目前爲止,咱們隱含的假設系統中全部進程屬於不一樣的分組用戶而且進程間存在相互競爭 CPU 的狀況。一般狀況下確實如此,但有時也會發生一個進程會有不少子進程並在其控制下運行的狀況。例如,一個數據庫管理系統進程會有不少子進程。每個子進程可能處理不一樣的請求,或者每一個子進程實現不一樣的功能(如請求分析、磁盤訪問等)。主進程徹底可能掌握哪個子進程最重要(或最緊迫),而哪個最不重要。可是,以上討論的調度算法中沒有一個算法從用戶進程接收有關的調度決策信息,這就致使了調度程序不多可以作出最優的選擇。
解決問題的辦法是將 調度機制(scheduling mechanism)
和 調度策略(scheduling policy)
分開,這是長期一向的原則。這也就意味着調度算法在某種方式下被參數化了,可是參數能夠被用戶進程填寫。讓咱們首先考慮數據庫的例子。假設內核使用優先級調度算法,並提供了一條可供進程設置優先級的系統調用。這樣,儘管父進程自己並不參與調度,但它能夠控制如何調度子進程的細節。調度機制位於內核,而調度策略由用戶進程決定,調度策略和機制分離是一種關鍵性思路。
當若干進程都有多個線程時,就存在兩個層次的並行:進程和線程。在這樣的系統中調度處理有本質的差異,這取決於所支持的是用戶級線程仍是內核級線程(或二者都支持)。
首先考慮用戶級線程,因爲內核並不知道有線程存在,因此內核仍是和之前同樣地操做,選取一個進程,假設爲 A,並給予 A 以時間片控制。A 中的線程調度程序決定哪一個線程運行。假設爲 A1。因爲多道線程並不存在時鐘中斷,因此這個線程能夠按其意願任意運行多長時間。若是該線程用完了進程的所有時間片,內核就會選擇另外一個進程繼續運行。
在進程 A 終於又一次運行時,線程 A1 會接着運行。該線程會繼續耗費 A 進程的全部時間,直到它完成工做。不過,線程運行不會影響到其餘進程。其餘進程會獲得調度程序所分配的合適份額,不會考慮進程 A 內部發生的事情。
如今考慮 A 線程每次 CPU 計算的工做比較少的狀況,例如:在 50 ms 的時間片中有 5 ms 的計算工做。因而,每一個線程運行一下子,而後把 CPU 交回給線程調度程序。這樣在內核切換到進程 B 以前,就會有序列 A1,A2,A3,A1,A2,A3,A1,A2,A3,A1 。 以下所示
運行時系統使用的調度算法能夠是上面介紹算法的任意一種。從實用方面考慮,輪轉調度和優先級調度更爲經常使用。惟一的侷限是,缺少一個時鐘中斷運行過長的線程。但因爲線程之間的合做關係,這一般也不是問題。
如今考慮使用內核線程的狀況,內核選擇一個特定的線程運行。它不用考慮線程屬於哪一個進程,不過若是有必要的話,也能夠這麼作。對被選擇的線程賦予一個時間片,並且若是超過了時間片,就會強制掛起該線程。一個線程在 50 ms 的時間片內,5 ms 以後被阻塞,在 30 ms 的時間片中,線程的順序會是 A1,B1,A2,B2,A3,B3。以下圖所示
用戶級線程和內核級線程之間的主要差異在於性能
。用戶級線程的切換須要少許的機器指令(想象一下Java程序的線程切換),而內核線程須要完整的上下文切換,修改內存映像,使高速緩存失效,這會致使了若干數量級的延遲。另外一方面,在使用內核級線程時,一旦線程阻塞在 I/O 上就不須要在用戶級線程中那樣將整個進程掛起。
從進程 A 的一個線程切換到進程 B 的一個線程,其消耗要遠高於運行進程 A 的兩個線程(涉及修改內存映像,修改高速緩存),內核對這種切換的消耗是瞭解到,能夠經過這些信息做出決定。
文章參考:
《現代操做系統》
《Modern Operating System》forth edition
https://www.encyclopedia.com/computing/news-wires-white-papers-and-books/interactive-systems
https://j00ru.vexillium.org/syscalls/nt/32/
https://www.bottomupcs.com/process_hierarchy.xhtml
https://en.wikipedia.org/wiki/Runtime_system
https://en.wikipedia.org/wiki/Execution_model
https://zhidao.baidu.com/question/113227654.html
https://baike.baidu.com/item/等待隊列/9223804?fr=aladdin
http://www.columbia.edu/cu/computinghistory/7094.html
https://baike.baidu.com/item/中斷向量/4947039?fr=aladdin
原文出處:https://www.cnblogs.com/cxuanBlog/p/12402424.html