Linux 執行緒淺析

發表於2016-10-02

關於linux執行緒

在許多經典的作業系統教科書中, 總是把程式定義為程式的執行例項, 它並不執行什麼, 只是維護應用程式所需的各種資源. 而執行緒則是真正的執行實體. 為了讓程式完成一定的工作, 程式必須至少包含一個執行緒. 如圖1.

程式所維護的是程式所包含的資源(靜態資源), 如: 地址空間, 開啟的檔案控制程式碼集, 檔案系統狀態, 訊號處理handler等;

執行緒所維護的執行相關的資源(動態資源), 如: 執行棧, 排程相關的控制資訊, 待處理的訊號集等;

然而, 一直以來, linux核心並沒有執行緒的概念. 每一個執行實體都是一個task_struct結構, 通常稱之為程式. 如圖2.

程式是一個執行單元, 維護著執行相關的動態資源. 同時, 它又引用著程式所需的靜態資源.通過系統呼叫clone建立子程式時, 可以有選擇性地讓子程式共享父程式所引用的資源. 這樣的子程式通常稱為輕量級程式.

linux上的執行緒就是基於輕量級程式, 由使用者態的pthread庫實現的.使用pthread以後, 在使用者看來, 每一個task_struct就對應一個執行緒, 而一組執行緒以及它們所共同引用的一組資源就是一個程式.

但是, 一組執行緒並不僅僅是引用同一組資源就夠了, 它們還必須被視為一個整體.

對此, POSIX標準提出瞭如下要求:

1.檢視程式列表的時候, 相關的一組task_struct應當被展現為列表中的一個節點;
2.傳送給這個”程式”的訊號(對應kill系統呼叫), 將被對應的這一組task_struct所共享, 並且被其中的任意一個”執行緒”處理;
3.傳送給某個”執行緒”的訊號(對應pthread_kill), 將只被對應的一個task_struct接收, 並且由它自己來處理;
4.當”程式”被停止或繼續時(對應SIGSTOP/SIGCONT訊號), 對應的這一組task_struct狀態將改變;
5. 當”程式”收到一個致命訊號(比如由於段錯誤收到SIGSEGV訊號), 對應的這一組task_struct將全部退出;
6.等等(以上可能不夠全);

linux threads

在linux 2.6以前, pthread執行緒庫對應的實現是一個名叫linuxthreads的lib.

linuxthreads利用前面提到的輕量級程式來實現執行緒, 但是對於POSIX提出的那些要求,linuxthreads除了第5點以外, 都沒有實現(實際上是無能為力):

1.如果執行了A程式, A程式建立了10個執行緒, 那麼在shell下執行ps命令時將看到11個A程式, 而不是1個(注意, 也不是10個, 下面會解釋);
2.不管是kill還是pthread_kill, 訊號只能被一個對應的執行緒所接收;
3.SIGSTOP/SIGCONT訊號只對一個執行緒起作用;

還好linuxthreads實現了第5點, 我認為這一點是最重要的. 如果某個執行緒”掛”了, 整個程式還在若無其事地執行著, 可能會出現很多的不一致狀態. 程式將不是一個整體, 而執行緒也不能稱為執行緒.

或許這也是為什麼linuxthreads雖然與POSIX的要求差距甚遠, 卻能夠存在, 並且還被使用了好幾年的原因吧~

但是, linuxthreads為了實現這個”第5點”, 還是付出了很多代價, 並且創造了linuxthreads本身的一大效能瓶頸.

接下來要說說, 為什麼A程式建立了10個執行緒, 但是ps時卻會出現11個A程式了. 因為linuxthreads自動建立了一個管理執行緒. 上面提到的”第5點”就是靠管理執行緒來實現的.

當程式開始執行時, 並沒有管理執行緒存在(因為儘管程式已經連結了pthread庫, 但是未必會使用多執行緒).

程式第一次呼叫pthread_create時, linuxthreads發現管理執行緒不存在, 於是建立這個管理執行緒. 這個管理執行緒是程式中的第一個執行緒(主執行緒)的兒子.

然後在pthread_create中, 會通過pipe向管理執行緒傳送一個命令, 告訴它建立執行緒.即是說, 除主執行緒外, 所有的執行緒都是由管理執行緒來建立的, 管理執行緒是它們的父親.於是, 當任何一個子執行緒退出時, 管理執行緒將收到SIGUSER1訊號(這是在通過clone建立子執行緒時指定的). 管理執行緒在對應的sig_handler中會判斷子執行緒是否正常退出, 如果不是, 則殺死所有執行緒, 然後自殺.

那麼, 主執行緒怎麼辦呢?主執行緒是管理執行緒的父親, 其退出時並不會給管理執行緒發訊號. 於是, 在管理執行緒的主迴圈中通過getppid檢查父程式的ID號, 如果ID號是1, 說明父親已經退出, 並把自己託管給了init程式(1號程式). 這時候, 管理執行緒也會殺掉所有子執行緒, 然後自殺. 那麼, 如果主執行緒是呼叫pthread_exit主動退出的呢? 按照posix的標準,這種情況下其他子執行緒是應該繼續執行的. 於是, 在linuxthreads中, 主執行緒呼叫pthread_exit以後並不會真正退出, 而是會在pthread_exit函式中阻塞等待所有子執行緒都退出了, pthread_exit才會讓主執行緒退出. (在這個等等過程中, 主執行緒一直處於睡眠狀態.)

可見,執行緒的建立與銷燬都是通過管理執行緒來完成的, 於是管理執行緒就成了linuxthreads的一個效能瓶頸.

建立與銷燬需要一次程式間通訊, 一次上下文切換之後才能被管理執行緒執行, 並且多個請求會被管理執行緒序列地執行.

NPTL

到了linux 2.6, glibc中有了一種新的pthread執行緒庫–NPTL(Native POSIX Threading Library).

NPTL實現了前面提到的POSIX的全部5點要求. 但是, 實際上, 與其說是NPTL實現了, 不如說是linux核心實現了.

在linux 2.6中, 核心有了執行緒組的概念,task_struct結構中增加了一個tgid(thread group id)欄位.

如果這個task是一個”主執行緒”, 則它的tgid等於pid, 否則tgid等於程式的pid(即主執行緒的pid).

在clone系統呼叫中, 傳遞CLONE_THREAD引數就可以把新程式的tgid設定為父程式的tgid(否則新程式的tgid會設為其自身的pid).

類似的XXid在task_struct中還有兩個:task->signal->pgid儲存程式組的打頭程式的pid、task->signal->session儲存會話打頭程式的pid。通過這兩個id來關聯程式組和會話。

有了tgid, 核心或相關的shell程式就知道某個tast_struct是代表一個程式還是代表一個執行緒, 也就知道在什麼時候該展現它們, 什麼時候不該展現(比如在ps的時候, 執行緒就不要展現了).

而getpid(獲取程式ID)系統呼叫返回的也是tast_struct中的tgid,而tast_struct中的pid則由gettid系統呼叫來返回.

在執行ps命令的時候不展現子執行緒,也是有一些問題的。比如程式a.out執行時,建立了一個執行緒。假設主執行緒的pid是10001、子執行緒是10002(它們的tgid都是10001)。這時如果你kill 10002,是可以把10001和10002這兩個執行緒一起殺死的,儘管執行ps命令的時候根本看不到10002這個程式。如果你不知道linux執行緒背後的故事,肯定會覺得遇到靈異事件了。

為了應付”傳送給程式的訊號”和”傳送給執行緒的訊號”, task_struct裡面維護了兩套signal_pending,一套是執行緒組共享的, 一套是執行緒獨有的.

通過kill傳送的訊號被放線上程組共享的signal_pending中, 可以由任意一個執行緒來處理; 通過pthread_kill傳送的訊號(pthread_kill是pthread庫的介面, 對應的系統呼叫中tkill)被放線上程獨有的signal_pending中, 只能由本執行緒來處理.

當執行緒停止/繼續, 或者是收到一個致命訊號時, 核心會將處理動作施加到整個執行緒組中.

NGPT

說到這裡, 也順便提一下NGPT(Next Generation POSIX Threads).

上面提到的兩種執行緒庫使用的都是核心級執行緒(每個執行緒都對應核心中的一個排程實體), 這種模型稱為1:1模型(1個執行緒對應1個核心級執行緒);而NGPT則打算實現M:N模型(M個執行緒對應N個核心級執行緒),也就是說若干個執行緒可能是在同一個執行實體上實現的. 執行緒庫需要在一個核心提供的執行實體上抽象出若干個執行實體, 並實現它們之間的排程. 這樣被抽象出來的執行實體稱為使用者級執行緒.

大體上, 這可以通過為每個使用者級執行緒分配一個棧, 然後通過longjmp的方式進行上下文切換. (百度一下”setjmp/longjmp”, 你就知道.)

但是實際上要處理的細節問題非常之多.

目前的NGPT好像並沒有實現所有預期的功能, 並且暫時也不準備去實現.

使用者級執行緒的切換顯然要比核心級執行緒的切換快一些, 前者可能只是一個簡單的長跳轉, 而後者則需要儲存/裝載暫存器, 進入然後退出核心態. (程式切換則還需要切換地址空間等.)

而使用者級執行緒則不能享受多處理器, 因為多個使用者級執行緒對應到一個核心級執行緒上, 一個核心級執行緒在同一時刻只能執行在一個處理器上.

不過, M:N的執行緒模型畢竟提供了這樣一種手段, 可以讓不需要並行執行的執行緒執行在一個核心級執行緒對應的若干個使用者級執行緒上, 可以節省它們的切換開銷.

據說一些類UNIX系統(如Solaris)已經實現了比較成熟的M:N執行緒模型, 其效能比起linux的執行緒還是有著一定的優勢.

相關文章