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

c++11标准(5)——并发库(互斥锁)

欢迎来到博主的专栏:c++杂谈
博主ID:代码小豪

文章目录

    • mutex
    • 其他类型的互斥锁
    • 具有RAII的管理锁方式
    • 其他相关函数

在并发的场景下,会存在线程安全的问题,其核心原因在于,线程之间会有调度切换,比如linux中基于优先级,时间片的线程调度,一个线程在运行一个时间片后,会切换到下一个线程。这就会导致一个线程未完成的任务影响到后续线程的运行,特别是那些对于临界资源的修改操作。

更多关于操作系统的原理就不赘述了,我们来看看常见的由于线程并发导致的问题。

#include<iostream>
#include<thread>
#include<mutex>int num = 0;
std::mutex mtx;void Add_num_Ntime(int n) {//mtx.lock();for (int i = 0; i < n; i++) {num++;}//mtx.unlock();
}int main() {std::thread threads[2];threads[0] = std::thread(Add_num_Ntime, 100000);threads[1] = std::thread(Add_num_Ntime, 200000);threads[0].join();threads[1].join();std::cout << num << std::endl;return 0;
}

这是很经典的多线程并发场景下对临界资源进行修改的场景,该代码的目的是对num增加N次,比如线程0让num增加100000次,线程1让num增加200000次,那么当线程1和线程2计算结束后,num的值应为300000。但是在不加锁的情况下,num的值基本不可能会是300000。其原因就在于num++这个操作不是原子的,因此并发操作会导致寄存器中关于num的值被污染,最终导致num的计算结果错误。

那么加上互斥锁之后,这个问题就迎刃而解了,因为互斥锁可以让线程在锁的范围内,从并行运行变为串行运行,其原理在于,当一个线程获取到锁(lock)之后,其余的线程无法进入锁中的代码,直到线程将锁进行释放(unlock)。因此上述的代码中,假设线程0先获取到锁,那么线程0就会对num增加100000次,在此期间,由于锁一直在线程0身上,因此即使线程0没运行完,调度切换到线程1之后,线程1也无法进入锁当中的代码,也就无法对num进行修改操作了,直到线程0执行结束,此时num=100000,将锁释放,接着线程1就可以获取到锁,进入锁中的代码,执行对num增加200000次的操作,因此最终的结果可以保证是300000。

但是线程由并行变为串行,这意味着本来是多个线程可以一起运行的,但是现在只有一个可以运行,所以加了锁之后,程序的运行效率一定是降低的。但是可以保证运行的安全性(或者说正确性?)。可以想象,一个程序肯定是要优先保证安全性的情况下,才要去考虑运行速度,因为如果程序当中的数据,都不能保证正确,那么这个程序能完成任务吗?比如微信支付2.5,结果扣费了25,你还会使用用微信吗?

mutex

在c++11标准中正式引入<mutex>库中,而mutex是<mutex>库中的一个类,是对互斥锁的封装。

我们先来看看mutex的构造函数

default (1)	
constexpr mutex() noexcept;
copy [deleted] (2)	
mutex (const mutex&) = delete;

mutex只支持默认构造函数,拷贝构造函数被禁用。

mutex中具有以下四个成员函数

void lock();
bool try_lock();
void unlock();
native_handle_type native_handle();

其中,lock表示申请获取锁,如果锁是空闲的(没有其他锁使用),那么该线程就能获取到锁,如果锁是未归还的,那么该线程就会阻塞等待在lock函数中,直到持有锁的线程将其归还。

而unlock则是释放锁,一个线程持有锁了,那么当这个线程执行完任务后,需要需要unlock来释放锁,不然其他的线程就无法使用了。

如果申请不到锁,那么线程就会阻塞等待其他锁,如果不想让线程阻塞等待锁,可以是try_lock。如果此时锁处于空闲状态,就能成功获取锁,并且try_lock返回true。反之返回0,而try_lock无论是否申请成功,都不会阻塞等待。一般情况下是线程可能处理多个任务。如果一个任务需要锁,那么就可以使用try_lock尝试申请,申请失败后执行下一个任务。那么关于try_lock有一个误区,有些人觉得lock会导致阻塞等待,而try_lock不会,那么是不是代表try_lock具有更好的效率呢?这里我们需要辩证的看待,首先要确定一点,线程阻塞等待时,只会占用非常非常小的CPU的效率,如果线程真的没有其他特别重要任务,还是让其乖乖的等待其他线程释放锁吧。

由于锁需要多个线程去竞争,因此一个mutex需要被多个线程去使用,所以mutex一般有两种使用方法,最简单的方式就是将mutex声明为全局变量,这样所有的线程就能使用共同的锁,而还有一种方法则是传引用的方式。

int num = 0;void Add_num_Ntime(int n,std::mutex& mtx) {mtx.lock();for (int i = 0; i < n; i++) {num++;}mtx.unlock();
}int main() {std::mutex mtx;std::thread threads[2];threads[0] = std::thread(Add_num_Ntime, 100000,mtx);threads[1] = std::thread(Add_num_Ntime, 200000,mtx);threads[0].join();threads[1].join();std::cout << num << std::endl;return 0;
}

但是这么做可行吗?理论上是正确的,在主线程中存在一个唯一的mutex,接着其余线程通过引用传参的方式,获取唯一的mutex,那么每个线程中对于锁的竞争是构成的,但是我们运行一下试试呢?
在这里插入图片描述

ok,可以看到报错了,而且报错原因也是怪怪的,实际上错误的原因和报错没什么关系。这是和<thread>库的设计有关,在c++11中,线程库的本质是对创建线程的系统调用进行封装,以linux为例,创建线程的pthread函数如下:

 int pthread_create(pthread_t *tidp,const pthread_attr_t *attr, void *(*start_rtn)(void*), void *arg);

我们可以看到,这个函数其实是固定的参数和返回值,但是c++的thread类却允许我们使用可变的参数列表,所以<thread>库本身肯定是在封装系统调用的同时,进行了一些额外的处理。这里我们就不看源码实现了,总而言之,与线程任务相关的一切参数,都会拷贝给tuple类,因此传引用mutex并不能真正的做到传引用,而是先将mutex拷贝给tuple类,再做为线程任务的参数。因此最终来到线程任务时,就不在是最初传参对象的引用了,而是一个拷贝,因此无法做到唯一性。

而c++的编写者也是考虑到了这一点,所以在c++11中还推出了ref函数,这个ref函数就是为了解决类似的传引用的问题。比如

int num = 0;void Add_num_Ntime(int n,std::mutex& mtx) {mtx.lock();for (int i = 0; i < n; i++) {num++;}mtx.unlock();
}int main() {std::mutex mtx;std::thread threads[2];threads[0] = std::thread(Add_num_Ntime, 100000,ref(mtx));//传引用的参数之前加上refthreads[1] = std::thread(Add_num_Ntime, 200000,ref(mtx));threads[0].join();threads[1].join();std::cout << num << std::endl;return 0;
}

如果觉得上述的原理不好理解的话,只需要记住结论,有关线程任务的参数如果出现传引用,那么一定要在参数前面加上ref。

其他类型的互斥锁

除了mutex以外,<mutex>还为我们提供了其他类型的锁。比如

class recursive_mutex;
class timed_mutex;
class recursive_timed_mutex;

recursive_mutex通常用于递归函数中使用,因为普通mutex无法运用在递归当中。我们简单的模拟一下递归函数。

std::mutex mtx;
void func(int x,int y){mtx.lock();//....func();//...mtx.unlock();
}

假设现在有一个线程0在执行func的过程中获取到了mtx,那么此时mtx就不再是一个空闲的互斥锁了,那么当线程0进入下一层递归时,又需要再次申请一个mtx,由于此时mtx还没有被释放,所以依旧是属于未归还状态,因此线程0无法继续运行,这也是死锁的一个场景。

而解决方式就是将锁mtx的类型,改为recursive_mutex。recursive_mutex支持递归场景下的互斥。recursive_mutex的成员函数和使用方法与mutex没有太多区别,只是支持在递归的场景下使用。
在这里插入图片描述

timed_mutex支持计时的功能。相比较mutex多了以下的成员函数

template <class Rep, class Period>bool try_lock_for (const chrono::duration<Rep,Period>& rel_time);template <class Clock, class Duration>bool try_lock_until (const chrono::time_point<Clock,Duration>& abs_time);

那么什么场景会使用timed_mutex呢?比如我们不想线程在申请锁失败后阻塞等待,又不想像try_lock那样,申请失败头也不回的跑了,希望是有一个时间作为缓冲,比如让线程等待1s,2s的时间内,如果锁好了就拿去用,锁没好就干其他的事情,此时使用timed_mutex就可以实现。

// timed_mutex::try_lock_for example
#include <iostream>       // std::cout
#include <chrono>         // std::chrono::milliseconds
#include <thread>         // std::thread
#include <mutex>          // std::timed_mutexstd::timed_mutex mtx;void fireworks() {// 在等待锁的过程中,每隔1s就会打印一个'-',直到获取锁:while (!mtx.try_lock_for(std::chrono::milliseconds(1000))) {std::cout << "-";}// 获取锁后, 等待1s,该线程会打印一个'*'std::this_thread::sleep_for(std::chrono::milliseconds(5000));std::cout << "*\n";mtx.unlock();
}int main()
{std::thread threads[2];// spawn 2 threads:for (int i = 0; i < 2; ++i)threads[i] = std::thread(fireworks);for (auto& th : threads) th.join();return 0;
}

而recursive_timed_mutex则是recursive_mutex和timed_mutex的结合体,这里不多赘述。

具有RAII的管理锁方式

我们来想象下面两种场景

  • 场景1:一个线程,获取了锁,但是在释放锁之前,由于一些原因强制退出了。那么锁怎么办?
  • 场景2:一个线程,获取了锁,但是在释放所之前,发生了异常处理,那么锁怎么办。

在上面的场景中,都有一个共同点,就是由于一些原因,线程在未归还锁之前,退出了当前执行的任务,一般情况下,如果没有使用unlock,锁是无法被归还的。场景1的行为是未定义的,因此不要强制退出一个线程。而场景2我们可以用lock_guard或者unique_lock来解决。

RAII(Resource Acquisition Is Initialization)是C++的核心编程理念,指资源获取即初始化。其核心思想是将资源(内存、文件句柄、锁等)的生命周期与对象的生命周期绑定:对象构造时获取资源,析构时自动释放资源。这种方法利用栈对象确定性析构的特性,确保资源在任何执行路径(包括异常)下都能正确释放,避免资源泄漏。

那么lock_guard是怎么做到在生命周期结束时释放锁的呢?lock_guard是一个只有构造函数和析构函数的对象,首先lock_guard需要用户传一个锁的引用,当调用构造函数时,调用锁的lock函数,当调用析构函数时,调用锁的unlock函数,这样就能保证锁的生命周期,与lock_guard的生命周期是一致的。具体原理可以参考下面的代码。

class lock_guard{
public:lock_guard(mutex&mtx) :_mtx(mtx){_mtx.lock();}~lock_guard(){_mtx.unlock();}
private:mutex& _mtx;
}

所以,如果我们使用lock_guard,就不用担心由于抛异常导致锁丢失的问题,因为lock_guard的生命周期一旦结束,锁也会随之释放。而lock_guard的构造函数如下:

locking (1)	
explicit lock_guard (mutex_type& m);
adopting (2)	
lock_guard (mutex_type& m, adopt_lock_t tag);

其中方法1类似于我们上面所写的示例代码,传入一个锁参数,构造lock_guard时,将锁给锁上,而方法2是只获取锁的使用权。这是什么意思呢?我们看看下面的实力代码。

// constructing lock_guard with adopt_lock
#include <iostream>       // std::cout
#include <thread>         // std::thread
#include <mutex>          // std::mutex, std::lock_guard, std::adopt_lockstd::mutex mtx;           // mutex for critical sectionvoid print_thread_id (int id) {mtx.lock();std::lock_guard<std::mutex> lck (mtx, std::adopt_lock);std::cout << "thread #" << id << '\n';std::this_thread::sleep_for(std::chrono::milliseconds(1000));
}int main ()
{std::thread threads[10];// spawn 10 threads:for (int i=0; i<10; ++i)threads[i] = std::thread(print_thread_id,i+1);for (auto& th : threads) th.join();return 0;
}

改代码创建10个线程,每个线程会打印信息,接着休眠1s,在整个过程中,线程只调用lock,没有调用unlock,在平时,其他的线程将会进入死锁状态。而lock_ruard获取了mtx的使用权,当lock_guard的生命周期结束时,会将锁进行释放,所以如果添加了lock_guard,将不会有死锁的问题。

unique_lock的功能更加强大,不仅仅有RAII机制,还可以像一般的互斥锁一样,手动的调用成员函数来进行加锁,解锁等操作。
在这里插入图片描述
这些功能就不说,我们重点讲讲unique_lock的构造函数和析构函数。

当unique_lock调用析构函数时,如果之前没有使用unlock释放当前的锁,那么就会自动的释放锁。

unique_lock的构造函数很多,不支持赋值和拷贝,支持移动构造和移动复制。大多数是为了支持更多类型的锁,比如6和7的构造函数是为了适应timed_lock。而我们重点放在3、4、5中。

default (1)	
unique_lock() noexcept;
locking (2)	
explicit unique_lock (mutex_type& m);
try-locking (3)	
unique_lock (mutex_type& m, try_to_lock_t tag);
deferred (4)	
unique_lock (mutex_type& m, defer_lock_t tag) noexcept;
adopting (5)	
unique_lock (mutex_type& m, adopt_lock_t tag);
locking for (6)	
template <class Rep, class Period>
unique_lock (mutex_type& m, const chrono::duration<Rep,Period>& rel_time);
locking until (7)	
template <class Clock, class Duration>
unique_lock (mutex_type& m, const chrono::time_point<Clock,Duration>& abs_time);
copy [deleted] (8)	
unique_lock (const unique_lock&) = delete;
move (9)	
unique_lock (unique_lock&& x);

unique_lock⾸先在构造的时候传不同的tag,⽤以⽀持在构造的时候不同的⽅式处理锁对象。

传入tag对应操作
不传tag在构造时调用lock
defer_lock在构造时不调用lock(用户手动的去加锁)
try_to_lock在构造时调用try_lock(尝试去锁)
adopt_lock不调用锁对象的lock,而是获取对象的权限

其他相关函数

我们判断下面的代码能否成功运行?为什么?

std::mutex foo,bar;
void task_a () {foo.lock(); std::this_thread::sleep_for(std::chrono::seconds(1));bar.lock(); // replaced by:std::cout << "task a\n";foo.unlock();bar.unlock();
}void task_b () {bar.lock(); std::this_thread::sleep_for(std::chrono::seconds(1));foo.lock(); // replaced by:std::cout << "task b\n";bar.unlock();foo.unlock();
}int main ()
{std::thread th1 (task_a);std::thread th2 (task_b);th1.join();th2.join();return 0;
}

答案是不能,为什么?因为th1和th2会陷入死锁状态,这是因为th1获取了锁foo,而th2获取了锁bar,但是th1想要继续运行,就要获取锁bar,而th2想要继续运行,就要获取foo。但是能成功获取吗?不能,因为锁都在别人那呢。有人也许看到这会感到不屑,觉得谁会写出这么奇怪的代码啊?但是实际上,如果在编写比较大型的项目时,一旦业务增多,程序员是不是要添加框架啊?如果此时项目需要加锁,在越复杂的逻辑中,就越容易出现死锁的问题。

那么如何应对这种需要申请多个锁的场景呢?lock是⼀个函数模板,可以⽀持对多个锁对象同时锁定,如果其中⼀个锁对象没有锁住,lock函数会把已经锁定的对象解锁⽽进⼊阻塞,直到锁定所有的所有的对象。

void task_a() {// foo.lock(); bar.lock(); // replaced by:std::lock(foo, bar);std::cout << "task a\n";foo.unlock();bar.unlock();
}void task_b() {// bar.lock(); foo.lock(); // replaced by:std::lock(bar, foo);std::cout << "task b\n";bar.unlock();foo.unlock();
}int main()
{std::thread th1(task_a);std::thread th2(task_b);th1.join();th2.join();return 0;
}

还有一个成员函数叫做call once,意思是所有线程只能执行一次。多线程执⾏时,让第⼀个线程执⾏Fn⼀次,其他线程不再执⾏Fn。这个函数用的不多,留一个印象就好。

// call_once example
#include <iostream>       // std::cout
#include <thread>         // std::thread, std::this_thread::sleep_for
#include <chrono>         // std::chrono::milliseconds
#include <mutex>          // std::call_once, std::once_flagint winner;
void set_winner (int x) { winner = x; }
std::once_flag winner_flag;void wait_1000ms (int id) {// count to 1000, waiting 1ms between increments:for (int i=0; i<1000; ++i)std::this_thread::sleep_for(std::chrono::milliseconds(1));// claim to be the winner (only the first such call is executed):std::call_once (winner_flag,set_winner,id);
}int main ()
{std::thread threads[10];// spawn 10 threads:for (int i=0; i<10; ++i)threads[i] = std::thread(wait_1000ms,i+1);std::cout << "waiting for the first among 10 threads to count 1000 ms...\n";for (auto& th : threads) th.join();std::cout << "winner thread: " << winner << '\n';return 0;
}

相关文章:

  • 偏微分方程通解求解2
  • ​《吠檀多不二论的四个基本原理》​(前三部分)
  • 【软考高级系统架构论文】论无服务器架构及其应用
  • 2025年- H83-Lc191--139.单词拆分(动态规划)--Java版
  • Axios 在 Vue3 项目中的使用:从安装到组件中的使用
  • XSS-labs的1-18关
  • 60天python训练营打卡day38
  • 【StarRocks系列】查询优化
  • C 语言结构体:从基础到内存对齐深度解析
  • springboot垃圾分类网站
  • 响应式数据的判断:Vue3中的方法
  • 学c++ cpp 可以投递哪些岗位
  • AI大模型(四)openAI应用实战
  • 大模型在急性弥漫性腹膜炎预测及治疗方案制定中的应用研究
  • rt-thread中使用usb官方自带的驱动问题记录
  • MySQL存储引擎与架构
  • 【Datawhale组队学习202506】零基础学爬虫 02 数据解析与提取
  • 在Docker网络中,同一网络下的容器可以直接通过内部端口通信,无需经过主机端口映射,这是由Docker的网络隔离和内部通信机制决定的。
  • Python 邻接表详细实现指南
  • LeetCode第279题_完全平方数
  • php 怎么做 网站 图片/网页制作教程书籍
  • 网站建设工具品牌/北京cms建站模板
  • 哪个网站做外贸好/有哪些平台可以做推广
  • 别人品牌的域名做网站吗/杭州网络推广网络优化
  • 谷歌建站多少钱/seo搜索引擎推广
  • 网站流量显示/网站按天扣费优化推广