出處:https://segmentfault.com/a/1190000038488005
說到程序,恐怕面試中最常見的問題就是執行緒和程序的關係了,那麼先說一下答案: 在 Linux 系統中,程序和執行緒幾乎沒有區別 。
Linux 中的程序就是一個數據結構,看明白就可以理解檔案描述符、重定向、管道命令的底層工作原理,最後我們從作業系統的角度看看為什麼說執行緒和程序基本沒有區別。
一、程序是什麼首先,抽象地來說,我們的計算機就是這個東西:
這個大的矩形表示計算機的 記憶體空間 ,其中的小矩形代表 程序 ,左下角的圓形表示 磁碟 ,右下角的圖形表示一些 輸入輸出裝置 ,比如滑鼠鍵盤顯示器等等。另外,注意到記憶體空間被劃分為了兩塊,上半部分表示 使用者空間 ,下半部分表示 核心空間 。
使用者空間裝著使用者程序需要使用的資源,比如你在程式程式碼裡開一個數組,這個陣列肯定存在使用者空間;核心空間存放核心程序需要載入的系統資源,這一些資源一般是不允許使用者訪問的。但是注意有的使用者程序會共享一些核心空間的資源,比如一些動態連結庫等等。
我們用 C 語言寫一個 hello 程式,編譯後得到一個可執行檔案,在命令列執行就可以打印出一句 hello world,然後程式退出。在作業系統層面,就是新建了一個程序,這個程序將我們編譯出來的可執行檔案讀入記憶體空間,然後執行,最後退出。
你編譯好的那個可執行程式只是一個檔案,不是程序,可執行檔案必須要載入記憶體,包裝成一個程序才能真正跑起來。程序是要依靠作業系統建立的,每個程序都有它的固有屬性,比如程序號(PID)、程序狀態、開啟的檔案等等,程序建立好之後,讀入你的程式,你的程式才被系統執行。
那麼,作業系統是如何建立程序的呢? 對於作業系統,程序就是一個數據結構 ,我們直接來看 Linux 的原始碼:
struct task_struct { // 程序狀態 long state; // 虛擬記憶體結構體 struct mm_struct *mm; // 程序號 pid_t pid; // 指向父程序的指標 struct task_struct __rcu *parent; // 子程序列表 struct list_head children; // 存放檔案系統資訊的指標 struct fs_struct *fs; // 一個數組,包含該程序開啟的檔案指標 struct files_struct *files;};
task_struct 就是 Linux 核心對於一個程序的描述,也可以稱為「程序描述符」。原始碼比較複雜,我這裡就截取了一小部分比較常見的。
其中比較有意思的是 mm 指標和 files 指標。 mm 指向的是程序的虛擬記憶體,也就是載入資源和可執行檔案的地方; files 指標指向一個數組,這個數組裡裝著所有該程序開啟的檔案的指標。
二、檔案描述符是什麼先說 files ,它是一個檔案指標陣列。一般來說,一個程序會從 files[0] 讀取輸入,將輸出寫入 files[1] ,將錯誤資訊寫入 files[2] 。
舉個例子,以我們的角度 C 語言的 printf 函式是向命令列列印字元,但是從程序的角度來看,就是向 files[1] 寫入資料;同理, scanf 函式就是程序試圖從 files[0] 這個檔案中讀取資料。
每個程序被建立時, files 的前三位被填入預設值,分別指向標準輸入流、標準輸出流、標準錯誤流。我們常說的「檔案描述符」就是指這個檔案指標陣列的索引 ,所以程式的檔案描述符預設情況下 0 是輸入,1 是輸出,2 是錯誤。
我們可以重新畫一幅圖:
對於一般的計算機,輸入流是鍵盤,輸出流是顯示器,錯誤流也是顯示器,所以現在這個程序和核心連了三根線。因為硬體都是由核心管理的,我們的程序需要透過「系統呼叫」讓核心程序訪問硬體資源。
PS:不要忘了,Linux 中一切都被抽象成檔案,裝置也是檔案,可以進行讀和寫。
如果我們寫的程式需要其他資源,比如開啟一個檔案進行讀寫,這也很簡單,進行系統呼叫,讓核心把檔案開啟,這個檔案就會被放到 files 的第 4 個位置:
明白了這個原理, 輸入重定向 就很好理解了,程式想讀取資料的時候就會去 files[0] 讀取,所以我們只要把 files[0] 指向一個檔案,那麼程式就會從這個檔案中讀取資料,而不是從鍵盤:
$ command < file.txt
同理, 輸出重定向 就是把 files[1] 指向一個檔案,那麼程式的輸出就不會寫入到顯示器,而是寫入到這個檔案中:
$ command > file.txt
錯誤重定向也是一樣的,就不再贅述。
管道符其實也是異曲同工,把一個程序的輸出流和另一個程序的輸入流接起一條「管道」,資料就在其中傳遞,不得不說這種設計思想真的很優美:
$ cmd1 | cmd2 | cmd3
到這裡,你可能也看出「Linux 中一切皆檔案」設計思路的高明瞭,不管是裝置、另一個程序、socket 套接字還是真正的檔案,全部都可以讀寫,統一裝進一個簡單的 files 陣列,程序透過簡單的檔案描述符訪問相應資源,具體細節交於作業系統,有效解耦,優美高效。
三、執行緒是什麼首先要明確的是,多程序和多執行緒都是併發,都可以提高處理器的利用效率,所以現在的關鍵是,多執行緒和多程序有啥區別。
為什麼說 Linux 中執行緒和程序基本沒有區別呢,因為從 Linux 核心的角度來看,並沒有把執行緒和程序區別對待。
我們知道系統呼叫 fork() 可以新建一個子程序,函式 pthread() 可以新建一個執行緒。 但無論執行緒還是程序,都是用 task_struct 結構表示的,唯一的區別就是共享的資料區域不同 。
換句話說,執行緒看起來跟程序沒有區別,只是執行緒的某些資料區域和其父程序是共享的,而子程序是複製副本,而不是共享。就比如說, mm 結構和 files 結構線上程中都是共享的,我畫兩張圖你就明白了:
所以說,我們的多執行緒程式要利用鎖機制,避免多個執行緒同時往同一區域寫入資料,否則可能造成資料錯亂。
那麼你可能問, 既然程序和執行緒差不多,而且多程序資料不共享,即不存在資料錯亂的問題,為什麼多執行緒的使用比多程序普遍得多呢 ?
因為現實中資料共享的併發更普遍呀,比如十個人同時從一個賬戶取十元,我們希望的是這個共享賬戶的餘額正確減少一百元,而不是希望每人獲得一個賬戶的複製,每個複製賬戶減少十元。
當然,必須要說明的是,只有 Linux 系統將執行緒看做共享資料的程序,不對其做特殊看待,其他的很多作業系統是對執行緒和程序區別對待的,執行緒有其特有的資料結構,我個人認為不如 Linux 的這種設計簡潔,增加了系統的複雜度。
在 Linux 中新建執行緒和程序的效率都是很高的,對於新建程序時記憶體區域複製的問題,Linux 採用了 copy-on-write 的策略最佳化,也就是並不真正複製父程序的記憶體空間,而是等到需要寫操作時才去複製。 所以 Linux 中新建程序和新建執行緒都是很迅速的 。
出處:https://segmentfault.com/a/1190000038488005