在 C++ 中使用 std::mutex 同步基元

Jinku Hu 2021年11月8日 2021年10月2日
在 C++ 中使用 std::mutex 同步基元

本文將演示如何在 C++ 中使用 std::mutex 同步基元。

使用 std::mutex 保護對 C++ 執行緒間共享資料的訪問

通常,同步基元是程式設計師在利用併發的程式中安全地控制對共享資料的訪問的工具。

由於來自多個執行緒的共享記憶體位置的無序修改會產生錯誤的結果和不可預測的程式行為,因此程式設計師有責任保證程式以確定性方式執行。併發程式設計中的同步和其他主題非常複雜,通常需要對現代計算系統中的多層軟體和硬體特性有廣泛的瞭解。

因此,我們將假設對這些概念有一些先前的瞭解,同時涵蓋本文中同步主題的很小一部分。即,我們將介紹互斥的概念,也稱為互斥(通常,程式語言中的物件名稱會被賦予相同的名稱,例如 std::mutex)。

互斥鎖是一種鎖定機制,它可以包圍程式中的臨界區並確保對其的訪問受到保護。當我們說共享資源受到保護時,是指如果一個執行緒正在對共享物件執行寫操作,則其他執行緒在前一個執行緒完成操作之前不會操作。

請注意,這樣的行為對於某些問題可能不是最佳的,因為可能會出現資源爭用、資源匱乏或其他與效能相關的問題。因此,一些其他機制解決了這些問題,並提供了超出本文範圍的不同特性。

在以下示例中,我們展示了 C++ STL 提供的 std::mutex 類的基本用法。請注意,自 C++11 版本以來,已經新增了對執行緒的標準支援。

首先,我們需要構造一個 std::mutex 物件,然後可以使用它來控制對共享資源的訪問。std::mutex 有兩個核心成員函式 - lockunlocklock 操作通常在修改共享資源之前呼叫,unlock 操作在修改後呼叫。

在這些呼叫之間插入的程式碼稱為臨界區。儘管前面的程式碼佈局順序是正確的,但 C++ 提供了另一個有用的模板類 - std::lock_guard,它可以在離開作用域時自動解鎖給定的 mutex。使用 lock_guard 而不是直接使用 lockunlock 成員函式的主要原因是為了保證即使引發異常,也會在所有程式碼路徑中解鎖 mutex。因此,我們的程式碼示例也將使用後一種方法來演示 std::mutex 的用法。

main 程式被構造為建立兩個隨機整數的 vector 容器,然後將兩者的內容推送到列表。棘手的部分是我們想要利用多個執行緒將元素新增到列表中。

實際上,我們用兩個執行緒呼叫了 generateNumbers 函式,但它們對不同的物件進行操作,因此不需要同步。一旦我們生成了整數,就可以通過呼叫 addToList 函式來填充列表。

請注意,此函式以 lock_guard 構造開始,然後包含需要對列表進行的操作。在這種情況下,它只在給定的列表物件上呼叫 push_back 函式。

#include <iostream>
#include <string>
#include <chrono>
#include <thread>
#include <mutex>
#include <list>
#include <vector>

using std::cout; using std::endl;
using std::string; using std::list;
using std::vector;

std::mutex list1_mutex;

const int MAX = 1000;
const int NUMS_TO_GENERATE = 1000000;

void addToList(const int &num, list<int> &l)
{
    std::lock_guard<std::mutex> guard(list1_mutex);
    l.push_back(num);
}

void generateNumbers(vector<int> &v)
{
    for (int n = 0; n < NUMS_TO_GENERATE; ++n) {
        v.push_back(std::rand() % MAX);
    }
}

int main() {
    list<int> list1;
    vector<int> vec1;
    vector<int> vec2;

    std::thread t1(generateNumbers, std::ref(vec1));
    std::thread t2(generateNumbers, std::ref(vec2));
    t1.join();
    t2.join();

    cout << vec1.size() << ", " << vec2.size() << endl;

    for (int i = 0; i < NUMS_TO_GENERATE; ++i) {
        std::thread t3(addToList, vec1[i], std::ref(list1));
        std::thread t4(addToList, vec2[i], std::ref(list1));
        t3.join();
        t4.join();
    }

    cout << "list size = " << list1.size() << endl;

    return EXIT_SUCCESS;
}

輸出:

1000000, 1000000
list size = 2000000

在前面的程式碼片段中,我們選擇在 for 迴圈的每次迭代中建立兩個單獨的執行緒,並在同一個迴圈中將它們連線到主執行緒。這種場景效率低下,因為建立和銷燬執行緒需要寶貴的執行時間,但我們僅提供它來演示互斥用法。

通常,如果需要在程式中的某個任意時間管理多個執行緒,就會用到執行緒池的概念。此概念的最簡單形式將在工作例程開始時建立固定數量的執行緒,然後開始以類似佇列的方式為它們分配工作單元。當一個執行緒完成其工作單元時,它可以重新用於下一個待處理的工作單元。不過請注意,我們的驅動程式程式碼僅設計為對程式中的多執行緒工作流進行簡單模擬。

Author: Jinku Hu
Jinku Hu avatar Jinku Hu avatar

Founder of DelftStack.com. Jinku has worked in the robotics and automotive industries for over 8 years. He sharpened his coding skills when he needed to do the automatic testing, data collection from remote servers and report creation from the endurance test. He is from an electrical/electronics engineering background but has expanded his interest to embedded electronics, embedded programming and front-/back-end programming.

LinkedIn