大碰撞!當Linux多線程遭遇Linux多進程

背景

本文並不是介紹Linux多進程多線程編程的科普文,如果希望系統學習Linux編程,可以看[《Unix環境高級編程》第3版]

本文是描述多進程多線程編程中遇到過的一個坑,並從內核角度分析其原理。這裡說的多進程多線程並不是單一的多進程或多線程,而是多進程和多線程,往往會在寫一個大型應用時才會用到多進程多線程的模型。

這是怎麼樣的一個坑呢?假設有下面的代碼:

大碰撞!當Linux多線程遭遇Linux多進程

童鞋們能分析出來,線程函數sub_pthread會被執行多少次麼?線程函數打印出來的ID是父進程ID呢?還是子進程ID?還是父子進程都有?

答案是,只會執行1次,且是父進程的ID!為什麼呢?

[GMPY@10:02 share]$./signal-safe 
ID 6889: in sub_pthread
ID 6889 (father)
ID 6891 (children)

褲子都脫了,你就給我看這個?當然,這個沒什麼懸念,到目前為止還很簡單。精彩的地方正式開始。

線程和fork

在已經創建了多線程的進程中調用fork創建子進程,稍不注意就會陷入死鎖的尷尬局面

以下面的代碼做個例子:

大碰撞!當Linux多線程遭遇Linux多進程

大碰撞!當Linux多線程遭遇Linux多進程

執行效果如下:執行效果如下:

[GMPY@10:37 share]$./test 
--- sub thread lock ---
children burn
--- sub thread unlock ---
--- father lock ---
--- father unlock ---
--- sub thread lock ---
--- father lock ---
--- sub thread unlock ---
--- father unlock ---
--- sub thread lock ---
--- sub thread unlock ---
--- father lock ---

我們發現,子進程掛了,在打印了children burn後,沒有了下文,因為在子進程獲取鎖的時候,死鎖了!

憑什麼啊?sub_pthread線程不是有釋放鎖麼?父進程都能在線程釋放後獲取到鎖,為什麼子線程就獲取不到鎖呢?

在《Unix環境高級編程 第3版》的12.9章節中是這麼描述的:

子進程通過繼承整個地址空間的副本,還從父進程那兒繼承了每個互斥量、讀寫鎖和條件變量的狀態。
如果父進程包含一個以上的線程,子進程在fork返回以後,如果緊接著不是馬上調用exec的話,就需要清理鎖狀態。
在子進程內部,只存在一個線程,它是由父進程中調用fork的線程的副本構成的。
如果父進程中的線程佔有鎖,子進程將同樣佔有這些鎖。
問題是子進程並不包含佔有鎖的線程的副本,所以子進程沒有辦法知道它佔有了哪些鎖、需要釋放哪些鎖。
......
在多線程的進程中,為了避免不一致狀態的問題,POSIX.1聲明,在fork返回和子進程調用其中一個exec函數之間,

子進程只能調用異步信號安全的函數。這就限制了在調用exec之前子進程能做什麼,但不涉及子進程中鎖狀態的問題。

究其原因,就是子進程成孤家寡人了。

每個進程都有一個主線程,這個線程參與到任務調度,而不是進程,[可以參考文章](https://www.cnblogs.com/gmpy/p/10265284.html)。


大碰撞!當Linux多線程遭遇Linux多進程


在上面的例子中,父進程通過pthread_create創建出了一個小弟sub_pthread,父進程與小弟之間配合默契,你釋放鎖我就獲取,玩得不亦樂乎。


大碰撞!當Linux多線程遭遇Linux多進程


這時候,父進程生娃娃了,這個新生娃娃集成了父進程的絕大部分資源,包括了鎖的狀態

,然而,子進程並沒有共生出小弟,就是說子進程並沒同時創建出小弟線程,他就是一個坐擁金山的孤家寡人。

所以,問題就來了。如果在父進程創建子進程的時候,父進程的鎖被小弟```sub_pthread```佔用了,```fork```生出來的子進程鎖的狀態跟父進程一樣一樣的,鎖上了!被人佔有了!因此子進程再獲取鎖就死鎖了。

或者你會說,我在fork前獲取鎖,在fork後再釋放鎖不就好了?是的,能解決這個問題,我們自己創建的鎖,所以我們知道有什麼鎖。

最慘的是什麼呢?你根本無法知道你調用的函數是否有鎖。例如常用的printf,其內部實現是有獲取鎖的,因此在fork出來的子進程執行exec之前,甚至都不能調用printf

我們看看下面的示例:


大碰撞!當Linux多線程遭遇Linux多進程


上面的代碼主要做了兩件事:

1. 創建線程,循環printf打印字符'\\r'

2. 循環創建進程,在子進程中調用printf打印字串

由於printf的鎖不可控,為了加大死鎖的概率,為```fork```套了一層循環。執行結果怎麼樣呢?

root@TinaLinux:/mnt/UDISK# demo-c 
fork
ID 1684: in sub_pthread
ID 1684 (father)
ID 1686 (children)
ID 1686 (children) exit
fork
ID 1684 (father)
ID 1687 (children)
ID 1687 (children) exit
fork
ID 1684 (father)

結果在第3次fork循環的時候陷入了死鎖,子進程不打印不退出,導致父進程wait一直阻塞。

上面的結果在全志嵌入式Tina Linux平臺驗證,比較有意思的是,同樣的代碼在PC上卻很難復現,可能是C庫的差異引起的

在fork的子進程到exec之間,只能調用異步信號安全的函數,這異步信號安全的函數就是認證過不會造成死鎖的!

異步信號安全不再展開討論,有問題找男人

man 7 signal

檢索關鍵字Async-signal-safe functions

內核原理分析

我們知道,Linux內核中,用```task_struct```表示一個進程/線程,嗯,換句話說,不管是進程還是線程,在Linux內核中都是用task_struct的結構體表示

關於進程與線程的異同,可以看文章[《線程調度為什麼比進程調度更少開銷?》](https://www.cnblogs.com/gmpy/p/10265284.html),這裡不累述。

按這個結論,我們pthread_create創建小弟線程時,內核實際上是copy父進程的task_struct,創建小弟線程的task_struct,且讓小弟task_struct與父進程task_struct共享同一套資源。

如下圖


大碰撞!當Linux多線程遭遇Linux多進程


在父進程pthread_create之後,父進程和小弟線程組成了我們概念上的父進程。什麼是概念上的父進程呢?在我們的理解中,創建的線程也是歸屬於父進程,這是概念上的父進程集合體,然而在Linux中,父進程和線程是獨立的個體,他們有自己的調度,有自己的流程,就好像一個屋子下不同的人。

父進程fork過程,發生了什麼?

跟進系統調用fork的代碼:


大碰撞!當Linux多線程遭遇Linux多進程


嗯...只是copy了task_struct,怪不得fork之後,子進程沒有伴生小弟線程。所以fork之後,如下圖:


大碰撞!當Linux多線程遭遇Linux多進程


(為了方便理解,下圖忽略了Linux的寫時copy機制)

Linux如此fork,這與鎖有什麼關係呢?我們看下內核中對互斥鎖的定義:


大碰撞!當Linux多線程遭遇Linux多進程


一句話概述,就是 通過原子變量標識和記錄鎖狀態,用戶空間也是一樣的做法。

變量值終究是保存在內存中的,不管是保存在堆還是棧亦或其他,終究是(虛擬)內存中某一個地址存儲的值。

結合Linux內核的fork流程,我們用這樣一張圖描述進程/線程與鎖的關係:


大碰撞!當Linux多線程遭遇Linux多進程


(完)


分享到:


相關文章: