C++ 学习(7)——多线程(2)--数据竞争与互斥锁

Day 2:数据竞争与互斥锁 std::mutex

在多线程编程中,线程间共享数据时常会遇到“数据竞争(Data Race)”的问题。为保证数据安全访问,C++ 标准库提供了 std::mutex 互斥锁机制,本文将详细介绍数据竞争的概念以及如何使用 std::mutex 来解决。


一、什么是数据竞争?

数据竞争指的是多个线程同时访问同一内存位置,且至少有一个线程在写操作时,没有适当的同步机制保障访问顺序,导致程序行为不可预测或错误。

举个简单的例子:

#include <iostream>
#include <thread>

int counter = 0;

void increase() {
    for(int i = 0; i < 100000; ++i) {
        ++counter;  // 非原子操作,可能导致数据竞争
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(increase);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;  // 结果通常不会是200000
    return 0;
}

由于 counter++ 不是原子操作,两个线程的操作可能交叉执行,导致最后计数值小于预期,出现“数据竞争”。
输出:
image


二、如何解决数据竞争?

解决数据竞争的核心思想是保证同一时间只有一个线程访问共享资源。这就引出了互斥锁(mutex)

互斥锁 std::mutex

std::mutex 是 C++11 标准库提供的一个互斥锁类,用于保护临界区(即访问共享资源的代码段),确保一次只有一个线程能进入临界区。


三、示例:使用 std::mutex 保护共享变量

这里用到了一个类和一个模板std::mutex,std::lock_guard

1. std::mutex

  • 是 C++ 标准库提供的一个互斥锁类(类),用来实现线程间的互斥访问。
  • 你可以把它理解成“锁”,用于保护共享资源,防止多个线程同时访问导致数据冲突。

基本使用:

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtk;

void increment() {
    for (int i = 0; i < 100000; ++i) {
        mtk.lock();
        ++counter;  // 非原子操作,可能产生冲突
        mtk.unlock();
    }
}

int main() {
    std::thread t1(increment);
    std::thread t2(increment);
    t1.join();
    t2.join();

    std::cout << "Final counter: " << counter << std::endl;  // 结果不会是 200000
}

2. lock_guard和unique_lock

我们也可以使用lock_guard和unique_lock进行锁管理,std::lock_guard 与 std::unique_lock 都是 RAII 封装,把“手动 mtx.lock()/unlock()”变成 构造即加锁、析构即解锁,从而 避免忘记解锁、异常路径未解锁、提前返回未解锁 三大类常见 bug。

std::lock_guard

  • 是一个模板类(class template),它接受一个锁类型作为模板参数(比如 std::mutex)。
  • 它的主要作用是管理锁的生命周期,构造时自动加锁,析构时自动解锁,实现 RAII(资源获取即初始化)原则。
  • 这样你就不用手动写 lock()unlock(),减少错误风险。

下面是对上述示例的改进,使用 std::mutex 保护 counter

#include <iostream>
#include <thread>
#include <mutex>

int counter = 0;
std::mutex mtx;  // 声明互斥锁

void increase() {
    for(int i = 0; i < 100000; ++i) {
        std::lock_guard<std::mutex> lock(mtx);  // 加锁,作用域结束自动解锁
        ++counter;  // 受保护的临界区
    }
}

int main() {
    std::thread t1(increase);
    std::thread t2(increase);

    t1.join();
    t2.join();

    std::cout << "Counter: " << counter << std::endl;  // 结果稳定为200000
    return 0;
}

输出:
image

说明:

  • std::mutex mtx; 声明一个互斥锁对象;
  • 使用 std::lock_guard<std::mutex> 来自动管理锁的生命周期,保证函数退出时自动解锁,避免死锁;
  • 每次访问共享变量时都先加锁,访问结束后自动解锁;
  • 这样可以保证即使两个线程同时执行 increase(),也不会出现数据竞争。

注意这个生命周期是在作用域内,每个{}可以理解为一个作用域,从定义锁的代码开始加锁,一直到作用域结束

#例1
void func() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        // 代码块1,受锁保护
    }  // 这里 lock 销毁,解锁

    {
        // 代码块2,不在锁保护范围内
    }
}
#例2
void func() {
    {
        std::lock_guard<std::mutex> lock(mtx);
        // 代码块1,受锁保护
    }  // 这里 lock 销毁,解锁

    {
        // 代码块2,不在锁保护范围内
    }
}

当 std::unique_lockstd::mutex 被定义在 for 循环体内时(如 for (int i=0; i<10; i++) { std::unique_lock lock2(mtk1); }),该锁的作用域和生命周期是仅限于单次循环迭代(即每次循环独立加锁/解锁),还是覆盖整个循环过程(从 i=0 到 i=10 的全部迭代)?

答案:当 std::unique_lock<std::mutex> 被定义在 for 循环体内时(如代码所示),其作用域和生命周期仅限于单次循环迭代,而非整个循环过程。具体表现为:

  1. 每次循环迭代

    • 进入循环体时,unique_lock 构造并立即对 mtk1 加锁。
    • 退出循环体时(即每次迭代结束的 } 处),unique_lock 析构并释放锁。
  2. 锁的行为

    • 锁的持有范围仅覆盖单次循环体的执行(如 i=0 时加锁、解锁,i=1 时再次加锁、解锁……直到 i=10)。
    • 整个循环期间,锁会被反复加锁/解锁 10 次,而非从 i=0i=10 持续持有。
  3. 并发影响

    • 其他线程在每次循环迭代结束后有机会竞争 mtk1 锁,因此锁的粒度较细,适合保护循环体内每次独立的共享资源访问。

结论
锁的作用域是单次循环体的 {} 范围,而非整个循环。若需锁覆盖整个循环过程,需将 unique_lock 定义在循环外部。

3.unique_lock

std::unique_lock

  • 功能更强大,支持手动控制锁的生命周期。
  • 可延迟加锁 / 尝试加锁 / 提前解锁 / 重新加锁。
  • 可与 std::condition_variable 搭配使用。
  • 支持移动构造、移动赋值。

1. 默认加锁(常用)

std::unique_lock<std::mutex> lock(mtx);

2. 延迟加锁

std::unique_lock<std::mutex> lock(mtx, std::defer_lock); // 不加锁
// ...
lock.lock(); // 手动加锁

3. 尝试加锁

std::unique_lock<std::mutex> lock(mtx, std::try_to_lock);
if (lock.owns_lock()) {
    // 加锁成功
}

4. 手动加锁后交给它管理(adopt_lock)

mtx.lock();
std::unique_lock<std::mutex> lock(mtx, std::adopt_lock); // 不再重复加锁,但析构时自动解锁

5. 提前解锁 / 重新加锁

lock.unlock();
// do something
lock.lock();

四、 lock_guard vs unique_lock 对比

特性 std::lock_guard std::unique_lock
自动加锁 / 解锁(RAII)
延迟加锁 (defer_lock)
尝试加锁 (try_to_lock)
提前解锁 / 重新加锁
支持条件变量
可移动构造 / 赋值
内存占用 更小(1个指针) 稍大(多个状态变量)
使用复杂度 简单 略复杂

🧠 选择建议

使用场景 推荐工具
简单锁管理,临界区很小 std::lock_guard
需要 unlock()/lock(),延迟加锁或条件变量 std::unique_lock
搭配 std::condition_variable 使用 std::unique_lock
注重性能、锁生命周期明确 std::lock_guard

🧪 实例:条件变量需要 unique_lock

#include <mutex>
#include <condition_variable>
#include <iostream>
#include <thread>

std::mutex mtx;
std::condition_variable cv;
bool ready = false;

void worker() {
    std::unique_lock<std::mutex> lock(mtx);
    cv.wait(lock, [] { return ready; });
    std::cout << "Worker running...\n";
}

五、总结

  • 数据竞争是多线程编程中常见的错误,会导致程序运行结果不确定甚至崩溃;
  • 互斥锁 std::mutex 是 C++ 提供的用于保护共享数据的标准机制;
  • 使用 std::lock_guard 可自动管理互斥锁,降低死锁风险;
  • 保护共享资源时,务必保证每次访问都加锁,防止数据竞争。

六、练习题

  1. 改写计数器程序
    将示例程序改写成使用 std::unique_lock<std::mutex> 替代 std::lock_guard<std::mutex>,并解释两者区别。

  2. 多线程访问共享容器
    编写程序创建多个线程同时向一个 std::vector<int> 中添加元素,使用 std::mutex 保证线程安全。注意在访问和修改容器时加锁。

  3. 计时器模拟
    用多线程模拟一个计时器,主线程每秒输出时间,子线程修改计时器状态(如暂停、重置),使用互斥锁保护计时器变量。


如果你觉得本文有帮助,欢迎点赞、评论和分享!有任何问题,也欢迎留言交流。


需要帮你写下一篇关于 std::unique_lock 还是 condition_variable 的文章吗?

posted @ 2025-07-21 19:03  seekwhale13  阅读(61)  评论(0)    收藏  举报