【摘要】NP項目 code review checklist在NPTL多線程分類和信號分類中提出了一些具體的檢查點要求,特別對於可重入函數、線程安全、信號安全以及fork安全類型的函數具備特殊的檢查要求。本文主要對以上概念進行了詳細的闡述,並對在併發應用程序設計過程當中容易混淆和忽略的一些問題進行了說明。爲了提升讀者閱讀的興趣和效率,本文還對部分問題提供了較爲典型的情景代碼供讀者參考。
【關鍵詞】可重入函數 線程安全 信號安全 fork安全 code review
1 引言
1.1 概述
在linux環境下,能夠經過不一樣的方式使應用程序併發執行,產生併發執行序列,提升應用程序的運行效率。這些手段包括:
※多線程,本文只討論linux 2.6之後版本提供的NPTL多線程庫的實現
※異步信號,linux經過signal和sigaction系統調用提供了用戶級的信號處理機制
※子進程,從應用程序設計和實現的角度,咱們這裏只討論經過使用fork建立子進程實現的進程併發。html
在併發應用程序設計與實現層面上存在一些典型的術語和概念,例如在一些場合中咱們常常能夠看到一些關鍵詞,可重入函數、線程安全、信號安全等。在設計和實現併發執行的應用程序時,咱們必需要了解這些關鍵詞所表達的確切含義。那麼這些術語的背後究竟隱藏了些什麼內容呢,本文將就這一問題展開具體的論述。
1.2 文檔內容
本文首先對併發應用程序常見的概念進行了介紹,並對這些概念容易引發混淆和誤用的地方進行了詳細說明,本文在描述的過程當中針對部份內容提供了代碼示例。本文所提供的代碼片斷所有在64位linux開發機上編譯經過。
本文未對多線程同步和互斥的實現原理進行說明,關於多線程背後所隱藏的內容,請詳見做者的另一篇文章《NPTL多線程庫源代碼情景分析》。
2 內容
2.1 可重入函數
咱們首先討論一下可重入函數,維基百科上對可重入函數使用瞭如下三條規則進行限定:
1) 函數中不能使用任何非const的靜態或者全局變量
2) 不能產生任何「反作用」,即不能對所處的環境產生影響。wiki上使用的用語爲「Must not modify its own code.」,主要針對特定的實現技術,本文對其進行了擴展。
3) 不能調用其餘的不可重入函數linux
所述的第一點和第三點較容易理解,第二點其實在強調上下文環境在可重入函數實現中的重要性。例以下面的示例代碼:
chrome
能夠看出,上述代碼若是在多線程的環境下執行,可能會帶來嚴重的問題。
另外,值得強調的是,代碼中不能使用非const的靜態變量這一條內容務必是強制性要求,不能忽視,例如一種單例模式(singleton)的實現方法以下所示:
編程
此代碼的開發人員想經過static對象的方式避免在代碼中使用double-check的方式來提供函數的可重用語義功能,供在多線程的場合下使用。這種局部靜態對象是一種lazy initialization的方式,其語義爲當函數開始執行時完成對象的建立,爲了達到C標準規定語義的要求,編譯器一般提供了相似下面的實現方式(使用僞代碼進行描述):
api
以粉紅色背景顯示的僞代碼提供了編譯器一種可能的實現,咱們能夠在gcc下進行驗證,將上面的代碼編譯爲彙編代碼,咱們查看gcc如何進行的處理:
瀏覽器
從所附的彙編代碼能夠看出,gcc提供的實現與前述使用C++描述的僞代碼的執行邏輯相同。所以,在函數中使用局部靜態對象是不可重入的,code review過程當中尤爲須要關注。
靜態對象是一個很微妙的事物,語言自己爲其提供了靈活並且強大的功能,但在使用的過程當中若是不注意細節也很容易出現一些問題,關於在使用靜態對象時須要注意的問題筆者將另行撰文闡述。
2.2 線程安全
線程安全,顧名思義,即代表在多線程的環境下執行是安全的。如前所述,能夠得出結論,可重入函數必定爲線程安全函數。
爲了達到線程安全的實現要求,一般使用一些同步互斥的手段對使用到的全局變量進行保護。
2.2.1 互斥鎖
linux攜帶的glibc提供了POSIX兼容的互斥鎖pthread_mutex_t,這是一種推薦採用的方式。具體實現方式本文暫不贅述。
glibc還提供了一種基於「寫者優先」的讀寫鎖的同步機制,適合在必定的應用場合下采用。
代碼在使用互斥鎖進行同步時,常見的問題是使用的鎖的「粒度」過大,這應是一種避免的實現方式,進行code review時須要重點關注。
2.2.2 sig_ atomic _t
linux提供了sig_ atomic _t數據類型,該類型定義爲int,其實是一種weak atomic 數據類型,只能執行一些很是受限的原子操做。
sig_ atomic _t類型的變量只保證特定的操做爲原子操做,實際上操做的原子性是由底層的硬件平臺保障的,即基於比機器字長短的數據類型的操做通常都爲原子操做。
咱們能夠在代碼中使用test and set機制實現基於多線程的同步:
安全
該種處理方法採用了busy-loop的方式,性能較差,一般狀況下不建議使用。
2.3 信號安全
本文所指的「信號安全」主要包含兩方面的內容:
※指定的函數是否容許在信號處理函數中使用
※指定的函數在執行過程當中是否可能被信號中斷,errno返回EINTR類型的錯誤。
2.3.1 信號安全函數
按用戶使用方式進行劃分,linux提供了同步信號和異步信號兩種不一樣類型的信號,本文所說起的信號主要指的是異步信號。linux提供了signal和sigaction兩個信號初始化函數,相比較而言,sigaction的可移植性更好,另外功能上也有所擴充,例如能夠指定信號處理函數執行期間可屏蔽的其餘信號。
信號處理函數運行通常運行在主線程即main函數所在線程的上下文當中,咱們能夠編寫一個程序對響應信號的線程進行驗證。因爲篇幅受限,此程序本文暫不提供,讀者有興趣的話能夠自行完成驗證。
對於NPTL線程庫而言,若主線程存在,發生的信號將在主線程的上下文中響應,不然,運行庫將挑選一個線程做爲信號處理函數的運行環境。
如前所述,信號與線程同爲可併發的執行序列,但在執行方式上具備顯著不一樣,當信號被阻塞時,並不會引發上下文的切換,也就是說不會發生線程的切換,信號安全類的函數相對於線程安全函數來講具備更嚴格的要求。
例如,glibc提供的malloc、printf等函數都屬於線程安全函數,其內部使用互斥鎖的方式對使用到的全局數據結構進行保護,所以能夠在多線程的環境下使用,但所述函數不屬於信號安全的範疇,若是在信號處理函數和線程中同時執行,有可能產生死鎖,例如:
數據結構
所以一個常見的設計約束爲在信號處理函數中不能使用任何有可能致使發生阻塞的庫函數,這也是嵌入式實時操做系統用戶使用手冊上常見的使用限制。
咱們能夠經過在函數中屏蔽指定的信號來達到信號安全的目的,linux提供的sigaction系統調用能夠完成這一功能,讀者有興趣的話能夠參考相關的資料。
2.3.2 EINTR錯誤
POSIX規定,當系統調用(system call)在執行的過程當中被信號中斷時,應返回錯誤值,並將指示錯誤狀態的全局變量errno設置爲EINTR。
google維護的開源瀏覽器項目chrome的開發者郵件列表中對可能返回EINTR錯誤類型的函數進行了整理:
* read, readv, write, writev, ioctl
* open() when dealing with a fifo
* wait*
* Anything socket based (send*, recv*, connect, accept etc)
* flock and lock control with fcntl
* mq_ functions which can block
* futex
* sem_wait (and timed wait)
* pause, sigsuspend, sigtimedwait, sigwaitinfo
* poll, epoll_wait, select and 'p' versions of the same
* msgrcv, msgsnd, semop, semtimedop
* close (although, on Linux, EINTR won't happen here)
* any sleep functions (careful, you need to handle this are restart with
different arguments)
所以,對於以上函數,根據程序所完成功能的須要,開發人員應正確進行處理,例如能夠採起下面的宏簡化處理方式:
多線程
該宏使用了gcc的擴展關鍵字typeof用來得到指定函數的類型,使用時能夠採用以下調用方式:
併發
linux提供的系統調用sigaction能夠改變針對特定信號中斷時系統調用的行爲爲BSD風格的restart,即若產生信號中斷事件,系統調用將被重置。
值得注意的是,部分與時間相關的系統調用並不在設置SA_RESTART標誌位影響的範圍以內,這一類系統調用包括select、connect以及nanosleep函數等。
2.4 虛假喚醒
當線程經過等待函數進行等待時,可能由於發生信號致使等待函數返回。不一樣發行版本的類unix平臺提供的處理方式因實現不一樣而各不相同。在linux下,查看pthread_cond_wait函數的man手冊,內容中具備明確描述:
能夠看出,linux提供的信號等待同步函數不會返回EINTR類型的錯誤。
「虛假喚醒」還包括另一個方面的內容,主要指條件變量wait和signal操做之間的不匹配,解決的方法一般是採用以下的編碼風格:
這種編程方式主要解決了先釋放信號再等待信號這種不一樣步可能致使應用程序陷入死鎖的問題。若是隻有一個signal條件變量的線程,等待代碼中的while循環能夠調整爲if語句。
2.5 fork安全
linux提供的系統調用fork完成子進程建立的任務,建立後的子進程徹底繼承父進程的內存佈局,但並不會繼承建立子進程是父進程所處的多線程的運行環境。換一種思路理解起來更爲容易,fork api爲爲操做系統調用,而多線程是以運行庫的方式提供的,所以fork建立的子進程並不會繼承父進程的線程狀況,換言之不論父進程是否使用了多線程,建立的子進程都將採用單線程的執行方式。
因爲系統調用fork實現方式的緣由,子進程的代碼與父進程的代碼將重用相同的源文件,若是父進程採用了多線程的實現方式,那麼子進程不該依賴於父進程所採用的多線程控制結構,不然容易出現問題,例如以下所示的代碼片斷:
從上面的代碼能夠看出,fork系統調用將在建立的線程以後運行,若線程執行到加鎖時切換到主線程,主線程將開始執行fork建立子進程,根據前面內容的描述,fork將複製父進程的內存到子進程當中,此時已加了鎖的pthread_mutex_t類型變量將被完整的複製到子進程,當子進程執行上面標記爲紅色的代碼從新開始獲取鎖時,因獲取不到因此將被無限期的掛起。
以上代碼給出的是直接使用mutex的方式,glibc提供的不少庫函數(例如printf)爲了確保線程安全的特性大都在內部使用了互斥鎖,對於這一類函數在fork建立的子進程中使用會出現相同的問題。
所以在執行的code review過程當中,咱們應認真檢查所述的相似代碼,特別是fork出來的子進程代碼所使用到的各種函數,防止誤用而致使出現各種問題。
瞭解了問題產生的本質,相應的解決方法也較爲容易發現,本文暫且賣一個關子,留給讀者來完成這個解決方案的具體實現。
3 總結
經過以上內容的描述,能夠得出如下結論:
1) 可重入函數必定是線程安全函數,也必定是信號安全函數。
2) 不可重入函數能夠經過在函數內增長互斥機制成爲線程安全函數。
3) 知足線程安全不必定可以知足信號安全。例如:
※errno是線程安全的全局變量,其實現原理爲經過NPTL提供的線程局部存儲功能完成,當發生上下文切換時,被切換線程與切換線程的errno被保存和恢復。
※內部使用了同步互斥機制的函數是線程安全的,但必定不是信號安全的,若是在信號處理函數中使用可能會形成死鎖
4) 信號安全的函數也不必定是線程安全函數
5) fork安全與信號安全在問題產生的機理方面具備必定的類似程度。
(做者:zhouwei)