C++ : 線程庫
- 一、線程thread
- 1.1 thread類
- 1.1.1 thread對象構造函數
- 1.1.2 thread類的成員函數
- 1.1.3 線程函數的參數問題
- 1.2 this_thread 命名空間域
- 1.2.1 chrono
- 二、mutex互斥量庫
- 2.1 mutex的四種類型
- 2.1.1 mutex 互斥鎖
- 2.2.2 timed_mutex 時間鎖
- 2.2.3 recursive_muetx 遞歸鎖
- 2.2.4 recursive_timed_muetx 時間遞歸鎖
- 2.2 RAII的加鎖策略
- 2.2.1 lock_guard 作用域鎖
- 2.2.2 unique_lock 獨占鎖
- 三、condition_variable 條件變量
- 3.1 wait系列函數
- 3.2 notify系列函數
- 3.3 簡單示例
- 四、atomic 原子性操作庫
- 4.1 atomic使用
- 4.2 CAS
一、線程thread
操作線程需要頭文件,頭文件包含線程相關操作,內含兩個內容:
thread類
:操作線程的基本類this_thread
命名空間域:用于操作當前線程
1.1 thread類
1.1.1 thread對象構造函數
- 調用無參的構造函數
??thread提供了無參的構造函數,調用無參的構造函數創建出來的線程對象沒有關聯任何線程函數,即沒有啟動任何線程。
??由于thread提供了移動賦值函數,因此當后續需要讓該線程對象與線程函數關聯時,可以以帶參的方式創建一個匿名對象,然后調用移動賦值將該匿名對象關聯線程的狀態轉移給該線程對象。
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t1;//...t1 = thread(func, 10);t1.join();return 0;
}
使用場景:
??實現線程池的時候就是需要先創建一批線程,但一開始這些線程什么也不做,當有任務到來時再讓這些線程來處理這些任務。
- 調用帶參的構造函數
template <class Fn, class... Args>
explicit thread (Fn&& fn, Args&&... args);
參數說明:
- fn:可調用對象,比如函數指針、仿函數、lambda表達式、被包裝器包裝后的可調用對象等。
- args…:調用可調用對象fn時所需要的若干參數。
調用帶參的構造函數創建線程對象,能夠將線程對象與線程函數fn進行關聯。
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t2(func, 10);t2.join();return 0;
}
- 調用移動構造函數
void func(int n)
{for (int i = 0; i <= n; i++){cout << i << endl;}
}
int main()
{thread t3 = thread(func, 10); //右邊值對象t3.join();return 0;
}
1.1.2 thread類的成員函數
成員函數 | 功能描述 |
---|---|
join | 對該線程進行等待,在等待的線程返回之前,調用 join 函數的線程將會被阻塞 |
joinable | 判斷該線程是否已經執行完畢,如果是則返回 true,否則返回 false |
detach | 將該線程與創建線程進行分離,被分離后的線程不再需要創建線程調用 join 函數等待 |
get_id | 獲取該線程的 id |
swap | 將兩個線程對象關聯線程的狀態進行交換 |
joinable函數同樣也可以判斷線程是否有效,下面情況都是線程無效的情況:
- 采用無參構造函數構造的線程對象。(該線程對象沒有關聯任何線程)
- 線程對象的狀態已經轉移給其他線程對象。(已經將線程交給其他線程對象管理)
- 線程已經調用join或detach結束。(線程已經結束)
1.1.3 線程函數的參數問題
??線程函數的參數是以值拷貝方式拷貝到線程空間中的
,就算線程函數的參數為引用類型,在線程函數中修改后也不會影響到外部實參,因為其實際引用的是線程棧中的拷貝,而不是外部實參。
#include <thread>
void ThreadFunc1(int& x)
{x += 10;
}int main()
{int a = 10;// 在線程函數中對a修改,不會影響外部實參,因為:線程函數參數雖然是引用方式,但其實際引用的是線程棧中的拷貝thread t1(ThreadFunc1, a);t1.join();cout << a << endl;
}
??要通過線程函數的形參改變外部的實參,**下面有三種方式:
1. 借助std::ref函數
??線程函數的參數類型為引用類型時,如果要想線程函數形參引用的是外部傳入的實參,而不是線程棧空間中的拷貝,那么在傳入實參時需要借助ref函數保持對實參的引用。
#include <thread>
void ThreadFunc1(int& x)
{x += 10;
}int main()
{int a = 10;// 如果想要通過形參改變外部實參時,必須借助std::ref()函數thread t2(ThreadFunc1, std::ref(a));t2.join();cout << a << endl;
}
2. 地址的拷貝
??將線程函數的參數類型改為指針類型,將實參的地址傳入線程函數,此時在線程函數中可以通過修改該地址處的變量,進而影響到外部實參。
#include <thread>
void ThreadFunc2(int* x)
{*x += 10;
}int main()
{int a = 10;// 地址的拷貝thread t3(ThreadFunc2, &a);t3.join();cout << a << endl;return 0;
}
3. 借助lambda表達式
?? 將lambda表達式作為線程函數,利用lambda函數的捕捉列表,以引用的方式對外部實參進行捕捉,此時在lambda表達式中對形參的修改也能影響到外部實參。
#include <thread>int main()
{int a = 10;// 借助lambda表達式thread t3([&a]{a+=10;});t3.join();cout << a << endl;return 0;
}
1.2 this_thread 命名空間域
std::this_thread
是一個命名空間,用于訪問當前線程。它提供了一組函數來操作當前線程。
函數名 | 功能描述 |
---|---|
yield | 當前線程"放棄"執行,讓操作系統調度另一線程繼續執行 |
get_id | 返回當前線程的 ID |
sleep_until | 讓當前線程休眠到一個具體時間點 |
sleep_for | 讓當前線程休眠一個時間段 |
get_id
和yield
都可以直接執行,不用傳入參數。而后兩個函數與時間相關,要用到C++封裝的時間庫chrono
。
1.2.1 chrono
是一個頭文件,內包含chrono命名空間域,該域內部封裝了各種時間的相關操作。
std::chrono::system_clock
:系統時鐘,表示從 Unix 紀元開始的時間(1970 年 1 月 1 日 00:00:00 UTC)。std::chrono::steady_clock
:穩定時鐘,表示從程序啟動開始的時間。
這兩個時鐘都有一個now成員函數,返回當前的時間。但是system_clock會受到系統時鐘影響,如果用戶調整了系統時間,就有可能造成時間錯誤,而穩定時鐘不受系統時鐘影響。如下:
auto t1 = std::chrono::system_clock::now();
auto t2 = std::chrono::steady_clock::now();
這兩個函數都返回一個time_point類型,表示當前時間點。
duration
用于表示一個時間段,這個類的用法比較復雜,因此C++封裝了一些可以直接使用的類:
- std::chrono::nanoseconds (納秒)
std::chrono::microseconds (微秒)
std::chrono::milliseconds (毫秒)
std::chrono::seconds (秒)
std::chrono::minutes (分鐘)
std::chrono::hours (小時)
這些類都是typedef后的duration,如果想要表示一個時間段,直接傳數字即可:
auto dur1 = std::chrono::seconds(3); // 3秒
auto dur2 = std::chrono::minutes(5); // 5分鐘
sleep_until需要傳入一個時間點time_point,比如想要睡眠10秒,就可以用當前時間 + 10秒得到一個時間點,再用sleep_until完成睡眠:
auto t1 = std::chrono::steady_clock::now(); // 獲取當前時間
auto dur = std::chrono::seconds(10); // 獲取十秒時間段
auto t2 = t1 + dur; // 時間點 + 時間段 = 時間點,十秒后std::this_thread::sleep_until(t2); // 睡眠到 10 秒后
sleep_for需要傳入一個時間段duration,同樣的睡眠十秒:
auto dur = std::chrono::seconds(10); // 獲取十秒時間段
std::this_thread::sleep_for(dur); // 睡眠到 10 秒后
二、mutex互斥量庫
mutex的種類:
mutex
:互斥鎖recursive_muetx
:遞歸鎖timed_mutex
:時間鎖recursive_timed_muetx
:時間遞歸鎖
兩種基于RAII的加鎖策略:
lock_guard
:作用域鎖unique_lock
:獨占鎖
2.1 mutex的四種類型
2.1.1 mutex 互斥鎖
mutex鎖是C++11提供的最基本的互斥量,mutex對象之間不能進行拷貝,也不能進行移動。
成員函數如下:
成員函數 | 功能描述 |
---|---|
lock | 對互斥量進行加鎖 |
unlock | 對互斥量進行解鎖,釋放互斥量的所有權 |
try_lock | 對互斥量嘗試進行加鎖 |
線程函數調用lock
時,可能會發生以下三種情況:
- 如果該互斥量當前沒有被其他線程鎖住,則調用線程將該互斥量鎖住,直到調用unlock之前,該線程一致擁有該鎖。
- 如果該互斥量已經被其他線程鎖住,則當前的調用線程會被阻塞。
- 如果該互斥量被當前調用線程鎖住,則會產生死鎖(deadlock)。
線程調用try_lock
時,類似也可能會發生以下三種情況:
- 如果該互斥量當前沒有被其他線程鎖住,則調用線程將該互斥量鎖住,直到調用unlock之前,該線程一致擁有該鎖。
- 如果該互斥量已經被其他線程鎖住,則try_lock調用返回false,當前的調用線程不會被阻塞。
- 如果該互斥量被當前調用線程鎖住,則會產生死鎖(deadlock)。
簡單實例:
int num = 0;void test(int n, std::mutex& mtx)
{for (int i = 0; i < n; i++){mtx.lock();num++;mtx.unlock();}
}int main()
{std::mutex mtx;std::thread t1(test, 2000, std::ref(mtx));std::thread t2(test, 2000, std::ref(mtx));t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
2.2.2 timed_mutex 時間鎖
時間鎖就是限定每次申請鎖的時長,如果超過一定時間沒有申請到鎖,就返回。
成員函數:
成員函數 | 功能描述 |
---|---|
lock | 對互斥量進行加鎖 |
unlock | 對互斥量進行解鎖,釋放互斥量的所有權 |
try_lock | 對互斥量嘗試進行加鎖 |
try_lock_until | 如果到指定時間還沒申請到鎖就返回false,申請到鎖返回true |
try_lock_for | 如果一段時間內沒申請到鎖就返回false,申請到鎖返回true |
try_lock_for
:接受一個時間范圍,表示在這一段時間范圍之內線程如果沒有獲得鎖則被阻塞住,如果在此期間其他線程釋放了鎖,則該線程可以獲得對互斥量的鎖,如果超時(即在指定時間之內還是沒有獲得鎖),則返回false。try_lock_untill
:接受一個時間點作為參數,在指定時間點未到來之前線程如果沒有獲得鎖則被阻塞住,如果在此期間其他線程釋放了鎖,則該線程可以獲得對互斥量的鎖,如果超時(即在指定時間點到來時還是沒有獲得鎖),則返回false。
簡單示例:
int num = 0;void test(int n, std::timed_mutex& mtx)
{while (n){bool ret = mtx.try_lock_for(std::chrono::microseconds(1));if (ret){num++;n--;mtx.unlock();}else{std::cout << "加鎖超時" << std::endl;}}
}int main()
{std::timed_mutex mtx;std::thread t1(test, 2000, std::ref(mtx));std::thread t2(test, 2000, std::ref(mtx));t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
2.2.3 recursive_muetx 遞歸鎖
recursive_mutex叫做遞歸互斥鎖,該鎖專門用于遞歸函數中的加鎖操作。
- 如果在遞歸函數中使用mutex互斥鎖進行加鎖,那么在線程進行遞歸調用時,可能會重復申請已經申請到但自己還未釋放的鎖,進而導致死鎖問題。
- 而recursive_mutex允許同一個線程對互斥量多次上鎖(即遞歸上鎖),來獲得互斥量對象的多層所有權,但是釋放互斥量時需要調用與該鎖層次深度相同次數的unlock。
簡單實例:
int num = 0;void test(int n, std::recursive_mutex& mtx)
{if (n <= 0)return;mtx.lock();test(n - 1, mtx);mtx.unlock();
}int main()
{std::recursive_mutexmtx;std::thread t1(test, 2000, std::ref(mtx));t1.join();std::cout << "num = " << num << std::endl;return 0;
}
如果是使用mutex.lock()時,申請不到鎖,不論是誰占有這把鎖,都會陷入阻塞,直到鎖被釋放。recursive_mutex則會記錄是誰占有這把鎖,在recursive_mutex.lock()時,會檢查申請鎖的線程和占有鎖的線程是不是同一個,如果是同一個,則直接申請成功,因此可以避免遞歸死鎖。
2.2.4 recursive_timed_muetx 時間遞歸鎖
與時間和遞歸鎖效果相加。
2.2 RAII的加鎖策略
2.2.1 lock_guard 作用域鎖
lock_guard
是C++11中的一個模板類,其定義如下:
template <class Mutex>
class lock_guard;
C++是用的是RAII方法進行加鎖:
- 在需要加鎖的地方,用互斥鎖實例化一個lock_guard對象,在lock_guard的構造函數中會調用lock進行加鎖。
- 當lock_guard對象出作用域前會調用析構函數,在lock_guard的析構函數中會調用unlock自動解鎖
簡單示例:
#include<iostream>
#include<thread>
#include<mutex>
using namespace std;mutex mtx;
bool Stop()
{return false;
}void func()
{// 保護一段匿名的代碼區間{lock_guard<mutex> lg(mtx); // 調用構造函數構造if (!Stop())return; // 調用析構函數析構}}// 生命周期結束后調用析構函數析構
int main()
{func();return 0;
}
模擬實現lock_guard:
namespace JRH
{template<class Mutex>class lock_guard{public:lock_guard(Mutex& mtx):_mtx(mtx){_mtx.lock(); // 加鎖}~lock_guard(){_mtx.unlock();}// 防拷貝lock_guard(const Mutex&) = delete;lock_guard& operator=(const Mutex&) = delete;private:Mutex& _mtx;};
}
1.lock_guard類中包含一個鎖成員變量(引用類型),這個鎖就是每個lock_guard對象管理的互斥鎖。
2.調用lock_guard的構造函數時需要傳入一個被管理互斥鎖,用該互斥鎖來初始化鎖成員變量后,調用互斥鎖的lock函數進行加鎖。
3.lock_guard的析構函數中調用互斥鎖的unlock進行解鎖。
4.需要刪除lock_guard類的拷貝構造和拷貝賦值,因為lock_guard類中的鎖成員變量本身也是不支持拷貝的。(防拷貝)
2.2.2 unique_lock 獨占鎖
lock_guard的可操作性很低,只有構造和析構兩個函數,也就是只有自動釋放鎖的能力。而unique_lock功能更加豐富,而且可以自由操作鎖。
unique_lock在構造時,可以傳入一把鎖,在構造的同時會對該鎖進行加鎖。在unique_lock析構時,判斷當前的鎖有沒有加鎖,如果加鎖了就先釋放鎖,后銷毀對象。
而在構造與析構之間,也就是整個unique_lock的生命周期,可以自由的加鎖解鎖:
lock
:加鎖
unlock
:解鎖
try_lock
:如果沒上鎖就加鎖,上鎖了就返回
try_lock_until
:如果到指定時間還沒申請到鎖就返回false,申請到鎖返回true
try_lock_for
:如果一段時間內沒申請到鎖就返回false,申請到鎖返回true
提供了以上五個接口,也就是說可以作用于前面的任何一款鎖。另外的unique_lcok還允許賦值operator=,調用賦值時,如果當前鎖沒有持有鎖,那么直接拷貝。如果當前鎖持有鎖,那么把鎖的所有權轉移給新的unique_lcok,自己不再持有鎖。
三、condition_variable 條件變量
談到鎖,自然也要談條件變量,這是線程同步的重要手段,C++將條件變量放在頭文件<condition_variable>
中。
條件變量庫有很多函數,但總體分為兩類函數,分別是wait系列函數和notify系列函數。
3.1 wait系列函數
wait函數提供了兩個不同版本的接口:
void wait (unique_lock<mutex>& lck);template <class Predicate>
void wait (unique_lock<mutex>& lck, Predicate pred);
其有兩個重載,第一個只有一個參數,也就是我剛剛提到的只要傳入一個unique_lock<mutex>
。第二個重載允許傳入第二個參數pred,這是一個可調用對象,用于作為條件變量的判斷值。
wait的第二個參數要求是一個可調用對象,返回值類型偽bool,作用如下:
- 返回true:表示條件成立,wait直接返回,不進入等待隊列
- 返回false:表示條件不成立,wait阻塞,進入等待隊列直到被喚醒
為什么調用wait系列函數時需要傳入一個互斥鎖
- 因為wait系列函數一般是在臨界區中調用的,為了讓當前線程調用wait阻塞時其他線程能夠獲取到鎖,因此調用wait系列函數時需要傳入一個互斥鎖,當線程被阻塞時這個互斥鎖會被自動解鎖,而當這個線程被喚醒時,又會自動獲得這個互斥鎖。
- 因此wait系列函數實際上有兩個功能,一個是讓線程在條件不滿足時進行阻塞等待,另一個是讓線程將對應的互斥鎖進行解鎖。
wait_for和wait_until函數
- wait_for:進入條件變量的等待隊列,一定時間后如果沒有被喚醒,則不再等待返回false
- wait_until:進入條件變量的等待隊列,到指定時間后如果沒有被喚醒,則不再等待返回false
- 這兩個與時間相關的等待,分別要傳入時間段duration和時間點time_point。至于為什么要傳入一把鎖,這屬于并發編程的知識,就不在博客中講解了。
3.2 notify系列函數
條件變量下可能會有多個線程在進行阻塞等待,這些線程會被放到一個等待隊列中進行排隊。
notify系列成員函數的作用就是喚醒等待的線程,包括notify_one和notify_all。
- notify_one:喚醒等待隊列中的首個線程,如果等待隊列為空則什么也不做。
- notify_all:喚醒等待隊列中的所有線程,如果等待隊列為空則什么也不做。
3.3 簡單示例
下面我們通過 實現兩個線程交替打印1-100數 的例子來更好的認識條件變量
int n = 0;
bool flag = true;std::mutex mtx;
std::condition_variable cv; // 條件變量void func(bool run) // run用于標識是否輪到當前線程輸出
{while (n < 100){std::unique_lock<std::mutex> lock(mtx);while (flag != run) // 使用while代替if,防止偽喚醒cv.wait(lock); // 沒輪到當前線程,進入條件變量等待std::cout << n << std::endl;n++;flag = !flag;cv.notify_one();}
}int main()
{std::thread t1(func, true); // falg == true 輸出偶數std::thread t2(func, false); // falg == false 輸出奇數t1.join();t2.join();return 0;
}
while循壞防止偽喚醒。
四、atomic 原子性操作庫
在多線程情況下要加鎖,就是因為很多操作不是原子性的。但是有一些簡單的操作,比如num++
,每次都加鎖解鎖,性能必然會降低。因此C++又提供了原子庫,其實現了簡單操作的原子化,一些簡單的++,–等操作都實現了原子化,可以在不加鎖的情況下保證線程安全,需要頭文件<atomic>
。
4.1 atomic使用
在C++11中,程序員不需要對原子類型變量進行加鎖解鎖操作,線程能夠對原子類型變量互斥的
訪問。更為普遍的,程序員可以使用atomic類模板,定義出需要的任意原子類型。
atomic<類型> 變量名;
簡單示例:
std::atomic<int> num = 0;void test(int n)
{for (int i = 0; i < n; i++){num++;}
}int main()
{std::thread t1(test, 2000);std::thread t2(test, 2000);t1.join();t2.join();std::cout << "num = " << num << std::endl;return 0;
}
atomic類成員函數:
operator++
和operator--
,自增自減的操作是原子的。- fetch_* :
- fetch_add:原子性,增加指定的值
- fetch_sub:原子性,減少指定的值
- fetch_and:原子性,與指定值按位與
- fetch_or:原子性,與指定值按位或
- fetch_xor:原子性,與指定值按位異或
std::atomic<int> num = 3;
num.fetch_add(5);
例如fetch_add,用于實現對一個原子類型增加指定值,
以上代碼完成了3 + 5的計算,且過程是原子性的
- store :用于設定原子類型為指定值
std::atomic<int> num = 3;
num.store(100);
--num.store(100)相當于num = 100,但是過程是原子性的
-
load用于獲取原子類型當前的值,也是原子的。
-
operator T是隱式類型轉換,也就是從atomic轉化為T類型,此時就可以把原子類型當作一般類型來使用了,不過要注意的是,隱式轉換后就是一般類型,不再具有原子性。
4.2 CAS
CAS(Compare and Set)是一種原子操作,用于實現并發編程中的無鎖同步。它通過比較內存位置的當前值與預期值,如果相等則更新為新值,從而避免競態條件。CAS操作廣泛應用于多線程環境。
C++之所以可以實現變量的原子操作,是基于CAS的原子操作,這是一個硬件級別的操作,其涉及三個操作數:
- 內存位置
- 預期值
- 更新值
操作流程為:讀取內存位置的當前值,判斷是否與預期值相等,如果相等,將其變為更新值,如果不相等,返回當前值。
下面是一個CAS的偽代碼:
boolean CAS(內存R, 寄存器A, 寄存器B) {if (R == A) { // 比較內存位置R的當前值是否等于寄存器A中的預期值R = B; // 如果相等,則將內存位置R更新為寄存器B中的新值return true; // 返回操作成功}return false; // 否則返回操作失敗
}
- 參數說明:
內存R
:共享變量的內存位置。寄存器A
:預期值(expected value)。寄存器B
:新值(new value)。
- 工作流程:
- 首先,讀取內存位置RRR的當前值。
- 比較當前值是否等于預期值AAA。
- 如果相等,原子性地將RRR更新為BBB,并返回
true
。 - 如果不相等,說明RRR已被其他線程修改,操作失敗,返回
false
。
- 原子性保證:在實際硬件中,CAS操作由一條CPU指令(如x86的
CMPXCHG
)實現,確保整個比較和更新過程不可中斷。
CAS操作的優缺點
CAS操作在并發編程中高效,但也存在一些限制:
- 優點:避免線程阻塞,減少上下文切換開銷,適用于高并發場景(如計數器或鎖實現)。
- 缺點:
- ABA問題:如果RRR的值從AAA變為BBB后又變回AAA,CAS會誤以為值未變,可能導致邏輯錯誤。解決方法包括添加版本號(如Java的
AtomicStampedReference
)。 - 僅支持單個變量的原子操作,對多變量復合操作需額外機制(如循環CAS)。
- 在高競爭環境下,頻繁失敗會消耗CPU資源。
- ABA問題:如果RRR的值從AAA變為BBB后又變回AAA,CAS會誤以為值未變,可能導致邏輯錯誤。解決方法包括添加版本號(如Java的