目錄
前言
2.線程控制
1.驗證理論
2.引入pthread線程庫
3.linux線程控制的接口
3.線程id及進程地址空間布局
4.線程棧
前言
? 本篇是緊接著上一篇的內容,在有了相關線程概念的基礎之上,我們將要學習線程控制相關話題!!
2.線程控制
1.驗證理論
先來驗證一下我們上面的理論
創建線程可用pthread_create函數(不是系統調用)
第一個參數傳pthread_t類型變量來獲取新線程的id;第二個參數為線程屬性(設置為nullptr就可以);第三個參數是傳返回值為void*,參數為void *的函數指針;第四個參數就是想傳遞給第三個參數的指針/參數
然后我們正常鏈接是過不了的,因為這不屬于系統調用,我們需要在鏈接時加上pthread第三方庫名稱才行,因為是第三方庫,所以需要帶上l選項—— -lpthread
使用命令:ps -aL來查看所有線程
其中pid是一樣的,證明這兩線程(兩執行流)屬于同一個進程;TTY表示終端,它們都屬于同一個終端,都往顯示器上打印;而LWP則是light weight process——輕量級進程,所以這兩執行流的輕量級進程號分別是902075和902076,LWP和pid相等的那個是主線程
CPU調度的時候看的是LWP,調度只看輕量級進程,我們之前學的getpid雖然拿的是pid,但是在調度的時候拿的還是lwp,因為單進程的話,pid就是lwp嘛
細節問題:
-
關于調度的時間片問題:進程的時間片是等分給不同的線程的,因為時間片也是共享的(不可能說創建一個線程就拷貝一份時間片,那樣如果有惡意程序不斷分裂線程就會導致時間片是一直累加的)
-
我們可以驗證一下線程異常的情況
#include <iostream> #include <pthread.h> #include <unistd.h> using namespace std; ? void *threadrun(void *args) {string name = (const char *)args;while (true){sleep(1);cout << "我是新線程: name: " << name << ",pid: " << getpid() << endl;int a = 10;a /= 0;}return nullptr; } ? int main() {pthread_t tid;pthread_create(&tid, nullptr, threadrun, (void *)"thread-1");while (true) // 主線程往這里執行,新線程轉而去執行我們的threadrun了{cout << "我是主線程..." << ",pid: " << getpid() << endl;sleep(1);}return 0; }
可以看到當新線程發生異常之后,系統終止的是整個進程——任何一個線程崩潰,都會導致整個進程崩潰,一個崩潰會影響其他人,所以健壯性低
-
為什么正常打印出的消息會混雜在一起
這是多線程程序輸出混亂問題,因為多個線程(主線程、新線程 )共享標準輸出流,CPU 調度線程時,沒有加保護時,若一個線程輸出未完成就切換到另一個線程繼續輸出,就會導致消息混雜
2.引入pthread線程庫
為什么會有這個庫,這個庫是什么東西?
我們上面的pthread_create封裝的就是底層系統的clone
我們在c++階段也學習過創建線程的方式,在linux下其本質也是封裝了pthread庫,在windows下是封裝了windows創建線程的接口,目的是為了保證語言的跨平臺、可移植性,所以語言的跨平臺或者可移植性一般都是大力出奇跡,所有平臺全部干一份,然后條件編譯形成庫(所有的熱門偏后端的語言基本多線程都這樣封裝的)
3.linux線程控制的接口
前面我們所提及的pthread庫其實叫做POSIX線程庫
? 與線程有關的函數構成了?個完整的系列,絕?多數函數的名字都是以“pthread_”打頭的
? 要使?這些函數庫,要通過引?頭? <pthread.h>
? 鏈接這些線程函數庫時要使?編譯器命令的“-lpthread”選項
-
創建線程函數pthread_create(我們在上面也談過的)
[^] ?pthread_t類型其實是一個無符號長整型?
在我們的新線程被創建出來之后,主線程和新線程誰先運行是不確定的,這一點在我們的fork創建子進程之后的父子進程之間的誰先運行的時候也是一樣的
這里其實我們的參數3屬于是回調函數的范疇了,說到回調函數,這里就不得不講一下了
關于回調函數:
1. 回調函數的角色
pthread_create
是創建線程的系統調用,需要一個 “線程執行邏輯”,但它沒辦法直接把邏輯寫死在函數里(要支持不同業務場景)。所以設計成讓調用者傳入一個函數指針,這個被傳入的函數(routine
)就是 “回調函數”—— 由pthread_create
“回調” 執行,實現線程的自定義邏輯。2. 代碼里的關鍵關聯
pthread_create(&tid, nullptr, routine, (void *)"thread-1");
routine
的函數簽名要求:必須符合pthread約定的線程函數原型void* ()(void)即:
返回值是
void*
(可用來給主線程返回數據)參數是
void*
(能兼容任意類型的入參,比如這里傳字符串"thread-1"
)調用時機:
pthread_create
成功創建線程后,新線程會自動執行routine
函數,把(void *)"thread-1"
作為參數傳入。解耦思想:
pthread_create
只負責 “線程創建 + 觸發回調”,具體線程要做什么(routine
里的邏輯)交給調用者實現,靈活適配不同需求。總結:回調函數是一種 “反向調用” 設計,
pthread_create
預先留好 “函數指針的坑”,你填自己的routine
邏輯,讓線程按你的邏輯跑。核心是解耦框架(pthread
)和業務邏輯(你要線程做的事),讓代碼更靈活。
線程創建好之后,新線程要被主線程等待,不然就會產生類似僵尸進程的問題,導致內存泄漏
-
我們可以通過pthread_join函數來讓主線程進行等待
[^] ?參數1是傳新線程id,參數2則是獲取上面pthread_create參數3的返回值(不關心新線程執行的怎么樣,也就是不關心新線程執行的退出結果可以傳nullptr,需要獲取則要取地址傳入一個指針變量才能拿到返回值為void*的變量)?
新線程return
主線程接收
打印的結果就是123
我們可能會有一個疑惑,那就是在進程等待時會有異常相關的字段,為啥線程這里的join卻沒有呢?答:那是因為等待的目標線程如果異常了,整個進程都退出了,包括主線程,所以join異常是沒有意義的,壓根就看不到;join都是基于線程健康跑完的情況,不需要處理異常信號,異常信號是進程要處理的話題!!
如果我們獲取一下新線程的tid,會發現它壓根就不是我們查看的線程lwp
因為lwp是輕量級進程的概念,而我們在用戶上不要看到這個,因為封裝要封裝徹底(不然用戶本來只需要專注于線程就好了,這樣一搞豈不是還需要去了解輕量級進程嘛),我們這里獲取到的線程id就不會是lwp!!
-
我們使用pthread_self函數來獲取調用了這個函數的線程的id
我們通過這個函數來看看pthread_create返回的線程id是否與新線程通過pthread_self獲取到的自己的id相等,進而驗證pthread_create返回的線程id就是新線程的id
#include <iostream>
#include <pthread.h>
#include <thread>
#include <unistd.h>
using namespace std;
?
void FormatId(pthread_t tid)
{printf("新線程通過pthread_self獲取到的自己的id為: %ld\n", tid);
}
?
void *routine(void *args)
{string name = static_cast<const char *>(args);// 獲取該線程的id來驗證一下在主線程中pthread_create中取得的id是否一致pthread_t tid = pthread_self();FormatId(tid);int cnt = 5;while (cnt){cout << "我是一個新線程, 我的名字是: " << name << endl;cnt--;sleep(1);}return nullptr;
}
?
void showid(pthread_t id)
{printf("pthread_create返回的線程id為: %ld\n", id);
}
?
int main()
{pthread_t tid;int n = pthread_create(&tid, nullptr, routine, (void *)"thread-1");showid(tid);(void)n;
?// 主線程進行等待pthread_join(tid, nullptr);
?return 0;
}
通過結果可以看到pthread_create返回的線程id就是新線程的id!!
關于上述代碼的一些結論:
-
main函數結束,代表主線程結束,一般也代表著進程結束
-
新線程對應的入口函數運行結束,代表當前線程運行結束
-
給線程傳遞參數和返回值,可以是任意類型,不一定非得是內置類型,我們自定義類型對象也可以
線程終止問題:
-
線程的入口函數進行return就是線程終止(這種方式用的最多)
-
注意:線程不能用exit()終止,因為exit是終止進程的
-
線程要終止也可用pthread_exit()函數,可以終止調用這個函數的線程
[^]: return res等價于pthread_exit(res)
-
終止線程還可以使用pthread_cancel函數,一般都是由主線程來用這個函數來取消新線程,用此方式終止線程的退出結果是-1【PTHREAD_CANCELED】
問題:如果主線程不想再關心新線程,而是當新線程結束的時候,讓它自己進行釋放,此時我們要如何做呢?
解決方案:設置新線程為分離狀態
技術層面:線程默認是需要被等待的,狀態是joinable;如果不想讓主線程等待新線程,
想讓新線程結束之后,自己退出,設置為分離狀態(!joinable or detach)
理解層面:線程分離,可以是主線程分離新線程,也可以是新線程把自己分離
注意:分離的線程依舊在進程的地址空間中,進程的所有資源,被分離的線程依舊可以訪問,可以操作,只不過主線程不等待新線程了
分離操作:
-
可以使用pthread_detach函數進行分離
[^] ?新線程分離自己可用pthread_detach(pthread_self())?
如果線程被設置為分離狀態,不需要進行join等待,join會失敗
實現一個簡單的多線程代碼:
#include <iostream>
#include <vector>
#include <pthread.h>
#include <thread>
#include <string.h>
#include <unistd.h>
using namespace std;
?
// 創建多線程
?
const int num = 10;
?
void *routine(void *args)
{string name = static_cast<const char *>(args);delete args;int cnt = 5;while (cnt--){cout << "new線程名字: " << name << endl;sleep(1);}
?return nullptr;
}
?
int main()
{vector<pthread_t> tids;for (int i = 0; i < num; i++){pthread_t tid;// 我們采用下面這種id的做法是有問題不安全的// 因為傳的指向id的首地址,各線程看到的是同一個id緩沖區,那么最后線程打出來的id// 都會是最后一次循環時修改覆蓋掉前面內容的thread-9// char id[64];// 需要的是,每一次循環,都給對應的線程申請堆空間,這樣才能讓這一循環中創建的新線程// 獨享這塊堆空間的起始地址char *id = new char[64];snprintf(id, 64, "thread-%d", i); // 將后面的格式化輸出到id緩沖區中int n = pthread_create(&tid, nullptr, routine, id);if (n == 0){tids.push_back(tid);}else{continue;}}
?// 主線程才往下走for (int i = 0; i < num; i++){// 一個一個的等待int n = pthread_join(tids[i], nullptr);if (n == 0){cout << "等待新線程成功" << endl;}}
?return 0;
}
我們可以自主封裝一個線程接口類,具體可見:thread/Thread.hpp?
3.線程id及進程地址空間布局
線程的概念是在庫中維護的(linux所有的線程都在庫中),在庫內部就一定會存在多個被創建好的線程,庫當然要管理這樣線程,管理的方法也還是先描述,再組織
會有struct tcb這樣的結構體,當我們調用pthread_create時,這個pthread_create內部就會幫我們在系統當中申請對應的tcb,就如同我們的fopen調用時會在內部申請FILE對象
struct tcb
{//線程應該有的屬性,用戶需要的線程狀態線程id線程獨立的棧結構線程棧大小...(而像優先級、時間片、上下文這種用戶不需要的與調度有關的屬性是被寫到內核的lwp->pcb中)
}
[^] ?我們上面的tid其實就是線程在庫中對應管理塊(紅框)的起始虛擬地址,當線程return退出后,管理塊中的數據并沒有被釋放,所以得join,傳入起始地址用來釋放以及通過ret取到管理塊中保存的結果?
通過上圖中每個管理塊都有線程棧,我們可以知道,每個線程都必須有自己獨立的棧空間(在申請的管理塊當中),主線程則用的是地址空間中的棧,新線程用的是自己申請的管理塊中的棧,所以說每個線程都必須有自己獨立的棧結構
庫中創建好管理塊把一些數據給線程,直接等該線程執行對應方法就好了
linux 用戶級線程 :內核lwp = 1:1
在前面加上__thread的變量會分別在不同線程的線程局部存儲位置開辟一份,名字一樣,但是底層的虛擬地址不一樣了,就可以實現全局變量變成分別新線程的局部變量了
線程的局部存儲有什么用:有時我們需要全局變量,但又不想讓這個全局變量被其他線程看到時就可以在這個變量前面加上__thread
(但是線程局部存儲只能存儲內置類型和部分指針)
4.線程棧
獨立的上下文:有獨立的PCB(內核)+TCP(用戶層,pthread庫內部)
獨立的棧:每個線程都有自己的棧,要么是線程自己的,要么是庫中創建進程時mmap申請出來的
雖然 Linux 將線程和進程不加區分的統?到了 task_struct ,但是對待其地址空間的stack還是有些區別的。
? 對于 Linux 進程或者說主線程,簡單理解就是main函數的棧空間,在fork的時候,實際上就是復制了?親的stack 空間地址,然后寫時拷?(cow)以及動態增?。如果擴充超出該上限則棧溢出會報段錯誤(發送段錯誤信號給該進程)。進程棧是唯?可以訪問未映射??不?定會發?段錯誤?超出擴充上限才報。
? 然?對于主線程?成的?線程??,其 stack將不再是向下??的,?是事先固定下來的。線程棧?般是調?glibc/uclibc等的pthread 庫接? pthread_create創建的線程,在?件映射區(或稱之為共享區)。其中使?mmap 系統調?,這個可以從 glibc的nptl/allocatestack.c 中的 allocate_stack函數中看到:
mem = mmap (NULL, size, prot,MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK, -1, 0);此調?中的 size 參數的獲取很是復雜,你可以??傳?stack的??,也可以使?默認的,?般??就是默認的 8M 。這些都不重要,重要的是,這種stack不能動態增?,?旦?盡就沒了,這是和?成進程的fork不同的地?。在glibc中通過mmap得到了stack之后,底層將調? sys_clone系統調?:
因此,對于?線程的 stack ,它其實是在進程的地址空間中map出來的?塊內存區域,原則上是線程私有的,但是同?個進程的所有線程?成的時候,是會淺拷??成者的 task_struct的很多字段,如果愿意,其它線程也還是可以訪問到的,于是?定要注意