当前位置: 首页 > news >正文

C++互斥锁使用详解与案例分析

使用原理:

核心目标: 解决多线程环境下的数据竞争问题。

什么是数据竞争?
当多个线程在没有同步机制的情况下,同时读写同一个共享资源(如全局变量、静态变量、堆内存、引用传递的对象等),并且至少有一个线程是写入操作时,程序的行为将变得不可预测。这会导致程序崩溃、计算结果错误、数据损坏等严重后果。

互斥锁的工作原理:
互斥锁(Mutual Exclusion)是一种同步原语。它的工作原理可以类比为一个只有一个钥匙的卫生间:

  1. 加锁(Lock): 当一个线程(Thread A)需要访问共享资源时,它会尝试“锁住”与该资源关联的互斥锁。如果锁当前没有被其他线程持有,Thread A 将立即获得锁,并继续执行其临界区代码(访问共享资源的代码段)。

  2. 阻塞(Block): 如果锁已经被另一个线程(Thread B)持有,那么 Thread A 的尝试加锁操作会被阻塞。这意味着 Thread A 会停止执行,进入等待状态,直到锁被释放。

  3. 解锁(Unlock): 当持有锁的线程(Thread B)完成了对共享资源的操作,它会“解锁”互斥锁。

  4. 唤醒(Wake up): 锁被释放后,系统会唤醒其中一个正在等待该锁的线程(例如 Thread A)。Thread A 随后获得锁,并开始执行它的临界区代码。

通过这种方式,互斥锁确保了在任何时刻,最多只有一个线程可以执行临界区代码,从而保证了共享资源访问的原子性和正确性。


使用方法 (C++11 <mutex> 库):

C++11 在标准库中引入了 <mutex> 头文件,提供了多种互斥量类。

1. 基本类:std::mutex

这是最基础、最常用的互斥锁。

成员函数:

  • lock(): 加锁。如果锁已被其他线程持有,则调用线程被阻塞。

  • unlock(): 解锁。释放锁的所有权。

  • try_lock(): 尝试加锁。如果锁可用则加锁并返回 true;如果不可用则立即返回 false不阻塞线程。

2. 使用模式:RAII 包装器(强烈推荐

手动调用 lock() 和 unlock() 非常容易出错,比如在临界区代码中提前返回或抛出异常可能导致 unlock() 没有被调用,从而造成永久的死锁。

C++提供了资源获取即初始化(RAII) 风格的包装器,来自管理互斥量的锁定与释放。它们的析构函数会自动调用 unlock(),是异常安全的。

  • std::lock_guard:

    C++ 中 std::lock_guard 的使用方法
    在 C++ 多线程编程中,std::lock_guard 是一个用于简化互斥锁管理的 RAII(Resource Acquisition Is Initialization)类模板。它自动获取锁并在作用域结束时释放锁,避免了手动调用 lock() 和 unlock() 的繁琐操作,从而减少死锁和资源泄露的风险。下面我将逐步解释其使用方法、原理和注意事项,确保回答基于可靠的引用来源。1. 基本用法
    std::lock_guard 通常与 std::mutex 配合使用。你需要创建一个 std::lock_guard 对象,并在其构造函数中传入一个互斥锁对象。当对象被构造时,它会自动锁定互斥锁;当对象离开作用域被销毁时,它会自动解锁互斥锁。这使得代码更简洁和安全。步骤:
    包含头文件:使用 std::lock_guard 需要包含 <mutex> 头文件。创建互斥锁:定义一个 std::mutex 对象。使用 lock_guard:在需要保护的临界区代码块中,声明一个 std::lock_guard 对象。代码示例:#include <iostream>
    #include <mutex>
    #include <thread>std::mutex mtx;  // 全局互斥锁
    int shared_data = 0;  // 共享数据void increment_data() {std::lock_guard<std::mutex> lock(mtx);  // 构造 lock_guard,自动加锁++shared_data;  // 临界区操作// 不需要手动解锁!析构时会自动解锁。
    }int main() {std::thread t1(increment_data);std::thread t2(increment_data);t1.join();t2.join();std::cout << "Shared data: " << shared_data << std::endl;  // 输出应为 2return 0;
    }在这个示例中,std::lock_guard 确保多个线程安全地访问 shared_data。构造 lock 对象时自动调用 mtx.lock(),函数结束时析构对象调用 mtx.unlock()关键点:自动加锁/解锁:构造函数调用 lock(),析构函数调用 unlock(),确保锁在作用域结束时释放。不可拷贝或赋值:避免多个 lock_guard 对象管理同一锁导致未定义行为13。轻量高效:相比手动管理锁,它减少了错误机会,但不支持手动解锁(这点与 std::unique_lock 不同)。3. 实际应用场景
    std::lock_guard 适用于简单的临界区保护,尤其当锁的生命周期与作用域一致时:共享数据访问:如计数器、队列或缓存,防止数据竞争。函数内部同步:在成员函数中保护对象状态,确保线程安全。结合其他工具:偶尔与条件变量一起使用(但 std::unique_lock 更适合复杂场景)。4. 注意事项
    作用域限制:std::lock_guard 的生命周期必须严格局限于临界区。如果过早返回或抛出异常,析构函数确保锁被释放,避免死锁。避免悬空引用:确保传入的互斥锁对象在整个生命周期有效。常见错误:如果互斥锁无效(如空指针或坏指针),使用 std::lock_guard 会导致未定义行为。解决方案是先检查锁的有效性:
    if (&_saveMapLock != nullptr) {  // 检查有效性std::lock_guard<std::mutex> lock(_saveMapLock);//创建lock实例维护锁_saveMapLock// 安全操作共享数据
    }
    与 unique_lock 的区别:std::lock_guard 更简单高效,但不支持手动解锁或条件变量等待。std::unique_lock 更灵活,但开销稍大。
    性能考虑:在高频临界区中,std::lock_guard 的轻量设计有助于减少开销。5. 总结
    std::lock_guard 是 C++ 并发编程的核心工具,用于自动管理互斥锁,简化代码并增强安全性。核心方法是:在临界区开始处声明对象,让 RAII 机制处理锁的获取和释放。对于简单场景,它是首选;对于需要更细粒度控制的场景,可考虑 std::unique_lock
  • std::lock_guard:
    最简单的RAII包装器。在构造时加锁,在析构时解锁。它只能在作用域结束时解锁

    {std::lock_guard<std::mutex> guard(my_mutex); // 构造时加锁// ... 临界区代码 ...
    } // 作用域结束,guard析构,自动解锁
  • std::unique_lock:
    比 std::lock_guard 更灵活但开销稍大。它除了具备 lock_guard 的功能外,还允许:

  • 延迟加锁:构造时不立即加锁,之后手动调用 lock()

  • 提前解锁:在作用域结束前手动调用 unlock()

  • 条件变量:必须与 std::condition_variable 配合使用。

    {std::unique_lock<std::mutex> ulock(my_mutex, std::defer_lock); // 延迟加锁// ... 做一些不需要锁的操作 ...ulock.lock(); // 手动加锁// ... 临界区代码 ...ulock.unlock(); // 手动提前解锁// ... 做一些不需要锁的操作 ...
    } // 析构时,如果ulock还拥有锁,会自动解锁

使用注意事项:

  1. 死锁(Deadlock)

    • 场景:多个线程互相等待对方持有的锁,导致所有线程都无法继续执行。

    • 常见原因

      • 锁顺序不一致:Thread 1 先锁 A 再锁 B,而 Thread 2 先锁 B 再锁 A。

      • 未释放锁:忘记解锁,或临界区代码抛出异常导致未解锁。

    • 解决方案

      • 固定锁的顺序:所有需要同时获取多个锁的线程,都按照相同的全局顺序去申请(例如先锁A再锁B)。

      • 使用 std::lock():这是一个函数,可以一次性锁住多个std::unique_lock对象,并且保证不会发生死锁(它使用死锁避免算法)。std::lock(mutex1, mutex2);

      • 使用RAIIlock_guard 或 unique_lock 确保异常发生时锁也能被释放。

  2. 性能开销

    • 加锁和解锁操作本身需要CPU时间。

    • 锁的竞争会导致线程阻塞和上下文切换,开销很大。

    • 优化建议尽量缩小临界区范围,只锁住真正需要共享的数据操作。不要在临界区内做耗时的操作(如I/O操作)。

  3. 不要将受保护数据的指针或引用传递到锁的作用域之外

    • 这会使锁形同虚设,其他线程可能通过这些指针或引用直接修改数据,从而绕开锁的保护。


案例说明:

案例1:使用 std::mutex 和 std::lock_guard 保护全局计数器

这是一个最经典的场景,多个线程同时增加一个计数器。

#include <iostream>
#include <thread>
#include <mutex>
#include <vector>int shared_counter = 0;
std::mutex counter_mutex; // 互斥锁,用于保护shared_countervoid increment_counter(int num_iterations) {for (int i = 0; i < num_iterations; ++i) {// 使用lock_guard自动管理锁的生命周期std::lock_guard<std::mutex> guard(counter_mutex);// 进入临界区++shared_counter;// guard析构,自动解锁,退出临界区}
}int main() {const int num_threads = 10;const int iterations_per_thread = 10000;std::vector<std::thread> threads;// 创建10个线程,每个线程增加计数器10000次for (int i = 0; i < num_threads; ++i) {threads.emplace_back(increment_counter, iterations_per_thread);}// 等待所有线程完成for (auto& t : threads) {t.join();}// 如果没有数据竞争,结果应该是 10 * 10000 = 100000std::cout << "Final counter value: " << shared_counter << std::endl;// 输出: Final counter value: 100000// 如果去掉锁,结果将远小于100000return 0;
}//threads.emplace_back(increment_counter, iterations_per_thread)用法解析//emplace_back函数被用于在threads向量的末尾构造并插入一个新的std::thread对象。这里,emplace_back接受两个参数:increment_counter和iterations_per_thread。‌increment_counter‌:这是一个函数指针,指向你想要线程执行的函数。在这个例子中,increment_counter是一个接受单个整数参数(表示迭代次数)的函数。‌iterations_per_thread‌:这是传递给increment_counter函数的参数,表示每个线程应该增加计数器的次数。当你调用emplace_back(increment_counter, iterations_per_thread)时,std::thread的构造函数会被调用,以increment_counter作为线程函数,iterations_per_thread作为该函数的参数。这样,一个新的线程对象就会被构造并直接插入到threads向量的末尾。这个过程的好处是,它避免了先创建一个临时的std::thread对象,然后再将其拷贝或移动到向量中的开销。emplace_back直接在向量的内存位置构造对象,这通常更高效。此外,使用emplace_back还可以确保线程对象的正确构造和初始化,因为它是在向量的内存位置直接进行的。这有助于避免由于拷贝或移动构造函数导致的潜在问题。总的来说,emplace_back在创建和管理线程时是一个非常有用的工具,它可以提高代码的效率并简化线程对象的构造过程。

代码分析

  1. 线程创建‌:在main函数中,创建了10个线程,每个线程都执行increment_counter函数,该函数接受一个参数num_iterations,表示线程需要增加计数器的次数。

  2. 互斥锁的使用‌:在increment_counter函数中,使用了std::lock_guard来管理counter_mutex互斥锁的生命周期。这确保了每次只有一个线程可以进入临界区(即增加计数器的代码块),从而避免了数据竞争。

  3. 共享计数器的增加‌:在临界区内,共享计数器shared_counter被安全地增加。由于互斥锁的保护,即使多个线程同时尝试增加计数器,也不会导致数据不一致的问题。

  4. 线程同步‌:所有线程创建后,主线程通过调用join方法等待所有线程完成。这确保了主线程在输出最终计数器值之前,所有线程都已经执行完毕。

潜在问题

尽管代码在功能上是正确的,但仍存在一些潜在的问题和改进空间:

  1. 性能瓶颈‌:使用互斥锁可能会导致性能瓶颈,特别是在高并发场景下。由于互斥锁是独占的,当一个线程持有锁时,其他线程必须等待。这可能导致线程饥饿或不必要的延迟。

  2. 死锁风险‌:虽然本示例中不存在死锁的风险(因为每个线程只锁定和解锁一次,且没有嵌套锁),但在更复杂的程序中,死锁是一个需要特别注意的问题。

  3. 锁粒度‌:本示例中的锁粒度较粗,因为整个增加计数器的操作都被锁定。在某些情况下,可以通过细化锁粒度来提高性能(例如,使用读写锁或自旋锁)。

改进建议

针对上述问题,以下是一些改进建议:

  1. 考虑使用更高效的同步机制‌:根据具体的应用场景,可以考虑使用读写锁、条件变量、信号量等更高效的同步机制。这些机制在某些情况下可以提供比互斥锁更好的性能和可扩展性。

  2. 优化锁的使用‌:尽量减少锁的使用时间和范围。例如,可以将临界区内的代码尽可能简化,只包含必须同步的操作。此外,还可以考虑使用锁降级等技术来优化性能。

  3. 使用原子操作‌:对于简单的计数器增加操作,可以考虑使用C++11中的原子操作(如std::atomic<int>)。原子操作可以确保操作的原子性和可见性,而无需使用锁。这通常可以提供比互斥锁更好的性能。

以下是使用原子操作优化后的代码示例:

#include <iostream>
#include <thread>
#include <atomic>
#include <vector>std::atomic<int> shared_counter(0); // 使用原子操作来保护共享计数器void increment_counter(int num_iterations) {for (int i = 0; i < num_iterations; ++i) {// 直接使用原子操作增加计数器,无需锁++shared_counter;}
}int main() {const int num_threads = 10;const int iterations_per_thread = 10000;std::vector<std::thread> threads;// 创建10个线程,每个线程增加计数器10000次for (int i = 0; i < num_threads; ++i) {threads.emplace_back(increment_counter, iterations_per_thread);}// 等待所有线程完成for (auto& t : threads) {t.join();}// 输出最终计数器值std::cout << "Final counter value: " << shared_counter << std::endl;return 0;
}

我们使用了std::atomic<int>来声明共享计数器shared_counter,并使用原子操作来增加计数器的值。这样,我们就不需要再使用互斥锁来保护计数器,从而提高了性能。

案例2:演示死锁和 std::lock 的解决方案
#include <iostream>
#include <thread>
#include <mutex>std::mutex mutex1;
std::mutex mutex2;void thread_func_bad() {// 不好的方式:锁顺序与另一个线程相反std::lock_guard<std::mutex> lock1(mutex1); // 先锁mutex1std::cout << "Thread 1 acquired mutex1" << std::endl;std::this_thread::sleep_for(std::chrono::milliseconds(1)); // 增加死锁概率std::lock_guard<std::mutex> lock2(mutex2); // 再请求mutex2std::cout << "Thread 1 acquired mutex2" << std::endl;// 临界区操作...
}void thread_func_bad2() {// 不好的方式:锁顺序与另一个线程相反std::lock_guard<std::mutex> lock2(mutex2); // 先锁mutex2std::cout << "Thread 2 acquired mutex2" << std::endl;std::this_thread::sleep_for(std::chrono::milliseconds(1));std::lock_guard<std::mutex> lock1(mutex1); // 再请求mutex1std::cout << "Thread 2 acquired mutex1" << std::endl;// 临界区操作...
}void thread_func_good() {// 好的方式:使用std::lock一次性按顺序锁住所有互斥量// std::lock会使用死锁避免算法来同时锁定两个互斥量std::unique_lock<std::mutex> lock1(mutex1, std::defer_lock);std::unique_lock<std::mutex> lock2(mutex2, std::defer_lock);std::lock(lock1, lock2); // 同时加锁,不会死锁std::cout << "Thread (good) acquired both mutexes" << std::endl;// 临界区操作...// unique_lock会在析构时自动解锁
}int main() {std::cout << "=== Demonstrating Deadlock (potential) ===" << std::endl;// 运行下面两行代码有很高概率会导致死锁,程序卡住// std::thread t1(thread_func_bad);// std::thread t2(thread_func_bad2);// t1.join(); t2.join();std::cout << "=== Demonstrating Solution ===" << std::endl;std::thread t3(thread_func_good);std::thread t4(thread_func_good); // 两个线程使用相同的安全函数t3.join();t4.join();std::cout << "Both threads finished successfully." << std::endl;return 0;
}

总结:

项目说明
核心原理通过“加锁-解锁”机制,确保同一时间只有一个线程能进入临界区,解决数据竞争。
核心类std::mutex
最佳实践始终使用RAII包装器std::lock_guard 或 std::unique_lock)来管理锁,而不是手动调用 lock()/unlock()
关键注意事项1. 防止死锁(固定顺序、使用std::lock)。
2. 最小化临界区以提升性能。
3. 不泄露受保护数据的引用

文章转载自:

http://OZsxos1K.hjrjy.cn
http://nlk8CEVp.hjrjy.cn
http://KduidfxN.hjrjy.cn
http://4gSL0SBJ.hjrjy.cn
http://t6NCHQCI.hjrjy.cn
http://AS2pVnX1.hjrjy.cn
http://DMrdBNtn.hjrjy.cn
http://w9XPMSIF.hjrjy.cn
http://rV6LMwvw.hjrjy.cn
http://yTnsCZEv.hjrjy.cn
http://tAwUEk8A.hjrjy.cn
http://KJrc7rUR.hjrjy.cn
http://XScPH0WO.hjrjy.cn
http://wOfdeY5C.hjrjy.cn
http://aTkzdkiT.hjrjy.cn
http://iAyokp28.hjrjy.cn
http://gUBafxoM.hjrjy.cn
http://54Ucjciu.hjrjy.cn
http://rJx21Iou.hjrjy.cn
http://lxr2ITXD.hjrjy.cn
http://pYTHx76x.hjrjy.cn
http://5rbNNnKX.hjrjy.cn
http://ZMtjhj7n.hjrjy.cn
http://ea7nK9iB.hjrjy.cn
http://KWiRZMBW.hjrjy.cn
http://KcGrywja.hjrjy.cn
http://PHX01SNR.hjrjy.cn
http://v72YP7qY.hjrjy.cn
http://B0D3OTf2.hjrjy.cn
http://ss0QBXPR.hjrjy.cn
http://www.dtcms.com/a/375549.html

相关文章:

  • Python+DRVT 从外部调用 Revit:批量创建柱
  • Matlab机器人工具箱6.2 导入stl模型——用urdf文件描述
  • 网页设计模板 HTML源码网站模板下载
  • 南京大学计算机学院 智能软件工程导论 + Luciano Baresi 教授讲座
  • Rust/C/C++ 混合构建 - Buck2构建工具一探究竟
  • Drawnix:开源一体化白板工具,让你的创意无限流动!
  • stm32 链接脚本没有 .gcc_except_table 段也能支持 C++ 异常
  • K8S集群管理(4)
  • flutter TabBar 设置isScrollable 第一个有间距
  • 学习 Android (二十一) 学习 OpenCV (六)
  • Maven项目中修改公共依赖项目并发布到nexus供三方引用全流程示例
  • GD32VW553-IOT开发板移植适配openharmony
  • nuxt3在使用vue-echarts报错 document is not defined
  • 嵌入式第四十九天(ARM汇编指令)
  • RS485通信 , 和modus RTU
  • 7. LangChain4j + 记忆缓存详细说明
  • 【超简单】Anaconda 安装教程(Windows 图文版)
  • Docker 搭建 Harbor 镜像仓库
  • 数据采集平台的起源与演进:从ETL到数据复制
  • Blender 制作中世纪风格的水磨坊(2):场景元素、纹理与渲染后期
  • 【Python】pytorch安装(使用conda)
  • 阿里云centos7-mysql的使用
  • Android实战进阶 - 启动页
  • 【从零开始编写数据库系统】基于Python语言实现存储引擎
  • 【Pywinauto库】8.3 pywinauto.findwindows 模块
  • 351章:Python Web爬虫入门:使用Requests和BeautifulSoup
  • 禅道,用域名访问之后不能登录的问题
  • Lodash-es 完整开发指南:ES模块化JavaScript工具库实战教程
  • 实践《数字图像处理》之图像方向性自适应阈值处理
  • 【Linux】系统部分——信号的概念和产生