C++ 多线程编程(二) 各种各样的锁

 

目录

前言

一、基本锁

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>
接口

void lock();

锁定互斥锁,若另一线程已锁定互斥锁,则到 lock 的调用将阻塞执行,直至获得锁。

bool try_lock();

尝试锁定互斥锁,成功上锁返回true,若另一线程已锁定互斥锁,则返回false ,不会阻塞

void unlock();

解锁互斥锁。

 描述:互斥锁是最基本、简单的锁,只有三个接口,lock()上锁,unlock()解锁,try_lock()尝试上锁。lock()只会进行一次上锁操作,如果失败了(其他线程正在占用),就会进入睡眠状态并阻塞线程,等到互斥锁解锁之后,系统会将其唤醒并进行上锁操作(当然,这时候也不一定能成功)。try_lock()上锁失败会返回false,并继续执行后面的代码。

针对问题:为了初步解决多线程之间数据竞争的问题。

使用场景:由于是最基本的锁,在一些复杂场景中难以满足需求,因此多用于一些不算复杂的情况。

2. 定时互斥锁(timed_mutex)

起始版本 C++11
头文件 <mutex>
接口

void lock();

锁定互斥锁,若另一线程已锁定互斥锁,则到 lock 的调用将阻塞执行,直至获得锁。

bool try_lock();

尝试锁定互斥锁,成功上锁返回true,若另一线程已锁定互斥锁,则返回false,不会阻塞

void unlock();

解锁互斥锁。

template< class Rep, class Period >
bool try_lock_for( const std::chrono::duration& timeout_duration );

尝试加锁,如果锁被占用,阻塞最久 timeout_duration 这个由用户输入的时间,如果在此期间未得到锁,则返回false;成功获得锁时返回 true 。

template< class Clock, class Duration >
bool try_lock_until( const std::chrono::time_point& timeout_time );

尝试获取互斥锁。阻塞直至抵达指定的 timeout_time 或得到锁,成功得到锁时返回ture,失败时返回false。

 描述:相对于mutex而言,timed_mutex增加了try_lock_fortry_lock_until两个接口,前者在尝试获取锁失败时会等待指定时间,在此期间会不断尝试获取锁;后者会获取锁失败时会等待至指定时刻。如果忽视这两个接口,那么timed_mutexmutex无异。

针对问题:为了缓解mutex不够灵活的问题,增加了可选的等待时延。

3. 条件变量 (condition_variable)

起始版本 C++11
头文件 <condition_variable>
接口

void wait( std::unique_lock& lock );

等待并阻塞线程,直到别的线程进行通知,wait( )会释放lock

template< class Predicate >
void wait( std::unique_lock& lock, Predicate pred );

pred是一个lambda函数,返回值为bool,此时的wait等价于

while (!pred()) {
    wait(lock);
}

wait_for(time)    函数原型太长了,就没有全写出来,wait_until也是。

最多等待time时间,就解除阻塞。

wait_until(time)

最多等待至time时刻,就解除阻塞。

void notify_one( ) noexcept;

知一个正在wait的线程

void notify_all( ) noexcept;

通知所有正在wait的线程

描述:条件变量的作用是用于多线程之间的线程同步。线程同步是指线程间需要按照预定的先后顺序进行的行为,比如我想要线程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>
接口

void lock();

排他式锁定互斥。若另一线程已锁定互斥,则到 lock 的调用将阻塞执行,直至获得锁。这里说的“另一线程已锁定互斥”,不仅可能是别的线程提前调用了lock,也可能是别的线程提前调用了shared_lock函数,这也是shared_mutex特殊的地方,有两种上锁方式。

bool try_lock();

尝试排他式锁定互斥。立即返回,成功获得锁时返回 true ,否则返回 false

void unlock();

解锁排他式互斥。

void lock_shared();

以共享式锁定互斥。如果另一线程以排他式锁定互斥,则会阻塞,直到获得锁;如果另一线程或者多个线程以共享式锁定了互斥,则调用者同样会获得锁。

bool try_lock_shared();

尝试共享式锁定互斥。立即返回,成功获得锁时返回 true ,否则返回 false

void unlock_shared();

解锁共享互斥。

描述:shared_mutex有两种上锁方式,一种是排他式,另一种是共享式。排他式上锁同一时间只允许一个线程拥有锁,共享式上锁允许多个线程拥有锁。

针对问题:对于一个线程写,多个线程读的场景,mutex的效率很低。因为不仅读与写之间要加锁,读与读之间也要加锁,但是读与读之间的加锁是不必要的,毕竟它不会改变数据,于是就产生了可以同时读的需求。

适用场景:有多个读线程存在的时候,可以考虑读写锁。

相关文章:

C++多线程——读写锁shared_lock/shared_mutex

5. 递归锁(recursive_mutex)

起始版本 C++11
头文件 <mutex>
接口

void lock();

锁定互斥。若另一线程已锁定互斥,则到lock的调用将阻塞执行,直至获得锁。在同一线程中,可以多次调用lock,不会造成死锁,但是要调用相应次数的unlock

bool try_lock();

尝试锁定互斥。立即返回。成功获得锁时返true ,否则返回 false

void unlock();

解锁互斥。要与lock调用的次数一致才能完成解锁。

描述:recursive_mutexmutex 唯一的区别就在于它可以在同一个线程里多次加锁。

针对问题:想象这样一个场景,函数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
头文件
原型

template< class Mutex >
class lock_guard;

构造函数

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
头文件
构造函数

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的构造函数比较多,包含了默认构造函数,移动构造函数,带锁构造函数,甚至还可以选择加锁的方式,我列几个:

shared_mutex() noexcept;     默认构造函数,没法直接加锁,需要先调用=或者swap;

explicit shared_mutex( mutex_type& m );   带锁的构造函数,会调用m.lock();

shared_mutex( mutex_type& m,
std::defer_lock_t
t ) ;  只与锁进行关联,不执行上锁; 

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
头文件
构造函数

constexpr explicit counting_semaphore( std::ptrdiff_t desired );

创建一个计数器的值为 desired的信号量。

接口

void acquire();

若内部计数器大于 ​0​ 则尝试将它减少 1 ,否则阻塞直至它大于 ​0​ 。

在线程请求访问共享资源的时候调用。

void release( std::ptrdiff_t update = 1 );

将内部计数器的值增加 update 。在线程结束访问资源的时候,可以调用。或者你想增加资源的并发数,也可以调用。

bool try_acquire() noexcept;

尝试将内部计数器减少 1 ,成功返回true,失败返回false,不会阻塞线程。

template
bool try_acquire_for( const std::chrono::duration&  time );

尝试将计数器减1,如果失败则阻塞time时长

template
bool try_acquire_until( const std::chrono::time_point& abs_time );

尝试将计数器减1,如果失败则阻塞至abs_time时刻

constexpr std::ptrdiff_t max() noexcept;

返回最大并发数

针对问题:对于多个线程同时访问某个资源,shared_mutex是可以做到的,但是它不能控制并发的访问数量,而实际中很多计算机资源都存在并发限制,所以我们需要控制对这类资源的并发访问数量。

适用场景:适用于需要控制对资源的并发访问数量,比如线程池中控制线程数量,限制网络连接数等等。


总结

C++20还增加了闩 (latch) 与屏障 (barrier),暂时不写了,下次再写。

你可能感兴趣的:(C++相关,c++)