1. 程式人生 > >程序和執行緒的區別(作業系統級別解析)

程序和執行緒的區別(作業系統級別解析)

關於程序和執行緒,大家總是說的一句話是“程序是作業系統分配資源的最小單元,執行緒是作業系統排程的最小單元”。這句話理論上沒問題,我們來看看什麼是所謂的“資源”呢。

 

什麼是計算機資源

經典的馮諾依曼結構把計算機系統抽象成 CPU + 儲存器 + IO,那麼計算機資源無非就兩種:

1. 計算資源
2. 儲存資源

CPU是計算單元,單純從CPU的角度來說它是一個黑盒,它只對輸入的指令和資料進行計算,然後輸出結果,它不負責管理計算哪些“指令和資料”。 換句話說CPU只提供了計算能力,但是不負責分配計算資源。

計算資源是作業系統來分配的,也就是常說的作業系統的排程模組,由作業系統按照一定的規則來分配什麼時候由誰來獲得CPU的計算資源,比如分時間片

儲存資源就是記憶體,磁碟這些儲存裝置的資源。作業系統使用了虛擬記憶體機制來管理儲存器,從快取原理的角度來說,把記憶體作為磁碟的快取。程序是面向磁碟的,為什麼這麼說呢,程序表示一個執行的程式,程式的程式碼段,資料段這些都是存放在磁碟中的,在執行時載入到記憶體中。所以虛擬記憶體面向的是磁碟,虛擬頁是對磁碟檔案的分配,然後被快取到實體記憶體的物理頁中。

所以儲存資源是作業系統由虛擬記憶體機制來管理和分配的。程序應該是作業系統分配儲存資源的最小單元。

再來看看執行緒,理論上說Linux核心是沒有執行緒這個概念的,只有核心排程實體(Kernal Scheduling Entry, KSE)這個概念。Linux的執行緒本質上是一種輕量級的程序,是通過clone系統呼叫來建立的。何謂“輕量級”會在後面細說。程序是一種KSE,執行緒也是一種KSE。所以“執行緒是作業系統排程的最小單元”這句話沒問題。
 

什麼是程序

程序是對計算機的一種抽象

1. 程序表示一個邏輯控制流,就是一種計算過程,它造成一個假象,好像這個程序一直在獨佔CPU資源
2. 程序擁有一個獨立的虛擬記憶體地址空間,它造成一個假象,好像這個程序一致在獨佔儲存器資源

下面這張圖是程序的虛擬記憶體地址空間的分配模型圖,可以看到程序的虛擬記憶體地址空間分為使用者空間和核心空間。使用者空間從低端地址往高階地址發展,核心空間從高階地址往低端地址發展。使用者空間存放著這個程序的程式碼段和資料段,以及執行時的堆和使用者棧。堆是從低端地址往高階地址發展,棧是從高階地址往低端地址發展。

核心空間存放著核心的程式碼和資料,以及核心為這個程序建立的相關資料結構,比如頁表資料結構,task資料結構,area區域資料結構等等。

從檔案IO的角度來說,Linux把一切IO都抽象成了檔案,比如普通檔案IO,網路IO,統統都是檔案,利用open系統呼叫返回一個整數作為檔案描述符file descriptor,程序可以利用file descriptor作為引數在任何系統呼叫中表示那個開啟的檔案。核心為程序維護了一個檔案描述符表來保持程序所有獲得的file descriptor。

每呼叫一次open系統呼叫核心會建立一個開啟檔案open file的資料結構來表示這個開啟的檔案,記錄了該檔案目前讀取的位置等資訊。開啟檔案又唯一了一個指標指向檔案系統中該檔案的inode結構。inode記錄了該檔案的檔名,路徑,訪問許可權等元資料。

操作作業系統用了3個數據結構來為每個程序管理它開啟的檔案資源

 

fork系統呼叫

作業系統利用fork系統呼叫來建立一個子程序。fork所建立的子程序會複製父程序的虛擬地址空間。

要理解“複製”和“共享”的區別,複製的意思是會真正在實體記憶體複製一份內容,會真正消耗新的實體記憶體。共享的意思是使用指標指向同一個地址,不會真正的消耗實體記憶體。理解這兩個概念的區別很重要,這是程序和執行緒的根本區別之一。

那麼有人問了如果我父程序佔了1G的實體記憶體,那麼fork會再使用1G的實體記憶體來複制嗎,相當於一下用了2G的實體記憶體? 

答案是早期的作業系統的確是這麼幹的,但是這樣效能也太差了,所以現代作業系統使用了 寫時複製Copy on write的方式來優化fork的效能,fork剛建立的子程序採用了共享的方式,只用指標指向了父程序的物理資源。當子程序真正要對某些物理資源寫操作時,才會真正的複製一塊物理資源來供子程序使用。這樣就極大的優化了fork的效能,並且從邏輯來說子程序的確是擁有了獨立的虛擬記憶體空間。

fork不只是複製了頁表結構,還複製了父程序的檔案描述符表,訊號控制表,程序資訊,暫存器資源等等。它是一個較為深入的複製。

從邏輯控制流的角度來說,fork建立的子程序開始執行的位置是fork函式返回的位置。這點和執行緒是不一樣的,我們知道Java中的Thread需要寫run方法,執行緒開始後會從run方法開始執行。

既然我們知道了核心為程序維護了這麼多資源,那麼當記憶體進行程序排程時進行的程序上下文切換就容易理解了,一個程序執行要依賴這麼些資源,那麼程序上下文切換就要把這些資源都儲存起來寫回到記憶體中,等下次這個程序被排程時再把這些資源再載入到暫存器和快取記憶體硬體。

程序上下文切換儲存的內容有:

1.頁表 -- 對應虛擬記憶體資源
2.檔案描述符表/開啟檔案表 -- 對應開啟的檔案資源
3.暫存器 -- 對應執行時資料
4.訊號控制資訊/程序執行資訊

程序間通訊

虛擬記憶體機制為程序管理儲存資源帶來了種種好處,但是它也給程序帶來了一些小麻煩,我們知道每個程序擁有獨立的虛擬記憶體地址空間,看到一樣的虛擬內地址空間檢視,所以對不同的程序來說,一個相同的虛擬地址意味著不同的實體地址。我們知道CPU執行指令時採用了虛擬地址,對應一個特定的變數來說,它對應著一個特定的虛擬地址。這樣帶來的問題就是兩個程序不能通過簡單的共享變數的方式來進行程序間通訊,也就是說程序不能通過直接共享記憶體的方式來進行程序間通訊,只能採用訊號,管道等方式來進行程序間通訊。這樣的效率肯定比直接共享記憶體的方式差

什麼是執行緒

上面說了一堆核心為程序分配了哪些資源,我們知道程序管理了一堆資源,並且每個程序還擁有獨立的虛擬記憶體地址空間,會真正地擁有獨立與父程序之外的實體記憶體。並且由於程序擁有獨立的記憶體地址空間,導致了程序之間無法利用直接的記憶體對映進行程序間通訊。

併發的本質是在時間上重疊的多個邏輯流,也就是說同時執行的多個邏輯流。併發程式設計要解決的一個很重要的問題就是對資源的併發訪問的問題,也就是共享資源的問題。而兩個程序恰恰很難在邏輯上表示共享資源。

執行緒解決的最大問題就是它可以很簡單地表示共享資源的問題,這裡說的資源指的是儲存器資源,資源最後都會載入到實體記憶體,一個程序的所有執行緒都是共享這個程序的同一個虛擬地址空間的,也就是說從執行緒的角度來說,它們看到的物理資源都是一樣的,這樣就可以通過共享變數的方式來表示共享資源,也就是直接共享記憶體的方式解決了執行緒通訊的問題。而執行緒也表示一個獨立的邏輯流,這樣就完美解決了程序的一個大難題。

從儲存資源的角度理解了執行緒之後,就不難理解計算資源的分配了。從計算資源的角度來說,對核心而言,程序和執行緒沒有什麼區別,所以核心用核心排程實體(KSE)來表示一個排程的單元。

clone系統呼叫

在Linux系統中,執行緒是使用clone系統呼叫,clone是一個輕量級的fork,它提供了一系列的引數來表示執行緒可以共享父類的哪些資源,比如頁表,開啟檔案表等等。我們上面說過了共享和複製的區別,共享只是簡單地用指標指向同一個實體地址,不會在父程序之外開闢新的實體記憶體。

clone系統呼叫可以指定建立的執行緒開始執行程式碼位置,也就是Java中的Thread類的run方法。

Linux核心只提供了clone這個系統呼叫來建立類似執行緒的輕量級程序的概念。C語言利用了Pthreads庫來真正建立了執行緒這個資料結構。Linux採用了1:1的模型,即C語言的Pthreads庫建立的執行緒實體1:1對應著核心建立的一個KSE。Pthreads執行在使用者空間,KSE執行在核心空間。

既然執行緒共享了程序的資源,那麼執行緒的上下文切換就好理解了。對作業系統來說,它看到要被排程進來的執行緒和剛執行的執行緒是同一個程序的,那麼執行緒的上下文切換隻需要儲存執行緒的一些執行時的資料,比如執行緒的id、暫存器中的值、棧資料。而不需要像程序上下文切換那樣要儲存頁表、檔案描述符表、訊號控制資料和程序資訊等資料。頁表是一個很重的資源,我們之前說過,如果採用一級頁表的結構,那麼32位機器的頁表要達到4MB的物理空間。所以執行緒上下文切換是很輕量級的。

程序採用父子結構,init程序是最頂端的父程序,其他程序都是從init程序派生出來的。這樣就很容易理解程序是如何共享核心的程式碼和資料的了。

而執行緒採用對等結構,即執行緒沒有父子的概念,所有執行緒都屬於同一個執行緒組,執行緒組的組號等於第一個執行緒的執行緒號。

我們來看看Java的執行緒到底是如何實現的。Java語言層面提供了java.lang.Thread這個類來表示Java語言層面的執行緒,並提供了run方法表示執行緒執行的邏輯控制流。

我們知道JVM是C++/C寫的,JVM本身利用了Pthreads庫來建立作業系統的執行緒。JVM還要支援Java語言建立的執行緒的概念。

JVM提供了JavaThread類來對應Java語言的Thread,即Java語言中建立一個java.lang.Thread物件,JVM會相應的在JVM中建立一個JavaThread物件。同時JVM還建立了一個OSThread類來對應用Pthreads建立的底層作業系統的執行緒物件。

構建併發程式可以基於程序也可以執行緒

比如Nginx就是基於程序構建併發程式的。而Java天生只支援基於執行緒的方式來構建併發程式。

 

總結

程序VS 執行緒

1. 程序採用fork建立,執行緒採用clone建立
2. 程序fork建立的子程序的邏輯流位置在fork返回的位置,執行緒clone建立的KSE的邏輯流位置在clone呼叫傳入的方法位置,比如Java的Thread的run方法位置
3. 程序擁有獨立的虛擬記憶體地址空間和核心資料結構(頁表,開啟檔案表等),當子程序修改了虛擬頁之後,會通過寫時拷貝建立真正的物理頁。執行緒共享程序的虛擬地址空間和核心資料結構,共享同樣的物理頁
4. 多個程序通訊只能採用程序間通訊的方式,比如訊號,管道,而不能直接採用簡單的共享記憶體方式,原因是每個程序維護獨立的虛擬記憶體空間,所以每個程序的變數採用的虛擬地址是不同的。多個執行緒通訊就很簡單,直接採用共享記憶體的方式,因為不同執行緒共享一個虛擬記憶體地址空間,變數定址採用同一個虛擬記憶體
5. 程序上下文切換需要切換頁表等重量級資源,執行緒上下文切換隻需要切換暫存器等輕量級資料
6. 程序的使用者棧獨享棧空間,執行緒的使用者棧共享虛擬記憶體中的棧空間,沒有程序高效
7. 一個應用程式可以有多個程序,執行多個程式程式碼,多個執行緒只能執行一個程式程式碼,共享程序的程式碼段
8. 程序採用父子結構,執行緒採用對等結構

 

附:一篇用現實事務類比來說明程序和執行緒的文章

程序與執行緒的一個簡單解釋