std string与线程安全_C++标准库多线程简介Part1
Part1:線程與互斥量
本篇文章將簡單的介紹一下C++的標準線程庫,本篇內容十分基礎,如果你有C++多線程相關的使用經驗或者知識,就不必在這篇文章上浪費時間了...
如果你認為本篇文章對你有幫助,請點贊!!!
1.進程與線程
在介紹標準庫多線程之前,需要先介紹一下進程與線程的概念與它們之間的差別。
進程是被執行的應用程序(程序即指令(code)與數據(data)的集合)的實例(可以啟動多個相同的應用程序,它們是不同的進程),同時也是系統資源分配的最小單位(虛擬地址空間等資源,見下圖),一個進程中可以包含多個線程。
操作系統會為每個進程分配一定的虛擬地址空間,該虛擬地址空間由進程獨享線程則是CPU進行運算調度的最小單位。線程被包含在進程之中,是進程中的實際運作單位。一個進程中可以包含多個線程,這些線程可以同時執行不同的任務(例如一個線程監聽用戶輸入,一個線程執行IO任務,它們是同時進行互相獨立的)。同一個進程中的多個線程共享操作系統為該進程分配的系統資源(如虛擬地址空間,信號量等...),但同時多個線程又獨立的擁有各自的調用棧,寄存器環境,和線程本地存儲(thread-local storage)。
一個操作系統中可以有多個進程,這些進程可以異步(同時)或同步(順序)執行,操作系統為這些進程分配獨立的系統資源。而進程中又可以擁有多個線程(至少一個),這些線程共享進程的系統資源,線程又被稱為輕量級的進程。
2.并發與并行
并發與并行對于初學者來說是很難區分的兩種概念。
并發是指的是在一個重疊的時間段內,有多個任務(兩個以上的task)可以被啟動,執行或者完成,但是這并不意味著,這些任務必須在這一時間段內的某一時刻同時被運行。比如在一個擁有單核CPU上計算機上,我們可以同時運行瀏覽器和文檔編輯器程序,卻并不會感受到任何操作上的延遲。這是因為操作系統采用了時間片輪轉算法(Round-Robin,RR),操作系統中的每個進程被分配了一定的時間段(時間片),操作系統將CPU分配給某一進程讓其在處理器上執行一個時間片。當進程占用CPU的時間超過時間片的時間后,將由計時器發起中斷請求,隨后操作系統保存該進程的執行狀態并將其掛起,然后將CPU分配給另一個進程執行一個時間片。由于時間片劃分的很短,而且進程間的切換(進程間的切換也被稱為上下文切換Context switch)也很快,所以會給人一種好像多個進程在同時執行的錯覺。(在進行context switch的時候也會占用一定的時間,需要保存將被掛起的進程的執行狀態,還需要把將要執行的進程的指令與內存載入到緩存中,在這期間CPU無法執行其他指令,因而過多的context switch會降低CPU的效率。)
在單核CPU上同時運行進程A(紅色)與進程B(藍色),灰色為Context switch所占用的CPU時間并行是指在同一時刻有多個任務同時運行。比如在一個雙核的CPU上,在A核心上運行瀏覽器進程,而在B核心上運行文檔編輯器進程,在兩個核心上運行的進程相互獨立,同時運行(多進程并發)。又比如在一個游戲進程中,可以同時存在一個邏輯線程(處理游戲邏輯)和一個IO線程(處理IO任務),它們可以同時運行在兩個不同的CPU核心上(多線程并發)。(并發的概念是包含并行的,并行是多線程的一種形式,多線程是并發的一種形式。)
使用多進程并發時,進程的創建與銷毀速度都比較慢,而且進程間的通信也比較復雜(需要通過套接字,管道等..),但是操作系統會在進程間提供附加的保護機制,這可以我們更容易寫出并發安全的代碼。而使用多線程并發時,線程的創建與銷毀速度則要更快,由于同一進程中的所有線程共享虛擬地址空間,因此線程間的通信開銷要小得多,但由于缺少線程間的數據保護,可能會出現多個線程同時讀寫同一數據造成的數據不一致現象。
在C++11標準中引入了對于線程的支持,而本篇文章的主要內容就與多線程并發相關。
3.C++11中的線程
C++11中thread與thread id的定義如下:
//thread 定義 class thread {class id;// native_handle_type 是連接 thread 類和操作系統 SDK API 之間的橋梁。typedef implementation - dependent native_handle_type;// 構造與析構thread() noexcept;template<class F, class… Args> explicit thread(F&f, Args&&… args);~thread();thread(const thread&) = delete;thread(thread&&) noexcept;thread& operator=(const thread&) = delete;thread& operator=(thread&&) noexcept;//void swap(thread&) noexcept;bool joinable() const noexcept;void join();void detach();//獲取線程idid get_id() const noexcept;// 獲取物理線程數目static unsigned hardware_concurrency() noexcept;//獲取底層實現定義的線程句柄 native_handle_type native_handle();//thread id定義class id {id() noexcept;// 可以由==, < 兩個運算衍生出其它大小關系運算。bool operator==(thread::id x, thread::id y) noexcept;bool operator<(thread::id x, thread::id y) noexcept;// !=, <=, >=, >...template<class charT, class traits>basic_ostream<charT, traits>&operator<<(basic_ostream<charT, traits>&out, thread::id id);}; }首先std::thread類的對象是只能夠被移動(move,移動構造,移動賦值),而不能被拷貝(copy,拷貝構造,拷貝賦值)的。其次thread類存在一個無參的默認構造函數,與一個接受可調用對象與可調用對象參數的構造函數。thread類內也定義了一個id類,id類可以表示線程在操作系統內的唯一標志符,它重載了多個比較運算符還有輸出運算符。id類也可以表示線程運行狀態,它的默認值(thread::id(),構造函數)不表示任何執行中的線程。如果一個thread類的實例,其get_id方法返回的id與id類的默認值相等,則該線程實例處于一下狀態之一:
- 尚未指定運行的任務
- 線程運行完畢
- 線程已經被轉移 (move) 到另外一個線程類實例
- 線程已經被分離 (detached)
thread類中還定義了nativehandle方法,可以返回對應平臺的線程句柄(如linux中pthread的pthread_t),在我們需要使用一些原生線程支持而std::thread不支持的功能上,這個方法會比較有用(比如設置線程的優先級)。
thread類的hardware_concurrency靜態方法可以返回當前處理器所支持的最大并發線程數(比如我現在正在使用的e3-1230v3,hardware_concurrency的返回值為8)。
線程的移動操作只是改變了線程實例的id,線程的swap操作也是通過移動操作實現的。
3.1線程的管理
之前內容提到了一個進程中至少存在一個線程,這個線程被稱為主線程,我們可以在任意線程中創建線程類的實例。每個線程都需要一個入口函數,當入口函數返回時,線程就會退出,主線程的入口函數為main()。
a.線程的啟動
線程的創建十分簡單,我們只需創建一個線程類的實例,并為它傳入一個可調用對象,就可以啟動一個線程了:
void do_work() {std::cout << "work done" << std::endl; }void test() {std::thread worker(do_work);worker.detach(); }這里的可調用對象可以是lambda表達式,std::function,也可以是重載了調用運算符的類,或者成員函數或普通函數:
class Work { public:void operator()(){std::cout << "callable object" << std::endl;} };void test() {std::thread worker0([]() {std::cout << "lambda call" << std::endl;});worker0.detach();std::thread worker1(Work{});worker1.detach(); }也可以在線程的構造函數中傳入可調用對象的參數,此時線程構造函數的第一個參數為可調用對象,此后的參數為可調用對象的參數:
class Work { public:void operator()(int id){std::cout << "work id:" << id << std::endl;} };void test() {std::thread worker(Work{}, 0);worker.join(); }如果傳入的可調用對象是某個類的成員函數,則線程構造函數的第一個參數為該類型的成員函數指針,第二個參數為指向該類型的實例的指針,其后為成員函數的參數:
class Sampler { public:void sample(int random){std::cout << "sample with:" << random << std::endl;} };void test() {Sampler obj;std::thread worker(&Sampler::sample, &obj, 0);worker.join(); }在向線程中傳遞參數時需要注意的一點是:默認情況下會將傳遞的參數拷貝到線程的獨立內存中,即使傳入參數的類型為引用,但是可以使用std::ref將參數傳遞的方式更改為引用。
void test() {int work_id = 1;std::thread worker([](int &id) {std::cout << "do work:" << id << std::endl;}, std::ref(work_id));work_id = 2;worker.join(); }b.等待線程完成或分離線程
在啟動一個線程后,必須在線程相關聯的std::thread對象銷毀之前,決定以何種方式等待線程結束(等待線程執行結束(join)還是讓其自主運行(detach))。如果在std::thread對象銷毀前,還沒有作出決定那么在std::thread對象的析構函數中就會觸發std::terminate導致進程終止,如下所示:
void test() {{std::thread worker([]() {std::cout << "do work:" << std::endl;});//錯誤,未調用線程的join或者detach函數,會導致進程被終止} }即使在有異常的情況下也必須保證線程能夠被正確的被detach或join:
void test() {std::thread worker(do_work);try{error_fun();}catch (const std::exception&){worker.join();throw;}worker.join(); }也可以使用RAII(資源獲取即初始化,Resource Acquisition Is Initialization)的方式保證線程可以被正確的join:
class ThreadGuard {std::thread &m_thread; public:explicit ThreadGuard(std::thread &t) : m_thread(t) {}~ThreadGuard(){if (m_thread.joinable()){m_thread.join();}}ThreadGuard(ThreadGuard const&) = delete;ThreadGuard &operator=(ThreadGuard const&) = delete; };void test() {{std::thread worker(do_work);ThreadGuard guard(worker);} }當在某一線程調用另一個線程對象的join方法時,調用join的線程就會被阻塞,直到被調用的線程執行完畢,調用join的線程才能繼續執行,如下所示,若在主線程調用test函數,主線程在調用worker線程的join方法后,會一直等待worker線程執行完畢后才會繼續執行輸出語句:
void test() {std::thread worker(do_work);worker.join();std::cout << "test done" << std::endl; }如果不想等待線程運行結束(比如一個在后臺進行垃圾回收的線程),那么就可以調用detach使被調用的線程在后臺自主運行,而調用detach的線程則不會等待被調用線程執行結束,會直接繼續執行。如下所示,執行test函數的線程在調用worker線程的detach方法后繼續執行,輸出"test exit",而worker線程會休眠2秒后才會輸出"awakening",因此"test exit"會在"awakening"之前輸出:
void sleep() {using namespace std::chrono_literals;std::this_thread::sleep_for(2s);std::cout << "awakening" << std::endl; }void test() {std::thread worker(sleep);worker.detach();std::cout << "test exit" << std::endl; }使用detach時一定要注意,如果被調用detach的線程使用了調用detach線程的局部變量,那么在局部變量生命周期結束后,若被調用detach的線程還試圖訪問該局部變量時,就會出現錯誤:
void test() {size_t length = 10;int *value = new int [length];for (size_t i = 0; i < length; i++){value[i] = i;}std::thread worker([&]() {using namespace std::chrono_literals;std::this_thread::sleep_for(5s);for (size_t i = 0; i < length; i++){//會出現懸空指針std::cout << value[i] << std::endl;}});worker.detach();//局部變量已經被釋放delete[] value;std::cout << "test exit" << std::endl; }對于一個std::thread對象,只能對其調用一次join或者detach,被調用join后就無法再次調用join或者detach,同樣被調用detach后也無法再次被調用join或者detach。可以使用std::thread的joinable方法判斷std::thread對象是否時可以被join的,對一個std::thread對象在如下幾種情況下joinable方法會返回false:
- 空線程(在構造沒有附加任何運行任務)
- 已經被調用join方法的線程
- 已經被調用detach方法的線程
- 已經被move的線程
c.線程所有權的轉移
之前提到std::thread對象是只可以被move,而不能被copy的。可以通過move,轉移線程的所有權:
void test() {std::thread thread0(task);//顯式調用move方法,轉移線程所有權std::thread thread1 = std::move(thread0);std::thread thread2;//對于臨時對象,隱式地調用move,轉移線程所有權thread2 = std::thread(task);thread1.join();thread2.join(); }被move后的std::thread對象將不再代表執行線程,也無法再被join或者detach。
借助與move操作,我們可以在函數間,或者容器中轉移線程的所有權:
void test() {std::vector<std::thread> workers;for (size_t i = 0; i < 4; i++){//被創建出的線程的所有權被轉移到vector容器中workers.push_back(std::thread(task));}for (auto &t : workers){t.join();} }d.線程的調度
標準庫中出了std::thread和id定義外,還有定義了一個std::this_thread命名空間:
namespace this_thead {thread::id get_id();void yield();template<class Clock, class Duration>void sleep_until(const chrono::time_point<Clock, Duration>& abs_time);template<class Rep, class Period>void sleep_for(const chromo::duration<Rep, Period>& rel_time); }通過getid方法可以獲得當前線程的id,而yield,sleep_until和sleep_for方法則可以用于線程的調度。
調用yield方法會使操作系統重新調度當前線程,并允許其他線程運行一段時間。yield函數的準確行為依賴于具體實現,特別是使用中的 OS 調度器機制和系統狀態。例如,先進先出實時調度器( Linux 的SCHED_FIFO)將懸掛當前線程并將它放到準備運行的同優先級線程的隊列尾(而若無其他線程在同優先級,則yield無效果)。
sleep_for則是將當前線程阻塞一定時間段后喚醒,而sleep_until則是阻塞當前線程直至某一時間點后將當前線程喚醒:
void test() {std::thread thread_a([]() {using namespace std::chrono_literals;//線程a將被阻塞2sstd::this_thread::sleep_for(2s);});using namespace std::chrono_literals;auto time_point = std::chrono::steady_clock::now() + 10s;std::thread thread_b([=]() {//線程b將被阻塞,并在10s后被喚醒std::this_thread::sleep_until(time_point);});thread_a.join();thread_b.join(); }4.C++11中的互斥量與鎖管理
4.1數據競爭(Data race)
同一進程中的線程共享虛擬地址空間,這一特性為我們帶來便利的同時,也會產生一些麻煩,特別是在多個線程共享數據時。如果多個線程以只讀的方式共享數據,那么和單線程訪問數據的情況沒有什么不同,多個線程訪問到的數據都是一致的。但是如果在多個線程同時讀寫共享數據時,共享數據的一致性就會被破壞,這種情況也被稱為data race。
在下面的例子中線程a和b共享person變量,a線程對person數據進行修改,同時b線程讀取并輸出person數據。由于兩個線程是同時運行的,所以可能出現a線程剛剛把person數據的age成員修改為Dio Brando的age,而同時b線程剛剛輸出了Jonathan Joestar的名字,正準備讀取Jonathan Joestar的age時卻讀到了Dio Brando的age,這里就出現了數據的不一致,線程b讀取到的person數據既不是Jonathan Joestar的也不是Dio Brando的(或者說一半是Jonathan Joestar的,另一半是Dio Brando的),這種情況就是data race,是我們必須要盡量避免的。下面的程序可能輸出為Jonathan Joestar, 121, 0。
struct Person {std::string m_name;int m_age;int m_gender; };void test() {Person person{ "Jonathan Joestar", 21, 0};std::thread thread_a([&](){using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);person.m_name = "Dio Brando";person.m_age = 121;person.m_gender = 0;});std::thread thread_b([&]() {using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);std::cout << person.m_name << ", " << person.m_age << ", " << person.m_gender;});thread_b.join();thread_a.join(); }4.2使用互斥量保護共享數據
為了保持共享數據的一致性,我們可以采用c++標準庫提供的互斥量(std::mutex, std::recursive_mutex等..)對共享數據進行保護。(一般來說互斥量同一時刻只能被一個線程鎖定,在互斥量已經被鎖定的情況下,其他線程嘗試鎖定互斥量就會被阻塞。不過也有一些特殊的互斥量可以同時被多個線程鎖定。在之后的內容中鎖與互斥量為同義詞)
C++標準庫中提供的互斥量一般都有定義lock,unlock,trylock三個方法。這里以std::mutex為例做說明。
- 使用std::mutex的lock方法可以在調用lock的線程上鎖住互斥量,若互斥量已被其他線程上鎖,則當前調用lock的線程將被阻塞,直其他占有互斥量的線程解鎖互斥量使得當前線程獲得互斥量。對std::mutex來說,在已經占有互斥量的線程上調用lock方法是未定義行為。
- std::mutex的unlock方法可以解鎖當前線程占有的互斥量,若在未占有互斥量的線程上調用unlock則為未定義行為。
- std::mutex的trylock方法可以嘗試鎖定互斥量,若成功鎖定互斥量則返回true,否則返回false。在已經占有互斥量的線程上調用trylock為未定義行為。在互斥量未被任何線程鎖定的情況下,此函數也可能會返回false。
在調用std::mutex的lock方法鎖定互斥量后一定要記得在不需要占有互斥量的時候調用unlock解鎖互斥量,否則其他任何想要獲取鎖的線程都會被阻塞,此時多線程就可能會退化成為單線程。占有 std::mutex的線程在std::mutex對象銷毀前未調用其unlock方法則為未定義行為,且std::mutex對象不可復制也不可移動。
4.1中提到的例子可以使用std::mutex來保證共享數據的一致性:
void test() {Person person{ "Jonathan Joestar", 21, 0};std::mutex mutex;std::thread thread_a([&](){using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);//鎖住互斥量mutex.lock();person.m_name = "Dio Brando";person.m_age = 121;person.m_gender = 0;//解鎖互斥量mutex.unlock();});std::thread thread_b([&]() {using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);//鎖住互斥量mutex.lock();std::cout << person.m_name << ", " << person.m_age << ", " << person.m_gender;//解鎖互斥量mutex.unlock();});thread_b.join();thread_a.join(); }上面的代碼在使用鎖的情況下,只存在兩種情況:
- 線程a獲取鎖,修改數據,修改完畢后解鎖互斥量,若線程b在此期間調用互斥量的lock方法獲取鎖則會被阻塞,直到線程a解鎖互斥量,線程b讀取到的數據為修改后的數據。
- 線程b獲取鎖,讀取到未修改的數據,輸出完畢后解鎖互斥量,若線程a在此期間調用互斥量的lock方法獲取鎖則會被阻塞,直到線程b解鎖互斥量,線程a讀才能鎖定互斥量并修改數據。
此時共享數據的一致性得到了保證,線程b讀取到的數據要么是未修改的數據,要么是修改后的數據,不會讀取到只修改了一部分的數據。
4.3死鎖
互斥量雖然可以用來保護共享數據,但是也并非完美。
假設現在有兩個線程a和b,兩個互斥量M和N。線程a會先鎖住互斥量M隨后再鎖住互斥量N,而線程b則會先鎖住互斥量N然后再鎖住互斥量M:
void test() {std::mutex M;std::mutex N;std::thread thread_a([&](){//鎖住互斥量M.lock();N.lock();change("thread a");//解鎖互斥量M.unlock();N.lock();});std::thread thread_b([&]() {//鎖住互斥量N.lock();M.lock();change("thread b");//解鎖互斥量N.unlock();M.lock();});thread_b.join();thread_a.join(); }可能會出現線程a和線程b在同一時刻分別鎖住了互斥量M和N,隨后a想要鎖住互斥量N時發現互斥量N已被線程b上鎖,于是線程a被阻塞,而線程b想要鎖住互斥量M時發現互斥量M已經被線程a上鎖,于是線程b也被阻塞。兩個線程都想要鎖住對方占有的互斥量,于是兩個線程便僵持不下,誰也無法繼續運行,這種狀況就被稱為死鎖。
避免死鎖最簡單的方法就是在任何時候都保持以固定順序上鎖互斥量,在持有鎖的時候也要避免調用包含鎖操作的代碼(在持有鎖時,調用包含鎖操作的代碼,可能會造成死鎖。)對于上面的例子按這條原則修改如下:
void test() {std::mutex M;std::mutex N;std::thread thread_a([&](){//以固定順序上鎖互斥量M.lock();N.lock();change("thread a");//解鎖互斥量M.unlock();N.lock();});std::thread thread_b([&]() {//以固定順序上鎖互斥量M.lock();N.lock();change("thread b");//解鎖互斥量M.unlock();N.lock();});thread_b.join();thread_a.join(); }以固定順序上鎖可以保持同一時刻只有一個線程可以占有互斥量,而其他線程只有等到占有互斥量的線程解鎖互斥量才能夠占有互斥量。
避免死鎖的另一種方法是使用標準庫提供的鎖管理工具std::lock(c++11)或std::scopedlock(c++17)。在介紹std::lock之前,需要先介紹一下std::lock_guard和std::unique_lock。
std::lock_guard是標準庫提供的基于RAII的鎖管理工具。std::lock_guard類提供了兩種構造函數:
- 在std::lock_guard類的對象在構造時接受一個互斥量作為參數,并對該互斥量進行上鎖操作。
- 在std::lock_guard類的對象在構造時接受一個互斥量和std::adopt_lock作為參數,互斥的獲取互斥量的所有權,但并不對互斥量進行上鎖。
在std::lock_guard類對象析構時回對其占有的互斥量解鎖,除析構和構造函數外std::lock_guard沒有定義其他任何方法。
std::unique_lock則RAII式鎖管理的基礎上提供了更多的靈活性。std::unique_lock提供的lock,unlock,trylock方法與其所管理的互斥量提供的lock,unlock,trylock行為相同。std::unique_lock還提供了移動構造和移動賦值操作(支持移動操作意味著我們可以在函數和容器中轉移std::unique_lock的所有權),std::unique_lock的移動構造函數會以參數的內容初始化當前對象,并解除參數與其所管理的互斥量之前的關系。在調用std::unique_lock的移動賦值函數時,若當前對象有互斥量與其關聯且已對其上鎖,則對互斥量解鎖并解除關聯,隨后獲取參數所管理的互斥量,并解除參數鎖管理的互斥量與參數間的關系。
std::unique_lock的構造函數同std::lock_guard的構造函數一樣也提供了初始化策略:
- 在std::unique_lock類的對象在構造時接受一個互斥量作為參數,并對該互斥量進行上鎖操作。
- 在std::unique_lock類的對象在構造時接受一個互斥量和std::defer_lock作為參數,則不對該互斥量進行上鎖。
- 在std::unique_lock類的對象在構造時接受一個互斥量和std::try_to_lock作為參數,則嘗試對互斥量上鎖,上鎖失敗時不會阻塞線程。
- 在std::unique_lock類的對象在構造時接受一個互斥量和std::adopt_lock作為參數,則假定當前線程已經擁有互斥量的所有權。
std::unique_lock的owns_lock方法可以檢查std::unique_lock是否有互斥量與其關聯,且是否已對互斥量上鎖,若有互斥量與std::unique_lock對象關聯,且已經被std::unique_lock對象獲得所有權則返回true,否則返回false。
下面是std::lock_guard與std::unique_lock的簡單使用示例:
void test() {Person person{ "Jonathan Joestar", 21, 0 };std::mutex mutex;std::thread thread_a([&]() {using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);//關聯到mutex并對其上鎖std::lock_guard lg(mutex);//等價代碼//std::unique_lock ul(mutex);person.m_name = "Dio Brando";person.m_age = 121;person.m_gender = 0;//lg(或ul)生命周期結束后解鎖mutex});std::thread thread_b([&]() {using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);//關聯到mutex并對其上鎖std::lock_guard lg(mutex);//等價代碼//std::unique_lock ul(mutex);std::cout << person.m_name << ", " << person.m_age << ", " << person.m_gender;//lg(或ul)生命周期結束后解鎖mutex});thread_b.join();thread_a.join(); }介紹完std::unique_lock與std::lock_guard之后,我們再回到使用標準庫提供的鎖管理工具避免死鎖的內容上來。
標準庫提供的std::lock函數可以配合std::unique_lock或std::lock_guard來避免死鎖。在C++17中提供了基于RAII的更便于使用的std::scopedlock類也可以用于避免死鎖。
現在假設每條數據包含數據項和互斥量,在互換兩條數據內容時,需要對兩條數據的互斥量都進行上鎖。下面的代碼展示了如何在這種情況下使用std::lock或std::scopedlock避免死鎖:
struct Datum {//數據項std::string m_name;//互斥量std::mutex m_mutex;Datum(const std::string name) : m_name(name) {} };void swap_data(Datum &lhs, Datum &rhs) {using namespace std::chrono_literals;std::this_thread::sleep_for(10ns);//使用std::lock避免死鎖std::lock(lhs.m_mutex, rhs.m_mutex);std::lock_guard lg0(lhs.m_mutex, std::adopt_lock);std::lock_guard lg1(rhs.m_mutex, std::adopt_lock);//等價代碼//std::unique_lock ul0(lhs.m_mutex, std::defer_lock);//std::unique_lock ul1(rhs.m_mutex, std::defer_lock);//std::lock(ul0, ul1);//C++17等價代碼,使用std::scoped_lock避免死鎖//std::scoped_lock sl(lhs.m_mutex, rhs.m_mutex);std::string temp = lhs.m_name;lhs.m_name = rhs.m_name;rhs.m_name = temp; }void test() {Datum leon("Leon"), claire("Claire"), ada("Ada"), sherry("Sherry");std::vector<std::thread> workers;workers.emplace_back(swap_data, std::ref(leon), std::ref(ada));workers.emplace_back(swap_data, std::ref(claire), std::ref(ada));workers.emplace_back(swap_data, std::ref(leon), std::ref(sherry));workers.emplace_back(swap_data, std::ref(sherry), std::ref(claire));workers.emplace_back(swap_data, std::ref(sherry), std::ref(ada));workers.emplace_back(swap_data, std::ref(claire), std::ref(leon));for (auto &t : workers){t.join();}std::cout << "Leon's current name is :" << leon.m_name << std::endl;std::cout << "Claire's current name is :" << claire.m_name << std::endl;std::cout << "Ada's current name is :" << ada.m_name << std::endl;std::cout << "Sherry's current name is :" << sherry.m_name << std::endl; }上面代碼的可能輸出為:
Leon's current name is :Sherry Claire's current name is :Claire Ada's current name is :Leon Sherry's current name is :Ada4.4鎖的粒度
在使用互斥量時除了死鎖,鎖的粒度也是一個很值得關注的問題。鎖的粒度是指一個互斥量鎖保護的數據量的大小。一個細粒度的鎖所保護的數據量較小,而一個粗粒度的鎖所保護的數據量則較大。由于互斥量在同一時刻只能被一個線程鎖定,所以在使用粗粒度鎖的情況下一個線程會長時間占有互斥量,而其他嘗試鎖定互斥量的線程都會被長時間阻塞,這樣程序整體的效率便會降低。
最能體現鎖的粒度對程序效率影響的容器可能是hash map,我們知道一個hash map由多個bucket組成。假如現在我們需要一個可供多個線程安全讀寫的hash map,有如下兩種實現方法:
- 使用粗粒度鎖。使用一個互斥量保護整個hash map,這種方法實現起來簡單粗暴,而且十分有效,但是同一時刻只有一個線程能夠讀寫hash map。
- 使用細粒度鎖。對組成hash map的每個bucket分別使用一個互斥量進行保護,這樣一來每個互斥量所保護的數據量變少了,也可以支持多個線程同時讀寫hash map的不同bucket(此時讀寫同一個bucket的不同線程還是會被阻塞)。
上面兩種實現方案中,使用細粒度鎖的hash map顯然具有更高的效率。
除了鎖所保護的數據量大小外,持有鎖的時間的長短對程序的運行效率也會有很大的影響。現在假設要對一段數據依次進行讀取,處理和修改操作。為了保證線程安全,我們首先可以考慮使用std::lock_guard對這一系列操作進行保護:
struct DataBlock {std::string m_name; };std::vector<DataBlock> data; size_t index = 0; std::mutex mutex;DataBlock& get_data() {return data[index++]; }void update_data(DataBlock& old_bl, const DataBlock& new_bl) {old_bl = new_bl; }DataBlock process_data(const DataBlock& block) {return DataBlock{ block.m_name + " processed" }; }void get_and_update_data() {std::scoped_lock sl(mutex);DataBlock& original_data = get_data();DataBlock processed_data = process_data(original_data);update_data(original_data, processed_data); }void test() {data.push_back(DataBlock{ "ada" });data.push_back(DataBlock{ "leon" });data.push_back(DataBlock{ "claire" });data.push_back(DataBlock{ "sherry" });std::vector<std::thread> workers;{workers.emplace_back(get_and_update_data);workers.emplace_back(get_and_update_data);workers.emplace_back(get_and_update_data);workers.emplace_back(get_and_update_data);}for (auto &t : workers){t.join();}for (auto &item : data){std::cout << item.m_name << std::endl;} }但是更好的方案是采用更加靈活的std::unique_lock,在不需要持有鎖的時候對互斥量進行解鎖(比如進行數據處理時),這樣可以減少線程持有鎖的時間,讓其他線程在當前線程處理數據時也有機會讀取或者更新數據:
void get_and_update_data() {std::unique_lock ul(mutex);DataBlock& original_data = get_data();//在不需要持有鎖時,對互斥量解鎖ul.unlock();DataBlock processed_data = process_data(original_data);//在需要修改共享數據時,嘗試獲取鎖ul.lock();update_data(original_data, processed_data); }4.5 C++標準庫提供的互斥量
在4.2節中已經介紹了一種C++標準庫提供的互斥量std::mutex,本節還會介紹一些標準庫所提供的其它的互斥量。
a.std::recursive_mutex
std::recursive_mutex與std::mutex一樣也只定義了lock,trylock,unlock和native_handle(用于返回底層實現定義的原生句柄)方法。
std::recursive_mutex與std::mutex的不同點在于std::recursive_mutex允許在一個已經鎖定互斥量的線程上多次調用lock方法(與之對應的在一個已經鎖定std::mutex的線程上,再次調用std::mutex的lock方法是未定義行為),在已鎖定互斥量的情況下再次調用lock方法會增加std::recursive_mutex的所有權等級。在調用std::recursive_mutex的unlock方法時,若lock與unlock調用次數匹配時(即所有權等級為1時)會解鎖互斥量,否則會減少std::recursive_mutex的所有權等級。當一個線程鎖定互斥量時,其他線程若嘗試鎖定互斥量就會被阻塞。(所有權的最大層數是未指定的。若超出此數,則可能拋std::system_error類型異常。std::recursive_mutex的lock與unlock,有點類似與COM中的AddRef和release)
下面時std::recursive_mutex的使用示例:
std::recursive_mutex rmutex;void test() {std::thread worker{ []() {//鎖定互斥量,rmutex的所有權等級為1rmutex.lock();do_something();{//在鎖定互斥量的情況下,再次調用lock,所有權等級增加1(為2)rmutex.lock();do_something_else();//所有權等級為2,不等于1,將所有權登記減少1,此次unlock調用后線程依然占有互斥量rmutex.unlock();}//所有權等級為1,此次unlock調用后線程解鎖互斥量rmutex.unlock();} };worker.join(); }b.std::shared_mutex
std::shared_mutex是c++17引入的共享互斥量。出了提供lock,trylock,unlock方法以支持互斥的單個線程獨鎖(排他)定互斥量外,還提供了lock_shared,try_lock_shared和unlock_shared方法以支持多個線程同時占有(共享鎖定)互斥量。
std::shared_mutex的lock方法用于排他性的鎖定互斥量,若當前線程已經以任何模式(排他或共享)占有互斥量則調用lock方法為未定義行為。std::shared_mutex的unlock方法可以解鎖互斥量,若互斥量未被當前線程占有則調用unlock方法為未定義行為。
std::shared_mutex的lock_shared方法用于獲取互斥量的共享所有權。若另一線程以排他性所有權保有互斥,則到lock_shared的調用將阻塞執行,直到能取得共享所有權。若在以已任何模式(排他性或共享)占有互斥量的線程調用lock_shared,則為未定義行為。std::shared_mutex的unlock_shared方法用于將當前線程占有的共享互斥所有權釋放。若當前線程未以共享方式獲得互斥量所有權,則unlock_shared調用為未定義行為。
std::shared_mutex多用于多個線程共享讀取數據,而只有一個線程能夠寫入數據的情況。
c.支持時限的互斥量
標準庫除了提供std::mutex,std::recursive_mutex和std::shared_mutex外,還提供了與之分別對應的支持時限的互斥量std::timed_mutex,std::recursive_timed_mutex和std::shared_timed_mutex。這些支持時限的互斥量除了支持原有互斥量的全部功能外,還提供了try_lock_for和try_lock_until方法。
try_lock_for為在一段時間內嘗試鎖定互斥量,若超過給定時間段任未獲得鎖(在此期間調用try_lock_for的線程一直處于阻塞狀態),則返回false,若在給定時間段內成功鎖定互斥量則返回true。此方法與try_lock方法類似,可能會在滿足條件的情況下虛假的返回false。
try_lock_until為在給定時間點之前嘗試鎖定互斥量,若在給定時間點之后任未獲得鎖(在此期間調用try_lock_for的線程一直處于阻塞狀態),則返回false,若在給定時間點之前成功鎖定互斥量則返回true。此方法與try_lock方法類似,可能會在滿足條件的情況下虛假的返回false。
此類支持時限的互斥量,由于調度或資源爭議延遲等原因,可能調用對應方法的線程被阻塞的時間會超過給定時間段或超出給定時間點。
std::timed_mutex tmutex;void test() {//主線程一直持有鎖std::lock_guard lg(tmutex);std::thread worker([]() {auto start = std::chrono::steady_clock::now();//worker線程try_lock_until會失敗返回false,worker至少會被阻塞1stmutex.try_lock_until(start + std::chrono::seconds(1));auto end = std::chrono::steady_clock::now();std::chrono::duration<double> time_span = std::chrono::duration_cast<std::chrono::duration<double>>(end - start);std::cout << "Time Span: " << time_span.count() << std::endl;});worker.join(); }以上代碼的可能輸出為Time Span: 1.00057
4.5 C++標準庫提供的鎖管理工具
在4.3節中已經介紹了C++標準庫提供的鎖管理工具std::scoped_lock,std::lock,std::lock_guard和std::unique_lock。std::unique_lock除4.3節中已經介紹的功能外,也支持時限的try_lock_for和try_lock_until方法,其功能與互斥量提供的try_lock_for和try_lock_until方法功能相同。
這里還會介紹std::shared_lock與std::call_once。
a.std::shared_lock
std::shared_lock類似與std::unique_lock,但通常是與std::shared_mutex一起使用:
std::shared_mutex smutex;int value = 1;std::vector<int> results;//共享讀 void shared_read(int index) {//多個線程可以同時共享地鎖定互斥量std::shared_lock<std::shared_mutex> sl(smutex);results[index] = value; }//互斥寫 void exclusive_write() {//只有一個線程能夠排他的鎖定互斥量std::unique_lock<std::shared_mutex> ul(smutex);value = value * 2; }void test() {results.resize(4);std::vector<std::thread> writers;std::vector<std::thread> readers;writers.emplace_back([]() {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);exclusive_write();});writers.emplace_back([]() {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);exclusive_write();});readers.push_back(std::thread([](int index) {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);shared_read(index);}, 0));readers.emplace_back([](int index) {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);shared_read(index);}, 1);readers.emplace_back([](int index) {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);shared_read(index);}, 2);readers.emplace_back([](int index) {using namespace std::chrono_literals;std::this_thread::sleep_for(20ns);shared_read(index);}, 3);for (auto &t : writers){t.join();}for (auto &t : readers){t.join();}for (auto &var : results){std::cout << var << std::endl;} }上面代碼的可能輸出為:
2 4 4 4b.std::call_once
std::call_once用于在多線程環境下只執行一次的可調用對象。標準庫還提供了一個輔助類std::once_flag用于指示是否已經調用可調用對象。
若調用std::call_once時,std::once_flag指示可調用對象已被調用,則立即返回,否則調用可調用對象。若調用可調用對象時出現異常,則傳播異常給call_once的調用方,并且不翻轉once_flag。若調用成功,則正常返回并翻轉once_flag。
下面為std::call_once的使用示例:
std::once_flag flag;//只調用一次 void prepare_data() {std::cout << "data prepared" << std::endl; }void process(int index) {std::cout << "process data: " << index << std::endl; }void process_data(int index) {std::call_once(flag, prepare_data);process(index); }void test() {std::vector<std::thread> workers;workers.emplace_back([](int index) {process_data(index);}, 0);workers.emplace_back([](int index) {process_data(index);}, 1);workers.emplace_back([](int index) {process_data(index);}, 2);for (auto &t : workers){t.join();} }引用:
What is the difference between concurrency and parallelism??stackoverflow.comC++ 11 多線程--線程管理?www.cnblogs.com使用 C++11 編寫 Linux 多線程程序?www.ibm.com此外還參考了c++ concurrency in action的第1,2,3章節。
如果你發現了本篇文章存在的錯誤,請指出,我會及時修正。
總結
以上是生活随笔為你收集整理的std string与线程安全_C++标准库多线程简介Part1的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 我的KT库之----数据对象
- 下一篇: c++模板类静态成员变量_一文讲透父子类