C++中的多執行緒及其之後的周邊

真昼小天使daisuki發表於2024-12-09

多執行緒

參考:https://zhuanlan.zhihu.com/p/613630658

平臺差異:Linux 與 Windows,跨平臺方案

在Linux上,有pthread的使用,而C++ 11標準中使用了<thread>,是一個良好的跨平臺方案。
thread和pthread在實際的使用中有一些顯著的差別,典型例子如:
pthread_create用來建立執行緒,而std::thread可以直接被用來建立執行緒。

客觀來說,thread是一個更為簡潔的實現,而pthread難免有些粗暴,本文在C++的多執行緒程式設計中主要以thread風格來實現。

子執行緒退出與主執行緒退出的關係
引用:

  1. https://blog.csdn.net/a0408152/article/details/129093394
  2. 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的使用方式:

  1. 在定義變數時宣告為atomic型別 e.g. std::atomic<int> a = 0;
  2. 在使用變數時使用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狀態下的執行緒時,發現其等待的條件並沒有滿足。

解決方法:

  1. 使用一個while迴圈在每次被喚醒時判斷條件
while (g_deque.empty())
{
    g_cond.wait(lck);
}
  1. 使用一個帶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”

缺點:

  1. 假設有兩個執行緒,t1持有鎖的時候t2會反覆迴圈嘗試直到獲取位置,存在迴圈浪費(叫做busy waiting(忙等)
  2. 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,作為執行緒內部的私有本地變數
應用場景:多執行緒無鎖程式設計

相關文章