Linux線程的前世今生



最近在重新翻閱《Unix環境高級編程》的時候,被書上的一段例程所困擾,那段代碼是分別在主線程和子線程中使用 getpid() 函數打印進程標識符PID,書上告訴我們是不同的值,但是測試結果是主線程和子線程中打印出了相同的值。

在我的印象中《Linux內核設計與實現》這本書曾經談到線程時如是說:從內核的角度來說,它並沒有線程這個概念。Linux內核把所有的線程都當成進程來實現……在內核中,線程看起來就像是一個普通的進程(只是線程和其他一些進程共享某些資源,比如地址空間)。

《Unix環境高級編程》第二版著書時的測試內核是2.4.22,而《Linux內核設計與實現》這本書是針對2.6.34內核而言的(兼顧2.6.32),而我的內核是3.9.11,難道是內核發展過程中線程的實現發生了較大的變化?百度一番之後發現資料亂七八糟不成系統,索性翻閱諸多文檔和網頁,整理如下。如有偏差,煩請大家指正。

在 Linux 創建的初期,內核一直就沒有實現“線程”這個東西。後來因爲實際的需求,便逐步產生了LinuxThreads 這個項目,其主要的貢獻者是Xavier Leroy。LinuxThreads項目使用了 clone() 這個系統調用對線程進行了模擬,按照《Linux內核設計與實現》的說法,調用 clone() 函數參數是clone(CLONE_VM | CLONE_FS | CLONE_FILES | CLONE_SIGHAND, 0),即創建一個新的進程,同時讓父子進程共享地址空間、文件系統資源、文件描述符、信號處理程序以及被阻斷的信號等內容。也就是說,此時的所謂“線程”模型符合以上兩本經典鉅著的描述,即在內核看來,沒有所謂的“線程”,我們所謂的“線程”其實在內核看來不過是和其他進程共享了一些資源的進程罷了。

通過以上的描述,我們可以得到以下結論:

  1. 此時的內核確實不區分進程與線程,內核沒有“線程”這個意識。

  2. 在不同的“線程”內調用 getpid() 函數,打印的肯定是不同的值,因爲它們在內核的進程鏈表中有不同的 task_struct 結構體來表示,有各自不同的進程標識符PID。

值得一提的是,內核不區分線程,那麼在用戶態的實現就必須予以區分和處理。所以 LinuxThreads 有一個非常出名的特性就是管理線程(manager thread)(這也是爲什麼實際創建的線程數比程序自己創建的多一個的原因)。管理線程必須滿足以下要求:

  • 系統必須能夠響應終止信號並殺死整個進程。

  • 以堆棧形式使用的內存回收必須在線程完成之後進行。因此,線程無法自行完成這個過程。終止線程必須進行等待,這樣它們纔不會進入殭屍狀態。

  • 線程本地數據的回收需要對所有線程進行遍歷;這必須由管理線程來進行。

  • ……

LinuxThreads 這個項目固然在一定程度上模擬出了“線程”,而且看起來實現也是如此的優雅。所以常常有人說,Linux 內核沒有進程線程之分,其實就是這個意思。但這個方法也有問題,尤其是在信號處理、調度和進程間同步原語方面都存在問題。而且, 一組線程並不僅僅是引用同一組資源就夠了, 它們還必須被視爲一個整體。

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

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

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

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

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

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

  6. ……

另外還有好多好多的問題,我們不一一列舉,只引用 IBM 的相關論文作爲補充:http://www.ibm.com/developerworks/cn/linux/kernel/l-thread/

有問題自然就有人在嘗試解決問題,活躍的開源社區自然不會放任問題繼續下去,後來就有了各種各樣的嘗試,其中既包括用戶級線程庫,也包括核心級和用戶級配合改進的線程庫。知名的有 RedHat 公司牽頭研發的 NPTL(Native Posix Thread Library),另一個則是IBM投資開發的 NGPT(Next Generation Posix Threading),二者都是圍繞完全兼容POSIX 1003.1c,同時在覈內和核外做工作以而實現多對多線程模型。這兩種模型都在一定程度上彌補了 LinuxThreads 的缺點,且都是重起爐竈全新設計的。

在開始下文之前,我們在終端上執行這個命令 getconf GNU_LIBPTHREAD_VERSION來檢查自己機器所使用的線程庫。在我的 fedora 18 上得到了如下的輸出結果:



Fedora是RedHat系的,沒理由不使用NPTL(開個玩笑)。按照維基百科的說法是,是NPTL贏得了今天附帶絕大多數的Linux系統的支持,原文是:NPTL won out and is today shipped with the vast majority of Linux systems. 後來IBM貌似就慢慢放棄了,隨着IBM的放棄,RedHat 的 Native POSIX Thread Library(NPTL)就成唯一的解決方案了。隨着 NPTL 的崛起,Linux2.6 以及以上版本的內核中基本上很少能再看到 LinuxThreads 的身影了。

與 LinuxThreads 相比,NPTL 具有很多優點:

NPTL 就沒有使用管理線程。因爲管理線程的一些需求,例如向作爲進程一部分的所有線程發送終止信號,是並不需要的,因爲內核本身就可以實現這些功能。內核還會處理每個線程堆棧所使用的內存的回收工作。它甚至還通過在清除父線程之前進行等待,從而實現對所有線程結束的管理,這樣可以避免殭屍進程的問題。

還有好多的優勢和相關的比較,詳見這裏:

http://www.ibm.com/developerworks/cn/linux/l-threading.html

現在,我們關心的是在 NPTL 對內核作出改動之後,現在的線程模型大概是怎麼一回事,內核是否依舊不區分進程與線程呢?getpid() 函數返回的爲何是一樣的數值?別急,我們繼續往下看。

傳言在2002年8、9月份,一直不肯鬆勁的 Linus Torvalds 先生終於被說服了,Ingo Molnar 把一些重要特性加入到2.5開發版官方內核中。這些特性大體包括:新的clone系統調用,TLS系統調用,posix 線程間信號,exit_group (exit的一個變體 )等內容。此時有了OS的支持,Ingo Molnar 先生同 Ulrich Drepper(GLIBC的LinuxThreads庫的維護者,NPTL 的設計者與維護者,現工作於 RedHat 公司)和其他一些 Hackers 開始 NPTL 的完善工作。

所以說 NPTL 並不是完全在用戶態實現的線程庫,事實上內核也進行了一定程度的支持。既然getpid()函數返回了不一樣的值,那我們就從這個函數的實現開始研究。因爲現代的Linux內核引入了 “Container” 的概念。Container 類似於虛擬機的概念,每個 Container 都會有自己的 namespace。說了這麼多,其實意思就是內核中兩個 PID namespace 中可以有 PID 相同的進程;一個輕量級進程可以同時出現在兩個 namespace 中,這就意味着該輕量級進程具有兩個或以上的 PID。而 task_struct(進程控制塊PCB) 結構體中還有 group->leader 域來標記該輕量級進程所在組的領頭進程。我們今天就先看早前的實現,避免引入太多影響我們偏離主題。現代的實現方法有興趣的的童鞋訪問下面鏈接研究吧。

http://blog.csdn.net/fengtaocat/article/details/7001527

早些的實現是這樣:



對,你沒看錯,返回的是 TGID 這個成員,而 current 是一個宏,代表當前的程序。這個 TGID 又是何許人也?這個東西的全稱是”Thread Group ID”的意思,即線程組ID的意思,其值等於進程的 PID。所以在一個進程的各個線程中調用getpid()函數的話得到的值是一樣的。NPTL 通過這樣的一個途徑實現了之前的線程庫沒有解決的線程組的問題。

實質上到今天,Linux 內核依舊沒有區分進程與線程。這和 Microsoft Windows、或是Sun Solaris等操作系統的實現差異非常大。那麼,此時 Linux 內核裏那個 task_struct 組成的雙向循環鏈表此時又是什麼情景呢?

揣測了一會沒有答案,我們還是寫個內核模塊來看訪問下進程表看看。

代碼如下:

#include <linux/kernel.h> #include <linux/module.h> #include <linux/proc_fs.h> #include <linux/init.h> #include <linux/sched.h>static int __init print_init(void){        struct task_struct *task;

        printk("process info:n");
        for_each_process(task)
                printk("%s   pid:%d  tgid:%d  father pid:%dn",
                                thread->comm,
                                thread->pid,
                                thread->tgid,
                                thread->parent->pid);        return 0;
}static void __exit print_exit(void){
        printk("Goodbye, process_print!n");
}

module_init(print_init);
module_exit(print_exit);
MODULE_AUTHOR("hurley");
MODULE_DESCRIPTION("Print Process Info.");
MODULE_LICENSE("GPL");

Makefile如下:



但是光有這個程序是不夠的,我們再寫一個用戶態的創建線程的程序,它將創建兩個線程,而線程會一直睡眠不退出。代碼很簡單,就不貼了。我們編譯這個程序 thread_id,執行它,然後我們編譯內核模塊,載入,然後卸載。最後執行dmesg命令查看內核輸出:



我們在衆多的輸出最後找到了我們的程序,可是,只有一項結果,沒有多個來自 task_struct 的輸出。這…….難道?內核的管理方式發生了改變?等等,我們在內核頭文件裏我們使用的 for_each_process宏下面發現了這樣一組宏:



通過繼續對宏的展開分析,我們發現原來同一個線程組的線程只有主線程在那個大循環裏,而每一個進程的線程在自己的一個小循環裏(這裏的循環的實現是雙向循環鏈表)。

示意圖如下:



我們將遍歷部分的代碼如下修改:




然後重新執行上面的測試,果然,我們得到了來自三個task_struct結構體的輸出:




我們知道,在線程裏調用 getpid() 函數,獲取的是TGID,那麼如果我們需要獲得線程真實的 PID 怎麼辦呢?有一個系統調用是 sys_gettid() 可以幫助我們,不過 GLIBC 並沒有提供包裝函數,所以我們乾脆直接使用 syscall() 函數加系統調用號 224 來實現(另外支持在日誌裏打線程的 tid,proc 裏就能查到相關信息,也便於後期追查)。

結果如下:




我們簡單介紹下規則。如果這個 task 是一個”主線程”, 則它的 TGID 等於 PID, 否則 TGID 等於進程的PID(即主線程的PID)。在 clone 系統調用中, 傳遞 CLONE_THREAD 參數就可以把新進程的 TGID 設置爲父進程的 TGID (否則新進程的 TGID 會設爲其自身的 PID)。

時間不早了,就此打住。當然了,NPTL 其他的改變和設計還有很多,我就不一一列舉了,姑且留下其作者自己寫的一篇文章供有興趣繼續深究的同學研究吧。

地址在此:http://www.akkadia.org/drepper/nptl-design.pdf




關於阿里百川

阿里百川(baichuan.taobao.com)是阿里巴巴集團“雲”+“端”的核心戰略是阿里巴巴集團無線開放平臺,基於世界級的後端服務和成熟的商業組件,通過“技術、商業及大數據”的開放,爲移動創業者提供可快速搭建App、商業化APP並提升用戶體驗的解決方案;同時提供多元化的創業服務-物理空間、孵化運營、創業投資等,爲移動創業者提供全面保障。



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