萬字長文帶你還原進程和線程

咱們日常說的進程和線程更多的是基於編程語言的角度來講的,那麼你真的瞭解什麼是線程和進程嗎?那麼咱們就從操做系統的角度來了解一下什麼是進程和線程。html

進程

操做系統中最核心的概念就是 進程,進程是對正在運行中的程序的一個抽象。操做系統的其餘全部內容都是圍繞着進程展開的。進程是操做系統提供的最古老也是最重要的概念之一。即便可使用的 CPU 只有一個,但它們也支持(僞)併發操做。它們會將一個單獨的 CPU 變換爲多個虛擬機的 CPU。沒有進程的抽象,現代操做系統將不復存在。程序員

全部現代的計算機會在同一時刻作不少事情,過去使用計算機的人可能徹底沒法理解如今這種變化,舉個例子更能說明這一點:首先考慮一個 Web 服務器,請求都來自於 Web 網頁。當一個請求到達時,服務器會檢查當前頁是否在緩存中,若是是在緩存中,就直接把緩存中的內容返回。若是緩存中沒有的話,那麼請求就會交給磁盤來處理。可是,從 CPU 的角度來看,磁盤請求須要更長的時間,由於磁盤請求會很慢。當硬盤請求完成時,更多其餘請求才會進入。若是有多個磁盤的話,能夠在第一個請求完成前就能夠連續的對其餘磁盤發出部分或所有請求。很顯然,這是一種並發現象,須要有併發控制條件來控制並發現象。web

如今考慮只有一個用戶的 PC。當系統啓動時,許多進程也在後臺啓動,用戶一般不知道這些進程的啓動,試想一下,當你本身的計算機啓動的時候,你能知道哪些進程是須要啓動的麼?這些後臺進程多是一個須要輸入電子郵件的電子郵件進程,或者是一個計算機病毒查殺進程來週期性的更新病毒庫。某個用戶進程可能會在全部用戶上網的時候打印文件以及刻錄 CD-ROM,這些活動都須要管理。因而一個支持多進程的多道程序系統就會顯得頗有必要了。算法

在許多多道程序系統中,CPU 會在進程間快速切換,使每一個程序運行幾十或者幾百毫秒。然而,嚴格意義來講,在某一個瞬間,CPU 只能運行一個進程,然而咱們若是把時間定位爲 1 秒內的話,它可能運行多個進程。這樣就會讓咱們產生並行的錯覺。有時候人們說的 僞並行(pseudoparallelism) 就是這種狀況,以此來區分多處理器系統(該系統由兩個或多個 CPU 來共享同一個物理內存)shell

再來詳細解釋一下僞並行:僞並行是指單核或多核處理器同時執行多個進程,從而使程序更快。 經過以很是有限的時間間隔在程序之間快速切換CPU,所以會產生並行感。 缺點是時間可能分配給下一個進程,也可能不分配給下一個進程。編程

咱們很難對多個並行進程進行跟蹤,所以,在通過多年的努力後,操做系統的設計者開發了用於描述並行的一種概念模型(順序進程),使得並行更加容易理解和分析,對該模型的探討,也是本篇文章的主題瀏覽器

進程模型

在進程模型中,全部計算機上運行的軟件,一般也包括操做系統,被組織爲若干順序進程(sequential processes),簡稱爲 進程(process) 。一個進程就是一個正在執行的程序的實例,進程也包括程序計數器、寄存器和變量的當前值。從概念上來講,每一個進程都有各自的虛擬 CPU,可是實際狀況是 CPU 會在各個進程之間進行來回切換。緩存

如上圖所示,這是一個具備 4 個程序的多道處理程序,在進程不斷切換的過程當中,程序計數器也在不一樣的變化。安全

在上圖中,這 4 道程序被抽象爲 4 個擁有各自控制流程(即每一個本身的程序計數器)的進程,而且每一個程序都獨立的運行。固然,實際上只有一個物理程序計數器,每一個程序要運行時,其邏輯程序計數器會裝載到物理程序計數器中。當程序運行結束後,其物理程序計數器就會是真正的程序計數器,而後再把它放回進程的邏輯計數器中。服務器

從下圖咱們能夠看到,在觀察足夠長的一段時間後,全部的進程都運行了,但在任何一個給定的瞬間僅有一個進程真正運行

在咱們接下來的討論中,咱們假設只有一個 CPU 的情形。固然,這個假設一般放到如今不會存在了,由於新的芯片一般是多核芯片,包含 2 個、4 個或更多的 CPU。可是如今,一次只考慮一個 CPU 會便於咱們分析問題。所以,當咱們說一個 CPU 只能真正一次運行一個進程的時候,即便有 2 個核(或 CPU),每個核也只能一次運行一個線程。

因爲 CPU 會在各個進程之間來回快速切換,因此每一個進程在 CPU 中的運行時間是沒法肯定的。而且當同一個進程再次在 CPU 中運行時,其在 CPU 內部的運行時間每每也是不固定的。進程和程序之間的區別是很是微妙的,可是經過一個例子可讓你加以區分:想一想一位會作飯的計算機科學家正在爲他的女兒製做生日蛋糕。他有作生日蛋糕的食譜,廚房裏有所需的原諒:麪粉、雞蛋、糖、香草汁等。在這個比喻中,作蛋糕的食譜就是程序、計算機科學家就是 CPU、而作蛋糕的各類原諒都是輸入數據。進程就是科學家閱讀食譜、取來各類原料以及烘焙蛋糕等一系例了動做的總和。

如今假設科學家的兒子跑過來告訴他,說他的頭被蜜蜂蜇了一下,那麼此時科學家會記錄出來他作蛋糕這個過程到了哪一步,而後拿出急救手冊,按照上面的步驟給他兒子實施救助。這裏,會涉及到進程之間的切換,科學家(CPU)會從作蛋糕(進程)切換到實施醫療救助(另外一個進程)。等待傷口處理完畢後,科學家會回到剛剛記錄作蛋糕的那一步,繼續製做。

這裏的關鍵思想是認識到一個進程所需的條件,進程是某一類特定活動的總和,它有程序、輸入輸出以及狀態。單個處理器能夠被若干進程共享,它使用某種調度算法決定什麼時候中止一個進程的工做,並轉而爲另一個進程提供服務。另外須要注意的是,若是一個進程運行了兩遍,則被認爲是兩個進程。

進程的建立

操做系統須要一些方式來建立進程。在很是簡單的系統中,或者操做系統被設計用來運行單個應用程序(例如微波爐中的控制器),可能在系統啓動時,也須要全部的進程一塊兒啓動。但在通用系統中,然而,須要有某種方法在運行時按需建立或銷燬進程,如今須要考察這個問題,下面是建立進程的方式

  • 系統初始化
  • 正在運行的程序執行了建立進程的系統調用(好比 fork)
  • 用戶請求建立一個新進程
  • 初始化一個批處理工做

啓動操做系統時,一般會建立若干個進程。其中有些是前臺進程(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 來爲每一個進程提供本身私有的處理器。

當一個進程開始運行時,它可能會經歷下面這幾種狀態

圖中會涉及三種狀態

  1. 運行態,運行態指的就是進程實際佔用 CPU 運行時
  2. 就緒態,就緒態指的是可運行,但由於其餘進程正在運行而終止
  3. 阻塞態,除非某種外部事件發生,不然進程不能運行

邏輯上來講,運行態和就緒態是很類似的。這兩種狀況下都表示進程可運行,可是第二種狀況沒有得到 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 程序來處理中斷剩下的工做。在完成剩下的工做後,會使某些進程就緒,接着調用調度程序,決定隨後運行哪一個進程。隨後將控制權轉移給一段彙編語言代碼,爲當前的進程裝入寄存器值以及內存映射並啓動該進程運行,下面顯示了中斷處理和調度的過程。

  1. 硬件壓入堆棧程序計數器等

  2. 硬件從中斷向量裝入新的程序計數器

  3. 彙編語言過程保存寄存器的值

  4. 彙編語言過程設置新的堆棧

  5. C 中斷服務器運行(典型的讀和緩存寫入)

  6. 調度器決定下面哪一個程序先運行

  7. C 過程返回至彙編代碼

  8. 彙編語言過程開始運行新的當前進程

一個進程在執行過程當中可能被中斷數千次,但關鍵每次中斷後,被中斷的進程都返回到與中斷髮生前徹底相同的狀態。

線程

在傳統的操做系統中,每一個進程都有一個地址空間和一個控制線程。事實上,這是大部分進程的定義。不過,在許多狀況下,常常存在在同一地址空間中運行多個控制線程的情形,這些線程就像是分離的進程。下面咱們就着重探討一下什麼是線程

線程的使用

或許這個疑問也是你的疑問,爲何要在進程的基礎上再建立一個線程的概念,準確的說,這實際上是進程模型和線程模型的討論,回答這個問題,可能須要分三步來回答

  • 多線程之間會共享同一塊地址空間和全部可用數據的能力,這是進程所不具有的
  • 線程要比進程更輕量級,因爲線程更輕,因此它比進程更容易建立,也更容易撤銷。在許多系統中,建立一個線程要比建立一個進程快 10 - 100 倍。
  • 第三個緣由多是性能方面的探討,若是多個線程都是 CPU 密集型的,那麼並不能得到性能上的加強,可是若是存在着大量的計算和大量的 I/O 處理,擁有多個線程能在這些活動中彼此重疊進行,從而會加快應用程序的執行速度

多線程解決方案

如今考慮一個線程使用的例子:一個萬維網服務器,對頁面的請求發送給服務器,而所請求的頁面發送回客戶端。在多數 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 系統中運行時,線程輪流運行,在對進程進行描述的過程當中,咱們知道了進程的多道程序是如何工做的。經過在多個進程之間來回切換,系統製造了不一樣的順序進程並行運行的假象。多線程的工做方式也是相似。CPU 在線程之間來回切換,系統製造了不一樣的順序進程並行運行的假象。

可是進程中的不一樣線程沒有不一樣進程間較強的獨立性。同一個進程中的全部線程都會有徹底同樣的地址空間,這意味着它們也共享一樣的全局變量。因爲每一個線程均可以訪問進程地址空間內每一個內存地址,所以一個線程能夠讀取、寫入甚至擦除另外一個線程的堆棧。線程之間爲何沒有保護呢?既不可能也沒有必要。這與不一樣進程間是有差異的,不一樣的進程會來自不一樣的用戶,它們彼此之間可能有敵意,由於彼此不一樣的進程間會互相爭搶資源。而一個進程老是由一個用戶所擁有,因此操做系統設計者把線程設計出來是爲了讓他們 相互合做而不是相互鬥爭的。線程之間除了共享同一內存空間外,還具備以下不一樣的內容

上圖左邊的是同一個進程中每一個線程共享的內容,上圖右邊是每一個線程中的內容。也就是說左邊的列表是進程的屬性,右邊的列表是線程的屬性。

線程概念試圖實現的是,共享一組資源的多個線程的執行能力,以便這些線程能夠爲完成某一任務而共同工做。和進程同樣,線程能夠處於下面這幾種狀態:運行中、阻塞、就緒和終止(進程圖中沒有畫)。正在運行的線程擁有 CPU 時間片而且狀態是運行中。一個被阻塞的線程會等待某個釋放它的事件。例如,當一個線程執行從鍵盤讀入數據的系統調用時,該線程就被阻塞直到有輸入爲止。線程一般會被阻塞,直到它等待某個外部事件的發生或者有其餘線程來釋放它。線程之間的狀態轉換和進程之間的狀態轉換是同樣的

每一個線程都會有本身的堆棧,以下圖所示

在多線程狀況下,進程一般會從當前的某個單線程開始,而後這個線程經過調用一個庫函數(好比 thread_create)建立新的線程。線程建立的函數會要求指定新建立線程的名稱。建立的線程一般都返回一個線程標識符,該標識符就是新線程的名字。

當一個線程完成工做後,能夠經過調用一個函數(好比 thread_exit)來退出。緊接着線程消失,狀態變爲死亡,不能再進行調度。在某些線程的運行過程當中,能夠經過調用函數例如 thread_join ,表示一個線程能夠等待另外一個線程退出。這個過程阻塞調用線程直到等待特定的線程退出。在這種狀況下,線程的建立和終止很是相似於進程的建立和終止。

另外一個常見的線程是調用 thread_yield,它容許線程自動放棄 CPU 從而讓另外一個線程運行。這樣一個調用仍是很重要的,由於不一樣於進程,線程是沒法利用時鐘中斷強制讓線程讓出 CPU 的。因此設法讓線程的行爲 高大上一些仍是比較重要的,而且隨着時間的推移讓線程讓出 CPU,以便讓其餘線程得到運行的機會。線程會帶來不少的問題,必需要在設計時考慮全面。

POSIX 線程

爲了使編寫可移植線程程序成爲可能,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 時間片(或者沒有可運行的線程存在了)爲止。

因爲在內核中建立或者銷燬線程的開銷比較大,因此某些系統會採用可循環利用的方式來回收線程。當某個線程被銷燬時,就把它標誌爲不可運行的狀態,可是其內部結構沒有受到影響。稍後,在必須建立一個新線程時,就會從新啓用舊線程,把它標誌爲可用狀態。其實在用戶空間也能夠循環利用線程,可是因爲用戶空間建立或者銷燬線程開銷小,所以沒有必要。

若是某個進程中的線程形成缺頁故障後,內核很容易的就能檢查出來是否有其餘可運行的線程,若是有的話,在等待所須要的頁面從磁盤讀入時,就選擇一個可運行的線程運行。這樣作的缺點是系統調用的代價比較大,因此若是線程的操做(建立、終止)比較多,就會帶來很大的開銷。

混合實現

結合用戶空間和內核空間的優勢,設計人員採用了一種內核級線程的方式,而後將用戶級線程與某些或者所有內核線程多路複用起來

在這種模型中,編程人員能夠自由控制用戶線程和內核線程的數量,具備很大的靈活度。採用這種方法,內核只識別內核級線程,並對其進行調度。其中一些內核級線程會被多個用戶級線程多路複用。

總結

這篇文章爲你講述操做系統的層面來講,進程和線程分別是什麼?進程模型和線程模型的區別,進程和線程的狀態、層次結構、還有許多的專業術語描述。

下一篇文章咱們會把目光放在進程間如何通訊上,也是操做系統級別多線程的底層原理,敬請期待。

文章參考:

《現代操做系統》

《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

相關文章
相關標籤/搜索