目錄
一? 前言
二 線程互斥
??三? Mutex互斥量
1. 定義一個鎖(造鎖)?
2. 初始化鎖
3.?上鎖
4.?解鎖
5.?摧毀鎖
四?鎖的使用
五?鎖的宏初始化?
六 鎖的原理
1.如何看待鎖?
2. 如何理解加鎖和解鎖的本質?
七 c++封裝互斥鎖
八 可重入與線程安全?
1. 可重入與線程安全聯系
2.?可重入與線程安全區別
九 死鎖
1.死鎖產生的必要條件
2.死鎖的避免方法
一? 前言
我們在上一章節Linux: 線程控制-CSDN博客學習了什么是多線程,以及多線程的控制和其優點,多線程可以提高程序的并發性和運行效率。但是多線程控制也有一定缺點,例如有些多線程的程序運行結果是有一些問題的,如出現了輸出混亂、訪問共享資源混亂等特點。所以我們下面提出的這個概念是關于保護共享資源這方面的——線程互斥。
二 線程互斥
在正式認識線程互斥之前,我們先來介紹幾個概念:
- 臨界資源:多線程執行流共享的資源(且這個資源是被保護的)就叫做臨界資源
- 臨界區:每個線程內部,訪問臨界資源的代碼,就叫做臨界區
- 互斥:任何時刻,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,通常對臨界資源起保護作用。
- 原子性:不會被任何調度機制打斷的操作,該操作只有兩態,要么不做,要么做完。(原子性針對的是操作)。
接下來我們用一個測試來學習多線程訪問共享資源可能帶來的問題,以及如何解決。
🚀:系統調用接口大多都是用c接口,我們通過c/c++混編的方式對線程的創建以及等待進行封裝
//makefile/
mythread:mythread.cppg++ -o $@ $^ -std=c++11 -lpthread
.PHONY:clean
clean:rm -f mythread
#pragma once
#include <iostream>
#include <string>
#include <pthread.h>
#include <functional>
#include <cstdio>
#include <cassert>///Thread.hpp/頭文件///
class Thread; //對類進行聲明//上下文
class Context
{
public:Thread* this_;void* args_;
public:Context():this_(nullptr),args_(nullptr){}~Context(){}
};
//對一個線程進行封裝
class Thread
{
public:typedef std::function<void*(void*)> func_t;const int num =1024;
public://1.構造函數,完成對線程的創建Thread(func_t func, void* args, int number):func_(func),args_(args)//c++自帶類型func_,所以可以用拷貝構造func_(func){char buffer[num];//字符數組,char* buffersnprintf(buffer, sizeof(buffer), "thread->%d", number);name_=buffer;Context* ctx=new Context();ctx->this_=this;ctx->args_=args_;int n=pthread_create(&tid_,nullptr,start_routine,ctx);assert(n==0);(void)n;}static void* start_routine(void* args){//靜態方法不能調用成員變量//return func_(args);Context* ctx=static_cast<Context*>(args);void* ret=ctx->this_->run(ctx->args_);delete ctx;return ret;}//線程等待void join(){int n=pthread_join(tid_,nullptr);assert(n==0);(void)n;}void* run(void* args){return func_(args);}private:std::string name_;pthread_t tid_;func_t func_;//實現方法void* args_;
};
#include <iostream>
#include <unistd.h>
#include <memory>
#include "Thread.hpp"
//測試用例//
int tickets=10000;
void* getTickets(void* args)
{std::string username=static_cast<const char*>(args);while(true){ if(tickets >0){//usleep(1244);std::cout<< username <<"我正在搶票"<<tickets--<< std::endl;usleep(1244);}else{break;}}return nullptr;
}
int main()
{std::unique_ptr<Thread> thread1(new Thread(getTickets,(void*)"user1",1));std::unique_ptr<Thread> thread2(new Thread(getTickets,(void*)"user2",2));std::unique_ptr<Thread> thread3(new Thread(getTickets,(void*)"user3",3));std::unique_ptr<Thread> thread4(new Thread(getTickets,(void*)"user4",4));thread1->join();thread2->join();thread3->join();thread4->join();
}
測試結果
?🚌:接下來我們對代碼進行一定改動,再看一下運行結果
while(true){ if(tickets >0){usleep(1244);//現在我們在進行tickets--操作之前讓線程進行休眠//再來看看運行結果會和之前的一樣嗎?std::cout<< username <<"我正在搶票"<<tickets--<< std::endl;//usleep(1244);//之前的代碼是在這里進行了休眠,}else{break;}}
從結果來看,我們放票了10000張照片,而竟然搶到了-2張票,明顯不合理。接下來我們回答一下為什么會出現這種現象??
?代碼中凡是關于算數計算的問題,實際上都是交給CPU進行執行的,這里面包括了加減乘除、邏輯運算、邏輯判斷,最終都由CPU來解決的。對變量tickets進行--,看起來只有一條語句,但是匯編至少是三條語句,即cpu 會對 tickets--?的操作會分成三步來執行?
- 從內存讀取數據到cpu寄存器中
- 在寄存器中讓cpu進行對應的邏輯判斷和運算
- 將新的結果寫到內存中變量的位置
接下來我們用下面圖進行說明,為了方便,假設我們只有兩個線程。
?
上面就是該程序出錯的原因,其主要原因是在判斷 tickets>0 由于會調用其他線程,從而使得錯誤發生在 tickets-- 操作上,對票的數量修改產生混亂。?
?🚴造成這種結果的原因是什么呢?
- 我們對共享資源的訪問和修改都不是原子的(即沒有做完),這兩個操作都會存在中間態,即CPU在計算的過程中需要讀取、計算、返回等多個操作,一旦CPU執行某個線程處在某個中間狀態的時候暫停了,其他線程可能會“趁虛而入”。
- 存在多個線程同時訪問共享資源的情況。
??三? Mutex互斥量
?了解了程序出現問題的原因,下來我們就討論如何解決它:我們先從如何防止多個線程同時訪問共享資源開始
? ? ? ?代碼必須要有互斥行為:當一個線程訪問并執行共享資源的代碼時,其他線程不能進入
要想使線程具有互斥行為,我們要引出一個關鍵工具——鎖,通過給執行共享資源區上一把鎖,從而阻止其他線程進入,這種鎖被稱為互斥鎖,給予代碼互斥的效果。
?鎖的接口及其使用
pthread 庫為我們提供了 “定義一個鎖”、“初始化一個鎖? ?“上鎖”、“解鎖”、“銷毀一個鎖” 的接口:
1. 定義一個鎖(造鎖)?
pthread_mutex_t ?是一個類型,可以來定義一個互斥鎖。就像定義一個變量一樣使用它定義互斥鎖的時候,鎖名可以隨便設置。互斥鎖的類型?pthread_mutex_t?是一個聯合體。?
2. 初始化鎖
pthread_mutex_init( )?是pthread庫提供的一個初始化鎖的一個接口,第一個參數傳入的就是需要初始化的鎖的地址。?第二個參數需要傳入鎖初始化的屬性,在接下來的使用中暫時不考慮,使用默認屬性即傳入nullptr 。成功返回0,否則返回錯誤碼。?
3.?上鎖
pthread_mutex_lock() ,阻塞式上鎖,即 線程執行此接口,指定的鎖已經被鎖上了,那么線程就進入阻塞狀態,直到解鎖之后,此線程再上鎖。當上鎖成功,則返回0,否則返回一個錯誤碼。
4.?解鎖
pthread_mutex_unlock()?,作用是解鎖接口,一般用于出了執行共享資源區的時候。當解鎖成功,返回0,否則返回一個錯誤碼。
5.?摧毀鎖
pthread_mutex_destroy 是用來摧毀定義的鎖,參數需要傳入的是需要摧毀的鎖的指針。成功則返回0,否則返回錯誤碼。
四?鎖的使用
#include <iostream>
#include <unistd.h>
#include <memory>
#include <vector>
#include "Thread.hpp"class ThreadData
{
public:ThreadData(const std::string & threadname,pthread_mutex_t* mutex_p):threadname_(threadname), mutex_p_(mutex_p){}~ThreadData(){}
public:std::string threadname_;pthread_mutex_t* mutex_p_;//鎖的指針
};
int tickets=10000;
void* getTickets(void* args)
{//std::string username=static_cast<const char*>(args);//加鎖和解鎖是多個線程串行執行的,程序變慢了。ThreadData* td=static_cast<ThreadData*>(args);while(true){ //加鎖pthread_mutex_lock(td->mutex_p_);if(tickets >0){usleep(1244);std::cout<< td->threadname_ <<"我正在搶票"<<tickets<< std::endl;tickets--; pthread_mutex_unlock(td->mutex_p_);//解鎖// usleep(1244);}else{ pthread_mutex_unlock(td->mutex_p_);//解鎖break;}}return nullptr;
}int main()
{#define NUM 4pthread_mutex_t lock;//定義一個鎖pthread_mutex_init(&lock,nullptr);//初始化一個鎖//接下來我們如何把這個鎖以及一些參數傳遞給線程呢?我們創建一個類ThreadDatastd::vector<pthread_t> tids(NUM);for(int i=0;i<NUM;i++){char buffer[64];snprintf(buffer,sizeof buffer,"thread %d",i+1);ThreadData* td=new ThreadData(buffer,&lock);//用的同一把鎖pthread_create(&tids[i],nullptr,getTickets,td);}for( const auto &tid:tids){pthread_join(tid,nullptr);}pthread_mutex_destroy(&lock);//銷毀鎖return 0;}
測試結果?
可以看到同過加鎖的操作對共享資源的代碼進行加鎖保護之后,程序已經能正常的進行搶票了。但是我們又發現一個問題,那就是都是線程4進行搶票,這是為什么呢??
🚩:鎖只規定互斥訪問,沒有規定誰優先執行?,接下里我們通過修改一下代碼,來進行測試。
?通過usleep(1000),線程在訪問加鎖的資源之后,進行休眠即阻塞狀態,這個時候cpu會對其他線程進行隨機調度,從而實現了多個進程對保護的共享資源進行搶票的過程?。
while(true){ //加鎖pthread_mutex_lock(td->mutex_p_);if(tickets >0){usleep(1244);std::cout<< td->threadname_ <<"我正在搶票"<<tickets<< std::endl;tickets--; pthread_mutex_unlock(td->mutex_p_);//解鎖// usleep(1244);}else{ pthread_mutex_unlock(td->mutex_p_);//解鎖break;}//搶完票之后,我們設置一個任務。例如形成訂單usleep(1000);//形成一個訂單給用戶//通過usleep(1000),線程在訪問加鎖的資源之后,進行休眠即阻塞狀態,這個時候cpu會對其他線程進行隨機調度//從而實現了多個進程對保護的共享資源進行搶票的過程}
五?鎖的宏初始化?
在上面我們已經簡單學習了鎖的使用,關于鎖的初始化上面用到的是pthread庫提供的接口:pthread_mutex_init() ,但是在系統中還存在另一種初始化鎖的方法,還方法只針對全局鎖進行初始化,使用該宏初始化的鎖是不需要手動銷毀的,即不需要我們調用 pthread_mutex_destroy() 接口
下面演示該宏定義的全局鎖的使用:
int tickets=10000;
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//宏定義的全局鎖
void* getTickets(void* args)
{//加鎖和解鎖是多個線程串行執行的,程序變慢了。std::string username=static_cast<const char*>(args);//ThreadData* td=static_cast<ThreadData*>(args);while(true){ //加鎖pthread_mutex_lock(&lock);//加鎖直接取地址&lockif(tickets >0){usleep(1244);std::cout<< username<<"我正在搶票"<<tickets<< std::endl;tickets--; pthread_mutex_unlock(&lock);//解鎖// usleep(1244);}else{ pthread_mutex_unlock(&lock);//解鎖break;}//搶完票之后,我們設置一個任務。例如形成訂單usleep(1000);//形成一個訂單給用戶return nullptr;
}int main()
{//創建四個線程pthread_t t1, t2, t3, t4;pthread_create(&t1,nullptr,getTickets,(void*)"thread 1");pthread_create(&t2,nullptr,getTickets,(void*)"thread 2");pthread_create(&t3,nullptr,getTickets,(void*)"thread 3");pthread_create(&t4,nullptr,getTickets,(void*)"thread 4");pthread_join(t1,nullptr);pthread_join(t2,nullptr);pthread_join(t3,nullptr);pthread_join(t4,nullptr);//不需要手動銷毀鎖了即pthread_mutex_destroy(&lock,nullptr) }
測試結果:
接下來我們對幾個概念進行再次說明一下:
- 臨界資源:多線程執行流共享的資源(且這個資源是被保護的)就叫做臨界資源,例如上文代碼的共享資源tickets通過加鎖被保護,叫做臨界資源
- 臨界區:每個線程內部,訪問臨界資源的代碼,就叫做臨界區,例如上文加鎖和解鎖的中間部分即對tickets進行--的代碼區叫做臨界區
六 鎖的原理
前邊說了這么多有關于鎖的介紹,那么我們該如何看待鎖呢?
1.如何看待鎖?
- ?a.? 多個線程都能利用鎖,即:鎖本身就是一個共享資源,既然是共享資源,那么共享資源就要被保護?鎖的安全誰來保護呢
- ?b.? 鎖是共享資源需要被保護,那么加鎖這個操作就是原子性的(即要么加鎖成功,要么加鎖不成功)
- ?c. 加鎖如果申請成功,就繼續向后執行,如果申請暫時沒有成功,執行流會進行阻塞。
- d. 誰持有鎖,誰進入臨界區。
如果線程1,申請成功,進入臨界資源,正在訪問臨界資源期間,其他線程只能阻塞等待。
如果線程1,申請成功,進入臨界資源,正在訪問臨界資源期間,那么線程1可以被cpu進行切換嗎?答案是可以的,但是當持有鎖的線程被切走的時候,即使自己被切走了,其他線程
依然無法繼續申鎖成功,也便無法繼續向后執行,直到線程1釋放了鎖,其他線程才能申請鎖成功,繼續往后執行。
2. 如何理解加鎖和解鎖的本質?
- 經過上面的例子,大家已經意識到單純的 i++ 或者 ++i 都不是原子的,有可能會有數據一致性問題
- 為了實現互斥鎖操作,大多數體系結構都提供了swap或exchange指令,該指令的作用是把寄存器和內存單元的數據相交換,由于只有一條指令,所以保證了原子性,
針對上面的偽代碼我們對加鎖和解鎖進行分析?
首先, al 表示寄存器, mutex 則表示在內存中的鎖?? (mutex互斥量可以理解為就是一個鎖)
movb $0, %al, 把 0 存入 al 寄存器中
xchgb %al, mutex, 交換 al寄存器 和 內存中mutex 的數據
if(al > 0) { return 0; }, 如果 al 寄存器中的數據 大于 0, 則 申請鎖成功, 返回 0. 否則, 就阻塞等待.
整個上鎖函數執行的語句可以看作這幾個過程.? 其中, xchgb %al, mutex 操作 是實際上鎖的操作.
我們用圖來描述, 如果線程1 在執行上鎖的操作。
🚲如果沒有上鎖時, 鎖的值是1.那么 執行 xchgb %al, mutex 將 al 中的0 與 mutex 的值交換,
此時 al中的值變為1,這個時候其實以及申鎖成功了,如果線程沒有被cpu切走,那么
if(al>0)滿足,線程就執行后續語句。
al中的值變為1,這個時候表示申鎖成功了,但是線程還沒往下執行就被cpu切走了,那么后面的線程也不可能申鎖成功執行后續代碼,這是因為cpu中寄存器對線程進行切換的時候,會把寄存器中關于線程的上下文切走。所以當下一個線程來的時候,寄存器會把新線程的al=0與內存中的mutex=0交換,al的值還是0,不會繼續執行,進入阻塞狀態,所以此時cpu又會對
上一個線程調度,cpu對線程加載的時候,會把線程上下文重新加載過來,即al=1,所以執行后續代碼,當執行完相應的臨界區的時候,寄存器再將al=1 與mutex交換,這就是解鎖過程,此時mutex=1,后面的線程才有可能申請鎖成功,上面的分析說明了加鎖和解鎖是個二原性行為,保證了共享資源不會被多個線程同時執行,即只能串行運行。
七 c++封裝互斥鎖
系統調用接口大多采樣c接口,c語言是面向過程的,而c++面向對象的,為了更好使用加鎖解鎖。我們使用c++對互斥鎖進行封裝。
//Mutex.hpp/
#pragma once
#include <iostream>
#include <pthread.h>//對鎖進行封裝,類似undersort_map一樣先定義一個結點
//結點成員包含鎖,以及加鎖解鎖,然后在定義了一個類
//類中成員變量是結點,然會類的加鎖解鎖分別調用結點的成員函數
class Mutex
{
public:Mutex(pthread_mutex_t* lock_p=nullptr):lock_p_(lock_p){}void lock(){if(lock_p_) pthread_mutex_lock(lock_p_);}void unlock(){if(lock_p_) pthread_mutex_unlock(lock_p_);}~Mutex(){}
private:pthread_mutex_t* lock_p_;
};//這才是我們最終想要的封裝
class LockGuard
{
public:LockGuard(pthread_mutex_t* mutex):mutex_(mutex){mutex_.lock();//在構造函數中進行加鎖}~LockGuard(){mutex_.unlock();//在析構函數中進行解鎖}
private:Mutex mutex_;
};
然后我們對測試代碼做一下改動
int tickets=10000;
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//定義一個鎖
void* getTickets(void* args)
{//加鎖和解鎖是多個線程串行執行的,程序變慢了。std::string username=static_cast<const char*>(args);while(true){ {//加了這個{}是相當于加了個作用域,當出了{}解鎖成功,后面的usleep()代碼沒有加鎖LockGuard lockguard(&lock);//構造并且加鎖,處理作用域自帶析構調用解鎖函數if(tickets >0){usleep(1244);std::cout<< username<<"我正在搶票"<<tickets<< std::endl;tickets--; }else{ break;}}//搶完票之后,我們設置一個任務。例如形成訂單usleep(1000);//形成一個訂單給用戶}return nullptr;
}
八 可重入與線程安全?
- ?線程安全:多線程并發運行同一段代碼時,并不會影響到整個進程的運行結果,就成為線程安全
- 可重入:同一個函數被不同執行流調用, 在一個執行流執行沒結束時, 有其他執行流再次執行此函數, 這個現象叫 重入
1. 可重入與線程安全聯系
- 函數是可重入的,那就是線程安全的
- 函數是不可重入的,那就不能由多個線程使用,有可能引發線程安全問題
2.?可重入與線程安全區別
- 可重入函數是線程安全函數的一種
- 線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
九 死鎖
在多把鎖的場景下,我們持有自己的鎖不釋放,還要對方的鎖,對方也是如此,此時就容易造成死鎖。自己同時申請多把鎖也可能造成死鎖。
我們用一個例子進行說明
pthread_mutex_t lock=PTHREAD_MUTEX_INITIALIZER;//定義一個鎖
void* getTickets(void* args)
{//加鎖和解鎖是多個線程串行執行的,程序變慢了。std::string username=static_cast<const char*>(args);//ThreadData* td=static_cast<ThreadData*>(args);while(true){ // //加鎖
//**********************************************************************pthread_mutex_lock(&lock);//在這里我們申請了兩把鎖pthread_mutex_lock(&lock);//在這里我們申請了兩把鎖
//**********************************************************************if(tickets >0){usleep(1244);std::cout<< username<<"我正在搶票"<<tickets<< std::endl;tickets--; pthread_mutex_unlock(&lock);//解鎖// usleep(1244);}else{ pthread_mutex_unlock(&lock);//解鎖break;}//搶完票之后,我們設置一個任務。例如形成訂單usleep(1000);//形成一個訂單給用戶//通過usleep(1000),線程在訪問加鎖的資源之后,進行休眠即阻塞狀態,這個時候cpu會對其他線 程進行隨機調度//從而實現了多個進程對保護的共享資源進行搶票的過程}return nullptr;
}
運行結果
🚢:為什么會造成進程卡住的情況呢?首先前面我們說明了鎖的原理。?當我們申請了一把鎖的時候?pthread_mutex_lock(&lock); 寄存器al 變成1,內存中mutex=0.此時al=1, 滿足條件,執行后續語句,然后下一個語句又是申請一把鎖??pthread_mutex_lock(&lock),前面的鎖沒有釋放,那么后面的?pthread_mutex_lock(&lock)就會阻塞等待,當cpu切換線程執行其他線程也是會遇到這種情況,那么整個多線程就一直處于阻塞狀態,從而不會執行后續cout,和tickets--等語句。導致的結果就是線程一直阻塞,顯示器上什么也不顯示。
1.死鎖產生的必要條件
- 互斥條件:: 一個資源每次只能被一個執行流使用
- 請求與保持條件: 一個執行流因請求資源(索要鎖時)而阻塞時,對已獲得的資源(鎖)保持不放(鎖不釋放)
- 不剝奪條件: 一個執行流已獲得的鎖資源,在末使用完之前,不能強行剝奪
- 循環等待條件: 若干執行流之間形成一種頭尾相接的循環等待資源的關系(我向你要鎖你向我要鎖)
2.死鎖的避免方法
最直接有效的避免方法是不使用鎖. 雖然鎖可以解決一些多線程的問題, 但是可能會造成死鎖。
如果非要使用鎖, 那就得考慮避免死鎖,破壞死鎖的四個必要條件。