
什么是線程
線程,有時被稱為輕量級進程(Lightweight Process,LWP),是程序執行流的最小單元。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成,每一個程序都至少有一個線程,若程序只有一個線程,那就是程序本身。
同時線程是進程中的一個實體,是被系統獨立調度和分派的基本單位,線程自己不擁有系統資源,只擁有一點兒在運行中必不可少的資源,但它可與同屬一個進程的其它線程共享進程所擁有的全部資源。
一個線程可以創建和撤消另一個線程,同一進程中的多個線程之間可以并發執行。由于線程之間的相互制約,致使線程在運行中呈現出間斷性。因此線程也有就緒、阻塞和運行三種基本狀態。就緒狀態是指線程具備運行的所有條件,邏輯上可以運行,在等待處理機;運行狀態是指線程占有處理機正在運行;阻塞狀態是指線程在等待一個事件(如某個信號量),邏輯上不可執行。
什么是信號
信號是一種IPC通信的形式,一般在Unix,類Unix或POSIX兼容的系統中使用。信號是一種異步通知進程或同進程中某個指定線程的方式。 當信號被發送到進程的時候,操作系統會中斷進程的控制流程,并且在執行非原子性的CPU指令時可以中斷進程。
信號使用的風險(新手坑)
信號處理在存在競態的,因為信號本身是異步的,在處理一個信號的過程中,令一個信號(甚至肯能是同類型的信號)會被直接發送到進程中請求進程處理。 信號是可以打斷系統調用的,不謹慎處理會引起程序自身的混亂,所以進程的信號處理過程,盡量做到沒有副作用,也不要使用不可重入的函數。
Linux的線程
LinuxThreads
在Linux的上古時代,Linux的線程技術和POSIX的標準是不同的,它使用自己的LinuxThreads庫。這會為我們帶來什么影響呢?
讓我們來回顧一下 LinuxThreads 設計細節的一些基本理念:
- 系統必須能夠響應終止信號并殺死整個進程。
- 以堆棧形式使用的內存回收必須在線程完成之后進行。因此,線程無法自行完成這個過程。
- 終止線程必須進行等待,這樣它們才不會進入僵尸狀態。
- 線程本地數據的回收需要對所有線程進行遍歷;這必須由管理線程來進行。
- 如果主線程需要調用 pthread_exit(),那么這個線程就無法結束。主線程要進入睡眠狀態,而管理線程的工作就是在所有線程都被殺死之后來喚醒這個主線程。
為了維護線程本地數據和內存,LinuxThreads使用了進程地址空間的高位內存(就在堆棧地址之下)。 同步元語是使用信號來實現的。例如,線程會一直阻塞,直到被信號喚醒為止。并且,LinuxThreads將每個線程都是作為一個具有惟一進程ID的進程實現的。LinuxThreads接收到終止信號之后,管理線程就會使用相同的信號殺死所有其他線程(進程)。 由于異步信號是內核以進程為單位分發的,而LinuxThreads的每個線程對內核來說都是一個進程,且沒有實現“線程組”,因此,某些語義不符合POSIX標準,比如沒有實現向進程中所有線程發送信號。如果核心不提供實時信號,LinuxThreads將使用SIGUSR1和SIGUSR2作為內部使用的restart和cancel信號,這樣應用程序就不能使用這兩個原本為用戶保留的信號了。在Linux kernel 2.1.60以后的版本都支持擴展的實時信號(從_SIGRTMIN到_SIGRTMAX),因此不存在這個問題。根據 LinuxThreads 的設計,如果一個異步信號被發送了,那么管理線程就會將這個信號發送給一個線程,如果這個線程現在阻塞了這個信號,那么這個信號也就會被掛起,因此某些信號的缺省動作難以在現行體系上實現,比如SIGSTOP和SIGCONT,LinuxThreads只能將一個線程掛起,而無法掛起整個進程。
LinuxThreads帶來了什么問題
首先我們說下POSIX是如何定義多線程的:POSIX下一個多線程的進程只有一個PID。 根據上面我們對LinuxThreads的描述,我們可以總結出LinuxThreads有下面這些問題:
- 它使用管理線程來創建線程,并對每個進程所擁有的所有線程進行協調。這增加了創建和銷毀線程所需要的開銷。
- 由于它是圍繞一個管理線程來設計的,因此會導致很多的上下文切換的開銷,這可能會妨礙系統的可伸縮性和性能。
- 由于管理線程只能在一個 CPU 上運行,因此所執行的同步操作在 SMP 或 NUMA 系統上可能會產生可伸縮性的問題。
- 由于線程的管理方式,以及每個線程都使用了一個不同的進程 ID,因此 LinuxThreads 與其他與 POSIX 相關的線程庫并不兼容。
- 信號用來實現同步原語,這會影響操作的響應時間。另外,將信號發送到主進程的概念也并不存在。因此,這并不遵守 POSIX 中處理信號的方法。
我們在這里不關注性能如何只關注POSIX兼容和信號處理問題。
NPTL
LinuxThreads的問題,特別是兼容性上的問題,嚴重阻礙了Linux上的跨平臺應用(如Apache)采用多線程設計,從而使得Linux上的線程應用一直保持在比較低的水平。在Linux社區中,已經有很多人在為改進線程性能而努力,其中既包括用戶級線程庫,也包括核心級和用戶級配合改進的線程庫。目前最為人看好的有兩個項目,一個是RedHat公司牽頭研發的NPTL(Native Posix Thread Library),另一個則是IBM投資開發的NGPT(Next Generation Posix Threading),二者都是圍繞完全兼容POSIX 1003.1c,同時在核內和核外做工作以而實現多對多線程模型。這兩種模型都在一定程度上彌補了LinuxThreads的缺點,且都是重起爐灶全新設計的。 NPTL的設計目標歸納可歸納為以下幾點:
- POSIX兼容性
- SMP結構的利用
- 低啟動開銷
- 低鏈接開銷(即不使用線程的程序不應當受線程庫的影響)
- 與LinuxThreads應用的二進制兼容性
- 軟硬件的可擴展能力
- 多體系結構支持
- NUMA支持
在技術實現上,NPTL仍然采用1:1的線程模型,并配合glibc和最新的Linux Kernel2.5.x開發版在信號處理、線程同步、存儲管理等多方面進行了優化。和LinuxThreads不同,NPTL沒有使用管理線程,核心線程的管理直接放在核內進行,這也帶了性能的優化。
Linux線程總結
比較新的Linux都已經開始使用NPTL了,所以我們可以忽略LinuxThreads的存在了,介紹它主要是為了讓諸位讀者更深入的了解線程和信號的恩恩怨怨(不要丟雞蛋)。
Linux的信號
Linux是如何處理信號的
隨著Linux的內核版本不斷提升,Linux的信號現在已經可以按照線程級別的觸發了,換句話說就是,每個線程可以關注自己的信號了,并且可以區別性對待了。那我們需要注意什么呢?
在多線程應用中,我們應當使用sigaction來代替singal函數,因為按POSIX的說法singal函數并沒有明確定義自己在多線程應用中的行為。
可以使用pthread_sigmask來為每個線程設置獨立的信號掩碼。同時在多線程應用中應當避免使用sigprocmask這個函數,原因也是POSIX中該函數并沒有明確定義自己在多線程應用中的行為。
這個時候,有人會產生疑問了,那么多線程下kill發出的進程級別的信號A怎么辦?Linux是這樣解決的,它會把這個信號交付給任意一個沒有屏蔽信號A的線程。如果這信號沒有被任何線程設置handler進行處理,就會觸發POSIX規定的默認動作。
接著有人就會問,我怎么向某個線程發消息呢,POSIX為我們準備了pthread_kill函數,我們可以直接向特定的線程發送消息。那么如果一個線程收到信號A,但是自己沒有安裝handler會發生什么?其實和進程級別的信號處理方法一樣,直接觸發默認動作,同樣會結束整個進程。
如何避免新手坑
在具有事件循環的應用中,在信號的的handler中,可以將信號直接放入程序的隊列中,立刻返回。這樣直到線程從程序的隊列中取出這個信號為止,整個線程看起來就像沒有“中斷”。 如果不知道該怎么做,去看看著名的libev吧。
信號SIGSEGV
這個信號,也許是大家最不想見到,為什么呢?我們看這個信號的定義:
當當前程序對內存的引用無效時,就會產生當前信號,也就是我們常說的“段違例”。
以下幾種情況會產生該信號:
1.進程引用的內存頁面不存在(例如,該頁面位于堆和棧之間的映射的區域)
2.進程試圖更新只讀內存頁(例如,程序文本段或已經被標記為只讀的內存映射區域)
3.進程試圖在用戶態去訪問內核部分的內存
好了,我們都知道這個信號引發的結果就是進程退出。不過我們都忽視了一個問題,在現代的Linux上,按照POSIX的定義,這個信號是系統產生的線程級別的信號。換句話說,如果某個線程A出現了內存引用無效,那么產生的信號,會投遞到線程A的信號隊列中,而不是像進程級別的信號無法確定接受者是誰。
JVM的安全區域
如果我們想讓所有Java線程停下來的時候,在JVM的JavaThread執行到大家所知道的test 特定頁面的指令時,就會因為更新不可讀頁面而觸發SIGSEGV信號。那么對于那些正在執行native代碼的JavaThread該怎么辦,JVM中的注釋寫的非常清楚,native返回JVM時會檢查是否能返回的。
好了再多說一句,JVM是如果將特定內存保護起來的呢?這個需要看操作系統的API了,在Linux中是mprotect。
總結
多讀讀POSIX標準和Intel的CPU體系結構,會讓自己在開發變的容易些。