Linux 多任務編程——線程淺析

進程和線程的區別與聯繫
在許多經典的操作系統教科書中,總是把進程定義爲程序的執行實例,它並不執行什麼, 只是維護應用程序所需的各種資源,而線程則是真正的執行實體。

爲了讓進程完成一定的工作,進程必須至少包含一個線程。

進程,直觀點說,保存在硬盤上的程序運行以後,會在內存空間裏形成一個獨立的內存體,這個內存體有自己的地址空間,有自己的堆,上級掛靠單位是操作系統。操作系統會以進程爲單位,分配系統資源,所以我們也說,進程是資源分配的最小單位。

線程存在與進程當中,是操作系統調度執行的最小單位。說通俗點,線程就是幹活的。

如果說進程是一個資源管家,負責從主人那裏要資源的話,那麼線程就是幹活的苦力。一個管家必須完成一項工作,就需要最少一個苦力,也就是說,一個進程最少包含一個線程,也可以包含多個線程。苦力要幹活,就需要依託於管家,所以說一個線程,必須屬於某一個進程。進程有自己的地址空間,線程使用進程的地址空間,也就是說,進程裏的資源,線程都是有權訪問的,比如說堆啊,棧啊,靜態存儲區什麼的。


線程就是個無產階級,但無產階級幹活,總得有自己的勞動工具吧,這個勞動工具就是棧,線程有自己的棧,這個棧仍然是使用進程的地址空間,只是這塊空間被線程標記爲了棧。每個線程都會有自己私有的棧,這個棧是不可以被其他線程所訪問的。

進程所維護的是程序所包含的資源(靜態資源), 如:地址空間,打開的文件句柄集,文件系統狀態,信號處理handler,等;


線程所維護的運行相關的資源(動態資源),如:運行棧,調度相關的控制信息,待處理的信號集,等;

然而,一直以來,linux 內核並沒有線程的概念。每一個執行實體都是一個 task_struct 結構,通常稱之爲進程。

進程是一個執行單元,維護着執行相關的動態資源。同時,它又引用着程序所需的靜態資源。通過系統調用 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)等等(以上可能不夠全)

 

LinuxThreads
在 linux 2.6 以前,pthread 線程庫對應的實現是一個名叫 LinuxThreads 的 lib。


LinuxThreads 利用前面提到的輕量級進程來實現線程,但是對於 POSIX 提出的那些要求,LinuxThreads 除了第 5 點以外(當“進程”收到一個致命信號(比如由於段錯誤收到 SIGSEGV 信號), 對應的這一組 task_struct 將全部退出),都沒有實現(實際上是無能爲力):

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點要求:


1)查看進程列表的時候,相關的一組 task_struct 應當被展現爲列表中的一個節點;


2)發送給這個“進程”的信號(對應 kill 系統調用), 將被對應的這一組 task_struct 所共享,並且被其中的任意一個“線程”處理;


3)發送給某個“線程”的信號(對應 pthread_kill), 將只被對應的一個task_struct接收,並且由它自己來處理;


4)當“進程”被停止或繼續時(對應 SIGSTOP/SIGCONT 信號), 對應的這一組 task_struct 狀態將改變;


5)當“進程”收到一個致命信號(比如由於段錯誤收到 SIGSEGV 信號), 對應的這一組 task_struct 將全部退出;


但是,實際上,與其說是 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 線程背後的故事,肯定會覺得遇到靈異事件了。

 

下面我們一起來驗證這靈異事件:

#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <pthread.h>
 
void *fun(void *arg)
{
    printf("thread is created!\n");
    pause(); //掛起線程
}
 
int main(void)
{
    pthread_t tid;
    pthread_create(&tid, NULL, fun, NULL);
    pause();//主線程掛起(否則主線程終止,子線程也就掛了)
    
    return 0;
}

這個程序創建一個線程後掛起,子線程在輸出 “thread is created!” 也掛起。運行結果如下圖:

我們打開另外一個終端,查看後臺運行的進程,發現 demo 的進程號(pid)是 2361,我們使用 kill 終止 pid 爲 2362 的進程(注意 ps 中並沒有這個進程),如下圖:

結果發現,demo 進程也終止了,如下圖。其原因就是 2362 就是所創建線程的線程號,線程異常終止了,其對應的進程也就終止了。

爲了應付“發送給進程的信號”和“發送給線程的信號”, 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 的線程還是有着一定的優勢。


--------------------- 
作者:Mike__Jiang 
來源:CSDN 
原文:https://blog.csdn.net/tennysonsky/article/details/45030645 
版權聲明:本文爲博主原創文章,轉載請附上博文鏈接!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章