線程間到底共享了哪些進程資源?看完這篇你就懂了~

前言

進程和線程這兩個話題是程序員繞不開的,操做系統提供的這兩個抽象概念實在是過重要了。關於進程和線程有一個極其經典的問題,那就是進程和線程的區別是什麼?相信不少同窗對答案似懂非懂。程序員

記住了不必定真懂markdown

關於這個問題有的同窗可能已經「背得」倒背如流了:「進程是操做系統分配資源的單位,線程是調度的基本單位,線程之間共享進程資源」。但是你真的理解了上面最後一句話嗎?到底線程之間共享了哪些進程資源,共享資源意味着什麼?共享資源這種機制是如何實現的?對此若是你沒有答案的話,那麼這意味着你幾乎很難寫出能正確工做的多線程程序,同時也意味着這篇文章就是爲你準備的。多線程

####逆向思考函數

查理芒格常常說這樣一句話:「反過來想,老是反過來想」,若是你對線程之間共享了哪些進程資源這個問題想不清楚的話那麼也能夠反過來思考,那就是有哪些資源是線程私有的工具

線程私有資源spa

線程運行的本質其實就是函數的執行,函數的執行總會有一個源頭,這個源頭就是所謂的入口函數,CPU從入口函數開始執行從而造成一個執行流,只不過咱們人爲的給執行流起一個名字,這個名字就叫線程。操作系統

既然線程運行的本質就是函數的執行,那麼函數執行都有哪些信息呢?在**《[函數運行時在內存中是什麼樣子]》**這篇文章中應該提過,函數運行時的信息保存在棧幀中,棧幀中保存了函數的返回值、調用其它函數的參數、該函數使用的局部變量以及該函數使用的寄存器信。線程

如圖所示,假設函數A調用函數B:翻譯

此外,CPU執行指令的信息保存在一個叫作程序計數器的寄存器中,經過這個寄存器咱們就知道接下來要執行哪一條指令。因爲操做系統隨時能夠暫停線程的運行,所以咱們保存以及恢復程序計數器中的值就能知道線程是從哪裏暫停的以及該從哪裏繼續運行了。因爲線程運行的本質就是函數運行,函數運行時信息是保存在棧幀中的,所以每一個線程都有本身獨立的、私有的棧區。debug

圖片

同時函數運行時須要額外的寄存器來保存一些信息,像部分局部變量之類,這些寄存器也是線程私有的,一個線程不可能訪問到另外一個線程的這類寄存器信息

從上面的討論中咱們知道,到目前爲止,所屬線程的棧區、程序計數器、棧指針以及函數運行使用的寄存器是線程私有的。

以上這些信息有一個統一的名字,就是線程上下文,thread context。咱們也說過操做系統調度線程須要隨時中斷線程的運行而且須要線程被暫停後能夠繼續運行,操做系統之因此能實現這一點,依靠的就是線程上下文信息。

如今你應該知道哪些是線程私有的了吧。除此以外,剩下的都是線程間共享資源。那麼剩下的還有什麼呢?還有圖中的這些。

這其實就是進程地址空間的樣子,也就是說線程共享進程地址空間中除線程上下文信息中的全部內容,意思就是說線程能夠直接讀取這些內容。接下來咱們分別來看一下這些區域。

代碼區

進程地址空間中的代碼區,這裏保存的是什麼呢?從名字中有的同窗可能已經猜到了,沒錯,這裏保存的就是咱們寫的代碼,更準確的是編譯後的可執行機器指令

那麼這些機器指令又是從哪裏來的呢?答案是從可執行文件中加載到內存的,可執行程序中的代碼區就是用來初始化進程地址空間中的代碼區的。

線程之間共享代碼區,這就意味着程序中的任何一個函數均可以放到線程中去執行,不存在某個函數只能被特定線程執行的狀況

數據區

進程地址空間中的數據區,這裏存放的就是所謂的全局變量。什麼是全局變量?所謂全局變量就是那些你定義在函數以外的變量,在C語言中就像這樣:

其中字符c就是全局變量,存放在進程地址空間中的數據區。

在程序員運行期間,也就是run time,數據區中的全局變量有且僅有一個實例,全部的線程均可以訪問到該全局變量。值得注意的是,在C語言中還有一類特殊的「全局變量」,那就是用static關鍵詞修飾過的變量,就像這樣:

注意到,雖然變量a定義在函數內部,但變量a依然具備全局變量的特性,也就是說變量a放在了進程地址空間的數據區域,即便函數執行完後該變量依然存在,而普通的局部變量隨着函數調用結束和函數棧幀一塊兒被回收掉了,但這裏的變量a不會被回收,由於其被放到了數據區。 這樣的變量對每一個線程來講也是可見的,也就是說每一個線程均可以訪問到該變量。

堆區

堆區是程序員比較熟悉的,咱們在C/C++中用malloc或者new出來的數據就存放在這個區域,很顯然,只要知道變量的地址,也就是指針,任何一個線程均可以訪問指針指向的數據,所以堆區也是線程共享的屬於進程的資源。

棧區

唉,等等!剛不是說棧區是線程私有資源嗎,怎麼這會兒又提及棧區了?確實,從線程這個抽象的概念上來講,棧區是線程私有的,然而從實際的實現上看,棧區屬於線程私有這一規則並無嚴格遵照

這句話是什麼意思?

一般來講,注意這裏的用詞是一般,一般來講棧區是線程私有,既然有一般就有不一般的時候。不一般是由於不像進程地址空間之間的嚴格隔離,線程的棧區沒有嚴格的隔離機制來保護

所以若是一個線程能拿到來自另外一個線程棧幀上的指針,那麼該線程就能夠改變另外一個線程的棧區,也就是說這些線程能夠任意修改本屬於另外一個線程棧區中的變量。

圖片

這從某種程度上給了程序員極大的便利,但同時,這也會致使極其難以排查到的bug。

試想一下你的程序運行的好好的,結果某個時刻忽然出問題,定位到出問題代碼行後根本就排查不到緣由,你固然是排查不到問題緣由的,由於你的程序原本就沒有任何問題。

是別人的問題致使你的函數棧幀數據被寫壞從而產生bug,這樣的問題一般很難排查到緣由,須要對總體的項目代碼很是熟悉,經常使用的一些debug工具這時可能已經沒有多大做用了。

說了這麼多,那麼同窗可能會問,一個線程是怎樣修改本屬於其它線程的數據呢?接下來咱們用一個代碼示例講解一下。

修改線程私有數據

不要擔憂,如下代碼足夠簡單:

void thread(void* var) {
複製代碼

####這段代碼是什麼意思呢? 首先咱們在主線程的棧區定義了一個局部變量,也就是 int a= 1這行代碼,如今咱們已經知道了,局部變量a屬於主線程私有數據,可是,接下來咱們建立了另一個線程。

在新建立的這個線程中,咱們將變量a的地址以參數的形式傳給了新建立的線程,而後我來看一下thread函數。在新建立的線程中,咱們獲取到了變量a的指針,而後將其修改成了2

也就是這行代碼,咱們在新建立的線程中修改了本屬於主線程的私有數據。

如今你應該看明白了吧,儘管棧區是線程的私有數據,但因爲棧區沒有添加任何保護機制,一個線程的棧區對其它線程是能夠見的,也就是說咱們能夠修改屬於任何一個線程的棧區。

就像咱們上文說獲得的,這給程序員帶來了極大便利的同時也帶來了無盡的麻煩,試想上面這段代碼,若是確實是項目須要那麼這樣寫代碼無可厚非。

但若是上述新建立線程是因bug修改了屬於其它線程的私有數據的話,那麼產生問題就很難定位了,由於bug可能距離問題暴露的這行代碼已經很遠了,這樣的問題一般難以排查。

####動態連接庫

進程地址空間中除了以上討論的這些實際上還有其它內容,還有什麼呢?這就要從可執行程序提及了。

什麼是可執行程序呢?

在Windows中就是咱們熟悉的exe文件,在Linux世界中就是ELF文件,這些能夠被操做系統直接運行的程序就是咱們所說的可執行程序。

那麼可執行程序是怎麼來的呢?有的同窗可能會說,廢話,不就是編譯器生成的嗎?實際上這個答案只答對了一半。

假設咱們的項目比較簡單隻有幾個源碼文件,編譯器是怎麼把這幾個源代碼文件轉換爲最終的一個可執行程序呢?

原來,編譯器在將可執行程序翻譯成機器指令後,接下來還有一個重要的步驟,這就是連接,連接完成後生成的纔是可執行程序。完成連接這一過程的就是連接器。

其中連接器能夠有兩種連接方式,這就是靜態連接動態連接。靜態連接的意思是說把全部的機器指令一股腦所有打包到可執行程序中,動態連接的意思是咱們不把動態連接的部分打包到可執行程序,而是在可執行程序運行起來後去內存中找動態連接的那部分代碼,這就是所謂的靜態連接和動態連接。

動態連接一個顯而易見的好處就是可執行程序的大小會很小,就像咱們在Windows下看一個exe文件可能很小,那麼該exe極可能是動態連接的方式生成的

而動態連接的部分生成的庫就是咱們熟悉的動態連接庫,在Windows下是以DLL結尾的文件,在Linux下是以so結尾的文件。說了這麼多,這和線程共享資源有什麼關係呢?

原來若是一個程序是動態連接生成的,那麼其地址空間中有一部分包含的就是動態連接庫,不然程序就運行不起來了,這一部分的地址空間也是被全部線程所共享的。

也就是說進程中的全部線程均可以使用動態連接庫中的代碼。以上實際上是關於連接這一主題的極簡介紹,關於連接這一話題的詳細討論能夠參考**《[完全理解連接器]》**系列文章。

####文件

最後,若是程序在運行過程當中打開了一些文件,那麼進程地址空間中還保存有打開的文件信息,進程打開的文件也能夠被全部的線程使用,這也屬於線程間的共享資源。

** One More Thing:TLS**

本文就這些了嗎?實際上關於線程私有數據還有一項沒有詳細講解,由於再講下去本篇就撐爆了,並且本篇已經講解的部分足夠用了,剩下的這一點僅僅做爲補充,也就是選學部分,若是你對此不感興趣的話徹底能夠跳過,沒有問題

。關於線程私有數據還有一項技術,那就是線程局部存儲,Thread Local Storage,TLS。這是什麼意思呢?其實從名字上也能夠看出,所謂線程局部存儲,是指存放在該區域中的變量有兩個含義:

  • 存放在該區域中的變量是全局變量,全部線程均可以訪問

  • 雖然看上去全部線程訪問的都是同一個變量,但該全局變量獨屬於一個線程,一個線程對此變量的修改對其餘線程不可見。

說了這麼多仍是沒懂有沒有?不要緊,接下來看完這兩段代碼還不懂你來打我。咱們先來看第一段代碼,不用擔憂,這段代碼很是很是的簡單:

int a = 1; // 全局變量
複製代碼

怎麼樣,這段代碼足夠簡單吧,上述代碼是用C++11寫的,我來說解下這段代碼是什麼意思。

  • 首先咱們建立了一個全局變量a,初始值爲1
  • 其次咱們建立了兩個線程,每一個線程對變量a加1
  • 線程的join函數表示該線程運行完畢後才繼續運行接下來的代碼

那麼這段代碼的運行起來會打印什麼呢?全局變量a的初始值爲1,第一個線程加1後a變爲2,所以會打印2;第二個線程再次加1後a變爲3,所以會打印3,讓咱們來看一下運行結果:

2
複製代碼

看來咱們分析的沒錯,全局變量在兩個線程分別加1後最終變爲3。接下來咱們對變量a的定義稍做修改,其它代碼不作改動:

__thread int a = 1; // 線程局部存儲
複製代碼

咱們看到全局變量a前面加了一個__thread關鍵詞用來修飾,也就是說咱們告訴編譯器把變量a放在線程局部存儲中,那這會對程序帶來哪些改變呢?簡單運行一下就知道了:

2
複製代碼

和你想的同樣嗎?有的同窗可能會大吃一驚,爲何咱們明明對變量a加了兩次,但第二次運行爲何仍是打印2而不是3呢?

想想這是爲何。原來,這就是線程局部存儲的做用所在,線程t1對變量a的修改不會影響到線程t2,線程t1在將變量a加到1後變爲2,但對於線程t2來講此時變量a依然是1,所以加1後依然是2。

所以,線程局部存儲可讓你使用一個獨屬於線程的全局變量。也就是說,雖然該變量能夠被全部線程訪問,但該變量在每一個線程中都有一個副本,一個線程對改變量的修改不會影響到其它線程。

總結

怎麼樣,沒想到教科書上一句簡單的「線程共享進程資源」背後居然會有這麼多的知識點吧,教科書上的知識看似容易,但,並不簡單。但願本篇能對你們理解進程、線程能有多幫助。最後的最後,若是以爲文章對你有幫助的話,請多多分享一下!!!

相關文章
相關標籤/搜索