最近在重新翻閱《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),即創建一個新的進程,同時讓父子進程共享地址空間、文件系統資源、文件描述符、信號處理程序以及被阻斷的信號等內容。也就是說,此時的所謂“線程”模型符合以上兩本經典鉅著的描述,即在內核看來,沒有所謂的“線程”,我們所謂的“線程”其實在內核看來不過是和其他進程共享了一些資源的進程罷了。
通過以上的描述,我們可以得到以下結論:
此時的內核確實不區分進程與線程,內核沒有“線程”這個意識。
在不同的“線程”內調用 getpid() 函數,打印的肯定是不同的值,因爲它們在內核的進程鏈表中有不同的 task_struct 結構體來表示,有各自不同的進程標識符PID。
值得一提的是,內核不區分線程,那麼在用戶態的實現就必須予以區分和處理。所以 LinuxThreads 有一個非常出名的特性就是管理線程(manager thread)(這也是爲什麼實際創建的線程數比程序自己創建的多一個的原因)。管理線程必須滿足以下要求:
系統必須能夠響應終止信號並殺死整個進程。
以堆棧形式使用的內存回收必須在線程完成之後進行。因此,線程無法自行完成這個過程。終止線程必須進行等待,這樣它們纔不會進入殭屍狀態。
線程本地數據的回收需要對所有線程進行遍歷;這必須由管理線程來進行。
……
LinuxThreads 這個項目固然在一定程度上模擬出了“線程”,而且看起來實現也是如此的優雅。所以常常有人說,Linux 內核沒有進程線程之分,其實就是這個意思。但這個方法也有問題,尤其是在信號處理、調度和進程間同步原語方面都存在問題。而且, 一組線程並不僅僅是引用同一組資源就夠了, 它們還必須被視爲一個整體。
對此,POSIX標準提出瞭如下要求:
查看進程列表的時候,相關的一組 task_struct 應當被展現爲列表中的一個節點;
發送給這個”進程”的信號(對應 kill 系統調用),將被對應的這一組 task_struct 所共享, 並且被其中的任意一個”線程”處理;
發送給某個”線程”的信號(對應 pthread_kill ),將只被對應的一個 task_struct 接收,並且由它自己來處理;
當”進程”被停止或繼續時(對應 SIGSTOP/SIGCONT 信號), 對應的這一組 task_struct 狀態將改變;
當”進程”收到一個致命信號(比如由於段錯誤收到 SIGSEGV 信號),對應的這一組 task_struct 將全部退出;
……
另外還有好多好多的問題,我們不一一列舉,只引用 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