首頁>技術>

前言

程序和執行緒這兩個話題是程式設計師繞不開的,作業系統提供的這兩個抽象概念實在是太重要了。關於程序和執行緒有一個極其經典的問題,那就是程序和執行緒的區別是什麼?相信很多同學對答案似懂非懂。

記住了不一定真懂

關於這個問題有的同學可能已經“背得”滾瓜爛熟了:“程序是作業系統分配資源的單位,執行緒是排程的基本單位,執行緒之間共享程序資源”。可是你真的理解了上面最後一句話嗎?到底執行緒之間共享了哪些程序資源,共享資源意味著什麼?共享資源這種機制是如何實現的?對此如果你沒有答案的話,那麼這意味著你幾乎很難寫出能正確工作的多執行緒程式,同時也意味著這篇文章就是為你準備的。

逆向思考

查理芒格經常說這樣一句話:“反過來想,總是反過來想”,如果你對執行緒之間共享了哪些程序資源這個問題想不清楚的話那麼也可以反過來思考,那就是有哪些資源是執行緒私有的

執行緒私有資源

執行緒執行的本質其實就是函式的執行,函式的執行總會有一個源頭,這個源頭就是所謂的入口函式,CPU從入口函式開始執行從而形成一個執行流,只不過我們人為的給執行流起一個名字,這個名字就叫執行緒。

既然執行緒執行的本質就是函式的執行,那麼函式執行都有哪些資訊呢?在《[函式執行時在記憶體中是什麼樣子]》這篇文章中應該提過,函式執行時的資訊儲存在棧幀中,棧幀中儲存了函式的返回值、呼叫其它函式的引數、該函式使用的區域性變數以及該函式使用的暫存器信。

如圖所示,假設函式A呼叫函式B:

此外,CPU執行指令的資訊儲存在一個叫做程式計數器的暫存器中,透過這個暫存器我們就知道接下來要執行哪一條指令。由於作業系統隨時可以暫停執行緒的執行,因此我們儲存以及恢復程式計數器中的值就能知道執行緒是從哪裡暫停的以及該從哪裡繼續運行了。由於執行緒執行的本質就是函式執行,函式執行時資訊是儲存在棧幀中的,因此每個執行緒都有自己獨立的、私有的棧區。

圖片

同時函式執行時需要額外的暫存器來儲存一些資訊,像部分區域性變數之類,這些暫存器也是執行緒私有的,一個執行緒不可能訪問到另一個執行緒的這類暫存器資訊

以上這些資訊有一個統一的名字,就是執行緒上下文,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。

因此,執行緒區域性儲存可以讓你使用一個獨屬於執行緒的全域性變數。也就是說,雖然該變數可以被所有執行緒訪問,但該變數在每個執行緒中都有一個副本,一個執行緒對改變數的修改不會影響到其它執行緒。

總結

怎麼樣,沒想到教科書上一句簡單的“執行緒共享程序資源”背後竟然會有這麼多的知識點吧,教科書上的知識看似容易,但,並不簡單。希望本篇能對大家理解程序、執行緒能有多幫助。最後的最後,如果覺得文章對你有幫助的話,請多多分享一下!!!

10
最新評論
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • 聊聊golang的zap的level