在 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