【電腦內功心法】十:執行緒間到底共享了哪些進程資源

  • 2021 年 2 月 24 日
  • 筆記

進程和執行緒這兩個話題是程式設計師繞不開的,作業系統提供的這兩個抽象概念實在是太重要了。

關於進程和執行緒有一個極其經典的問題,那就是進程和執行緒的區別是什麼?相信很多同學對答案似懂非懂。

記住了不一定真懂

有的同學可能已經「背得」滾瓜爛熟了:「進程是作業系統分配資源的單位,執行緒是調度的基本單位,執行緒之間共享進程資源」。

可是你真的理解了上面這句話嗎?到底執行緒之間共享了哪些進程資源,共享資源意味著什麼?共享資源這種機制是如何實現的?對此如果你沒有答案的話,那麼這意味著你幾乎很難寫出能正確工作的多執行緒程式,同時也意味著這篇文章就是為你準備的。

逆向思考

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

執行緒私有資源

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

既然執行緒運行的本質就是函數的執行,那麼函數執行都有哪些資訊呢?

在《函數運行時在記憶體中是什麼樣子?》這篇文章中我們說過,函數運行時的資訊保存在棧幀中,棧幀中保存了函數的返回值、調用其它函數的參數、該函數使用的局部變數以及該函數使用的暫存器資訊,如圖所示,假設函數A調用函數B:

1607559711161
1607559711161

此外,CPU執行指令的資訊保存在一個叫做程式計數器的暫存器中,通過這個暫存器我們就知道接下來要執行哪一條指令。由於作業系統隨時可以暫停執行緒的運行,因此我們保存以及恢復程式計數器中的值就能知道執行緒是從哪裡暫停的以及該從哪裡繼續運行了。

由於執行緒運行的本質就是函數運行,函數運行時資訊是保存在棧幀中的,因此每個執行緒都有自己獨立的、私有的棧區。

1607600679200
1607600679200

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

從上面的討論中我們知道,到目前為止,所屬執行緒的棧區、程式計數器、棧指針以及函數運行使用的暫存器是執行緒私有的。

以上這些資訊有一個統一的名字,就是執行緒上下文,thread context。

我們也說過作業系統調度執行緒需要隨時中斷執行緒的運行並且需要執行緒被暫停後可以繼續運行,作業系統之所以能實現這一點,依靠的就是執行緒上下文資訊。

現在你應該知道哪些是執行緒私有的了吧。

除此之外,剩下的都是執行緒間共享資源。

那麼剩下的還有什麼呢?還有圖中的這些。

1607559885584
1607559885584

這其實就是進程地址空間的樣子,也就是說執行緒共享進程地址空間中除執行緒上下文資訊中的所有內容,意思就是說執行緒可以直接讀取這些內容。

接下來我們分別來看一下這些區域。

程式碼區

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

那麼這些機器指令又是從哪裡來的呢?答案是從可執行文件中載入到記憶體的,可執行程式中的程式碼區就是用來初始化進程地址空間中的程式碼區的。

1607560572568
1607560572568

執行緒之間共享程式碼區,這就意味著程式中的任何一個函數都可以放到執行緒中去執行,不存在某個函數只能被特定執行緒執行的情況

堆區

堆區是程式設計師比較熟悉的,我們在C/C++中用malloc或者new出來的數據就存放在這個區域,很顯然,只要知道變數的地址,也就是指針,任何一個執行緒都可以訪問指針指向的數據,因此堆區也是執行緒共享的屬於進程的資源。

1607561353196
1607561353196

棧區

唉,等等!剛不是說棧區是執行緒私有資源嗎,怎麼這會兒又說起棧區了?

確實,從執行緒這個抽象的概念上來說,棧區是執行緒私有的,然而從實際的實現上看,棧區屬於執行緒私有這一規則並沒有嚴格遵守,這句話是什麼意思?

通常來說,注意這裡的用詞是通常,通常來說棧區是執行緒私有,既然有通常就有不通常的時候。

不通常是因為不像進程地址空間之間的嚴格隔離,執行緒的棧區沒有嚴格的隔離機制來保護,因此如果一個執行緒能拿到來自另一個執行緒棧幀上的指針,那麼該執行緒就可以改變另一個執行緒的棧區,也就是說這些執行緒可以任意修改本屬於另一個執行緒棧區中的變數。

1607562006889
1607562006889

這從某種程度上給了程式設計師極大的便利,但同時,這也會導致極其難以排查到的bug。

試想一下你的程式運行的好好的,結果某個時刻突然出問題,定位到出問題程式碼行後根本就排查不到原因,你當然是排查不到問題原因的,因為你的程式本來就沒有任何問題,是別人的問題導致你的函數棧幀數據被寫壞從而產生bug,這樣的問題通常很難排查到原因,需要對整體的項目程式碼非常熟悉,常用的一些debug工具這時可能已經沒有多大作用了。

說了這麼多,那麼同學可能會問,一個執行緒是怎樣修改本屬於其它執行緒的數據呢?

接下來我們用一個程式碼示例講解一下。

文件

最後,如果程式在運行過程中打開了一些文件,那麼進程地址空間中還保存有打開的文件資訊,進程打開的文件也可以被所有的執行緒使用,這也屬於執行緒間的共享資源。關於文件IO操作,你可以參考《讀取文件時,程式經歷了什麼?

1607563147233
1607563147233

One More Thing:TLS

本文就這些了嗎?

實際上本篇開頭關於執行緒私有數據還有一個項沒有詳細講解,因為再講下去本篇就撐爆了,實際上本篇講解的已經足夠用了,剩下的這一點僅僅作為補充。

關於執行緒私有數據還有一項技術,那就是執行緒局部存儲,Thread Local Storage,TLS。

這是什麼意思呢?

其實從名字上也可以看出,所謂執行緒局部存儲,是指存放在該區域中的變數有兩個含義:

  • 存放在該區域中的變數是全局變數,所有執行緒都可以訪問
  • 雖然看上去所有執行緒訪問的都是同一個變數,但該全局變數獨屬於一個執行緒,一個執行緒對此變數的修改對其他執行緒不可見。

說了這麼多還是沒懂有沒有?沒關係,接下來看完這兩段程式碼還不懂你來打我。

我們先來看第一段程式碼,不用擔心,這段程式碼非常非常的簡單:

int a = 1// 全局變數

void print_a() {
    cout<<a<<endl;
}

void run() {
    ++a;
    print_a();
}

void main() {
    thread t1(run);
    t1.join();

    thread t2(run);
    t2.join();
}

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

  • 首先我們創建了一個全局變數a,初始值為1
  • 其次我們創建了兩個執行緒,每個執行緒對變數a加1
  • 執行緒的join函數表示該執行緒運行完畢後才繼續運行接下來的程式碼

那麼這段程式碼的運行起來會列印什麼呢?

全局變數a的初始值為1,第一個執行緒加1後a變為2,因此會列印2;第二個執行緒再次加1後a變為3,因此會列印3,讓我們來看一下運行結果:

2
3

看來我們分析的沒錯,全局變數在兩個執行緒分別加1後最終變為3。

接下來我們對變數a的定義稍作修改,其它程式碼不做改動:

__thread int a = 1// 執行緒局部存儲

我們看到全局變數a前面加了一個__thread關鍵詞用來修飾,也就是說我們告訴編譯器把變數a放在執行緒局部存儲中,那這會對程式帶來哪些改變呢?

簡單運行一下就知道了:

2
2

和你想的一樣嗎,有的同學可能會大吃一驚,為什麼我們明明對變數a加了兩次,但第二次運行為什麼還是列印2而不是3呢?

想一想這是為什麼。

原來,這就是執行緒局部存儲的作用所在,執行緒t1對變數a的修改不會影響到執行緒t2,執行緒t1在將變數a加到1後變為2,但對於執行緒t2來說此時變數a依然是1,因此加1後依然是2。

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

1607513993036
1607513993036

總結

怎麼樣,沒想到教科書上一句簡單的「執行緒共享進程資源」背後竟然會有這麼多的知識點吧,教科書上的知識確實枯燥,但,並不簡單

希望本篇能對大家理解進程、執行緒能有多幫助。