楔子
本文來自於公眾號《小林coding》。
我們在編寫代碼的時候經常會用到多線程或者多進程,但是你對進程和線程本身的了解有多深呢?這次讓我們來仔細地梳理一下關於進程和線程方面的知識吧。
進程
比如我們寫了一份代碼,無論是 Go 代碼也好,Python 代碼也罷,它們本質上都只是一個靜態文件。當我們將其編譯成二進制文件(Go 語言)然后執行,或者解釋器直接解釋執行這個文件(Python語言),那么它們就會對應一個運行中的程序,我們將這個運行中的程序稱之為 "進程"。
再比如 QQ、微信、音樂播放器、瀏覽器、或者游戲等等,它們本質上也是一個文件(這里說的是啟動文件 exe,當然還有很多依賴的動態庫等等),只不過是二進制文件,不是進程。然而一旦我們雙擊運行的時候,操作系統就會為它們創建一個進程,並將程序內部的所有指令加載到內存中,然后 CPU 一條一條執行。
但是 CPU 在同一時刻只會專注於一個進程、並且不會一直專注於某一個進程,比如早期的 CPU 只有一個核,可我們即可以聽歌、也可以瀏覽網頁,說明 CPU 是為所有的進程服務的。但是明明只有一個核,為什么卻能同時聽歌和瀏覽網頁呢?這就涉及到了時間片原理,因為 CPU 會切換的,CPU 執行某個進程一段時間后,便會切換到其它的進程上,保證每個進程都能得到執行。只不過它切換的速度非常快,導致我們產生了並行執行的錯覺。
或者我們有一個需要從硬盤上讀取文件的程序,當程序執行到讀取文件的指令之后就會從硬盤上讀取,但是我們知道硬盤的讀取是非常慢的(相較於 CPU 的執行速度而言),難道 CPU 要一直傻等着程序將文件讀取完畢嗎?顯然不會,因為這樣的話 CPU 的利用率也太低了,更何況 CPU 是非常寶貴的資源。所以當進程需要從硬盤中讀取數據的時候,CPU 不需要阻塞然后等待數據的返回,而是去執行另外的進程。當硬盤數據讀取成功之后,CPU 會收到一個 "中斷",於是 CPU 再繼續運行這個進程。
這種多個程序交替執行的思想,就是 CPU 管理多個進程的初步想法。對於一個支持多進程的系統,CPU 會從一個進程迅速切換至另一個進程,其中每個進程各運行幾十或者幾百毫秒,就這樣不停地切換,保證每個進程都能有機會得到執行。雖然單核的 CPU 在某一個瞬間只能運行一個進程,但是在一秒鍾內,它可能會切換好幾個進程,這樣就產生了 "並行" 的錯覺,但實際上這是 "並發"。
並行和並發有什么區別?
一張圖說明一切:
進程和程序之間的關系?
估計有人會問:我們平時說的程序和這里的進程是一回事嗎?答案不是一回事,進程和程序之間的區別如下:
1. 程序只是一組指令的有序集合,它本身沒有任何運行的含義,只是一個靜態的實體;而進程是一個動態的實體,它有自己的聲明周期,因創建而產生、因調度而運行、因等待某個資源或者某個事件觸發而處於阻塞狀態、因完成任務之后而被銷毀,所以進程反映了一個程序在一定的數據集上運行的全部動態過程。估計到這里有人已經猜到了,說人話就是假設我們編寫一個正確的 C 源文件,那么這個 C 源文件我們就可以稱之為源代碼,對源代碼進行編譯得到的二進制可執行文件就是程序,雙擊執行這個可執行程序就會創建一個進程。
2. 進程和程序不是一一對應的,比如 Chrome 瀏覽器。一個 chrome.exe 就可以看成是一個程序(當然它也是一個文件,只不過是二進制可執行文件),然后當我們雙擊的時候就產生了一個進程。但是我們只能打開一個 Chrome 嗎,顯然是可以打開多個的,因此此時就會有多個進程,但是這些進程都是由一個程序啟動時創建的,並且每一個進程都會有一個唯一的 ID,所以一個程序可以對應多個進程。但是一個進程會不會也對應多個程序呢?關於這一點說法不一,我個人認為從可執行文件的角度考慮的話,一個進程只會對應一個程序。
比如到了晚飯時間,一對小情侶肚子餓了,於是男生見機行事,就想給女生做飯。他在網上找了辣子雞的菜譜,接着買了一些雞肉、辣椒、香料等材料,然后邊看邊學着做。那么這一過程和我們這里的主題就可以用如下關系對應:
突然,女生說她想喝可樂,那么男生只好把做菜的事情暫時擱置一下,並記錄自己當前做菜做到哪一步了,比如正在切黃瓜。然后聽從女生的指令,去給女生買可樂,完事之后繼續回到廚房做菜,並且不是從頭做,而是從剛才中斷的位置開始做,也就是繼續切黃瓜。
這里變體現了CPU可以從一個進程(做菜)
切換到另一個進程(買可樂)
,並且在切換之前會記錄自己在當前進程中運行的狀態信息,然后切換回來之后可以從中斷的地方繼續執行。因此進程有着 "運行--暫停--運行" 的活動規律。
進程的狀態
我們說進程有着 "運行--暫停--運行" 的活動規律,而一般說來,一個進程並不是自始至終都連續不斷地運行,它與並發過程中的其它進程都是相互制約的。它有時處於運行狀態,有時又因為某種原因處於阻塞狀態(比如執行讀取大文件的指令,在文件讀取完畢之前 CPU 沒辦法繼續往下執行內部的指令集),當使它阻塞的原因消失之后又進入准備運行狀態、或者說就緒狀態。
所以在一個進程活動期間,至少具備三種狀態:運行狀態、阻塞狀態、就緒狀態。
運行狀態(Running):進程占用 CPU,開開心心地讓 CPU 執行自己內部的一條一條指令。
阻塞狀態(Blocked):因為某種原因處於阻塞狀態,比如當執行到從硬盤中加載大文件的指令時,在文件加載完畢之前是不會繼續往下執行指令的。也就是說阻塞狀態下,即使將 CPU 控制權交給該進程,它也沒辦法調度 CPU 執行(當然具體調度是由進程內部的線程做的,后面會說),因為當前的指令還沒結束,所以 CPU 就會去執行其它的進程。
就緒狀態(Ready):當進程阻塞的原因消失后,比如:上面的大文件已經加載完畢了,該進程就會給 CPU 發送一個"中斷",告訴 CPU 可以向下執行指令啦。但是 CPU 表示我不要你覺得可以,我要我覺得可以,老子還有其它進程要服務,不是你想讓我執行我就執行的。因此此時進程就處於就緒狀態了,所以我們看到就緒狀態指的就是可以執行但是 CPU 在時間片輪轉的時候還沒轉到自己這里來。
我們知道 IO 阻塞是不耗費 CPU 的,而純計算則是需要耗費 CPU 的。如果一個進程內部指令全部都是計算相關,不涉及 IO,那么它也可以沒有阻塞狀態。但即便如此,操作系統也不會一直讓該進程霸占着 CPU,當該進程執行一段時間之后,操作系統也會強制將 CPU 的控制權交給其它進程,此時進程就直接由運行狀態變成了就緒狀態。因此從這里我們可以看出 CPU 切換會有兩種情況,一種是遇見不需要 CPU 的 IO 操作;另一種就是 CPU 的時間片輪轉,時間到了也會強制從一個進程切換到另一個進程。
當然啦,除了上面三種狀態之外,還要有兩種狀態。顯然各位都猜到了,那就是 "創建狀態" 和 "結束狀態",因為進程肯定是要被創建的,而且最終也是要被銷毀的。
創建狀態(new):進程正在被創建時的狀態。
結束狀態(exit):進程正在從系統中消失時的狀態。
於是一個完整的進程狀態的生命周期與變遷就可以用下面這張圖表示:
再來詳細說明一下進程的狀態變遷:
NULL -> 創建狀態:一個新進程被創建時的狀態,也是第一個狀態
創建狀態 -> 就緒狀態:當進程被創建並完成初始化后,一切就准備就緒了,此時就變成了就緒狀態,這個過程是很快的
就緒狀態 -> 運行狀態:處於就緒狀態的進程被操作系統的進程調度器選中之后,就分配 CPU 來執行指令
運行狀態 -> 結束狀態:當進程運行完畢或者出錯時,會被操作系統銷毀,此時進入結束狀態
運行狀態 -> 就緒狀態:處於運行狀態的進程用完時間片之后,操作系統會將其變成就緒狀態,然后選擇另一個就緒狀態的進程執行
運行狀態 -> 阻塞狀態:當進程因為某個事件阻塞、並且必須要等待該事件完成時,那么操作系統會將其設置為阻塞狀態
阻塞狀態 -> 就緒狀態:導致某個進程處於阻塞狀態的事件完成時,它會從阻塞狀態變成就緒狀態
如果有大量處於阻塞狀態的進程,那么這是非常浪費物理內存的,顯然這不是我們想要的。畢竟物理內存有限,讓阻塞狀態的進程一直占用着物理內存是一件非常奢侈的事情。所以,在虛擬內存管理的操作系統中,通常會把處於阻塞狀態的進程占用的物理內存 "換出" 到硬盤,等需要再次運行的時候再從硬盤 "換入" 到物理內存。
因此,此時就需要一個新的狀態,來表示進程沒有占用實際的物理內存,該狀態就是 "掛起狀態"。注意:這跟阻塞狀態不一樣,阻塞狀態是等待某個事件返回。並且掛起狀態可以分為兩種:
阻塞掛起狀態:進程在外存(硬盤)並等待某個事件返回。
就緒掛起狀態:進程在外存(硬盤),但只要進入內存,便能立刻運行。
這兩種掛起狀態再加上之前的五種狀態,就變成了七種狀態變遷,如果所示:
導致進程掛起的原因不只是因為進程所使用的內存空間不在物理內存,還包括如下情況:
通過 sleep 讓進程間歇性掛起,其工作原理是設置一個定時器,到期后喚醒進程;
用戶希望掛起一個程序的執行,比如在 Linux 中用 Ctrl+Z 掛起進程;
進程的控制結構
在操作系統中,是用 "進程控制塊(process control block,PCB)" 數據結構來描述進程的。
那 PCB 是什么呢?我們搜索一下吧。
抱歉,我失態了。
PCB 是進程存在的唯一標識,這意味着一個進程的存在必然會有一個 PCB,如果進程消失,那么對應的 PCB 也會隨之消失。
PCB具體包含什么信息呢?
進程描述信息:
進程標識符:標識各個進程,每個進程都會有一個、並且唯一一個標識符;
用戶標識符:進程歸屬的用戶,用戶標識符主要為共享和保護而服務;
進程控制和管理信息:
進程當前狀態,如:new(創建)、ready(就緒)、running(運行)、blocked(阻塞)、waiting(等待,和阻塞類似)、suspend(掛起)、exit(退出);
進程優先級:進程搶占 CPU 的優先級;
資源分配清單:
有關內存地址空間或者虛擬地址空間的信息,所打開文件的列表和所使用的 I/O 設備信息。
CPU 相關信息:
CPU 中各個寄存器的值,當進程被切換時,CPU 的狀態信息都會保存在相應的 PCB 中,以便進程重新執行時,能夠從斷點處繼續執行。
由此可見 PCB 包含的信息還是很多的。
每個 PCB 是如何組織的呢?
通常是通過鏈表的方式進行組織,把具有相同狀態的進程鏈在一起,組成各種隊列,比如:
把所有處於就緒狀態的進程鏈在一起,組成 "就緒隊列";
把所有因等待事件而處於阻塞狀態的進程鏈在一起,組成 "阻塞隊列";
另外,對於運行隊列來說,在單核 CPU 系統中只會有一個運行指針,因為單核 CPU 同一時刻只會執行一個程序。
就緒隊列和阻塞隊列的鏈表組織如圖所示:
除了鏈接的組織方式,還有索引方式,它的工作原理:將同一狀態的進程組織在一個索引表中,索引表項指向相應的 PCB,不同狀態對應不同的索引表。
一般會選擇鏈表,因為可能面臨進程創建、銷毀等調度,從而導致進程狀態發生變化,所以鏈表能夠更加靈活的插入和刪除。
進程的控制
在我們了解了進程的狀態變遷和數據結構 PCB 之后,再來看看進程的創建、終止、阻塞、喚醒的過程,這些過程便是進程的控制。
1. 創建進程
操作系統允許一個進程創建另一個進程,而且允許子進程繼承父進程所擁有的資源,當子進程被終止時,其繼承的父進程的資源也會歸還給相應的父進程。同時,終止父進程的同時也會終止其所有的子進程。
創建進程的過程如下:
為新進程分配一個唯一的進程標識號,並申請一個空白的 PCB。另外,PCB 是有限的,所申請失敗則進程也會創建失敗。
為進程分配資源,此處如果資源不足,進程就會進入等待狀態,以等待資源。
初始化 PCB。
如果進程的調度隊列能夠接納新進程,那就將進程插入到就緒隊列,等待被調度運行。
2. 終止進程
進程可以有三種終止方式:正常結束、異常結束、以及外界干預(信號kill掉)
。
終止進程的過程如下:
查找需要終止的進程的 PCB;
如果處於執行狀態,則立即終止該進程的執行,然后將 CPU 資源分配給其它進程;
如果還有子進程,則應將其所有子進程終止;
將該進程所擁有的全部資源都歸還給父進程或操作系統;
將其 PCB 從所在隊列中刪除;
3. 阻塞進程
當進程需要等待某一事件完成時,它可以調用阻塞語句將自己變成阻塞等待狀態。而一旦進入此狀態,則必須由另一個進程喚醒。
阻塞進程的過程如下:
找到將要被阻塞的進程對應的 PCB;
如果該進程為運行狀態,則保存當前進程的上下文,然后將其由運行狀態變成阻塞狀態,停止運行;
將其對應的 PCB 插入到阻塞隊列當中去;
4. 喚醒進程
進程由運行變為阻塞是由於進程必須等待某一事件的完成,所以處於阻塞狀態的進程是絕對不可能叫醒自己的。
如果某進程正在等待 I/O 事件,需要別的進程發消息給它,則只有當該進程所期待的事件出現時,才由發現者進程調用語句叫醒它。
喚醒進程的過程如下:
在阻塞隊列中找到相應的進程對應的 PCB;
將其從阻塞隊列中移除,並把狀態從阻塞狀態設變成就緒狀態;
把該 PCB 插入到就緒隊列中,等待調度。
進程的阻塞和喚醒是一對功能相反的語句,如果某個進程調用了阻塞語句,那么必有一個與之對應的喚醒語句(只不過調用的是其它進程)
。
進程的上下文切換
各個進程之間是共享 CPU 資源的,在不同的時候進程之間需要切換,讓不同的進程都有機會操作 CPU 執行指令。而一個進程切換到另一個進程運行,稱為進程的上下文切換。
在介紹進程上下文切換之前,先來看看 CPU 的上下文切換
大多數操作系統都是多任務,通常支持大於 CPU 數量的任務同時運行。實際上,這些任務並不是同時運行的,只是因為系統在很短的時間內,就能讓 CPU 在各個任務之間進行切換,因此造成了同時運行的錯覺。
而任務是交給 CPU 運行的,那么在每個任務運行之前,CPU 需要知道任務從哪里加載,又從哪里開始運行。因此,操作系統需要事先將 "這些關鍵信息" 設置到 CPU 的 "寄存器" 和 "程序計數器" 中。
CPU 寄存器是 CPU 內部一個容量小,但是速度極快的存儲介質,可以用來存儲一些臨時數據。舉個栗子:寄存器就像是你的口袋,內存像你身上的書包,硬盤則是你家里面的櫃子。你從口袋里面拿東西肯定比從書包和櫃子里面快,不過顯然速度越快容量就越小。
我們說 CPU 操作自身寄存器存儲的數據的速度是極快的,因此在 C 中也允許你通過 register 來聲明一個寄存器變量,不過還是那句話,速度越快容量就越有限。
而程序計數器則是用來存儲 CPU 正在執行的指令位置,或者即將執行的下一條指令的位置。
所以說 CPU 寄存器和程序計數器保存了 CPU 在運行任何任務時都必須依賴的環境,這些環境就叫做 CPU 上下文。
既然理解了 CPU 上下文,那么 CPU 上下文切換就不難了。
CPU 上下文切換就是先把前一個任務的 CPU 上下文保存起來,然后加載新任務的上下文到寄存器和程序計數器,最后再跳轉到程序計數器所指的位置,運行新任務。
這些上下文的保存會由系統內核負責,當此任務再次被分給 CPU 運行時,CPU 會重新加載這些上下文到 CPU 的寄存器和程序計數器中,然后跳轉到程序計數器所指的位置,從中斷的地方繼續運行,這樣就能保證原來任務的狀態不受影響,看起來就像是連續運行。
上面說到的任務,主要包含進程、線程和中斷。所以根據任務的不同,會把 CPU 上下文切換分為:進程上下文切換、線程上下文切換和中斷上下文切換,所以進程上下文切換是 CPU 上下文切換的一種。
進程的上下文切換到底是切換什么呢?
進程是由內核管理和調度的,所以進程的切換只會發生在內核態。
所以,進程的上下文切換不僅包含了虛擬內存、棧、全局變量等用戶空間的資源,還包括了內核堆棧、寄存器等內核空間的資源。
通常,會把交換的信息保存在進程的 PCB,當要運行另一個進程的時候,我們需要從這個進程的 PCB 中取出上下文,然后恢復到 CPU 中,使得該進程可以繼續執行,如下圖所示:
注意:進程的上下文開銷是很關鍵的,我們希望它的開銷越少越好,這樣可以把更多的時間用在執行程序上,而不是耗費在上下文切換。
有哪些場景會發生進程的上下文切換呢?
為了保證所有進程都可以得到公平調度,CPU 時間被划分為一段段的時間片,這些時間片被輪流分配給各個進程。這個當某個進程的時間片耗盡了,那么該進程就會從運行狀態變為就緒狀態,然后系統會從就緒隊列中選擇另外一個進程運行。
進程在系統資源不足(比如內存不足)時,要等到資源滿足后才可以運行,這個時候進程也會被掛起,並由系統調度其它進程運行。
當進程通過睡眠函數 sleep 這樣的方法將自己主動掛起時,自然也會重新調度。
當有優先級更高的進程運行時,為了保證高優先級的進程運行,當前進程會被掛起,從而將資源留給高優先級的進程。
發生硬件中斷時,CPU 上的進程會被中斷掛起,轉而執行內核中的中斷服務程序。
以上就是發生進程上下文切換的常見場景了。
線程
說完進程之后,我們來說說線程。線程是操作系統調度的最小單元,它才是真正操作 CPU 執行指令的,而上面說的進程是操作系統進行資源分配的最小單元,它是為線程提供資源的。線程必須要在進程中,不可能獨立存在,所以每一個線程都有對應的進程,每一個進程都至少會有一個線程。
然而在早期的操作系統中都是以進程作為獨立運行的基本單元,但是隨着發展,計算機科學家們又提出了更小的可以獨立運行的基本單元,也就是線程。
為什么使用線程?
舉個栗子,假設你要編寫一個視頻播放器軟件,這個軟件的核心模塊有三個:
1. 從視頻文件中讀取數據;
2. 對讀取的數據進行解壓縮;
3. 把解壓縮后的視頻數據播放出來;
對於單進程的實現方式,我想最簡單也是最直接的方式就是像下面這樣:
但是這種單進程的模式,存在以下問題:
播放出來的畫面和聲音會不連貫,因為當 CPU 能力不夠強的時候,read(讀取文件數據) 可能會導致進程就卡在這了,這樣就會導致等半天才會進程數據解壓和播放;
各個函數之間不是並發執行,因此會影響資源的使用效率;
那如果改成多進程的方式:
但是對於這種多進程的方式,依然會存在問題:
1. 進程之間如何通信、如何共享數據呢?
2. 維護進程的系統開銷很大,比如創建進程時需要分配資源、建立 PCB;終止進程時需要回收資源、撤銷 PCB;進程切換時需要保存當前進程的上下文信息
那么問題來了,如何才能解決這一點呢?顯然我們需要有一種新的實體,能滿足以下特性:
實體之間可以並發地運行;
實體之間共享相同的地址空間;
這個新的實體就是線程,線程之間可以並發運行並且共享相同的地址空間。
什么是線程?
線程是進程當中的一條執行流程。
同一個進程內的多個線程之間可以共享代碼段、數據段、打開的文件等資源,但每個線程都有一套獨立的寄存器和棧,這樣可以確保對線程的控制流是相對獨立的。
線程都有哪些優缺點呢?
1. 線程的優點:
一個進程中可以同時存在多個線程;
各個線程之間可以並發的執行;
各個線程之間可以共享地址空間和文件等資源,多個線程之間的通信是方便的。
2. 線程的缺點:
1. 當進程中的一個線程崩潰時,會導致該進程內的所有其它線程都崩潰;
2. 我們說進程內的資源是線程共享的,那么多個線程來同時訪問一塊數據的時候該給哪個線程呢?所以線程存在着資源競爭;
線程和進程的比較
我們介紹了進程和線程,那么它們之間的區別以及聯系是什么呢?
1. 進程是操作系統進行資源(包括內存、打開的文件等)分配的最小單元,線程是操作系統調度(去操作CPU)的最小單元;
2. 進程擁有一個完整的資源平台,而線程只具有一些必不可少的資源,比如寄存器和棧;
3. 線程同樣具有就緒、阻塞、執行三種基本狀態,同樣具有狀態之間的轉換關系;
4. 線程能減少並發執行的時間開銷和空間開銷;
5. 每一個進程都至少有一個線程,這個線程叫做主線程,每個線程可以創建新的線程,主線程之外的線程叫做子線程。
對於第4個區別,線程相比進程能夠減少開銷,主要體現在:
1. 線程的創建比進程快很多,因為進程在創建的過程中還需要資源管理信息,比如:內存管理信息、文件管理信息,而線程在創建的過程中不會涉及這些資源管理信息,而是共享它們;總之我們說線程是用來操作 CPU 執行指令集的,而進程是給線程提供資源的,所以你把創建一個進程想象成蓋一間房子,而創建一個線程就是讓一個人進去辦公,顯然創建線程的速度要比創建進程的速度快很多。
2. 線程的終止比進程快,因為線程釋放的資源要比進程少很多。
3. 同一個進程內的線程切換也比多個進程之間的切換快很多,因為線程具有相同的地址空間(虛擬內存共享),這意味着同一個進程的線程都具有同一個頁表,那么在切換的時候不需要切換頁表。而對於進程之間的切換是需要把頁表給切換掉的,而頁表切換的過程開銷恰恰又是比較大的。
4. 由於同一個進程內的所有線程都是共享資源,因此在線程之間進行數據傳遞都不需要經過內核了,這就使得線程之間的數據通信的成本大大降低,從而提高交互效率。
因此,在對比進程的時候,不管是時間效率還是空間效率,線程都要高上很多。
線程的上下文切換
我們已經知道,線程與進程之間的最大區別在於:線程是操作系統調度的最小單元,進程是操作系統資源分配的最小單元,線程不能獨立於進程而存在,一個進程內部至少有一個線程,而且線程是真正用來 "干活" 的。
所以操作系統的任務調度,調度的實際上是線程,而進程只是給線程提供了虛擬內存、全局變量等資源。
對於線程和進程,我們可以這么理解:
1. 創建一個進程時,默認會有一個主線程;
2. 線程可以創建其它的線程,當進程內部有多個線程時,這些線程會共享相同的虛擬內存和全局變量資源,這些資源在上下文切換時是不需要修改的;
另外,線程也有自己的私有數據,比如棧和寄存器等等,這些在上下文切換的時候也是需要保存的。
所謂線程上下文切換,到底切換的是什么?
這還得看互相切換線程是不是屬於同一個進程:
當兩個切換的線程不屬於同一個進程,那么線程的切換和進程的切換是一樣的;
當兩個線程屬於同一個進程,因為虛擬內存是共享的,所以在切換時,虛擬內存這些資源就保持不動,只需要切換線程的私有數據、以及寄存器內等不共享的數據;
所以相比進程,線程的切換要小很多。
線程的實現
線程的實現方式主要有三種:
用戶線程(user thread):在用戶空間實現的線程,不是由內核管理的線程,是由用戶態的線程庫來完成線程的管理。
內核線程(kernel thread):在內核中實現的線程,是由內核管理的線程。
輕量級進程(lightweight thread):在內核中來支持用戶線程。
線程的種類我們雖然知道了,但是它們之間的對應關系是什么呢?
1. 第一種關系是多對一的關系,即多個用戶線程對應一個內核線程:
2. 第二種關系是一對一的關系,即一個用戶線程對應一個內核線程:
3. 第三種關系是多對多的關系,即多個用戶線程對應多個內核線程:
用戶線程如何理解?存在什么優勢和缺陷?
用戶線程是基於用戶態的線程管理庫來實現的,那么線程控制塊(Thread Control Block,TCB)也是在庫里面實現的,而對於操作系統而言是看不到這個 TCB 的,操作系統只能看到進程對應的 PCB。
所以,用戶線程的整個線程管理和調度,操作系統是不直接參與的,而是由用戶級線程庫函數來完成線程的管理,包括線程的創建、終止、同步和調度等等。
用戶級線程的模型,也就類似前面提到了多對一的關系,即多個用戶線程對應一個內核線程,如下圖所示:
用戶線程的優點:
每個線程都需要擁有它獨有的線程控制塊(TCB)列表,用來跟蹤記錄它內部的各個線程狀態信息(PC、棧指針、寄存器),TCB 由用戶級線程庫來維護,可用於不支持線程技術的操作系統。
用戶線程的切換也是由線程庫函數來完成的,無需用戶態和內核態之間的切換,所以速度特別快。
用戶線程的缺點:
由於操作系統不參與線程的調度,如果一個線程發起了系統調用而阻塞,那該進程包含的所有用戶線程都無法執行了。
當一個線程開始運行后,除非它主動交出 CPU 的使用權,否則它所在進程中的其它線程都無法運行,因為用戶態的線程沒辦法打斷其它正在運行的線程,它們之間是平級的,沒有誰具有特權,只有操作系統才具有,但是操作系統不參與用戶態線程的管理。
由於 CPU 的時間片分給的是進程,然后進程內的線程去操作 CPU 執行,因此在多線程執行時,每個線程得到時間片較少,執行會比較慢。
以上便是用戶線程的優缺點。
內核線程如何理解?存在什么優勢和缺陷?
內核線程是由操作系統負責管理的,因此線程對應的 TCB 自然是放在操作系統里面的,這樣線程的創建、終止和管理都是由操作系統負責。
內核線程的模型就類似於前面提到的一對一的關系,即一個用戶線程對應一個內核線程,如下圖所示:
內核線程的優點:
在一個進程中,某個內核線程發起系統調用而被阻塞,並不會影響其它內核線程的運行。
分配給多線程的進程更多的操作 CPU 的時間。
內核線程的缺點:
在支持內核線程的操作系統中,由內核來維護進程和線程的上下文信息,如 PCB 和 TCB。
線程的創建、終止和切換都是通過系統調用的方式來執行,因此對於系統來說,開銷會比較大。
以上便是內核線程的優缺點。
最后的輕量級進程如何理解呢?
輕量級進程(lightweight process,LWP)是內核支持的用戶線程,一個進程可以有一個或多個 LWP,每個 LWP 跟內核線程都是一對一映射的,也就是說 LWP 都是由一個內核線程支持。
另外,LWP 只能由內核管理並像普通的進程一樣被調度,Linux 內核是支持 LWP 的典型例子。
在大多數系統中,LWP 與普通進程的區別在於它只有一個最小的執行上下文和調度程序所需要的統計信息。一般來說,一個進程代表一個程序的實例,而 LWP 代表程序的執行線程,因為一個執行線程不像進程一樣需要那么多的狀態信息,所以 LWP 也不帶有這樣的信息。
但在 LWP 之上也是可以使用用戶線程的,所以 LWP 和用戶線程之間的對應關系就有如下三種:
1:1,即一個 LWP 對應一個用戶線程;
1:n,即一個 LWP 對應多個用戶線程;
m:n,即多個 LWP 對應多個用戶線程;
先看LWP模型的一張圖,然后分析其優缺點。
從圖中我們可以看出,一個 LWP 對應一個內核線程,一個內核線程對應一個 CPU,但是用戶線程和 LWP 之間的關系則可以有多種。
1:1 模式
一個用戶線程對應一個 LWP 再對應一個內核線程,圖中的進程 4 便屬於此模型。
優點:實現並行,當一個 LWP 阻塞,不會影響其它 LWP。
缺點:每一個用戶線程,就會對應一個內核線程,因此創建線程的的開銷比較大。
n:1 模式
多個線程對應一個 LWP 再對應一個內核線程,圖中的進程 2 便屬於此模型,線程管理是在用戶空間完成的,此模式中的用戶線程對操作系統不可見。
優點:用戶線程開幾個都沒關系,且上下文切換發生在用戶空間,切換的效率較高。
缺點:一個用戶線程如果阻塞了,則整個進程都將阻塞,另外在多核 CPU 中也是沒有辦法充分利用多核的。
m:n 模式
將上面兩個模式混搭在一起,就形成 m:n 模型,該模型提供了兩級控制,首先多個用戶線程可以對應多個 LWP,LWP 再一一對應到內核線程,如圖中的進程 3。
優點:綜合了前面兩種優點,大部分的線程上下文切換發生在用戶空間,且多個線程又可以充分利用多核 CPU 資源。
組合模式
如圖中的進程5,此進程結合1:1和m:n兩種模型,開發人員可以針對不同的應用特點來調節內核線程的數目,以此達到物理並行性和邏輯並行性的最佳方案。
調度
進程都希望自己能盡可能多的占用 CPU 進行工作,但這顯然是不現實的,操作系統不會允許的,因此就涉及到了上下文切換。
一旦操作系統把進程切換到運行狀態,那么就意味着該進程要占有 CPU 了;但是當操作系統將進程從運行狀態切換到其它狀態時,那么它就無法占有 CPU 了,於是操作系統會從就緒隊列中選擇一個其它的線程。
而選擇一個進程執行這一功能是在操作系統中完成的,通常被稱為 "調度程序(scheduler)"。
那么到底什么時候、以什么原則來調度進程呢?
調度時機
在進程的生命周期中,當進程從一個狀態變成另一狀態的時候,就會觸發一次調度。
比如,以下狀態的變化都會觸發操作系統的調度:
就緒態 -> 運行態:當進程被創建時,會進入到就緒隊列,操作系統會從就緒隊列中選擇一個進程運行;
運行態 -> 阻塞態:當進程發生 I/O 事件而阻塞時,操作系統必須從就緒隊列中選擇另外一個進程運行;
運行態 -> 結束態:當進程退出結束后,操作系統同樣要從就緒隊列中選擇另外一個進程運行;
因為在狀態發生變化的時候,操作系統需要考慮是否將 CPU 的控制權交給新的進程,或者是否拿走 CPU 的控制權交給另一個進程。
另外,如果硬件時鍾提供某個頻率的周期性中斷,那么可以根據如何處理時鍾中斷,而將調度算法分為兩類:
非搶占式調度算法:挑選一個進程,然后讓該進程運行,直到阻塞或者退出,才會處理調用另一個進程,也就是說這種算法不會理會時鍾中斷這件事情。
搶占式調度算法:挑選一個進程,然后讓該進程只運行某段時間,如果時間結束時該進程仍在運行,那么操作系統會將其強制掛起,接着調度程序會從就緒隊列中選擇另外一個進程。這種搶占式調度處理,需要在時間間隔的末端發生時鍾中斷,以便把 CPU 控制權返還給調度程序進行調度,也就是常說的時間片機制。
調度原則
原則一:如果運行的程序,發生了 I/O 事件的請求,那 CPU 使用率必然會很低,因為此時進程在阻塞等待硬盤的數據返回。這樣的過程,勢必會造成 CPU 的空閑。所以,為了提高 CPU 利用率,在這種發送 I/O 事件致使 CPU 空閑的情況下,調度程序需要從就緒隊列中選擇一個進程來運行。
原則二:有的程序執行某個任務花費的時間會比較長,如果這個程序一直占用着 CPU,會造成系統吞吐量(CPU 在單位時間內操作的進程數量)的降低。所以,要提高系統的吞吐率,調度程序要權衡長任務進程和短任務進程的運行完成數量。
原則三:從進程開始到結束的過程中,實際上是包含兩個時間,分別是進程運行時間和進程等待時間,這兩個時間總和就稱為周轉時間。進程的周轉時間越小越好,如果進程的等待時間很長而運行時間很短,那周轉時間就很長,這不是我們所期望的,調度程序應該避免這種情況發生。
原則四:處於就緒隊列的進程,也不能等太久,當然希望這個等待的時間越短越好,這樣可以使得進程更快的在 CPU 中執行。所以,就緒隊列中進程的等待時間也是調度程序所需要考慮的原則。
原則五:對於鼠標、鍵盤這種交互式比較強的應用,我們當然希望它的響應時間越快越好,否則就會影響用戶體驗了。所以,對於交互式比較強的應用,響應時間也是調度程序需要考慮的原則。
針對上面的五種調度原則,總結如下:
CPU 利用率:調度程序應確保 CPU 是始終匆忙的狀態,這可提高 CPU 的利用率;
系統吞吐量:吞吐量表示的是單位時間內 CPU 完成進程的數量,長作業的進程會占用較長的 CPU 資源,因此會降低吞吐量,相反,短作業的進程會提升系統吞吐量;
周轉時間:周轉時間是進程運行和阻塞時間總和,一個進程的周轉時間越小越好;
等待時間:這個等待時間不是阻塞狀態的時間,而是進程處於就緒隊列的時間,等待的時間越長,用戶越不滿意;
響應時間:用戶提交請求到系統第一次產生響應所花費的時間,在交互式系統中,響應時間是衡量調度算法好壞的主要標准。
說白了,這么多調度原則,目的就是使得進程要「快」。
調度算法
不同的調度算法適用的場景也是不同的,先來說說在單核CPU中常見的調度算法。
1. "先來先" 服務調度算法
最簡單的一個調度算法,就是非搶占式的 "先來先服務(First Come First Serverd,FCFS)"算法。
顧名思義,就是先來后到,每次從就緒隊列中選擇最先進入隊列的進程,然后一直運行,直到進程退出或阻塞,才會繼續從隊列中選擇一個進程接着運行。
這似乎很公平,但是當一個長作業先運行了,那么后面的短作業等待的時間就會很長,不利於短作業。
FCFS 對長作業有利,適用於 CPU 密集型作業的系統,不適於 I/O 密集型。
2. "最短作業優先" 調度算法
最短作業優先(Short Job First,SJF)調度算法從名字上也能理解它的意思,會優先選擇運行時間最短的進程來執行,這有助於系統的吞吐量。
這顯然對長作業不利,很容易造成一種極端現象。
比如,一個長作業在就緒隊列等待運行,而這個就緒隊列有非常多的短作業,那么就會使得長作業不斷的往后推,周轉時間變長,致使長作業長期不會被運行。
3. "高響應比優先" 調度算法
前面的「先來先服務調度算法」和「最短作業優先調度算法」都沒有很好的權衡短作業和長作業,而高響應比優先(Highest Response Ratio Next,HRRN)調度算法顯然考慮到了這一問題。
每次進行進程調度時,先計算「響應比」優先級,然后把「響應比」優先級最高的進程投入運行,「響應比」優先級的計算公式:
「響應比」優先級 = (等待時間 + 要求服務時間) / 要求服務時間
從上面的公式,可以發現:
如果兩個進程的「等待時間」相同時,「要求的服務時間」越短,「響應比」就越高,這樣短作業的進程容易被選中運行;
如果兩個進程「要求的服務時間」相同時,「等待時間」越長,「響應比」就越高,這就兼顧到了長作業進程,因為進程的響應比可以隨時間等待的增加而提高,當其等待時間足夠長時,其響應比便可以升到很高,從而獲得運行的機會;
4. "時間片輪轉" 調度算法
最古老、最簡單、最公平且使用最廣的算法就是時間片輪轉(Round Robin,RR)調度算法。
每個進程會被分配一個時間段,稱之為時間片(Quantum),即允許進程執行的時間。
如果時間片用完,進程還在運行,那么將會把此進程的 CPU 控制權 拿走,並把 CPU 分配另外一個進程;
如果該進程在時間片結束前阻塞或結束,則 CPU 立即切換到另一個進程;
另外,時間片的長度也是一個很關鍵的點:
如果時間片設得太短會導致過多的進程上下文切換,降低了 CPU 效率;
如果設得太長又可能引起對短作業進程的響應時間變長,通常將時間片設為 20ms~50ms 是一個比較合理的折中值。
5. "最高優先級" 調度算法
前面的「時間片輪轉算法」做了個假設,即讓所有的進程同等重要,也不偏袒誰,大家的運行時間都一樣。
但是,對於多用戶計算機系統就有不同的看法了,它們希望調度是有優先級的,即希望調度程序能 "從就緒隊列中選擇最高優先級的進程去運行,稱為最高優先級(Highest Priority First,HPF)調度算法"。
進程的優先級可以分為,靜態優先級或動態優先級:
靜態優先級:創建進程時候,就已經確定了優先級了,然后整個運行時間優先級都不會變化;
動態優先級:根據進程的動態變化調整優先級,比如如果進程運行時間增加,則降低其優先級,如果進程等待時間(就緒隊列的等待時間)增加,則升高其優先級,也就是隨着時間的推移增加等待進程的優先級。
該算法也有兩種處理優先級高的方法,非搶占式和搶占式:
非搶占式:當就緒隊列中出現優先級高的進程,運行完當前進程,再選擇優先級高的進程。
搶占式:當就緒隊列中出現優先級高的進程,當前進程掛起,調度優先級高的進程運行。
但是依然有缺點,可能會導致低優先級的進程永遠不會運行。
6. "多級反饋隊列" 調度算法
多級反饋隊列(MultiLevel Feedback Queue)調度算法是「時間片輪轉算法」和「最高優先級算法」的綜合和發展。
顧名思義:
「多級」表示有多個隊列,每個隊列優先級從高到低,並且優先級越高時間片越短。
「反饋」表示如果有新的進程加入優先級高的隊列時,立刻停止當前正在運行的進程,轉而去運行優先級高的隊列。
來看看,它是如何工作的:
設置了多個隊列,賦予每個隊列不同的優先級,每個隊列的優先級從高到低,同時優先級越高時間片越短;
新的進程會被放入到第一級隊列的末尾,按先來先服務的原則排隊等待被調度,如果在第一級隊列規定的時間片沒運行完成,則將其轉入到第二級隊列的末尾,以此類推,直至完成;
當較高優先級的隊列為空,才調度較低優先級的隊列中的進程去運行。如果進程運行時,有新進程進入較高優先級的隊列,則停止當前運行的進程並將其移入到原隊列末尾,接着讓較高優先級的進程運行;
可以發現,對於短作業可能可以在第一級隊列很快被處理完。對於長作業,如果在第一級隊列處理不完,可以移入下次隊列等待被執行,雖然等待的時間變長了,但是運行時間也會更長了,所以該算法很好的 "兼顧了長短作業,同時還有較好的響應時間"。
舉個生活中的栗子
如果看的有些迷迷糊糊的話,那我們舉個銀行辦業務的例子,將上面的調度算法串起來,相信你一定會明白的。
辦理業務的客戶相當於進程,銀行窗口工作人員相當於 CPU。
現在,假設這個銀行只有一個窗口(單核 CPU ),那么工作人員一次只能處理一個業務。
那么最簡單的處理方式,就是先來的先處理,后面來的就乖乖排隊,這就是先來先服務(FCFS)調度算法。但是萬一先來的這位老哥是來貸款的,這一談就好幾個小時,一直占用着窗口,這樣后面的人只能干等,或許后面的人只是想簡單的取個錢,幾分鍾就能搞定,卻因為前面老哥辦長業務而要等幾個小時,你說氣不氣人?
有客戶抱怨了,那我們就要改進,我們干脆優先給那些幾分鍾就能搞定的人辦理業務,這就是短作業優先(SJF)調度算法。聽起來不錯,但是依然還是有個極端情況,萬一辦理短業務的人非常的多,這會導致長業務的人一直得不到服務,萬一這個長業務是個大客戶,那不就撿了芝麻丟了西瓜。
那就公平起見,現在窗口工作人員規定,每個人我只處理 10 分鍾。如果 10 分鍾之內處理完,就馬上換下一個人。如果沒處理完,依然換下一個人,但是客戶自己得記住辦理到哪個步驟了。這個也就是時間片輪轉(RR)調度算法。但是如果時間片設置過短,那么就會造成大量的上下文切換,增大了系統開銷。如果時間片過長,相當於退化成退化成 FCFS 算法了。
既然公平也可能存在問題,那銀行就對客戶分等級,分為普通客戶、VIP 客戶、SVIP 客戶。只要高優先級的客戶一來,就第一時間處理這個客戶,這就是最高優先級(HPF)調度算法。但依然也會有極端的問題,萬一當天來的全是高級客戶,那普通客戶不是沒有被服務的機會,不把普通客戶當人是嗎?那我們把優先級改成動態的,如果客戶辦理業務時間增加,則降低其優先級,如果客戶等待時間增加,則升高其優先級。
那有沒有兼顧到公平和效率的方式呢?這里介紹一種算法,考慮的還算充分的,多級反饋隊列(MFQ)調度算法,它是時間片輪轉算法和優先級算法的綜合和發展。它的工作方式:
- 銀行設置了多個排隊(就緒)隊列,每個隊列都有不同的優先級,各個隊列優先級從高到低,同時每個隊列執行時間片的長度也不同,優先級越高的時間片越短。
- 新客戶(進程)來了,先進入第一級隊列的末尾,按先來先服務原則排隊等待被叫號(運行)。如果時間片用完客戶的業務還沒辦理完成,則讓客戶進入到下一級隊列的末尾,以此類推,直至客戶業務辦理完成。
- 當第一級隊列沒人排隊時,就會叫號二級隊列的客戶。如果客戶辦理業務過程中,有新的客戶加入到較高優先級的隊列,那么此時辦理中的客戶需要停止辦理,回到原隊列的末尾等待再次叫號,因為要把窗口讓給剛進入較高優先級隊列的客戶。
可以發現,對於要辦理短業務的客戶來說,可以很快的輪到並解決。對於要辦理長業務的客戶,一下子解決不了,就可以放到下一個隊列,雖然等待的時間稍微變長了,但是輪到自己的辦理時間也變長了,也可以接受,不會造成極端的現象,可以說是綜合上面幾種算法的優點。
小結
線程和進程雖然是一門很復雜的學問,但是理解它們的意義、工作方式、主要作用是什么等等還是很輕松的。有興趣的話,可以用代碼寫一下多線程、多進程之類的,或者也可以更深入的研究一下,看一下編程語言的底層實現,它們的線程如何和操作系統的線程進行對應的。