當(dāng) Linux 最初開發(fā)時,在內(nèi)核中并不能真正支持線程。但是它的確可以通過 clone() 系統(tǒng)調(diào)用將進程作為可調(diào)度的實體。這個調(diào)用創(chuàng)建了調(diào)用進程(calling process)的一個拷貝,這個拷貝與調(diào)用進程共享相同的地址空間。LinuxThreads 項目使用這個調(diào)用來完全在用戶空間模擬對線程的支持。不幸的是,這種方法有一些缺點,尤其是在信號處理、調(diào)度和進程間同步原語方面都存在問題。另外,這個線程模型也不符合 POSIX 的要求。
要改進 LinuxThreads,非常明顯我們需要內(nèi)核的支持,并且需要重寫線程庫。有兩個相互競爭的項目開始來滿足這些要求。一個包括 IBM 的開發(fā)人員的團隊開展了 NGPT(Next-Generation POSIX Threads)項目。同時,Red Hat 的一些開發(fā)人員開展了 NPTL 項目。NGPT 在 2003 年中期被放棄了,把這個領(lǐng)域完全留給了 NPTL。
盡管從 LinuxThreads 到 NPTL 看起來似乎是一個必然的過程,但是如果您正在為一個歷史悠久的 Linux 發(fā)行版維護一些應(yīng)用程序,并且計劃很快就要進行升級,那么如何遷移到 NPTL 上就會變成整個移植過程中重要的一個部分。另外,我們可能會希望了解二者之間的區(qū)別,這樣就可以對自己的應(yīng)用程序進行設(shè)計,使其能夠更好地利用這兩種技術(shù)。
本文詳細介紹了這些線程模型分別是在哪些發(fā)行版上實現(xiàn)的。
LinuxThreads 設(shè)計細節(jié)
線程 將應(yīng)用程序劃分成一個或多個同時運行的任務(wù)。線程與傳統(tǒng)的多任務(wù)進程 之間的區(qū)別在于:線程共享的是單個進程的狀態(tài)信息,并會直接共享內(nèi)存和其他資源。同一個進程中線程之間的上下文切換通常要比進程之間的上下文切換速度更快。因此,多線程程序的優(yōu)點就是它可以比多進程應(yīng)用程序的執(zhí)行速度更快。另外,使用線程我們可以實現(xiàn)并行處理。這些相對于基于進程的方法所具有的優(yōu)點推動了 LinuxThreads 的實現(xiàn)。
LinuxThreads 最初的設(shè)計相信相關(guān)進程之間的上下文切換速度很快,因此每個內(nèi)核線程足以處理很多相關(guān)的用戶級線程。這就導(dǎo)致了一對一 線程模型的革命。
讓我們來回顧一下 LinuxThreads 設(shè)計細節(jié)的一些基本理念:
-
LinuxThreads 非常出名的一個特性就是管理線程(manager thread)。管理線程可以滿足以下要求:
- 系統(tǒng)必須能夠響應(yīng)終止信號并殺死整個進程。
- 以堆棧形式使用的內(nèi)存回收必須在線程完成之后進行。因此,線程無法自行完成這個過程。
- 終止線程必須進行等待,這樣它們才不會進入僵尸狀態(tài)。
- 線程本地數(shù)據(jù)的回收需要對所有線程進行遍歷;這必須由管理線程來進行。
- 如果主線程需要調(diào)用 pthread_exit(),那么這個線程就無法結(jié)束。主線程要進入睡眠狀態(tài),而管理線程的工作就是在所有線程都被殺死之后來喚醒這個主線程。
- 為了維護線程本地數(shù)據(jù)和內(nèi)存,LinuxThreads 使用了進程地址空間的高位內(nèi)存(就在堆棧地址之下)。
- 原語的同步是使用信號 來實現(xiàn)的。例如,線程會一直阻塞,直到被信號喚醒為止。
- 在克隆系統(tǒng)的最初設(shè)計之下,LinuxThreads 將每個線程都是作為一個具有惟一進程 ID 的進程實現(xiàn)的。
- 終止信號可以殺死所有的線程。LinuxThreads 接收到終止信號之后,管理線程就會使用相同的信號殺死所有其他線程(進程)。
- 根據(jù) LinuxThreads 的設(shè)計,如果一個異步信號被發(fā)送了,那么管理線程就會將這個信號發(fā)送給一個線程。如果這個線程現(xiàn)在阻塞了這個信號,那么這個信號也就會被掛起。這是因為管理線程無法將這個信號發(fā)送給進程;相反,每個線程都是作為一個進程在執(zhí)行。
- 線程之間的調(diào)度是由內(nèi)核調(diào)度器來處理的。
LinuxThreads 及其局限性
LinuxThreads 的設(shè)計通常都可以很好地工作;但是在壓力很大的應(yīng)用程序中,它的性能、可伸縮性和可用性都會存在問題。下面讓我們來看一下 LinuxThreads 設(shè)計的一些局限性:
- 它使用管理線程來創(chuàng)建線程,并對每個進程所擁有的所有線程進行協(xié)調(diào)。這增加了創(chuàng)建和銷毀線程所需要的開銷。
- 由于它是圍繞一個管理線程來設(shè)計的,因此會導(dǎo)致很多的上下文切換的開銷,這可能會妨礙系統(tǒng)的可伸縮性和性能。
- 由于管理線程只能在一個 CPU 上運行,因此所執(zhí)行的同步操作在 SMP 或 NUMA 系統(tǒng)上可能會產(chǎn)生可伸縮性的問題。
- 由于線程的管理方式,以及每個線程都使用了一個不同的進程 ID,因此 LinuxThreads 與其他與 POSIX 相關(guān)的線程庫并不兼容。
- 信號用來實現(xiàn)同步原語,這會影響操作的響應(yīng)時間。另外,將信號發(fā)送到主進程的概念也并不存在。因此,這并不遵守 POSIX 中處理信號的方法。
- LinuxThreads 中對信號的處理是按照每線程的原則建立的,而不是按照每進程的原則建立的,這是因為每個線程都有一個獨立的進程 ID。由于信號被發(fā)送給了一個專用的線程,因此信號是串行化的 —— 也就是說,信號是透過這個線程再傳遞給其他線程的。這與 POSIX 標(biāo)準(zhǔn)對線程進行并行處理的要求形成了鮮明的對比。例如,在 LinuxThreads 中,通過 kill() 所發(fā)送的信號被傳遞到一些單獨的線程,而不是集中整體進行處理。這意味著如果有線程阻塞了這個信號,那么 LinuxThreads 就只能對這個線程進行排隊,并在線程開放這個信號時在執(zhí)行處理,而不是像其他沒有阻塞信號的線程中一樣立即處理這個信號。
- 由于 LinuxThreads 中的每個線程都是一個進程,因此用戶和組 ID 的信息可能對單個進程中的所有線程來說都不是通用的。例如,一個多線程的 setuid()/setgid() 進程對于不同的線程來說可能都是不同的。
- 有一些情況下,所創(chuàng)建的多線程核心轉(zhuǎn)儲中并沒有包含所有的線程信息。同樣,這種行為也是每個線程都是一個進程這個事實所導(dǎo)致的結(jié)果。如果任何線程發(fā)生了問題,我們在系統(tǒng)的核心文件中只能看到這個線程的信息。不過,這種行為主要適用于早期版本的 LinuxThreads 實現(xiàn)。
- 由于每個線程都是一個單獨的進程,因此 /proc 目錄中會充滿眾多的進程項,而這實際上應(yīng)該是線程。
- 由于每個線程都是一個進程,因此對每個應(yīng)用程序只能創(chuàng)建有限數(shù)目的線程。例如,在 IA32 系統(tǒng)上,可用進程總數(shù) —— 也就是可以創(chuàng)建的線程總數(shù) —— 是 4,090。
- 由于計算線程本地數(shù)據(jù)的方法是基于堆棧地址的位置的,因此對于這些數(shù)據(jù)的訪問速度都很慢。另外一個缺點是用戶無法可信地指定堆棧的大小,因為用戶可能會意外地將堆棧地址映射到本來要為其他目的所使用的區(qū)域上了。按需增長(grow on demand) 的概念(也稱為浮動堆棧 的概念)是在 2.4.10 版本的 Linux 內(nèi)核中實現(xiàn)的。在此之前,LinuxThreads 使用的是固定堆棧。
關(guān)于 NPTL
NPTL,或稱為 Native POSIX Thread Library,是 Linux 線程的一個新實現(xiàn),它克服了 LinuxThreads 的缺點,同時也符合 POSIX 的需求。與 LinuxThreads 相比,它在性能和穩(wěn)定性方面都提供了重大的改進。與 LinuxThreads 一樣,NPTL 也實現(xiàn)了一對一的模型。
Ulrich Drepper 和 Ingo Molnar 是 Red Hat 參與 NPTL 設(shè)計的兩名員工。他們的總體設(shè)計目標(biāo)如下:
- 這個新線程庫應(yīng)該兼容 POSIX 標(biāo)準(zhǔn)。
- 這個線程實現(xiàn)應(yīng)該在具有很多處理器的系統(tǒng)上也能很好地工作。
- 為一小段任務(wù)創(chuàng)建新線程應(yīng)該具有很低的啟動成本。
- NPTL 線程庫應(yīng)該與 LinuxThreads 是二進制兼容的。注意,為此我們可以使用 LD_ASSUME_KERNEL,這會在本文稍后進行討論。
- 這個新線程庫應(yīng)該可以利用 NUMA 支持的優(yōu)點。
NPTL 的優(yōu)點
與 LinuxThreads 相比,NPTL 具有很多優(yōu)點:
- NPTL 沒有使用管理線程。管理線程的一些需求,例如向作為進程一部分的所有線程發(fā)送終止信號,是并不需要的;因為內(nèi)核本身就可以實現(xiàn)這些功能。內(nèi)核還會處理每個線程堆棧所使用的內(nèi)存的回收工作。它甚至還通過在清除父線程之前進行等待,從而實現(xiàn)對所有線程結(jié)束的管理,這樣可以避免僵尸進程的問題。
- 由于 NPTL 沒有使用管理線程,因此其線程模型在 NUMA 和 SMP 系統(tǒng)上具有更好的可伸縮性和同步機制。
- 使用 NPTL 線程庫與新內(nèi)核實現(xiàn),就可以避免使用信號來對線程進行同步了。為了這個目的,NPTL 引入了一種名為 futex 的新機制。futex 在共享內(nèi)存區(qū)域上進行工作,因此可以在進程之間進行共享,這樣就可以提供進程間 POSIX 同步機制。我們也可以在進程之間共享一個 futex。這種行為使得進程間同步成為可能。實際上,NPTL 包含了一個 PTHREAD_PROCESS_SHARED 宏,使得開發(fā)人員可以讓用戶級進程在不同進程的線程之間共享互斥鎖。
- 由于 NPTL 是 POSIX 兼容的,因此它對信號的處理是按照每進程的原則進行的;getpid() 會為所有的線程返回相同的進程 ID。例如,如果發(fā)送了 SIGSTOP 信號,那么整個進程都會停止;使用 LinuxThreads,只有接收到這個信號的線程才會停止。這樣可以在基于 NPTL 的應(yīng)用程序上更好地利用調(diào)試器,例如 GDB。
- 由于在 NPTL 中所有線程都具有一個父進程,因此對父進程匯報的資源使用情況(例如 CPU 和內(nèi)存百分比)都是對整個進程進行統(tǒng)計的,而不是對一個線程進行統(tǒng)計的。
- NPTL 線程庫所引入的一個實現(xiàn)特性是對 ABI(應(yīng)用程序二進制接口)的支持。這幫助實現(xiàn)了與 LinuxThreads 的向后兼容性。這個特性是通過使用 LD_ASSUME_KERNEL 實現(xiàn)的,下面就來介紹這個特性。
LD_ASSUME_KERNEL 環(huán)境變量
正如上面介紹的一樣,ABI 的引入使得可以同時支持 NPTL 和 LinuxThreads 模型。基本上來說,這是通過 ld (一個動態(tài)鏈接器/加載器)來進行處理的,它會決定動態(tài)鏈接到哪個運行時線程庫上。
舉例來說,下面是 WebSphere® Application Server 對這個變量所使用的一些通用設(shè)置;您可以根據(jù)自己的需要進行適當(dāng)?shù)脑O(shè)置:
- LD_ASSUME_KERNEL=2.4.19:這會覆蓋 NPTL 的實現(xiàn)。這種實現(xiàn)通常都表示使用標(biāo)準(zhǔn)的 LinuxThreads 模型,并啟用浮動堆棧的特性。
- LD_ASSUME_KERNEL=2.2.5:這會覆蓋 NPTL 的實現(xiàn)。這種實現(xiàn)通常都表示使用 LinuxThreads 模型,同時使用固定堆棧大小。
我們可以使用下面的命令來設(shè)置這個變量:
export LD_ASSUME_KERNEL=2.4.19
注意,對于任何 LD_ASSUME_KERNEL 設(shè)置的支持都取決于目前所支持的線程庫的 ABI 版本。例如,如果線程庫并不支持 2.2.5 版本的 ABI,那么用戶就不能將 LD_ASSUME_KERNEL 設(shè)置為 2.2.5。通常,NPTL 需要 2.4.20,而 LinuxThreads 則需要 2.4.1。
如果您正運行的是一個啟用了 NPTL 的 Linux 發(fā)行版,但是應(yīng)用程序卻是基于 LinuxThreads 模型來設(shè)計的,那么所有這些設(shè)置通常都可以使用。
GNU_LIBPTHREAD_VERSION 宏
大部分現(xiàn)代 Linux 發(fā)行版都預(yù)裝了 LinuxThreads 和 NPTL,因此它們提供了一種機制來在二者之間進行切換。要查看您的系統(tǒng)上正在使用的是哪個線程庫,請運行下面的命令:
$ getconf GNU_LIBPTHREAD_VERSION
這會產(chǎn)生類似于下面的輸出結(jié)果:
NPTL 0.34
或者:
linuxthreads-0.10
Linux 發(fā)行版所使用的線程模型、glibc 版本和內(nèi)核版本
表 1 列出了一些流行的 Linux 發(fā)行版,以及它們所采用的線程實現(xiàn)的類型、glibc 庫和內(nèi)核版本。
線程實現(xiàn) | C 庫 | 發(fā)行版 | 內(nèi)核 |
---|---|---|---|
LinuxThreads 0.7, 0.71 (for libc5) | libc 5.x | Red Hat 4.2 | |
LinuxThreads 0.7, 0.71 (for glibc 2) | glibc 2.0.x | Red Hat 5.x | |
LinuxThreads 0.8 | glibc 2.1.1 | Red Hat 6.0 | |
LinuxThreads 0.8 | glibc 2.1.2 | Red Hat 6.1 and 6.2 | |
LinuxThreads 0.9 | Red Hat 7.2 | 2.4.7 | |
LinuxThreads 0.9 | glibc 2.2.4 | Red Hat 2.1 AS | 2.4.9 |
LinuxThreads 0.10 | glibc 2.2.93 | Red Hat 8.0 | 2.4.18 |
NPTL 0.6 | glibc 2.3 | Red Hat 9.0 | 2.4.20 |
NPTL 0.61 | glibc 2.3.2 | Red Hat 3.0 EL | 2.4.21 |
NPTL 2.3.4 | glibc 2.3.4 | Red Hat 4.0 | 2.6.9 |
LinuxThreads 0.9 | glibc 2.2 | SUSE Linux Enterprise Server 7.1 | 2.4.18 |
LinuxThreads 0.9 | glibc 2.2.5 | SUSE Linux Enterprise Server 8 | 2.4.21 |
LinuxThreads 0.9 | glibc 2.2.5 | United Linux | 2.4.21 |
NPTL 2.3.5 | glibc 2.3.3 | SUSE Linux Enterprise Server 9 | 2.6.5 |
注意,從 2.6.x 版本的內(nèi)核和 glibc 2.3.3 開始,NPTL 所采用的版本號命名約定發(fā)生了變化:這個庫現(xiàn)在是根據(jù)所使用的 glibc 的版本進行編號的。
Java™ 虛擬機(JVM)的支持可能會稍有不同。IBM 的 JVM 可以支持表 1 中 glibc 版本高于 2.1 的大部分發(fā)行版。
結(jié)束語
LinuxThreads 的限制已經(jīng)在 NPTL 以及 LinuxThreads 后期的一些版本中得到了克服。例如,最新的 LinuxThreads 實現(xiàn)使用了線程注冊來定位線程本地數(shù)據(jù);例如在 Intel® 處理器上,它就使用了 %fs 和 %gs 段寄存器來定位訪問線程本地數(shù)據(jù)所使用的虛擬地址。盡管這個結(jié)果展示了 LinuxThreads 所采納的一些修改的改進結(jié)果,但是它在更高負載和壓力測試中,依然存在很多問題,因為它過分地依賴于一個管理線程,使用它來進行信號處理等操作。
您應(yīng)該記住,在使用 LinuxThreads 構(gòu)建庫時,需要使用 -D_REENTRANT 編譯時標(biāo)志。這使得庫線程是安全的。
最后,也許是最重要的事情,請記住 LinuxThreads 項目的創(chuàng)建者已經(jīng)不再積極更新它了,他們認為 NPTL 會取代 LinuxThreads。
LinuxThreads 的缺點并不意味著 NPTL 就沒有錯誤。作為一個面向 SMP 的設(shè)計,NPTL 也有一些缺點。我曾經(jīng)看到過在最近的 Red Hat 內(nèi)核上出現(xiàn)過這樣的問題:一個簡單線程在單處理器的機器上運行良好,但在 SMP 機器上卻掛起了。我相信在 Linux 上還有更多工作要做才能使它具有更好的可伸縮性,從而滿足高端應(yīng)用程序的需求。