提示:文章寫完后,目錄可以自動生成,如何生成可參考右邊的幫助文檔
目錄
文章目錄
前言
C++多線程的用法
對原生線程進行一次封裝
理解pthread線程
Linux線程互斥
進程線程間的互斥相關背景概念
互斥量mutex
操作共享變量會有問題的售票系統代碼
互斥量的接口
初始化互斥量
銷毀互斥量
互斥量加鎖和解鎖
改進上面的售票系統:
方法一:定義一個靜態或全局的鎖變量gmutex
方法二:定義一個局部的鎖
方法三:?臨時對象, RAII風格的加鎖和解鎖(構造加鎖,析構解鎖)
互斥量實現原理探究
可重入VS線程安全
概念
常見的線程不安全的情況
常見的線程安全的情況
常見不可重入的情況
常見可重入的情況
可重入與線程安全聯系
可重入與線程安全區別
總結
前言
世上有兩種耀眼的光芒,一種是正在升起的太陽,一種是正在努力學習編程的你!一個愛學編程的人。各位看官,我衷心的希望這篇博客能對你們有所幫助,同時也希望各位看官能對我的文章給與點評,希望我們能夠攜手共同促進進步,在編程的道路上越走越遠!
提示:以下是本篇文章正文內容,下面案例可供參考
C++多線程的用法
#include <thread> // C++多線程所對應的頭文件
#include <unistd.h>void threadrun(int num)
{while(num){std::cout << "I am a thread, num: " << num << std::endl;sleep(1);}
}
int main()
{std::thread t1(threadrun, 10);std::thread t2(threadrun, 10);std::thread t3(threadrun, 10);std::thread t4(threadrun, 10);std::thread t5(threadrun, 10);while(true){std::cout << "I am a main thread "<< std::endl;sleep(1);}t1.join();t2.join();t3.join();t4.join();t5.join();return 0;
}
對原生線程進行一次封裝
C++11的多線程,是對原生線程的封裝,所以在編譯時,要鏈接上 -lpthread原生線程庫。
為什么要封裝呢?
- 語言的跨平臺性。在Linux當中,我們所使用的C++11的多線程,用的是Linux的pthread庫;如果是在Windows當中,用的是Windows的原始對應的系統調用創建線程的接口,C++在給Linux和Windows當中提供的標準庫是不一樣的,C++給我們提供的標準庫編譯出來,在Windows中是Windows版本的,在Linux中是Linux版本的,所以對應的庫不一樣,但是代碼是一樣的。
Windows當中還要不要包含pthread庫呢?
- 不需要。語言具有跨平臺性。
其它語言呢?
- 大部分的語言要在Linux下跑多線程,必須要用原生線程庫,因為pthread庫是Linux提供多線程的底層唯一方式。
thread.hpp
#ifndef __THREAD_HPP__
#define __THREAD_HPP__#include <iostream>
#include <string>
#include <unistd.h>
#include <functional>
#include <pthread.h>// C++線程的頭文件namespace ThreadModule
{// using就相當于typedef,using定義了一個新類型std::function<void(T&)>,是一個新語法template<typename T>using func_t = std::function<void(T)>;// typedef std::function<void(const T&)> func_t;template<typename T>class Thread{public:void Excute(){_func(_data);}public:Thread(func_t<T> func, T data, const std::string& name = "none-name"): _func(func), _data(data), _threadname(name), _stop(true){}// 方法:static修飾的函數中的參數是沒有this指針的// 因為沒有this指針,所以該函數里面也無法調用該類的成員對象了static void* threadroutine(void* args) // 類成員函數,形參是有this指針的!!{Thread<T>* self = static_cast<Thread<T> *>(args);self->Excute();return nullptr;}bool Start(){// pthread_create()函數中的參數3的函數指針,要求的參數類型是void*,// 而threadroutine()函數是類成員函數,有一個this指針,所以調不了該函數int n = pthread_create(&_tid, nullptr, threadroutine, this);// 把當前對象this傳threadroutine()if (!n){_stop = false;return true;}else{return false;}}void Detach(){if (!_stop){pthread_detach(_tid);}}void Join(){if (!_stop){pthread_join(_tid, nullptr);}}std::string name(){return _threadname;}void Stop(){_stop = true;}~Thread() {}private:pthread_t _tid;std::string _threadname;T _data; // 模板的參數類型T就直接是指針了func_t<T> _func;bool _stop;};
} #endif
testThread.cc
using namespace ThreadModule;void print(int &cnt)
{while (cnt){std::cout << "hello I am myself thread, cnt: " << cnt-- << std::endl;sleep(1);}
}const int num = 10;int main()
{std::vector<Thread<int> > threads;// 1. 創建一批線程for (int i = 0; i < num; i++){std::string name = "thread-" + std::to_string(i + 1);threads.emplace_back(print, 10, name);}// 2. 啟動 一批線程for (auto &thread : threads){thread.Start();}// 3. 等待一批線程for (auto &thread : threads){thread.Join();std::cout << "wait thread done, thread is: " << thread.name() << std::endl;}// Thread<int> t1(print, 10);// t1.Start();// std::cout << "name: " << t1.name() << std::endl;// t1.Join();return 0;
}
理解pthread線程
- 二進制代碼剛運行時,pthread_t tid;只有一個進程,等執行到pthread_create()代碼時,才創建出了新線程,從操作層面上:創建、終止、等待線程的接口都是在庫當中實現的,庫是將輕量級進程做了封裝,所以給上層用戶提供的就是庫當中的方法,所以把我們用的線程叫做用戶級線程。
- 線程庫首先要映射到當前進程的地址空間中(堆棧之間的共享區)!
- 線程的管理工作要由庫來進行管理!
那么庫要如何管理線程呢?
- 先描述,再組織!
- 庫里面要有描述線程的結構體,以及把所有的線程都組織在一起,線程的控制塊:struct_pthread,一般我們喜歡將線程的控制塊叫做struct_tcb,只不過Linux不提供struct_tcb,創建一個線程就為我們在庫當中維護一個控制塊結構,而每一個控制塊結構的起始地址,就叫做線程的tid。tid的本質就是一個堆棧之間共享區的線程庫中的控制塊結構的起始地址(虛擬地址)。
- 線程的整體結構:struct_pthread、線程局部存儲、線程棧。
- 動態庫是共享庫,多個進程,每一個進程都創建多個線程,每個進程的進程地址空間堆棧之間的共享區都是同一個動態庫。
- 全局變量在進程地址空間當中的已初始化數據區。
- 線程局部存儲:不能用來存儲stl的容器數據,只能用來存儲內置類型,因為它是一個C語言的庫,不認識其它容器的數據。
線程庫是不是磁盤當中的一個普通文件呢?
- 線程庫是一個動態庫,也是磁盤當中的一個文件。
執行流(task_struct)是如何找到線程的線程棧的?
man clone
輕量級進程是Linux當中線程實現的底層方案,但真正線程實現是在庫當中實現的。
Linux線程互斥
進程線程間的互斥相關背景概念
- 臨界資源:多線程執行流共享的資源就叫做臨界資源
- 臨界區:每個線程內部,訪問臨界資源的代碼,就叫做臨界區
- 互斥:任何時刻,互斥保證有且只有一個執行流進入臨界區,訪問臨界資源,通常對臨界資源起保護作用
- 原子性(后面討論如何實現):不會被任何調度機制打斷的操作,該操作只有兩態,要么完成,要么未完成
- 當共享資源做了保護,就叫做臨界資源。
互斥量mutex
- 大部分情況,線程使用的數據都是局部變量,變量的地址空間在線程棧空間內,這種情況,變量歸屬單個線程,其他線程無法獲得這種變量。
- 但有時候,很多變量都需要在線程間共享,這樣的變量稱為共享變量,可以通過數據的共享,完成線程之間的交互。
- 多個線程并發的操作共享變量,會帶來一些問題。
操作共享變量會有問題的售票系統代碼
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
int ticket = 100;
void* route(void* arg)
{char* id = (char*)arg;while (1) {if (ticket > 0) {usleep(1000);printf("%s sells ticket:%d\n", id, ticket);ticket--;}else {break;}}
}
int main(void)
{pthread_t t1, t2, t3, t4;pthread_create(&t1, NULL, route, "thread 1");pthread_create(&t2, NULL, route, "thread 2");pthread_create(&t3, NULL, route, "thread 3");pthread_create(&t4, NULL, route, "thread 4");pthread_join(t1, NULL);pthread_join(t2, NULL);pthread_join(t3, NULL);pthread_join(t4, NULL);
}
一次執行結果:
thread 4 sells ticket : 100
...
thread 4 sells ticket : 1
thread 2 sells ticket : 0
thread 1 sells ticket : -1
thread 3 sells ticket : -2
搶票的結構最終出現了負數,造成了數據不一致,為什么?
- 因為g_tickets是一個全局的變量,這個全局的變量是沒有被保護起來的,并且對全局變量_tickets的判斷不是原子的。
- 當_tickets == 1時,多個線程并發的判斷,讓很多線程都進入搶票邏輯。
- 全局變量g_tickets是在內存當中的,當線程1執行if語句時,要進行票數的判斷,判斷是邏輯運算,必須在CPU內部運行,此時內存中的票數數據拷貝到CPU的寄存器當中,執行到usleep語句時,線程1被切換了出去,因為寄存器只有一套,所以為了保存線程1的上下文數據,數據被線程1帶走了;
- 此時切換到線程2執行判斷邏輯,與線程1的情況一樣也被切換走了,線程3和4都是如此;
- 那么當線程1再次切換回來的時候,要重新在內存中讀取數據,打印并--操作;
- _tickets--(不是原子的)等價于_tickets = _tickets - 1;--操作數據改變,會影響原生內存中的數據,因為會寫回內存;
- 那么其它3個線程也都進入了搶票邏輯,所以會讀取內存中的數據,打印并--操作,所以會打印出了負數的情況。
-- 操作并不是原子操作,而是對應三條匯編指令:
- load :將共享變量ticket從內存加載到寄存器中
- update : 更新寄存器里面的值,執行-1操作
- store :將新值,從寄存器寫回共享變量ticket的內存地址
要解決以上問題,需要做到三點:
- 代碼必須要有互斥行為:當代碼進入臨界區執行時,不允許其他線程進入該臨界區。
- 如果多個線程同時要求執行臨界區的代碼,并且臨界區沒有線程在執行,那么只能允許一個線程進入該臨界區。
- 如果線程不在臨界區中執行,那么該線程不能阻止其他線程進入臨界區。
要做到這三點,本質上就是需要一把鎖。Linux上提供的這把鎖叫互斥量。
互斥量的接口
初始化互斥量
初始化互斥量有兩種方法:
- 方法1,如果你定義的鎖是靜態的或者是全局的:那么這個鎖可以不用init初始化和destroy銷毀;你可以直接定義一個鎖,并用PTHREAD_ MUTEX_ INITIALIZER宏對其進行初始化。
pthread_mutex_t mutex = PTHREAD_MUTEX_INITIALIZER
- 方法2,動態分配:
如果這把鎖是一個局部的:建議init初始化和destroy銷毀
int pthread_mutex_init(pthread_mutex_t *restrict mutex, const pthread_mutexattr_t *restrict
attr);
參數:
mutex:要初始化的互斥量
attr:NULL
嘗試的去申請鎖:
int pthread_mutex_trylock(pthread_mutex_t *mutex);
嘗試的去申請鎖,跟申請鎖成功和函數調用失敗是一樣的,但是申請鎖失敗了,不會阻塞,會立馬出錯返回。
銷毀互斥量
銷毀互斥量需要注意:
- 使用 PTHREAD_ MUTEX_ INITIALIZER初始化的互斥量不需要銷毀
- 不要銷毀一個已經加鎖的互斥量
- 已經銷毀的互斥量,要確保后面不會有線程再嘗試加鎖
int pthread_mutex_destroy(pthread_mutex_t *mutex);
互斥量加鎖和解鎖
int pthread_mutex_lock(pthread_mutex_t *mutex);
int pthread_mutex_unlock(pthread_mutex_t *mutex);
返回值:成功返回0,失敗返回錯誤號
調用 int pthread_mutex_lock(pthread_mutex_t *mutex)?時,可能會遇到以下情況:
- 申請成功:函數就會返回,允許你繼續向后運行;
- 申請鎖失敗:函數就會阻塞,不允許你繼續向后運行;
- 函數調用失敗:出錯返回,比如:申請鎖的對象已經被釋放了
改進上面的售票系統:
方法一:定義一個靜態或全局的鎖變量gmutex
// 搶票邏輯
#include <iostream>
#include <vector>
#include <mutex> // C++11里面鎖的頭文件
#include "Thread.hpp"using namespace ThreadModule;// 數據不一致
int g_tickets = 10000; // 共享資源,沒有保護的
// 線程的數據類型
class ThreadData
{
public:ThreadData(int& tickets, const std::string& name): _tickets(tickets), _name(name), _total(0)){}~ThreadData(){}public:int& _tickets; // 所有的線程,最后都會引用同一個全局的g_ticketsstd::string _name;int _total;
};// 方法一:定義一個靜態或全局的鎖變量gmutex
// gmutex中的g表示globle的意思
pthread_mutex_t gmutex = PTHREAD_MUTEX_INITIALIZER;// 定義一個鎖// 線程執行的方法void route(ThreadData *td){// 加鎖while (true){// 訪問臨界資源的代碼,叫做臨界區!// 我們加鎖,本質就是把多線程的并行執行變為串行執行 --- 加鎖的力度要越細越好pthread_mutex_lock(&gmutex); // 加鎖 : 競爭鎖是自由競爭的,競爭鎖的能力太強的線程,會導致其他線程搶不到鎖 --- 造成了其他線程的饑餓問題!if (td->_tickets > 0) {// 模擬一次搶票的邏輯usleep(1000);printf("%s running, get tickets: %d\n", td->_name.c_str(), td->_tickets);td->_tickets--; pthread_mutex_unlock(&gmutex); // 解鎖 法一:td->_total++;}else{pthread_mutex_unlock(&gmutex); // 解鎖 法一:break;}}// 解鎖}const int num = 4;
int main()
{std::cout << "main: &tickets: " << &g_tickets << std::endl;// std::mutex mutex;// C++11的做法,不用初始化,因為它有構造函數std::vector<Thread<ThreadData*>> threads;std::vector<ThreadData*> datas;// 每個線程搶了多少張票// 1. 創建一批線程for (int i = 0; i < num; i++){std::string name = "thread-" + std::to_string(i + 1);ThreadData* td = new ThreadData(g_tickets, name);threads.emplace_back(route, td, name);datas.emplace_back(td);}// 2. 啟動 一批線程for (auto& thread : threads){thread.Start();}// 3. 等待一批線程for (auto& thread : threads){thread.Join();std::cout << "wait thread done, thread is: " << thread.name() << std::endl;}sleep(1);// 4. 輸出統計數據for (auto data : datas){std::cout << data->_name << " : " << data->_total << std::endl;delete data;}// pthread_mutex_destroy(&mutex);return 0;
}
方法二:定義一個局部的鎖
// 搶票邏輯
#include <iostream>
#include <vector>
#include <mutex> // C++11里面鎖的頭文件
#include "Thread.hpp"using namespace ThreadModule;// 數據不一致
int g_tickets = 10000; // 共享資源,沒有保護的
// 線程的數據類型
class ThreadData
{
public:ThreadData(int& tickets, const std::string& name, pthread_mutex_t &mutex): _tickets(tickets), _name(name), _total(0), _mutex(mutex){}~ThreadData(){}public:int& _tickets; // 所有的線程,最后都會引用同一個全局的g_ticketsstd::string _name;int _total;pthread_mutex_t& _mutex;
};// 線程執行的方法void route(ThreadData *td){// 加鎖while (true){ // 方法二:加鎖pthread_mutex_lock(&td->_mutex);if (td->_tickets > 0) {// 模擬一次搶票的邏輯usleep(1000);printf("%s running, get tickets: %d\n", td->_name.c_str(), td->_tickets); td->_tickets--; pthread_mutex_unlock(&td->_mutex); // 解鎖 法二:td->_total++;}else{pthread_mutex_unlock(&td->_mutex); // 解鎖 法二:break;}}// 解鎖}const int num = 4;
int main()
{// 方法二:定義一個局部的鎖pthread_mutex_t mutex;pthread_mutex_init(&mutex, nullptr);// 鎖的屬性設為nullptr// std::mutex mutex;// C++11的做法,不用初始化,因為它有構造函數std::vector<Thread<ThreadData*>> threads;std::vector<ThreadData*> datas;// 每個線程搶了多少張票// 1. 創建一批線程for (int i = 0; i < num; i++){std::string name = "thread-" + std::to_string(i + 1);ThreadData* td = new ThreadData(g_tickets, name, mutex);// 把局部的鎖,以參數的形式傳遞到線程內部,而不是以全局的形式threads.emplace_back(route, td, name);datas.emplace_back(td);}// 2. 啟動 一批線程for (auto& thread : threads){thread.Start();}// 3. 等待一批線程for (auto& thread : threads){thread.Join();std::cout << "wait thread done, thread is: " << thread.name() << std::endl;}sleep(1);// 4. 輸出統計數據for (auto data : datas){std::cout << data->_name << " : " << data->_total << std::endl;delete data;}pthread_mutex_destroy(&mutex);return 0;
}
方法三:?臨時對象, RAII風格的加鎖和解鎖(構造加鎖,析構解鎖)
LockGuard.hpp
#ifndef __LOCK_GUARD_HPP__
#define __LOCK_GUARD_HPP__#include <iostream>
#include <pthread.h>class LockGuard
{
public:LockGuard(pthread_mutex_t* mutex) :_mutex(mutex){pthread_mutex_lock(_mutex); // 構造加鎖}~LockGuard(){pthread_mutex_unlock(_mutex);}
private:pthread_mutex_t* _mutex;
};#endif
void route(ThreadData* td)
{while (true){{ // 擔心就用這個LockGuard guard(&td->_mutex); // 臨時對象, RAII風格的加鎖和解鎖(構造加鎖,析構解鎖)// td->_mutex.lock();C++11的做法// std::lock_guard<std::mutex> lock(td->_mutex);C++11中也封裝了lock_guardif (td->_tickets > 0) // 1{usleep(1000);printf("%s running, get tickets: %d\n", td->_name.c_str(), td->_tickets); // 2td->_tickets--; // 3td->_total++;// td->_mutex.unlock();C++11}else{// td->_mutex.unlock();break;}}}
}
什么是原子的?
- 一條語句將來被匯編之后,只有一條匯編。
互斥量實現原理探究
- 經過上面的例子,大家已經意識到單純的 i++ 或者 ++i 都不是原子的,有可能會有數據一致性問題
- 為了實現互斥鎖操作,大多數體系結構都提供了swap或exchange指令,該指令的作用是把寄存器和內存單元的數據相交換,由于只有一條指令,保證了原子性,即使是多處理器平臺,訪問內存的總線周期也有先后,一 個處理器上的交換指令執行時另一個處理器的交換指令只能等待總線周期。 現在我們把lock和unlock的偽代碼改一下
互斥的底層實現:
- 假設CPU中有一個寄存器%al,鎖相當于內存當中的整型變量;
- 假設剛開始把鎖初始化為1,線程1此時要申請鎖,它把0放入%al的寄存器中,再把寄存器中的值和鎖變量中的值進行交換,就完成了加鎖,若線程1加鎖成功,那么線程1就執行它的代碼;
- 假設線程1在執行完第二條語句時,線程1被切換成線程2,線程1被切換走時,會把寄存器中的數據帶走;
- 線程2開始申請鎖,線程2調用pthread_mutex_lock()函數從0開始申請鎖,將0放入寄存器中,再與內存中的值做交換,因為都是0,所以申請鎖失敗,掛起等待,將數據帶走;
- 等線程1回來時,將之前帶走的數據恢復到寄存器中,加鎖成功;
- 成功了之后,還要解鎖,將mutex變量重新置為1。
- 寄存器內部的數據不屬于CPU,它屬于當前線程的硬件上下文
- 臨界區內部,正在訪問臨界區的線程,可以被OS切換調度,被切換出去的時候,把鎖也帶走了。申請鎖成功的線程1正在訪問臨界區,即使線程1被掛起了,其它任何線程都進不來臨界區,那么臨界區對于其它的線程來說就是原子的,該線程是安全的。
- 互斥是為了解決數據安全的問題;同步是為了解決資源被充分利用的問題。
- 線程被切換的時機是隨機的。
- 交換的本質:不是拷貝到寄存器,而是所有線程在爭鎖的時候,只有一個1。
- 交換的時候,只有一條匯編 --- 原子的。
- CPU寄存器硬件只有一套,但是CPU寄存器內部的數據是線程的硬件上下文。
- 數據在內存里,所有線程都能訪問,屬于共享的。但是如果轉移到CPU內部寄存器中,就屬于一個線程私有了。
- 互斥:任何時刻只允許一個線程進行訪問。
線程互斥:
- 保護并不是把臨界資源怎么樣,而是保護多個線程都會執行訪問臨界資源的代碼,我們要保護的是臨界區。
可重入VS線程安全
概念
- 線程安全:多個線程并發同一段代碼時,不會出現不同的結果。常見對全局變量或者靜態變量進行操作, 并且沒有鎖保護的情況下,會出現該問題。
- 重入:同一個函數被不同的執行流調用,當前一個流程還沒有執行完,就有其他的執行流再次進入,我們稱之為重入。一個函數在重入的情況下,運行結果不會出現任何不同或者任何問題,則該函數被稱為可重入函數,否則,是不可重入函數。
常見的線程不安全的情況
- 不保護共享變量的函數
- 函數狀態隨著被調用,狀態發生變化的函數
- 返回指向靜態變量指針的函數
- 調用線程不安全函數的函數
常見的線程安全的情況
- 每個線程對全局變量或者靜態變量只有讀取的權限,而沒有寫入的權限,一般來說這些線程是安全的
- 類或者接口對于線程來說都是原子操作
- 多個線程之間的切換不會導致該接口的執行結果存在二義性
常見不可重入的情況
- 調用了malloc/free函數,因為malloc函數是用全局鏈表來管理堆的
- 調用了標準I/O庫函數,標準I/O庫的很多實現都以不可重入的方式使用全局數據結構
- 可重入函數體內使用了靜態的數據結構
常見可重入的情況
- 不使用全局變量或靜態變量
- 不使用用malloc或者new開辟出的空間
- 不調用不可重入函數
- 不返回靜態或全局數據,所有數據都有函數的調用者提供
- 使用本地數據,或者通過制作全局數據的本地拷貝來保護全局數據
可重入與線程安全聯系
- 函數是可重入的,那就是線程安全的
- 函數是不可重入的,那就不能由多個線程使用,有可能引發線程安全問題
- 如果一個函數中有全局變量,那么這個函數既不是線程安全也不是可重入的
可重入與線程安全區別
- 可重入函數是線程安全函數的一種
- 線程安全不一定是可重入的,而可重入函數則一定是線程安全的。
- 如果將對臨界資源的訪問加上鎖,則這個函數是線程安全的,但如果這個重入函數若鎖還未釋放則會產生 死鎖,因此是不可重入的。
總結
好了,本篇博客到這里就結束了,如果有更好的觀點,請及時留言,我會認真觀看并學習。
不積硅步,無以至千里;不積小流,無以成江海。