進程和線程:進程的開銷比線程大在了哪裏?

不知你在面試中是否遇到過這樣的問題,題目很短,看似簡單,但在回答時又感覺有點喫力?比如下面這兩個問題:

進程內部都有哪些數據?

爲什麼創建進程的成本很高?

這樣的問題確實不好回答,除非你真正理解了進程和線程的原理,否則很容易掉入面試大坑。

進程和線程

進程(Process),顧名思義就是正在執行的應用程序,是軟件的執行副本。而線程是輕量級的進程。進程是分配資源的基礎單位。而線程很長一段時間被稱作輕量級進程(Light Weighted Process),是程序執行的基本單位。

在計算機剛剛誕生的年代,程序員拿着一個寫好程序的閃存卡,插到機器裏,然後電能推動芯片計算,芯片每次從閃存卡中讀出一條指令,執行後接着讀取下一條指令。閃存中的所有指令執行結束後,計算機就關機。一開始,這種單任務的模型,在那個時代叫作作業(Job),當時計算機的設計就是希望可以多處理作業。圖形界面出現後,人們開始利用計算機進行辦公、購物、聊天、打遊戲等,因此一臺機器正在執行的程序會被隨時切來切去。於是人們想到,設計進程和線程來解決這個問題。

每一種應用,比如遊戲,執行後是一個進程。但是遊戲內部需要圖形渲染、需要網絡、需要響應用戶操作,這些行爲不可以互相阻塞,必須同時進行,這樣就設計成線程。

資源分配問題

設計進程和線程,操作系統需要思考分配資源。最重要的 3 種資源是:計算資源(CPU)、內存資源和文件資源。

早期的 OS 設計中沒有線程,3 種資源都分配給進程,多個進程通過分時技術交替執行,進程之間通過管道技術等進行通信。但是這樣做的話,設計者們發現用戶(程序員),一個應用往往需要開多個進程,因爲應用總是有很多必須要並行做的事情。並行並不是說絕對的同時,而是說需要讓這些事情看上去是同時進行的——比如圖形渲染和響應用戶輸入。於是設計者們想到了,進程下面,需要一種程序的執行單位,僅僅被分配 CPU 資源,這就是線程。

輕量級進程

線程設計出來後,因爲只被分配了計算資源(CPU),因此被稱爲輕量級進程。被分配的方式,就是由操作系統調度線程。操作系統創建一個進程後,進程的入口程序被分配到了一個主線程執行,這樣看上去操作系統是在調度進程,其實是調度進程中的線程。這種被操作系統直接調度的線程,我們也稱爲內核級線程。另外,有的程序語言或者應用,用戶(程序員)自己還實現了線程。相當於操作系統調度主線程,主線程的程序用算法實現子線程,這種情況我們稱爲用戶級線程。Linux 的 PThread API 就是用戶級線程,KThread API 則是內核級線程。

分時和調度

因爲通常機器中 CPU 核心數量少(從幾個到幾十個)、進程 & 線程數量很多(從幾十到幾百甚至更多),你可以類比爲發動機少,而機器多,因此進程們在操作系統中只能排着隊一個個執行。每個進程在執行時都會獲得操作系統分配的一個時間片段,如果超出這個時間,就會輪到下一個進程(線程)執行。再強調一下,現代操作系統都是直接調度線程,不會調度進程。

分配時間片段

如下圖所示,進程 1 需要 2 個時間片段,進程 2 只有 1 個時間片段,進程 3 需要 3 個時間片段。因此當進程 1 執行到一半時,會先掛起,然後進程 2 開始執行;進程 2 一次可以執行完,然後進程 3 開始執行,不過進程 3 一次執行不完,在執行了 1 個時間片段後,進程 1 開始執行;就這樣如此週而復始。這個就是分時技術。

下面這張圖更加直觀一些,進程 P1 先執行一個時間片段,然後進程 P2 開始執行一個時間片段, 然後進程 P3,然後進程 P4……

注意,上面的兩張圖是以進程爲單位演示,如果換成線程,操作系統依舊是這麼處理。

進程和線程的狀態

一個進程(線程)運行的過程,會經歷以下 3 個狀態:

進程(線程)創建後,就開始排隊,此時它會處在 “就緒”(Ready)狀態;當輪到該進程(線程)執行時,會變成 “運行”(Running)狀態;當一個進程(線程)將操作系統分配的時間片段用完後,會回到 “就緒”(Ready)狀態。我這裏一直用進程 (線程)是因爲舊的操作系統調度進程,沒有線程;現代操作系統調度線程。有時候一個進程(線程)會等待磁盤讀取數據,或者等待打印機響應,此時進程自己會進入 “阻塞”(Block)狀態。因爲這時計算機的響應不能馬上給出來,而是需要等待磁盤、打印機處理完成後,通過中斷通知 CPU,然後 CPU 再執行一小段中斷控制程序,將控制權轉給操作系統,操作系統再將原來阻塞的進程(線程)置爲 “就緒”(Ready)狀態重新排隊。而且,一旦一個進程(線程)進入阻塞狀態,這個進程(線程)此時就沒有事情做了,但又不能讓它重新排隊(因爲需要等待中斷),所以進程(線程)中需要增加一個 “阻塞”(Block)狀態。

注意,因爲一個處於 “就緒”(Ready)的進程(線程)還在排隊,所以進程(線程)內的程序無法執行,也就是不會觸發讀取磁盤數據的操作,這時,“就緒”(Ready)狀態無法變成阻塞的狀態,因此下圖中沒有從就緒到阻塞的箭頭。而處於 “阻塞”(Block)狀態的進程(線程)如果收到磁盤讀取完的數據,它又需要重新排隊,所以它也不能直接回到 “運行”(Running)狀態,因此下圖中沒有從阻塞態到運行態的箭頭。

進程和線程的設計

接下來我們思考幾個核心的設計約束:

進程和線程在內存中如何表示?需要哪些字段?

進程代表的是一個個應用,需要彼此隔離,這個隔離方案如何設計?

操作系統調度線程,線程間不斷切換,這種情況如何實現?

需要支持多 CPU 核心的環境,針對這種情況如何設計?

接下來我們來討論下這 4 個問題。

進程和線程的表示

可以這樣設計,在內存中設計兩張表,一張是進程表、一張是線程表。進程表記錄進程在內存中的存放位置、PID 是多少、當前是什麼狀態、內存分配了多大、屬於哪個用戶等,這就有了進程表。如果沒有這張表,進程就會丟失,操作系統不知道自己有哪些進程。這張表可以考慮直接放到內核中。細分的話,進程表需要這幾類信息。

描述信息:這部分是描述進程的唯一識別號,也就是 PID,包括進程的名稱、所屬的用戶等。

資源信息:這部分用於記錄進程擁有的資源,比如進程和虛擬內存如何映射、擁有哪些文件、在使用哪些 I/O 設備等,當然 I/O 設備也是文件。

內存佈局:操作系統也約定了進程如何使用內存。如下圖所示,描述了一個進程大致內存分成幾個區域,以及每個區域用來做什麼。每個區域我們叫作一個段。

操作系統還需要一張表來管理線程,這就是線程表。線程也需要 ID, 可以叫作 ThreadID。然後線程需要記錄自己的執行狀態(阻塞、運行、就緒)、優先級、程序計數器以及所有寄存器的值等等。線程需要記錄程序計數器和寄存器的值,是因爲多個線程需要共用一個 CPU,線程經常會來回切換,因此需要在內存中保存寄存器和 PC 指針的值。用戶級線程和內核級線程存在映射關係,因此可以考慮在內核中維護一張內核級線程的表,包括上面說的字段。如果考慮到這種映射關係,比如 n-m 的多對多映射,可以將線程信息還是存在進程中,每次執行的時候才使用內核級線程。相當於內核中有個線程池,等待用戶空間去使用。每次用戶級線程把程序計數器等傳遞過去,執行結束後,內核線程不銷燬,等待下一個任務。這裏其實有很多靈活的實現,總體來說,創建進程開銷大、成本高;創建線程開銷小,成本低。

隔離方案

操作系統中運行了大量進程,爲了不讓它們互相干擾,可以考慮爲它們分配彼此完全隔離的內存區域,即便進程內部程序讀取了相同地址,而實際的物理地址也不會相同。這就好比 A 小區的 10 號樓 808 和 B 小區的 10 號樓 808 不是一套房子,這種方法叫作地址空間。所以在正常情況下進程 A 無法訪問進程 B 的內存,除非進程 A 找到了某個操作系統的漏洞,惡意操作了進程 B 的內存。對於一個進程的多個線程來說,可以考慮共享進程分配到的內存資源,這樣線程就只需要被分配執行資源。

進程(線程)切換

進程(線程)在操作系統中是不斷切換的,現代操作系統中只有線程的切換。每次切換需要先保存當前寄存器的值的內存,注意 PC 指針也是一種寄存器。當恢復執行的時候,就需要從內存中讀出所有的寄存器,恢復之前的狀態,然後執行。

上面講到的內容,我們可以概括爲以下 5 個步驟:

當操作系統發現一個進程(線程)需要被切換的時候,直接控制 PC 指針跳轉是非常危險的事情,所以操作系統需要發送一個 “中斷” 信號給 CPU,停下正在執行的進程(線程)。

當 CPU 收到中斷信號後,正在執行的進程(線程)會立即停止。注意,因爲進程(線程)馬上被停止,它還來不及保存自己的狀態,所以後續操作系統必須完成這件事情。

操作系統接管中斷後,趁寄存器數據還沒有被破壞,必須馬上執行一小段非常底層的程序(通常是彙編編寫),幫助寄存器保存之前進程(線程)的狀態。

操作系統保存好進程狀態後,執行調度程序,決定下一個要被執行的進程(線程)。

最後,操作系統執行下一個進程(線程)。

當然,一個進程(線程)被選擇執行後,它會繼續完成之前被中斷時的任務,這需要操作系統來執行一小段底層的程序幫助進程(線程)恢復狀態。一種可能的算法就是通過棧這種數據結構。進程(線程)中斷後,操作系統負責壓棧關鍵數據(比如寄存器)。恢復執行時,操作系統負責出棧和恢復寄存器的值。

多核處理

在多核系統中我們上面所講的設計原則依然成立,只不過動力變多了,可以並行執行的進程(線程)。通常情況下,CPU 有幾個核,就可以並行執行幾個進程(線程)。這裏強調一個概念,我們通常說的併發,英文是 concurrent,指的在一段時間內幾個任務看上去在同時執行(不要求多核);而並行,英文是 parallel,任務必須絕對的同時執行(要求多核)。

比如一個 4 核的 CPU 就好像擁有 4 條流水線,可以並行執行 4 個任務。一個進程的多個線程執行過程則會產生競爭條件。因爲操作系統提供了保存、恢復進程狀態的能力,使得進程(線程)也可以在多個核心之間切換。

創建進程(線程)的 API

用戶想要創建一個進程,最直接的方法就是從命令行執行一個程序,或者雙擊打開一個應用。但對於程序員而言,顯然需要更好的設計。站在設計者的角度,你可以這樣思考:首先,應該有 API 打開應用,比如可以通過函數打開某個應用;另一方面,如果程序員希望執行完一段代價昂貴的初始化過程後,將當前程序的狀態複製好幾份,變成一個個單獨執行的進程,那麼操作系統提供了 fork 指令。

也就是說,每次 fork 會多創造一個克隆的進程,這個克隆的進程,所有狀態都和原來的進程一樣,但是會有自己的地址空間。如果要創造 2 個克隆進程,就要 fork 兩次。你可能會問:那如果我就是想啓動一個新的程序呢?我在上文說過:操作系統提供了啓動新程序的 API。你可能還會問:如果我就是想用一個新進程執行一小段程序,比如說每次服務端收到客戶端的請求時,我都想用一個進程去處理這個請求。如果是這種情況,我建議你不要單獨啓動進程,而是使用線程。因爲進程的創建成本實在太高了,因此不建議用來做這樣的事情:要創建條目、要分配內存,特別是還要在內存中形成一個個段,分成不同的區域。所以通常,我們更傾向於多創建線程。不同程序語言會自己提供創建線程的 API,比如 Java 有 Thread 類;go 有 go-routine(注意不是協程,是線程)。

總結

本講我們學習了進程和線程的基本概念。瞭解了操作系統如何調度進程(線程)和分時算法的基本概念,然後瞭解進程(線程)的 3 種基本狀態。線程也被稱作輕量級進程,由操作系統直接調度的,是內核級線程。我們還學習了線程切換保存、恢復狀態的過程。我們發現進程和線程是操作系統爲了分配資源設計的兩個概念,進程承接存儲資源,線程承接計算資源。而進程包含線程,這樣就可以做到進程間內存隔離。這是一個非常巧妙的設計,概念清晰,思路明確,你以後做架構的時候可以多參考這樣的設計。如果只有進程,或者只有線程,都不能如此簡單的解決我們遇到的問題。

那麼通過這次學習,你現在可以來回答本節關聯的面試題目:進程的開銷比線程大在了哪裏?

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/pqW85ItnKeIaprgigSsuOg