目录
前言
一、基本锁
1. 互斥锁(mutex)
2. 定时互斥锁(timed_mutex)
3. 条件变量 (condition_variable)
4. 读写锁 (shared_mutex)
5. 递归锁(recursive_mutex)
6. 自旋锁 (spinlock)
二、RAII锁
1. lock_guard
2. unique_lock
3. shared_lock
三、信号量
总结
前言
多线程编程一个重要的问题就是数据竞争,多个线程同时获取一份数据的使用权,如果不加以控制,必然会导致程序的崩溃。
锁(mutex),就是用来调度各线程使用共享数据的中介。锁有已锁和未锁两个状态,处于未锁状态的锁,线程可以将它锁上,此时只能由该线程访问共享数据;如果锁处于已锁状态,则需要等待别的线程解锁之后,才能上锁并使用数据,这样就避免了多个线程同时访问一份数据。如果不好理解,可以类比以下厕所的一个坑位。。。
本篇文章主要对C++现有的锁进行介绍,由于锁的种类繁多,而且相关文章已经非常多了,本文不再细讲各种锁的技术细节,而是更注重于各种锁的由来,以及他们针对的问题,如此便能针对自己面临的问题,选择合适的锁
。
今天是2023年4月21号,目前我使用的是C++20,因此在这篇文章,我们记录C++20中各类的锁。主要包括基本锁和RAII锁,基本锁包括互斥锁 (mutex),定时互斥锁 (timed_mutex),条件变量 (condition_variable),读写锁 (shared_mutex),递归锁 (recursive_mutex),自旋锁 (spinlock)。RAII锁是基本基本锁实现的更加智能的锁。
一、基本锁
1. 互斥锁(mutex)
起始版本 | C++11 |
头文件 | <mutex > |
接口 |
锁定互斥锁,若另一线程已锁定互斥锁,则到 |
尝试锁定互斥锁,成功上锁返回 | |
解锁互斥锁。 |
描述:互斥锁是最基本、简单的锁,只有三个接口,lock
()上锁,unlock
()解锁,try_lock
()尝试上锁。lock
()只会进行一次上锁操作,如果失败了(其他线程正在占用),就会进入睡眠状态并阻塞线程,等到互斥锁解锁之后,系统会将其唤醒并进行上锁操作(当然,这时候也不一定能成功)。try_lock
()上锁失败会返回false
,并继续执行后面的代码。
针对问题:为了初步解决多线程之间数据竞争的问题。
使用场景:由于是最基本的锁,在一些复杂场景中难以满足需求,因此多用于一些不算复杂的情况。
2. 定时互斥锁(timed_mutex)
起始版本 | C++11 |
头文件 | <mutex > |
接口 |
锁定互斥锁,若另一线程已锁定互斥锁,则到 |
尝试锁定互斥锁,成功上锁返回true,若另一线程已锁定互斥锁,则返回false,不会阻塞 | |
解锁互斥锁。 | |
template< class Rep, class Period > 尝试加锁,如果锁被占用,阻塞最久 | |
template< class Clock, class Duration > 尝试获取互斥锁。阻塞直至抵达指定的 |
描述:相对于mutex
而言,timed_mutex
增加了try_lock_for
和try_lock_until
两个接口,前者在尝试获取锁失败时会等待指定时间,在此期间会不断尝试获取锁;后者会获取锁失败时会等待至指定时刻。如果忽视这两个接口,那么timed_mutex
与mutex
无异。
针对问题:为了缓解mutex
不够灵活的问题,增加了可选的等待时延。
3. 条件变量 (condition_variable)
起始版本 | C++11 |
头文件 | <condition_variable > |
接口 |
等待并阻塞线程,直到别的线程进行通知,wait( )会释放lock。 |
template< class Predicate > pred是一个lambda函数,返回值为bool,此时的wait等价于 while (!pred()) { wait(lock); } | |
最多等待 | |
最多等待至 | |
通知一个正在 | |
通知所有正在 |
描述:条件变量的作用是用于多线程之间的线程同步。线程同步是指线程间需要按照预定的先后顺序进行的行为,比如我想要线程1完成了某个步骤之后,才允许线程2开始工作,这个时候就可以使用条件变量来达到目的。具体操作就可以是,线程2调用wait
函数进行等待,线程1调用notify
函数进行通知,这样就能保证线程1和线程2的顺序。
针对问题:上边说的线程同步问题,用互斥锁mutex
也能实现,但是并不优美。这里我引用别人的一个解释:
以一个生产者消费者的例子来看,生产者和消费者通过一个队列连接,因为队列属于共享变量,所以在访问队列时需要加锁。生产者向队列中放入消息的时间是不一定的,因为消费者不知道队列什么时候有消息,所以只能不停循环判断或者sleep一段时间,不停循环会浪费cpu资源,如果sleep那么要sleep多久,sleep太短又会浪费资源,sleep太长又会导致消息消费不及时。
应用场景:多用于生产消费队列中。
相关文章:
C++11条件变量condition_variable详解
为什么互斥锁和条件变量要一起使用
4. 读写锁 (shared_mutex)
起始版本 | C++17 |
头文件 | <shared_mutex > |
接口 |
排他式锁定互斥。若另一线程已锁定互斥,则到 |
尝试排他式锁定互斥。立即返回,成功获得锁时返回 | |
解锁排他式互斥。 | |
以共享式锁定互斥。如果另一线程以排他式锁定互斥,则会阻塞,直到获得锁;如果另一线程或者多个线程以共享式锁定了互斥,则调用者同样会获得锁。 | |
尝试共享式锁定互斥。立即返回,成功获得锁时返回 | |
解锁共享互斥。 |
描述:shared_mutex有两种上锁方式,一种是排他式,另一种是共享式。排他式上锁同一时间只允许一个线程拥有锁,共享式上锁允许多个线程拥有锁。
针对问题:对于一个线程写,多个线程读的场景,mutex
的效率很低。因为不仅读与写之间要加锁,读与读之间也要加锁,但是读与读之间的加锁是不必要的,毕竟它不会改变数据,于是就产生了可以同时读的需求。
适用场景:有多个读线程存在的时候,可以考虑读写锁。
相关文章:
C++多线程——读写锁shared_lock/shared_mutex
5. 递归锁(recursive_mutex)
起始版本 | C++11 |
头文件 | <mutex > |
接口 |
锁定互斥。若另一线程已锁定互斥,则到 |
尝试锁定互斥。立即返回。成功获得锁时返回 | |
解锁互斥。要与 |
描述:recursive_mutex
与 mutex
唯一的区别就在于它可以在同一个线程里多次加锁。
针对问题:想象这样一个场景,函数A调用了函数B,而且函数A和B都访问了一份共享数据,这样就可能造成死锁。
适用场景:共享数据存在递归调用的时候。
相关文章:
递归锁recursive_mutex的原理以及使用
6. 自旋锁 (spinlock)
C++标准库目前没有实现自旋锁。
描述:自旋锁与互斥锁(mutex
)的区别在于,mutex
调用lock
之后,会进入睡眠状态,等到锁可用了,再由cpu唤醒,再次获取锁;而自旋锁不会进去睡眠状态,会一直尝试获取锁。这种一直尝试获取锁的行为很耗cpu资源,所以要用在合适的场景。
用mutex就可以实现一个简单的自旋锁:
while(!mutex.try_lock()){}
针对问题:考虑这样一种情况,如果线程对共享资源占用时间非常短,也就是说mutex
.lock
()等待的时间非常短,那么CPU将线程挂起再唤醒所耗费的资源可能要大于一直尝试加锁。
适用场景:每个线程对共享资源占用时间非常短的情况。
相关文章:
c++之理解自旋锁
二、RAII锁
首先,什么是RAII?
RAII全称是Resource Acquisition Is Initialization,翻译过来是资源获取即初始化,RAII机制用于管理资源的申请和释放。对于资源,我们通常经历三个过程,申请,使用,释放,这里的资源不仅仅是内存,也可以是文件、socket、锁等等。但是我们往往只关注资源的申请和使用,而忘了释放,这不仅会导致内存泄漏,可能还会导致业务逻辑的错误。c++之父给出了解决问题的方案:RAII,它充分的利用了C++语言局部对象自动销毁的特性来控制资源的生命周期。
简而言之就是,将资源(内存、socket、锁等等)与一个局部变量绑定,这样就可以避免我们忘记释放资源,智能指针也是这样的思想。
RAII锁本质上都是模板类,模板类型是这种锁,也可以理解为对锁的进一步封装。
1. lock_guard
起始版本 | C++11 |
头文件 | <mutex> |
原型 | template< class Mutex > |
构造函数 | explicit lock_guard( mutex_type& m ); 等于调用 m.lock() 。 |
析构函数 | ~lock_guard(); 等效地调用 m.unlock() |
描述:lock_guard的构造函数需要传入一个锁,我们称这个锁为关联锁,然后在构造函数内部进行加锁,在析构函数中进行解锁。
针对问题:针对用户可能忘记解锁的问题。
适用场景:建议用RAII锁替换一般的锁,这样更加安全。
相关文章:
走进C++11 RAII风格锁std::lock_guard/std::unique_lock
2. unique_lock
起始版本 | C++11 |
头文件 | <mutex> |
构造函数 | unique_lock的构造函数比较多,包含了默认构造函数,移动构造函数,带锁构造函数,甚至还可以选择加锁的方式,我列几个: unique_lock() noexcept; 默认构造函数,没法直接加锁,需要先调用=或者swap; explicit unique_lock( mutex_type& m ); 带锁的构造函数,会调用m.lock(); unique_lock( mutex_type& m, std::defer_lock_t t ) ; 只与锁进行关联,不执行上锁; unique_lock( mutex_type& m, std::try_to_lock_t t ); 相当于调用m.try_lock(); .........更多 |
析构函数 | ~unique_lock(); 若当前线程拥有关联互斥且获得了其所有权,则解锁互斥。 |
上锁 | void lock(); 等同于m.lock(),m是与unique_lock关联的锁; bool try_lock(); 等同于m.try_lock(); bool try_lock_for(time); 等同于m.try_lock_for(time); bool try_lock_until(time); 等同于m.bool try_lock_until(); void unlock(); 解锁 |
修改unique_lock | void swap( unique_lock& other ) noexcept; 与另一 std::unique_lock 交换状态 mutex_type* release() noexcept; 将关联互斥解关联而不解锁它 |
其他 | mutex_type* mutex() const noexcept; 返回指向关联互斥的指针,或若无关联锁则null bool owns_lock() const noexcept; 检查当前线程是否占有关联锁。 |
描述:unique_lock是lock_guard的加强版,首先在构造函数中可以选择是否与锁关联,以及对锁的操作类型;其次它还可以像锁一样调用lock(),try_lock(),unlock()等函数,这使得unique_lock更加灵活;最后它增加了swap,release,owns_lock这些非常实用的接口。
针对问题:lock_guard的问题在于不够灵活,这个不够灵活首先体现在不能自由的释放锁,必须等到跳出作用域的时候调用析构函数才能解锁;再有就是lock_guard只提供了构造函数和析构函数,没有任何功能接口。
适用场景:建议用unique_lock替换lock_guard;
相关文章:
走进C++11 RAII风格锁std::lock_guard/std::unique_lock
3. shared_lock
起始版本 | C++14 |
头文件 | <shared_mutex > |
构造函数 |
shared_mutex() noexcept; 默认构造函数,没法直接加锁,需要先调用=或者swap; explicit shared_mutex( mutex_type& m ); 带锁的构造函数,会调用m.lock(); shared_mutex( mutex_type& m, shared_mutex( mutex_type& m, std::try_to_lock_t t ); 相当于调用m.try_lock(); .........更多 |
析构函数 | ~shared_mutex(); 若当前线程拥有关联互斥且获得了其所有权,则解锁互斥。 |
上锁 | void lock(); 等同于m.lock_shared(),m是与unique_lock关联的锁; bool try_lock(); 等同于m.try_lock_shared(); bool try_lock_for(time); 等同于m.try_lock_shared_for(time); bool try_lock_until(time); 等同于m.bool try_lock_shared_until(); void unlock(); 解锁互斥, 等同于m.unlock_shared() |
修改shared_mutex | void swap( shared_mutex& other ) noexcept; 与另一 std::shared_mutex 交换状态 mutex_type* release() noexcept; 将关联互斥解关联而不解锁它 |
其他 | mutex_type* mutex() const noexcept; 返回指向关联互斥的指针,或若无关联锁则null bool owns_lock() const noexcept; 检查当前线程是否占有关联锁。 |
描述:上边这个表可以看出来,shared_lock与unique_lock的接口一模一样,他们唯一的区别在于,unique_lock是以排他式上锁,同一时刻只允许一个线程获取锁;而shared_mutex是以共享式上锁,允许多个线程同时获得锁。此外,shared_mutex只能与shared_mutex关联,因为别的锁压根没有lock_shared接口。
针对问题:shared_mutex算是对unique_lock的补充,因为unique_lock在存在多个只读线程的情况时,会有较大的性能损失,因为只读线程之间加锁是不必要的。
适用场景:与shared_mutex一样。
相关文章:
C++多线程——读写锁shared_lock/shared_mutex
三、信号量
描述:
信号量 (semaphore) 是一种轻量的同步原件,主要用处是控制对共享资源的并发访问数,说白点就是控制同一时间访问某一资源的线程数。比如网吧,就那么多位置,满了网管就不会给你开机子,必须等有人下机才能上机,这里的网吧就是共享资源,他有并发数量限制,网管就类似信号量的功能,限制网吧的同时访问数量。
信号量不是C++语言特有的概念,而是计算机学的概念,C++在C++20里才在标准库里对其进行了实现。
信号量内部会维护一个计数器,每当有线程访问资源,就将计数器减1;当有线程结束访问,就将计数器加1。如果计数器不大于0,那么新的访问请求就需要等待。
C++20提供了两种信号量,std::counting_semaphore和std::binary_semaphore,由于binary_semaphore是counting_semaphore的特例,因此这里主要介绍counting_semaphore。
起始版本 | C++20 |
头文件 | <semaphore> |
构造函数 | constexpr explicit counting_semaphore( std::ptrdiff_t desired ); 创建一个计数器的值为 |
接口 | void acquire(); 若内部计数器大于 0 则尝试将它减少 1 ,否则阻塞直至它大于 0 。 在线程请求访问共享资源的时候调用。 |
void release( std::ptrdiff_t update = 1 ); 将内部计数器的值增加 | |
bool try_acquire() noexcept; 尝试将内部计数器减少 1 ,成功返回true,失败返回false,不会阻塞线程。 | |
template<class Rep, class Period> 尝试将计数器减1,如果失败则阻塞time时长 | |
template<class Clock, class Duration> 尝试将计数器减1,如果失败则阻塞至abs_time时刻 | |
constexpr std::ptrdiff_t max() noexcept; 返回最大并发数 |
针对问题:对于多个线程同时访问某个资源,shared_mutex是可以做到的,但是它不能控制并发的访问数量,而实际中很多计算机资源都存在并发限制,所以我们需要控制对这类资源的并发访问数量。
适用场景:适用于需要控制对资源的并发访问数量,比如线程池中控制线程数量,限制网络连接数等等。文章来源:https://www.toymoban.com/news/detail-617197.html
总结
C++20还增加了闩 (latch) 与屏障 (barrier),暂时不写了,下次再写。文章来源地址https://www.toymoban.com/news/detail-617197.html
到了这里,关于C++ 多线程编程(二) 各种各样的锁的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!