網(wǎng)站搭建素材百度總部電話
線程同進程一樣都是OS中非常重要的部分,線程的應(yīng)用場景非常的廣泛,試想我們使用的視頻軟件,在網(wǎng)絡(luò)不是很好的情況下,通常會采取下載的方式,現(xiàn)在你很想立即觀看,又想下載,于是你點擊了下載并且在線觀看。學(xué)過進程的你會不會想,視頻軟件運行后在OS內(nèi)形成一個進程,有一個執(zhí)行流,但下載和在線觀看是兩件事情,這兩件事情是如何同時進行的呢?你可能會想到CPU的時間片輪轉(zhuǎn),不過曾經(jīng)提到過的時間片輪轉(zhuǎn)是針對進程間的切換的,下載和在線觀看這兩件事本身處于同一個進程內(nèi)完成,你可能還會想到在這個進程內(nèi)創(chuàng)建一個子進程,主進程負責(zé)播放,子進程負責(zé)下載,這確實是一個解決問題的方法,但是創(chuàng)建一個進程所帶來的開銷是不小的。本篇文章將會介紹另一種更加輕便的解決方案——線程,同時我們需要重新理解CPU時間片輪轉(zhuǎn)的調(diào)度單位
目錄
什么是線程
深入理解頁表?
理解進程和線程?
實踐線程操作?
線程終止
線程等待?
分離線程?
線程取消?
TCB
線程的優(yōu)缺點?
優(yōu)點
缺點?
C++提供的線程庫?
什么是線程
按照課本上的定義,線程就是進程內(nèi)部的執(zhí)行流,有多個執(zhí)行流就意味著一個進程可以同時進行多個操作,比如視頻軟件,同時具備播放視頻和下載視頻的功能,如果只有一個執(zhí)行流,那么在播放視頻時就不能同時下載視頻,因為播放視頻和下載視頻的代碼是不同的
以前我們一直認為進程是CPU的調(diào)度單位,現(xiàn)在我們要改變這個看法,被CPU調(diào)度意味著被CPU執(zhí)行,也就是一個執(zhí)行流,一個進程里可以有多個線程,線程才是CPU的調(diào)度單位。所謂的調(diào)度單位就是CPU時間片輪轉(zhuǎn)時的切換單位,以前我們解釋CPU時間片輪轉(zhuǎn)時說的是每個進程都被分配一定的CPU執(zhí)行時間,到達時間,CPU會強制切換到下一個進程,以保證每個進程都能夠被執(zhí)行
此時,通過線程的概念能得知,CPU時間片輪轉(zhuǎn)切換的并不是進程,而是線程。但上面的話并沒有說錯,一是創(chuàng)建一個進程時,默認只有一個執(zhí)行流,也就是只有一個線程,時間片輪轉(zhuǎn)時可以認為是切換進程。二是在后面我們將學(xué)習(xí)到Linux其實并沒有線程的概念,所謂的線程在Linux中是輕量級進程
有些懵沒有關(guān)系,后面會一一解釋原因
現(xiàn)在線程的概念先放到一邊,我們接下來再次回顧曾經(jīng)學(xué)習(xí)過的進程地址空間
深入理解頁表?
這是筆者曾經(jīng)多次提到過的進程地址空間映射圖,并且說過虛擬地址空間和物理內(nèi)存之間一一映射,那么大家有沒有思考過這么一個問題,假設(shè)虛擬地址空間有4G大小,物理內(nèi)存也是4G大,而頁表是虛擬地址空間和物理地址空間的一一映射,這意味著頁表自身得有8G大小的空間才能夠滿足虛擬地址空間和物理內(nèi)存之間一一映射,要知道,頁表可也得加載到內(nèi)存中才能讓CPU執(zhí)行,照這樣的映射法,物理內(nèi)存連一個頁表都存不了,更何況4G物理內(nèi)存空間還得留1G給OS呢
可想而知,頁表的映射不會像哈希表那樣一一對應(yīng),要明白頁表的真實構(gòu)造,我們就得從物理內(nèi)存的劃分開始
????
實際上物理內(nèi)存是按4kb為單位進行劃分的,每個大小單位被稱為頁框,大家知道磁盤往內(nèi)存中加載數(shù)據(jù)時就是以4kb大小為單位,正好能夠加載到物理內(nèi)存的頁框中,這看似巧妙的背后是前人無數(shù)日夜的精心設(shè)計
但是這好像并沒有說明頁表的真實構(gòu)造,別急,接著往下看
真實的頁表并不是只有一張,頁表里存儲的也不是虛擬地址和物理地址的一一對應(yīng),頁表里正真存儲的是物理內(nèi)存中每個頁框的起始地址,一張頁表里只存儲指定數(shù)量的頁框,整個物理內(nèi)存的頁框被多張頁表存儲著
這多張頁表被頁目錄記錄著,通過頁目錄可以找到每一張頁表,到這里,頁表的整體結(jié)構(gòu)就出來了,可見,當(dāng)初我們剛了解頁表時,進行了很大程度的簡化。但是這就結(jié)束了嗎?筆者只是把頁表真實的結(jié)構(gòu)給描繪出來,但是并沒有解釋現(xiàn)在的頁表是如何進行映射的?
?
上圖是虛擬內(nèi)存中的一個虛擬地址,接下來我們刨析這個虛擬地址如何通過頁表最終映射到物理內(nèi)存?
虛擬地址映射到物理內(nèi)存的方法就在地址本身上,通過虛擬地址的前10位可以到頁目錄中找到該地址對應(yīng)在哪個頁表,找到具體的頁表之后,虛擬地址的中間10位標(biāo)識著該地址在物理內(nèi)存的哪個頁框里,找到具體的頁框之后,那么最后12位想必大家已經(jīng)猜出來了
最后12位正是頁框內(nèi)的偏移地址,因為一個頁框大小就是4kb,要想在某個頁框內(nèi)準確定位,就要知道該頁框的起始地址以及在該頁框內(nèi)的偏移地址。至于對不對,咱們驗證一下
一個地址的大小是4字節(jié),2的12次方是4096,4096 * 4字節(jié) = 4kb,所以驗證正確
如上,真實的頁表映射結(jié)構(gòu)就展現(xiàn)在我們眼前,筆者這里并不是心血來潮講一下頁表,通過上述的過程大家能感受到地址空間是進程接觸并使用資源的窗口,頁表則決定了,進程擁有哪些資源,只有頁表映射到的物理內(nèi)存,進程才能夠訪問,那么通過地址空間+頁表映射進行資源劃分,就可以對一個進程所用的資源進行分類
理解進程和線程?
現(xiàn)在回到對線程的講解上,前面說到過線程是進程內(nèi)部的執(zhí)行流,一個進程可以擁有多個線程,如下圖,這些線程通過使用共同的地址空間和頁表從而共享進程的資源,這意味著一個進程里的多個線程共享該進程的資源
前面還提到過,CPU的基本調(diào)度單位是線程,被CPU調(diào)度執(zhí)行,那就得有上下文信息,那么線程就要保存好自己的上下文信息,當(dāng)被CPU切換執(zhí)行時,可以將上下文信息重新載入到CPU的寄存器中。線程在共享進程資源的同時也會產(chǎn)生自己的執(zhí)行數(shù)據(jù),也是需要保存起來的。線程是CPU調(diào)度的基本單位,這就意味著系統(tǒng)中會存在大量的線程等待被CPU調(diào)用,根據(jù)以往的經(jīng)驗,存在大量的線程時,OS要有序?qū)⑵涔芾砥饋?#xff0c;就得給線程設(shè)計一種數(shù)據(jù)類型,設(shè)計方法還是多次提到過的先描述,再組織
給線程設(shè)計數(shù)據(jù)類型就要考慮線程的id號在系統(tǒng)中唯一,同時要能存儲上下文信息,線程在被CPU調(diào)度時,要有自己的狀態(tài)信息,同時在執(zhí)行過程中要有自己的棧結(jié)構(gòu), 并且線程共享進程資源,那么文件描述符表什么的也要有,越往下舉例,就能明顯感受到這不就是當(dāng)初學(xué)習(xí)進程時,進程的結(jié)構(gòu)里所包含的內(nèi)容嗎?
可以發(fā)現(xiàn)進程結(jié)構(gòu)和線程結(jié)構(gòu)大量的內(nèi)容都是重疊的,如果進程和線程兩種結(jié)構(gòu)同時存在系統(tǒng)中,就會造成大量的冗余,而Linux是一個非常注重效率的OS,于是聰明的Linux設(shè)計者決定不為線程設(shè)計一個獨立的結(jié)構(gòu),而是采用了輕量級進程結(jié)構(gòu),也就是說在Linux系統(tǒng)中,進程和線程實際上使用的是同一種結(jié)構(gòu)
這一點與windows有很大的不同,windows就為線程設(shè)計了一個獨立的結(jié)構(gòu),這也體現(xiàn)了兩種OS各自的設(shè)計哲學(xué)
如何理解線程就是輕量級進程呢?如何理解現(xiàn)在的進程概念呢?
曾經(jīng)我們認為一個task_struct就是一個進程,一個task_struct有一個執(zhí)行流,并且記錄著該執(zhí)行信息的執(zhí)行狀態(tài)?,F(xiàn)在學(xué)了線程,知道進程和線程共同使用task_struct結(jié)構(gòu),對于每個進程或線程,內(nèi)核都會為其分配一個唯一的task_struct結(jié)構(gòu),現(xiàn)在的task_struct是一種輕量級進程,也就是說一個進程里可能含有多個task_struct,不能再將一個task_struct理解成一個進程。但這并不是說曾經(jīng)學(xué)的就是錯誤的,曾經(jīng)創(chuàng)建一個進程,默認有一個執(zhí)行流,也就是有一個主線程,該主線程是創(chuàng)建進程本身的執(zhí)行流,task_struct就是這個主線程的結(jié)構(gòu),故而也可以將task_struct理解成進程本身,但是多線程后,有多個task_struct,再按照以前的方法理解進程就顯得不嚴謹了
假設(shè)現(xiàn)在一個進程創(chuàng)建了三個線程,那么會有幾個task_struct呢?
如果一個進程創(chuàng)建了三個線程,那么通常會有四個task_struct結(jié)構(gòu),在Linux中,每個進程都有一個主線程,也就是創(chuàng)建該進程的線程,主線程有一個對應(yīng)的task_struct結(jié)構(gòu),對于每個創(chuàng)建的線程,也會有一個對應(yīng)的task_struct結(jié)構(gòu)。 故而,對于一個進程而言,如果額外創(chuàng)建了三個線程,那么會有一個主線程的task_struct結(jié)構(gòu),以及三個子線程的task_struct結(jié)構(gòu),共計四個task_struct結(jié)構(gòu),這四個task_struct結(jié)構(gòu)共同構(gòu)成了該進程的線程組成部分
站在CPU的角度上,曾經(jīng)時間片輪轉(zhuǎn)時切換task_struct就是切換一個進程,現(xiàn)在CPU時間片輪轉(zhuǎn)切換一個task_struct是切換進程的一個分支,如果這個進程只有一個主線程,那就是切換進程本身
總而言之,現(xiàn)在一個進程有多個執(zhí)行流,進程的概念不能局限于曾經(jīng)只有一個執(zhí)行流的task_struct,而是一個擁有多個task_struct的承擔(dān)分配系統(tǒng)資源的基本實體
實踐線程操作?
說了這么多,咱們連線程長什么樣子都不知道,接下來咱們通過實踐來感受線程的魅力
不過在動手敲代碼之前,需要明確一些事情,因為用輕量級進程來表示線程是Linux系統(tǒng)獨特的線程處理方式。雖然這能很大提高效率,但是也帶來了不通用的麻煩,很多OS,包括OS的理論基礎(chǔ)上都是有線程這個概念的,因此并不通用Linux的輕量級進程,大家都在使用線程接口,而你Linux搞特殊提供輕量級進程接口,大家是不認的,為了解決這個問題,Linux工程師就將輕量級進程接口進行封裝,適配成大家都通用的線程接口
這意味著,我們在使用Linux線程接口時,要在編譯時帶上線程動態(tài)庫即選項 -l pthread
創(chuàng)建一個線程是通過接口
int pthread_create(pthread_t *thread, const pthread_attr_t *attr, void *(*start_routine)(void*), void *arg);
頭文件:pthread.h? ?
參數(shù)
thread:返回線程ID (輸出型參數(shù))
attr:設(shè)置線程的屬性,attr為NULL表示使用默認屬性
start_routine:是個函數(shù)地址,線程啟動后要執(zhí)行的函數(shù)
arg:傳給線程啟動函數(shù)的參數(shù)
返回值:成功返回0;失敗返回錯誤碼第一個參數(shù)是一個輸出型參數(shù),我們在主函數(shù)里創(chuàng)建一個pthread_t類型的變量,將其的地址傳過去,創(chuàng)建線程后,會把該線程的id寫入到這個pthread_t類型變量里
我們目前不需要關(guān)心 att r這個參數(shù),可以看到第三個參數(shù)是一個函數(shù)指針,其所指向的函數(shù)就是創(chuàng)建一個線程后,該線程去執(zhí)行的任務(wù)
第四個參數(shù)是對第三個參數(shù)的補充,在我們編寫線程要執(zhí)行的函數(shù)時,有時是需要外部給這個函數(shù)傳參的,那個這個函數(shù)就會默認有一個void* 類型的參數(shù),這個參數(shù)就是通過pthread_create的第四個參數(shù)傳遞過去的
下面看一個線程代碼示例
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<cstring>
#include<cstdlib>
#include<cstdio>using namespace std;void* start_routine(void * arg)
{while(true){printf("%s\n", (char*)arg);sleep(1);}
}int main()
{pthread_t thread_id;char buff[64];snprintf(buff, sizeof(buff), "我是新創(chuàng)建的線程,我正在運行");pthread_create(&thread_id, nullptr, start_routine, (void*)buff);int counter = 10;while(counter--){printf("我是主線程,運行倒計時:%d\n", counter);sleep(1);}return 0;
}
這個示例可以看到,真的有兩個執(zhí)行流同時在跑
通過命令ps -aL可以查看所有進程內(nèi)的線程,接下來我們讓兩個線程不間斷運行,然后查看這兩個線程的相關(guān)信息
可以發(fā)現(xiàn),當(dāng)test程序跑起來后,出現(xiàn)了兩個test線程,這兩個線程的PID是相同的,說明這兩個線程來自同一個進程,不過兩個線程的LWP不同,LWP(light weight process,即輕量級進程)LWP就是所謂的線程ID了,并且第一個線程的PID和LWP相同,這說明該線程是主線程,CPU在調(diào)度時,是以LWP為標(biāo)識,表示一個特定的執(zhí)行流
上面只是創(chuàng)建單個線程,那么如何同時創(chuàng)建多個線程呢?
看下面的demo,我們一次創(chuàng)建10個線程,并且不停打印他們的序號
#include<iostream>
#include<unistd.h>
#include<pthread.h>
#include<cstring>
#include<cstdlib>using namespace std;#define MAX 10void* _start_test(void* arg){while(true){sleep(1);cout << (char*)arg << endl;}return nullptr;
}int main(){for (int i = 0; i<MAX; i++){pthread_t tid;char buff[64];snprintf(buff, sizeof(buff), "this is %d thread", i);pthread_create(&tid, nullptr, _start_test, buff);}while(true){sleep(1);cout << "我是主線程"<<endl;}return 0;
}
?
當(dāng)執(zhí)行結(jié)果出來后,完全超出了我們的預(yù)期,我們本想這10個線程,各自打印各自的序號,可是結(jié)果每個線程都打印序號9
出現(xiàn)這種情況的原因是線程被創(chuàng)建后的執(zhí)行順序是不確定的,當(dāng)?shù)谝粋€被創(chuàng)建的進程還沒來得及執(zhí)行它的start_routine函數(shù)時,主線程就已經(jīng)把所有的線程都創(chuàng)建完畢了,buff是在循環(huán)里被被創(chuàng)建的,出了循環(huán)后就被銷毀,然后再次創(chuàng)建,因為都是在同一個棧里,所以每次buff的地址都不變,且buff的值不斷被覆寫,直到最后一個線程創(chuàng)建完畢,buff的值被覆寫為序號9
此時循環(huán)退出,buff也被銷毀了,但是由于main函數(shù)這個棧還在,也沒有開其他的棧,因此原先buff指向的空間并沒有被清理,導(dǎo)致所有的線程都打印最后一次覆寫buff的內(nèi)容,通過這個demo,可得知線程除了獨自的PCB,獨自的上下文結(jié)構(gòu),獨自的棧結(jié)構(gòu),其他幾乎所有內(nèi)容都是共享的
每一個線程都有自己獨立的棧,這是因為一個線程在執(zhí)行時,可能會調(diào)用各種函數(shù),因此需要一個獨立的棧,這個棧里的內(nèi)容不與其他線程共享
線程終止
會創(chuàng)建線程之后,自然而然的會想到,線程如何終止,導(dǎo)致線程終止的原因有很多
1.執(zhí)行完start_routine()后,線程會自動return結(jié)束
2.使用pthread_exit()來終止當(dāng)前線程,但是要注意,不要習(xí)慣性的使用exit()來終止線程,exit()是用來終止進程的,進程終止,該進程內(nèi)所有的線程都會終止
3.某個線程執(zhí)行過程中,出現(xiàn)錯誤,觸發(fā)OS檢查,會給當(dāng)前線程的進程發(fā)送信號,進程收到信號會終止,該進程內(nèi)其他所有線程都會終止
4.?一個線程可以調(diào)用pthread_ cancel()終止同一進程中的另一個線程
線程等待?
同進程一樣,線程結(jié)束后其所申請的各種資源都是需要被回收的,不然會產(chǎn)生類似僵尸進程一樣的問題,線程等待使用函數(shù)pthread_join()
int pthread_join(pthread_t thread, void **retval);
第一個參數(shù)就是被等待的線程id,第二個參數(shù)是獲取該線程的返回值的,還記得start_routine有一個void* 返回值嗎?這個返回值就是通過pthread_join獲取的
注意:OS維護的是輕量級進程PCB,因為Linux特殊的線程方案,可以說沒有線程概念。程序員日常使用習(xí)慣了線程接口,因此Linux提供了線程庫,線程庫負責(zé)線程接口與輕量級進程接口之間的轉(zhuǎn)換,以及維護用戶通過接口創(chuàng)建好的線程
分離線程?
默認情況下,新創(chuàng)建的線程是joinable的,線程退出后,需要對其進行pthread_join操作,否則無法釋放資源,從而造成系統(tǒng)泄漏
如果不關(guān)心線程的返回值,join是一種負擔(dān),這個時候,我們可以告訴系統(tǒng),當(dāng)線程退出時,自動釋放線程資源
使用接口:int pthread_detach(pthread_t thread);
可以是線程組內(nèi)其他線程對目標(biāo)線程進行分離,也可以是線程自己分離
pthread_detach(pthread_self());? ?pthread_self()獲取自己的線程id
?需要注意的是,一旦一個線程已經(jīng)處于分離狀態(tài),那么該線程就不能被等待
線程取消?
線程取消也就是當(dāng)線程跑起來后,我們通過主線程或者其他線程可以取消這個線程繼續(xù)運行
也可以自己取消自己
int pthread_cancel(pthread_t thread);
返回值:成功返回0;失敗返回錯誤碼
注意:只有當(dāng)該進程運行起來,有自己的線程ID時才可以被取消
TCB
PCB是Linux內(nèi)核用來管理輕量級進程的內(nèi)核,因為Linux沒有線程的概念,程序員要使用線程的接口,因此要通過線程庫進行轉(zhuǎn)接,那么程序員每申請一個線程,線程庫就得維護好這個線程和輕量級進程進行轉(zhuǎn)換,那么TCB就是線程庫維護線程的結(jié)構(gòu)
?
?
由圖中可以得知,我們接收的所謂的線程id值其實就是庫中維護的該線程TCB的起始地址?
線程的優(yōu)缺點?
優(yōu)點
線程的使用能非常大程度上發(fā)揮多核CPU的實力,并且創(chuàng)建多個線程比創(chuàng)建多個進程的開銷要小的多,為什么呢?
如果CPU執(zhí)行時,要切換一個進程,那么要切換的內(nèi)容至少包含頁表,虛擬地址空間,PCB,上下文數(shù)據(jù)
而切換一個線程,那么只需要切換PCB,上下文數(shù)據(jù)等主要內(nèi)容
CPU在執(zhí)行一個進程時,會在寄存器中緩存該進程的很多熱點數(shù)據(jù),例如虛擬地址空間,頁表等,一旦切換進程,這些熱點數(shù)據(jù)要全部重新加載,而切換線程,這些數(shù)據(jù)不需要動
缺點?
在運行計算密集型程序時,線程需要不停的計算,持續(xù)占有CPU,切換到其它線程的時間就會延長,導(dǎo)致效率低下
使用多線程編程會有互斥和同步等問題,程序的編寫和維護成本很高
C++提供的線程庫?
雖說Linux提供了線程庫,但是Linux的線程接口和Windows下的線程接口很多都是不同的,這就導(dǎo)致程序的可移植性很低,?C++11之后,在語言層面上對Linux和windows平臺下的線程接口再進行一次封裝。如此以來,用C++線程庫編寫的多線程程序可以同時在這兩個OS平臺下執(zhí)行,代碼的可移植性大大提高
下面的demo簡單演示了如何使用C++提供的線程庫,這部分屬于C++的知識了,筆者將在C++專欄中介紹其詳細使用方法
#include<thread>
#include<iostream>
#include<unistd.h>using namespace std;void* start_routine()
{int counter = 10;while(counter--){sleep(1);cout << "我是新創(chuàng)建的線程,運行倒計時:" << counter <<endl;}return nullptr;
}int main()
{//創(chuàng)建一個線程,并把執(zhí)行函數(shù)傳遞過去thread t1(start_routine);cout << "我是主線程" <<endl;//主線程阻塞等待回收子線程t1.join();cout << "線程回收完畢,準備退出"<<endl;return 0;
}
文章的最后,大家可以嘗試自己模仿C++的線程庫,對Linux的線程庫再進行一次封裝?