多執行緒
參考:https://zhuanlan.zhihu.com/p/613630658
平臺差異:Linux 與 Windows,跨平臺方案
在Linux上,有pthread的使用,而C++ 11標準中使用了<thread>,是一個良好的跨平臺方案。
thread和pthread在實際的使用中有一些顯著的差別,典型例子如:
pthread_create
用來建立執行緒,而std::thread可以直接被用來建立執行緒。
客觀來說,thread是一個更為簡潔的實現,而pthread難免有些粗暴,本文在C++的多執行緒程式設計中主要以thread風格來實現。
子執行緒退出與主執行緒退出的關係
引用:
- https://blog.csdn.net/a0408152/article/details/129093394
- https://blog.csdn.net/m0_56374992/article/details/119109979
detach是將當前執行緒物件所代表的執行例項與該執行緒物件分離,使得執行緒的執行可以單獨進行。
然而,在這種情況下,即使子執行緒detach,主執行緒退出也會導致子執行緒退出。
具體原因是主程序透過return或者exit方式退出,程序退出導致所有執行緒同步退出。
這裡和Linux的程序/執行緒模型有關,是posix(pthread)的具體實現,參見引用2。
為了防止這種情況,在不想要在主程序中回收子程序的情況時,使用pthread_exit(nullptr);
如果僅為了測試,也可以在主程序中加迴圈。
所以,重新理解detach:把主程序和子執行緒分離,使二者能夠獨立的執行。
原子操作
備註:此節部分內容及程式碼來源於帝國理工學院COMP60017 - L05 - Multi-core and Parallelism, Lluís Vilanova
由於現代CPU使用亂序流水線(out-of-order)的方式進行指令的執行,因此對於某一條單獨的a = b + 1指令來說,其在O2最佳化層級上可以被分解為以下三條彙編指令:
mov eax, DWORD PTR [rbp-8]
add eax, 1
mov DWORD PTR [rbp-4], eax
模擬編譯(以及編譯最佳化):https://godbolt.org/
由此可見,在沒有進行任何額外處理的情況下,有可能在add與mov操作之間出現程序的切換排程,因此就會出現偽遞增現象,即為兩個並行執行緒同時對一個變數自增10000次,最後結果通常要小於20000。
atomic關鍵字
atomic是C++中的一個關鍵字,作用是針對某一個具體變數,提供一組'原子的'操作。
在本質上是對單條指令的臨界區保護。
具體使用例子如下:
// Use atomic operations on data shared across threads
#include <atomic>
#include <thread>
#include <iostream>
int main(int argc, char** argv) {
int iters = 100000000; std::atomic<int> a = 0;
std::thread t1([&](){ for (volatile int i = 0; i < iters; i++) a++; });
std::thread t2([&](){ for (volatile int i = 0; i < iters; i++) a++; });
t1.join(); t2.join();
std::cout << "expected=" << iters*2 << " got=" << a << std::endl;
}
在C++中,有兩種atomic的使用方式:
- 在定義變數時宣告為atomic型別 e.g.
std::atomic<int> a = 0;
- 在使用變數時使用atomic_系列操作 e.g.
atomic_fetch_add(&a, 1);
參考:https://en.cppreference.com/w/cpp/atomic/atomic/compare_exchange
- atomic_{load, store}:讀取/寫入
- atomic_compare_exchange_{weak, strong}:注意,在atomic中,提供了兩個CAS操作:
compare_exchange_weak(T& expected, T desired)
compare_exchange_strong(T& expected, T desired)
首先與第一個引數比較:- 若相等,則改變原子變數的值為第二個引數,返回 true。
- 若不相等,則將第一個引數的值改成原子變數的當前值,返回 false。
但是需要注意,以weak方式實現的操作返回false時,並不一定完成了實際上的expected value修改,可能會出現偽失敗(spuriously fail)情況。
在實際操作中,尤其是在應用層面,如果不是對效能極度敏感的情況下,一律使用strong
- atomic_fetch_{add, sub, or, xor, and}:算數/邏輯運算
int beings, legs;
void enter_room(int nlegs) {
atomic_fetch_add(&beings, 1);
atomic_fetch_add(&legs, nlegs);
}
C++記憶體順序:memory order
參見:https://en.cppreference.com/w/cpp/atomic/atomic
std::memory_order_seq_cst
這個面試底層經常問,最好搞明白
CAS無鎖操作
無鎖相比於加鎖操作來說,最大的優勢是效能顯著提高。
大多數現代CPU在硬體層面上都提供了原子實現CAS的機制。
shared lock: 讀寫鎖,多個thread可以同時讀,但只有一個能寫
在Linux中的pthread庫中,我們採用了CAS作為讀寫鎖的實現方式
CAS操作,全名為Compare-and-swap(比較並交換)操作,是一個原子的操作。
在C++中,一個簡單的實現:
bool compare_and_swap(int *pAddr, int nExpected, int nNew)
{
if(*pAddr == nExpected)
{
*pAddr = nNew;
return true;
}
else
return false;
}
這裡提供一個使用CAS進行自增的操作:
void atomic_inc(uint64_t* addr) {
bool swapped = false;
while (not swapped) {
auto old = *addr;
swapped = CAS(addr, old, old+1);
}
}
執行緒同步
執行緒臨界區:對於只讀不寫的變數,不需要保護
▪ Lock/mutex
▪ Semaphore
▪ Shared lock (aka, read/write lock)
▪ Condition variables
▪ Barrier
例:使用條件變數condition_variable
例題:leetcode 1117. H2O 生成
在C++中,condition_variable
必須結合unique_lock
使用,此外還有一個condition_variable_any
類可以使用所有的鎖,此處暫時不論。
基本使用流程:mutex lock -> wait -> mutex unlock
wait函式阻塞完成後即自動unlock釋放鎖,不需要手動釋放。
虛假喚醒:使用notify_all()
函式喚醒所有wait狀態下的執行緒時,發現其等待的條件並沒有滿足。
解決方法:
- 使用一個while迴圈在每次被喚醒時判斷條件
while (g_deque.empty())
{
g_cond.wait(lck);
}
- 使用一個帶predicate判斷條件的wait
cv.wait(lck,[this]{return printo > 0;});//此處用了lambda函式,在類中所以需要this,較為方便
非同步程式設計
非同步程式設計:回撥函式callback
c++11:中新增了std::future 和 std::promise
更加輕量級:協程
c++20:提供了co_routine(協程),在適當的時候做掛起(suspend)和恢復(resume),是個基於state machine的無棧協程
評價為對golang的拙劣模仿(原生支援,從來沒見人在C++中用過)
我在這個專案中從核心到userspace維護了一個支援熱遷移的虛擬機器狀態流,就用了類似的思想,但是這裡是個有棧協程:https://github.com/mahiru23/intravisor/tree/syscall/src
這篇文章講了協程,感覺不錯:https://www.cnblogs.com/lizhaolong/p/16437246.html
鎖的實現
User-level
- Acquire/lock → Loop until CAS from “released” to “acquired”
- Release/unlock → Set value to “released”
缺點:
- 假設有兩個執行緒,t1持有鎖的時候t2會反覆迴圈嘗試直到獲取位置,存在迴圈浪費(叫做busy waiting(忙等))
- Potential thread starvation:等待的執行緒可能一直在等待(可能使用queue來解決問題?)
這裡給出一個僅使用
#include <iostream>
#include <atomic>
#include <thread>
#include <cstdlib>
#include <ctime>
#include <unistd.h>
class mysem {
public:
mysem(uint32_t init_value);
void acquire();
void release();
private:
std::atomic<uint32_t> counter;
};
mysem::mysem(uint32_t init_value) {
counter.store(init_value, std::memory_order_seq_cst);
}
void mysem::acquire() {
if(counter.load(std::memory_order_seq_cst) > 0) {
counter.fetch_sub(1);
}
else {
while (counter.load(std::memory_order_seq_cst) <= 0) {
// busy-wait
}
}
}
void mysem::release() {
counter.fetch_add(1);
}
void random_work() {
usleep((rand()%1000)*10);
}
int main(int argc, char**argv)
{
srand(time(nullptr));
mysem s(1);
std::thread t1([&](){
random_work();
s.acquire();
std::cout << 1; random_work(); std::cout << 1;
s.release();
});
std::thread t2([&](){
random_work();
s.acquire();
std::cout << 2; random_work(); std::cout << 2;
s.release();
});
t1.join(); t2.join();
std::cout << std::endl;
}
Kernel-level
阻塞後sleep,核心層面awake
按照順序,保證了阻塞執行緒的公平性
然而,這種方法更加expensive,因為過程中需要syscall
hybrid
在Linux的pthread_mutex_lock
內部使用了Linux futex
。
在較短的時間內使用user-level,對於等待時間較長的thread由kernel syscall處理(先busy-wait,再阻塞)
glibc的pthread實現方式:提前預測可能需要花費多長時間:Can adapt user-level busy-wait time dynamically
給出一段CAS + futex的混合程式碼:
#include <iostream>
#include <atomic>
#include <thread>
#include <cstdlib>
#include <ctime>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/futex.h>
#include <sys/time.h>
class mysem {
public:
mysem(uint32_t init_value);
void acquire();
void release();
private:
std::atomic<uint32_t> counter;
};
mysem::mysem(uint32_t init_value) {
counter.store(init_value, std::memory_order_seq_cst);
}
void mysem::acquire() {
for (int i = 0; i < 100; ++i) {
uint32_t expected = counter.load(std::memory_order_seq_cst);
if (expected > 0 && counter.compare_exchange_strong(expected, expected - 1, std::memory_order_seq_cst)) {
return;
}
}
uint32_t* counter_ptr = reinterpret_cast<uint32_t*>(&counter);
syscall(SYS_futex, counter_ptr, FUTEX_WAIT, counter.load(std::memory_order_seq_cst) > 0, nullptr, nullptr, 0);
}
void mysem::release() {
counter.fetch_add(1);
uint32_t* counter_ptr = reinterpret_cast<uint32_t*>(&counter);
syscall(SYS_futex, counter_ptr, FUTEX_WAKE, 1, nullptr, nullptr, 0);
}
void random_work() {
usleep((rand()%1000)*10);
}
int main(int argc, char**argv)
{
srand(time(nullptr));
mysem s(1);
std::thread t1([&](){
random_work();
s.acquire();
std::cout << 1; random_work(); std::cout << 1;
s.release();
});
std::thread t2([&](){
random_work();
s.acquire();
std::cout << 2; random_work(); std::cout << 2;
s.release();
});
t1.join(); t2.join();
std::cout << std::endl;
}
執行緒安全:
thread_local
引用:https://zhuanlan.zhihu.com/p/77585472
C++ 11 引入了thread_local,作為執行緒內部的私有本地變數
應用場景:多執行緒無鎖程式設計