C++中的锁总结

多线程中,锁有以下几种。
互斥锁和自旋锁。(互斥锁就是悲观锁,自旋锁就是乐观锁?最底层就是两种锁:「互斥锁」和「自旋锁」,其他高级锁,如读写锁、悲观锁、乐观锁等都是基于它们实现的。)
互斥锁使用
可以用pthread里的unique_lock
C++11用实例化 std::mutex 创建互斥量,通过调用成员函数lock()进行上锁,unlock()进行解锁。不过,不推荐实践中直接去调用成员函数,因为调用成员函数就意味着,必须记住在每个函数出口都要去调用unlock(),也包括异常的情况。
C++标准库为互斥量提供了一个RAII语法的模板类 std::lock_guard ,其会在构造的时候提供已锁的互斥量,并在析构的时候进行解锁,从而保证了一个已锁的互斥量总是会被正确的解锁。
互斥锁特点
互斥锁就是独占的,拿不到就会让出CPU阻塞。这会涉及到两次系统调用。并且,还会有热的chche凉透。如果锁住的代码执行时间很短,那就没有必要。

互斥锁实现原理
单线程下:硬件指令test and set就可以实现。进入先test看看flag,如果是false,就进入临界区值为true,退出值为false;进入如果是true,就阻塞。
多线程下,硬件提供了锁内存总线的机制,我们在锁内存总线的状态下执行test and set操作,就能保证同时只有一个核来test and set,从而避免了多核下发生的问题。

自旋锁特点
和互斥锁不同,不是阻塞切换而是「**忙等待」来应对。这里的忙等待,可以用「while」循环实现,但最好不要这么干!!CPU提供了「PAUSE」**指令来实现忙等待。「PAUSE」指令通过让CPU休息一定的时钟周期,在此休息期间,耗电几乎停滞。
可以使用 pthread_spin_lock()函数或 pthread_spin_trylock()函数对自旋锁进行加锁,前者在未获取到锁时一直“自旋”;对于后者,如果未能获取到锁,就立刻返回错误,错误码为 EBUSY。

自旋锁底层原理:
实际上可以说自旋锁是比互斥锁更加底层的实现。互斥锁通过test and set和锁住总线的方法来测试flag标志是否可以进入。底层也用了自旋锁。
自旋锁和互斥锁区别就是拿不到锁后怎么办?会while一直忙等。这里最重要的就是如何防止其他的并发干扰了。
比如有:中断的干扰。因为中断代码可以访问临界区,这样就破坏了原子性了。所以内核很多数据结构会加spinlock保护一下,保证禁用中断来保证原子性。
内核抢占的干扰。也可以用自旋锁禁用抢占。
标志位的干扰:多线程下就需要在内存加一个标志,每个需要进入临界区的代码都必须检查这个标志,看是否有进程已经在这个临界区中。这种情况下检查标志的代码也必须保证原子和快速,这就要求必须精细地实现,正常情况下每个构架都有自己的汇编实现方案,保证检查的原子性。比如互斥锁就是test and set加锁内存总线的方法。
(如果简单while循环肯定不行,因为不能保证多个线程同时访问flag,而且必须保证每个处理器都不会去读取高速缓存而是真正的内存中的标志(可以实现,编程上可以用volitale

所以总结来说:
应用场景上,互斥锁适合临界区时间长的,自旋锁适合临界区很短的。
第二就是自旋锁经常用在内核代码里面因为更底层,可以禁用中断和内核抢占,以及多种的标志检查方案,而互斥锁不行,只能保证标志检查和临界区在用户态的独占性。
还有一点就是:其实可以用原子变量来作为标志和临界区变量的类型,自然而然就实现了独占?这需要再看看下面原子锁的实现原理。

互斥锁和条件变量实现同步
当你不仅想要保护数据,还想对单独的线程进行同步。例如,在第一个线程完成前,可能需要等待另一个线程执行完成。通常情况下,线程会等待一个特定事件的发生,或者等待某一条件达成(为true)。
检查任务完成。要么持续检查mutex,这种方法显然很浪费资源。第二种是每隔一段时间进行一次检查,但是过长过短都不行。
第三种方案是使用条件变量(condition variable),标准库对条件变量提供了两种实现:std::condition_variable

条件变量相关函数
wait(unique_lock &lck)

notify_one():没有参数、没有返回值。解除阻塞当前正在等待此条件的线程之一。如果没有线程在等待,则还函数不执行任何操作。如果超过一个,不会指定具体哪一线程。

wait函数都在会阻塞时,自动释放锁权限,即调用unique_lock的成员函数unlock(),以便其他线程能有机会获得锁。这就是条件变量只能和unique_lock一起使用的原因,否则当前线程一直占有锁,线程被阻塞
虚假唤醒问题:由于别的原因导致wait返回(比如notify_all)。所以可以通过while(!pred())循环方式,虚假唤醒发生,由于while循环,再次检查条件是否满足,否则继续等待,解决虚假唤醒。
条件变量一个典型例子就是生产者消费者问题。该问题的关键就是要保证生产者不会在缓冲区满时加入数据,消费者也不会在缓冲区中空时消耗数据。
要解决该问题,就必须让生产者在缓冲区满时休眠(要么干脆就放弃数据),等到下次消费者消耗缓冲区中的数据的时候,生产者才能被唤醒,开始往缓冲区添加数据。

void  producer_thread(int thread_id)
{
    
    
	 while (true)
	 {
    
    
	     std::this_thread::sleep_for(std::chrono::milliseconds(500));
	     //加锁
	     std::unique_lock <std::mutex> lk(g_cvMutex);
	     //当队列未满时,继续添加数据
	     g_cv.wait(lk, [](){
    
     return g_data_deque.size() <= MAX_NUM; });
	     g_next_index++;
	     g_data_deque.push_back(g_next_index);
	     std::cout << "producer_thread: " << thread_id << " producer data: " << g_next_index;
	     std::cout << " queue size: " << g_data_deque.size() << std::endl;
	     //唤醒其他线程 
	     g_cv.notify_all();
	     //自动释放锁
	 }
}
void  consumer_thread(int thread_id)
{
    
    
    while (true)
    {
    
    
        std::this_thread::sleep_for(std::chrono::milliseconds(550));
        //加锁
        std::unique_lock <std::mutex> lk(g_cvMutex);
        //检测条件是否达成
        g_cv.wait( lk,   []{
    
     return !g_data_deque.empty(); });
        //互斥操作,消息数据
        int data = g_data_deque.front();
        g_data_deque.pop_front();
        std::cout << "\tconsumer_thread: " << thread_id << " consumer data: ";
        std::cout << data << " deque size: " << g_data_deque.size() << std::endl;
        //唤醒其他线程
        g_cv.notify_all();
        //自动释放锁
    }
}

所以,同步其实可以看成更严格的互斥。比如生产者消费者的队列是互斥资源,并且还要保证访问顺序,才需要条件变量和锁配合。不满足条件就wait阻塞,释放锁资源给别的线程。满足条件就进行操作。

更轻的原子锁
实现方式:基于缓存加锁与总线加锁。
所谓总线锁就是使用处理器提供的一个lock#信号,当一个处理器在总线上输出此信号时,其他处理器的请求会被阻塞住,那么该处理器可以独占共享内存。
但总线锁定把cpu和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以开销比较大。

缓存加锁
第二个机制是通过缓存锁定来保证原子性。在同一时刻,我们只需保证对某个内存地址的操作是原子性即可,但总线锁定把CPU和内存之间的通信锁住了,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大,目前处理器在某些场合下使用缓存锁定代替总线锁定来进行优化。
但是有两种情况下处理器不会使用缓存锁定。

第一种情况是:当操作的数据不能被缓存在处理器内部,或操作的数据跨多个缓存行(cache line)时,则处理器会调用总线锁定。

第二种情况是:有些处理器不支持缓存锁定。对于Intel 486和Pentium处理器,就算锁定的内存区域在处理器的缓存行中也会调用总线锁定。
C++11提供的原子操作:
C++11中在中定义了atomic模板类,atomic的模板参数类型可以为int、long、bool等等

疑问:原子锁和自旋锁有区别吗?自旋锁亦可以用原子操作实现啊CAS的作用,它就是实现乐观锁的啊,可以实现自旋锁吗?用硬件指令实现原子操作和用CAS实现原子操作的区别是什么?
为什么CAS比锁或 synchronized 关键字高效?

可以看到,其实原子类型或者说原子操作就是利用硬件比如锁总线锁缓存,让同一时刻只有一个能访问,其实CAS也是硬件指令保证比较交换的原子性,和硬件实现可以说一样,只是失败后会继续询问忙等。
从这个角度来看,自旋锁也可以说是基于CAS实现的或者说基于atomic实现,因为标志flag要保证原子操作互斥访问。
所以为什么说原子锁比自旋锁和互斥锁快,因为原子锁相当于直接把涉及的变量的缓存地址设为LOCK,是硬件层面上的非常快,其实CAS也是硬件指令是一样的;而自旋锁互斥锁都是加了一层封装,首先要对标志进行原子操作,可以是CAS,tset_and set,锁总线等,这一步是一样的,而标志是为了对临界区进行控制,所以多了一层控制,一般临界区太大的话就会影响性能了,而原子操作是最底层的变量加锁,临界区最小。
所以说无锁编程atomic,实际上只是没有加互斥锁自旋锁等软件层面的,在硬件上还是有锁的只是效率更高了。
但是,也不是说原子操作或者自旋锁(自旋锁一定程度上就是原子锁,只不过有临界区)一定比互斥锁快,以为当并发竞争很大的时候,CAS会导致太多的失败尝试,CPU消耗,这时候就不如使用互斥锁了。(比如临界区有个for循环时间很长,或者并发量很大,或者写多读少的场景,都会导致锁争用变激烈,比如项目里可以自己尝试,这时候就要作基准测试了,看看上下文切换和忙等的时间开销,只有量化才能提高。

CAS操作

CAS缺点:ABA问题(版本号解决)忙等(java8有分段cas,)只能锁一个变量(自定义对象,用AtomicReference,这个是封装自定义对象的)

乐观锁和悲观锁:乐观锁可以用CAS实现,悲观锁就是独占锁。

猜你喜欢

转载自blog.csdn.net/weixin_53344209/article/details/130443520