[導讀]進程和線程這兩個話題是線程享進程序員繞不開的,操作系統提供的底共這兩個抽象概念實在是太重要了。 關于進程和線程有一個極其經典的程資問題,那就是線程享進進程和線程的區別是什么?相信很多同學對答案似懂非懂。
進程和線程這兩個話題是底共程序員繞不開的,操作系統提供的程資這兩個抽象概念實在是太重要了。 關于進程和線程有一個極其經典的線程享進問題,那就是底共進程和線程的區別是什么?相信很多同學對答案似懂非懂。 記住了不一定真懂

關于這個問題有的程資同學可能已經“背得”滾瓜爛熟了:“進程是操作系統分配資源的單位,線程是線程享進調度的基本單位,線程之間共享進程資源”。底共 可是程資你真的理解了上面最后一句話嗎?到底線程之間共享了哪些進程資源,共享資源意味著什么?共享資源這種機制是線程享進如何實現的?對此如果你沒有答案的話,那么這意味著你幾乎很難寫出能正確工作的底共多線程程序,同時也意味著這篇文章就是程資為你準備的。 查理芒格經常說這樣一句話:“反過來想,總是反過來想”,如果你對線程之間共享了哪些進程資源這個問題想不清楚的話那么也可以反過來思考,那就是有哪些資源是線程私有的。 線程運行的本質其實就是函數的執行,函數的執行總會有一個源頭,這個源頭就是所謂的入口函數,CPU從入口函數開始執行從而形成一個執行流,只不過我們人為的給執行流起一個名字,這個名字就叫線程。 既然線程運行的本質就是函數的執行,那么函數執行都有哪些信息呢? 在《函數運行時在內存中是什么樣子》這篇文章中我們說過,函數運行時的信息保存在棧幀中,棧幀中保存了函數的返回值、調用其它函數的參數、該函數使用的局部變量以及該函數使用的寄存器信息,如圖所示,假設函數A調用函數B: 此外,CPU執行指令的信息保存在一個叫做程序計數器的寄存器中,通過這個寄存器我們就知道接下來要執行哪一條指令。由于操作系統隨時可以暫停線程的運行,因此我們保存以及恢復程序計數器中的值就能知道線程是從哪里暫停的以及該從哪里繼續運行了。 由于線程運行的本質就是函數運行,函數運行時信息是保存在棧幀中的,因此每個線程都有自己獨立的、私有的棧區。 同時函數運行時需要額外的寄存器來保存一些信息,像部分局部變量之類,這些寄存器也是線程私有的,一個線程不可能訪問到另一個線程的這類寄存器信息。 從上面的討論中我們知道,到目前為止,所屬線程的棧區、程序計數器、棧指針以及函數運行使用的寄存器是線程私有的。 以上這些信息有一個統一的名字,就是線程上下文,thread context。 我們也說過操作系統調度線程需要隨時中斷線程的運行并且需要線程被暫停后可以繼續運行,操作系統之所以能實現這一點,依靠的就是線程上下文信息。 這其實就是進程地址空間的樣子,也就是說線程共享進程地址空間中除線程上下文信息中的所有內容,意思就是說線程可以直接讀取這些內容。 進程地址空間中的代碼區,這里保存的是什么呢?從名字中有的同學可能已經猜到了,沒錯,這里保存的就是我們寫的代碼,更準確的是編譯后的可執行機器指令。 那么這些機器指令又是從哪里來的呢?答案是從可執行文件中加載到內存的,可執行程序中的代碼區就是用來初始化進程地址空間中的代碼區的。 線程之間共享代碼區,這就意味著程序中的任何一個函數都可以放到線程中去執行,不存在某個函數只能被特定線程執行的情況。 進程地址空間中的數據區,這里存放的就是所謂的全局變量。 什么是全局變量?所謂全局變量就是那些你定義在函數之外的變量,在C語言中就像這樣: char c; // 全局變量
void func() {
}
其中字符c就是全局變量,存放在進程地址空間中的數據區。 在程序員運行期間,也就是run time,數據區中的全局變量有且僅有一個實例,所有的線程都可以訪問到該全局變量。 值得注意的是,在C語言中還有一類特殊的“全局變量”,那就是用static關鍵詞修飾過的變量,就像這樣: void func(){
static int a = 10;
}
注意到, 雖然變量a定義在函數內部,但變量a依然具有全局變量的特性,也就是說變量a放在了進程地址空間的數據區域, 即使函數執行完后該變量依然存在,而普通的局部變量隨著函數調用結束和函數棧幀一起被回收掉了,但這里的變量a不會被回收,因為其被放到了數據區。
這樣的變量對每個線程來說也是可見的,也就是說每個線程都可以訪問到該變量。 堆區是程序員比較熟悉的,我們在C/C++中用malloc或者new出來的數據就存放在這個區域,很顯然,只要知道變量的地址,也就是指針,任何一個線程都可以訪問指針指向的數據,因此堆區也是線程共享的屬于進程的資源。 唉,等等!剛不是說棧區是線程私有資源嗎,怎么這會兒又說起棧區了? 確實,從線程這個抽象的概念上來說,棧區是線程私有的,然而從實際的實現上看,棧區屬于線程私有這一規則并沒有嚴格遵守,這句話是什么意思? 通常來說,注意這里的用詞是通常,通常來說棧區是線程私有,既然有通常就有不通常的時候。 不通常是因為不像進程地址空間之間的嚴格隔離,線程的棧區沒有嚴格的隔離機制來保護,因此如果一個線程能拿到來自另一個線程棧幀上的指針,那么該線程就可以改變另一個線程的棧區,也就是說這些線程可以任意修改本屬于另一個線程棧區中的變量。 這從某種程度上給了程序員極大的便利,但同時,這也會導致極其難以排查到的bug。 試想一下你的程序運行的好好的,結果某個時刻突然出問題,定位到出問題代碼行后根本就排查不到原因,你當然是排查不到問題原因的,因為你的程序本來就沒有任何問題,是別人的問題導致你的函數棧幀數據被寫壞從而產生bug,這樣的問題通常很難排查到原因,需要對整體的項目代碼非常熟悉,常用的一些debug工具這時可能已經沒有多大作用了。 說了這么多,那么同學可能會問,一個線程是怎樣修改本屬于其它線程的數據呢? void thread(void* var) {
int* p = (int*)var;
*p = 2;
}
int main() {
????int?a?=?1;
pthread_t tid;
pthread_create(&tid, NULL, thread, (void*)&a);
return 0;
}
首先我們在主線程的棧區定義了一個局部變量,也就是 int a= 1這行代碼,現在我們已經知道了,局部變量a屬于主線程私有數據,但是,接下來我們創建了另外一個線程。 在新創建的這個線程中,我們將變量a的地址以參數的形式傳給了新創建的線程,然后我來看一下thread函數。 在新創建的線程中,我們獲取到了變量a的指針,然后將其修改為了2,也就是這行代碼,我們在新創建的線程中修改了本屬于主線程的私有數據。 現在你應該看明白了吧,盡管棧區是線程的私有數據,但由于棧區沒有添加任何保護機制,一個線程的棧區對其它線程是可以見的,也就是說我們可以修改屬于任何一個線程的棧區。 就像我們上文說得到的,這給程序員帶來了極大便利的同時也帶來了無盡的麻煩,試想上面這段代碼,如果確實是項目需要那么這樣寫代碼無可厚非,但如果上述新創建線程是因bug修改了屬于其它線程的私有數據的話,那么產生問題就很難定位了,因為bug可能距離問題暴露的這行代碼已經很遠了,這樣的問題通常難以排查。 進程地址空間中除了以上討論的這些實際上還有其它內容,還有什么呢? 什么是可執行程序呢?在Windows中就是我們熟悉的exe文件,在Linux世界中就是ELF文件,這些可以被操作系統直接運行的程序就是我們所說的可執行程序。 假設我們的項目比較簡單只有幾個源碼文件,編譯器是怎么把這幾個源代碼文件轉換為最終的一個可執行程序呢? 原來,編譯器在將可執行程序翻譯成機器指令后,接下來還有一個重要的步驟,這就是鏈接,鏈接完成后生成的才是可執行程序。 其中鏈接器可以有兩種鏈接方式,這就是靜態鏈接和動態鏈接。 靜態鏈接的意思是說把所有的機器指令一股腦全部打包到可執行程序中,動態鏈接的意思是我們不把動態鏈接的部分打包到可執行程序,而是在可執行程序運行起來后去內存中找動態鏈接的那部分代碼,這就是所謂的靜態鏈接和動態鏈接。 動態鏈接一個顯而易見的好處就是可執行程序的大小會很小,就像我們在Windows下看一個exe文件可能很小,那么該exe很可能是動態鏈接的方式生成的。 而動態鏈接的部分生成的庫就是我們熟悉的動態鏈接庫,在Windows下是以DLL結尾的文件,在Linux下是以so結尾的文件。 原來如果一個程序是動態鏈接生成的,那么其地址空間中有一部分包含的就是動態鏈接庫,否則程序就運行不起來了,這一部分的地址空間也是被所有線程所共享的。 也就是說進程中的所有線程都可以使用動態鏈接庫中的代碼。 以上其實是關于鏈接這一主題的極簡介紹,關于鏈接這一話題的詳細討論可以參考《徹底理解鏈接器》系列文章。 最后,如果程序在運行過程中打開了一些文件,那么進程地址空間中還保存有打開的文件信息,進程打開的文件也可以被所有的線程使用,這也屬于線程間的共享資源。 實際上關于線程私有數據還有一項沒有詳細講解,因為再講下去本篇就撐爆了,而且本篇已經講解的部分足夠用了,剩下的這一點僅僅作為補充,也就是選學部分,如果你對此不感興趣的話完全可以跳過,沒有問題。 關于線程私有數據還有一項技術,那就是線程局部存儲,Thread Local Storage,TLS。 其實從名字上也可以看出,所謂線程局部存儲,是指存放在該區域中的變量有兩個含義: 說了這么多還是沒懂有沒有?沒關系,接下來看完這兩段代碼還不懂你來打我。 我們先來看第一段代碼,不用擔心,這段代碼非常非常的簡單: int a = 1; // 全局變量
void print_a() {
cout<}
void run() {
++a;
print_a();
}
void main() {
thread t1(run);
t1.join();
thread t2(run);
t2.join();
}
怎么樣,這段代碼足夠簡單吧,上述代碼是用C++11寫的,我來講解下這段代碼是什么意思。
- 線程的join函數表示該線程運行完畢后才繼續運行接下來的代碼
全局變量a的初始值為1,第一個線程加1后a變為2,因此會打印2;第二個線程再次加1后a變為3,因此會打印3,讓我們來看一下運行結果: 看來我們分析的沒錯,全局變量在兩個線程分別加1后最終變為3。 接下來我們對變量a的定義稍作修改,其它代碼不做改動: __thread int a = 1; // 線程局部存儲
我們看到全局變量a前面加了一個__thread關鍵詞用來修飾,也就是說我們告訴編譯器把變量a放在線程局部存儲中,那這會對程序帶來哪些改變呢? 和你想的一樣嗎?有的同學可能會大吃一驚,為什么我們明明對變量a加了兩次,但第二次運行為什么還是打印2而不是3呢? 原來,這就是線程局部存儲的作用所在,線程t1對變量a的修改不會影響到線程t2,線程t1在將變量a加到1后變為2,但對于線程t2來說此時變量a依然是1,因此加1后依然是2。 因此,線程局部存儲可以讓你使用一個獨屬于線程的全局變量。也就是說,雖然該變量可以被所有線程訪問,但該變量在每個線程中都有一個副本,一個線程對改變量的修改不會影響到其它線程。 怎么樣,沒想到教科書上一句簡單的“線程共享進程資源”背后竟然會有這么多的知識點吧,教科書上的知識看似容易,但,并不簡單。
免責聲明:本文內容由21ic獲得授權后發布,版權歸原作者所有,本平臺僅提供信息存儲服務。文章僅代表作者個人觀點,不代表本平臺立場,如有問題,請聯系我們,謝謝!