当前位置:首页 > > 充电吧
[导读]背景 C/C++语言的并发程序(Concurrent Programming)设计,一直是一个比较困难的话题。很多朋友都会尝试使用多线程编程,但是却很难保证自己所写的多线程程序的正确性。多线程程序,如

背景

 

C/C++语言的并发程序(Concurrent Programming)设计,一直是一个比较困难的话题。很多朋友都会尝试使用多线程编程,但是却很难保证自己所写的多线程程序的正确性。多线程程序,如果涉及到对共享资源的并发读写,就会产生资源争用(Data Race)。解决资源争用,最直接的想法是引入锁,对并发读写的数据进行保护(更高级的则包括无锁编程—— Lock Free Programming)。但是,锁又有很多种类,例如:自旋锁(Spinlock)、互斥锁(Mutex)、读写锁(Read-Write-Lock)等等。这么多的锁,每种锁有什么特点?对应哪些不同的使用场景?使用过程中需要注意哪些事项?各自分别有哪些不足之处?都是困扰程序员的一个个问题。

 

甚至,一个最基本的问题:为什么锁就能够用来保护共享资源?锁真正蕴含的意义有哪些?我相信很多使用过各种锁的程序员,都不一定能够完全正确的回答出来。

 

有鉴于此,本人希望将自己近10年数据库内核研发,所积累下的并发编程的经验记录下来,形成一个系列的文章,分享给大家。这个系列,个人打算对其命名为 #并发编程系列# ,作为此系列开篇的文章,本文将从一个简单的并发编程的例子出发,引出锁真正蕴含的意义。

 


一个测试用例

 

并发程序处理中,面临的一个最简单,也是最常见的共享资源的争用,就是针对一个全局变量进行并发的更新和读取。这个全局变量,可以是一个全局计数器,统计某个事件在多线程中发生的次数;或者是一个全局递增序列,每个线程需要获取属于其的唯一标识。诸如此类,多个线程,针对一个全局变量的并发读写,是十分常见的。如下图所示:

此用例中,N个线程,并发更新一个全局变量。让我们先来看一个简单的测试,全局变量global_count没有任何保护,此时会发生什么?

 

测试场景:500个线程,每个线程做10000次global_count++操作,主线程首先将global_count初始化为0,然后等待这500线程运行结束。待500个线程运行结束之后,由于每个线程都做了10000次global_count++,那么可以确定,最后的global_count取值应该是5000000。事实是这样吗?根据此测试场景,撰写测试代码,每个线程做的都是同样的事,代码很简单:

主线程等待所有500个线程结束之后,进行判断,若global_count不等于5000000,则打印出当前global_count的取值。运行结果如下:

通过上图,可以发现,global_count并不是每次都等于5000000,很大的几率,global_count要小于5000000。多线程对一个全局变量进行++操作,并不能保证最终得到的结果的正确性。究其内部原因,是因为++操作并不是一个原子操作(Atomic Operation),而是对应至少3条汇编语句,考虑如下两个线程的 ++ 操作并发:

线程1,2,分别读取了global_count的当前值,分别加1后写出。线程2的写覆盖了线程1的写,最后导致两次 ++ 操作,实际上却对global_count只加了1次。

 

如何解决此问题,相信大家都有很多方法,例如:将global_count声明为原子变量(C++ 11标准支持)。但是此文,并不打算使用原子变量,而是将global_count的++操作,通过Spinlock保护起来。一个全局的Spinlock,500个线程,在++操作前,需要获取Spinlock,然后进行global_count的++操作,完成后释放Spinlock。对应的每个线程代码修改如下:

主线程,仍旧是同样的逻辑,等待所有的500个线程执行结束之后,判断global_count取值是否等于5000000,如果不相等,则打印出来。此时,同样执行此测试程序,没有任何一条数据打印出来,每一个循环,都满足global_count等于5000000。通过引入了Spinlock,完美了解决上面的问题。

 

为什么引入了Spinlock保护之后,多线程针对全局变量的并发读写所带来的问题就解决了?此问题,恰好引入了锁意义的剖析。

 

锁的意义

 

在分析锁的意义前,先来简单看看Spinlock的功能:Spinlock是一把互斥锁,同一时间,只能有一个线程持有Spinlock锁,而所有其他的线程,处于等待Spinlock锁。当持有Spinlock的线程放锁之后,所有等待获取Spinlock的线程一起争抢,一个Lucky的线程,抢到这把锁,大部分Unlucky的线程,只能继续等待下一次抢锁的机会。

 

由此来说,在spinlock锁保护下的代码片段,同一时间只能有一个线程(获得Spinlock的线程)能够执行,而其他的线程,在获取spinlock之前,不可进入spinlock锁保护下的代码片段进行执行。前面的测试用例,由于spinlock保护了global_count++的代码,因此global_count++操作,同时只能有一个线程执行,不可能出现前面提到的两线程并发修改global_count变量出现的问题。How Perfect!!!(注:在spinlock加锁之前,以及spinlock放锁之后的代码段,可以由多线程并发执行。)

但是,故事到此就完了吗?我相信对于大部分程序员来说,或者是之前的我来说,认为故事到此就结束了。已经成功的使用了一个Spinlock,来保护全局变量的并发读写,保证了并发访问的正确性。

 

但是(又是这个该死的但是),故事并未结束,这个案子也还没有了结。有一定经验的C/C++程序员,或者是曾经看过我写过的一个PPT:《CPU Cache and Memory Ordering——并发程序设计入门》,以及一篇博客:《C/C++ Volatile关键词深度剖析》,的朋友来说,应该都知道这个故事还有一个点没有挖掘:内存模型(Memory Model),无论是程序语言(如:C/C++,Java),或者是CPU(如:Intel X86,Power PC,ARM),都有所谓的内存模型。

 

简单来说,内存模型规定了一种内存操作可见的顺序。为了提高程序运行的效率,编译器可能会对你写的程序进行重写,执行顺序调整等等,同样,CPU也会对其执行的汇编执行进行顺序的调整,这就是所谓的乱序执行。最基本的四种乱序行为,包含:LoadLoad乱序;LoadStore乱序;StoreLoad乱序;StoreStore乱序,分别对应于读读乱序,读写乱序,写读乱序,写写乱序。关于这四种乱序行为更为详细的介绍,可参考Preshing的博客:《Memory Reordering Caught in the Act》,《Memory Barriers Are Like Source Control Operations》,或者是《SMP Primer for Android》这篇文章。本文接下来的部分,假设读者已经知道了无论是编译器,还是CPU,都会存在编译乱序与指令执行乱序的现象。

 

编译乱序与指令执行乱序,跟本文讨论的锁的意义有何关系?可以说,不仅有关系,还有很大的关系,关系到锁之所以能够称之为锁,能够用来保护共享资源的关键。

 

一个简单的问题:在存在编译乱序与指令执行乱序的情况下,怎么保证锁所保护的代码片段,不会被提前到加锁之前,或者是放锁之后执行?如果编译器将锁保护下的代码,通过编译优化,放到了加锁之前运行?又如果CPU在执行指令时,将锁保护下的汇编代码,延迟到了放锁之后执行?如下图所示:

如上所示,如果编译器做了它不该做的优化,或者CPU做了其不该做的乱序,那么spinlock保护下的代码片段,同一时刻,一定只有一个线程能够执行的假设被打破了。此时,虽然spinlock仍旧只能有一个线程持有,但是spinlock保护下的代码,被提到了spinlock保护之外执行,spinlock哪怕功能再强大,也不能保护锁之外的代码,提取到spinlock锁之外的代码,能够并发执行。

 

但是上面的测试说明,spinlock保护下的global_count++操作,在多线程下能够正确执行。也就说明,无论是编译器,还是CPU,并没有不合时宜的做上面的这些优化。而分析其原因,刚好引出了锁(Spinlock、Mutex、RWLock等)的第二层意义:Lock Acquire和Unlock Release。

 

什么是Lock Acquire,Unlock Release又意味着什么?在此之前,需要先看看什么是Acquire和Release。Acquire和Release语义(Semantics)是程序语言和CPU内存模型(Memory Model)中的一个概念。以下,是截取自Preshing博客《Acquire and Release Semantics》一文中,对Acquire与Release Semantics的定义:

 

Acquire semantics is a property which can only apply to operations which read from shared memory, whether they are read-modify-write operations or plain loads. The operation is then considered a read-acquire. Acquire semantics prevent memory reordering of the read-acquire with any read or write operation which follows it in program order. (注:Acquire语义是一个作用于内存读操作上的特性,此内存读操作即被视为read-acquire。Acquire语义禁止read-acquire之后所有的内存读写操作,被提前到read-acquire操作之前进行。)

 

Release semantics is a property which can only apply to operations which write to shared memory, whether they are read-modify-write operations or plain stores. The operation is then considered a write-release. Release semantics prevent memory reordering of the write-release with any read or write operation which precedes it in program order.(注:Release语义作用于内存写操作之上的特性,此内存写操作即被视为write-release。Release语义禁止write-release之前所有的内存读写操作,被推迟到write-release操作之后进行。)

 

从Acquire与Release语义的定义可以看出,两个语义对编译器优化、CPU乱序分别做了一个限制条件:

 

Acquire语义限制了编译器优化、CPU乱序,不能将含有Acquire语义的操作之后的代码,提到含有Acquire语义的操作代码之前执行;



Release语义限制了编译器优化、CPU乱序,不能将含有Release语义的操作之前的代码,推迟到含有Release语义的操作代码之后执行;

有了明确的Acquire和Release语义的定义,再回过头来看前面提到的锁的第二层含义:Lock Acquire和Unlock Release。加锁操作自带Acquire语义,解锁操作自带Release语义。将加锁、解锁的两个语义结合起来,就构成了以下的完整的锁的含义图:

spinlock,只有带有了Acquire和Release语义,才算是一个真正完整可用的锁——Acquire与Release语义间,构成了一个临界区。获取spinlock后的线程,可以大胆的运行全局变量的读写,而不必担心其他并发线程对于此变量的并发访问。

 

好消息是,pthread lib所提供的spinlock、mutex,其加锁操作都自带了acquire语义,解锁操作都自带了release语义。因此,哪怕我们在使用的过程中,不知道有这两个语义的存在,也能够正确的使用这些锁。但是,读者需要实现自己的spinlock、mutex(注:实际情况下,确实有这个必要,数据库系统如Oracle/PostgreSQL/InnoDB,都有自己实现的Spinlock、Mutex等),那么对于锁的了解,到这个层次,是必不可少的。

 

总结

 

本文,作为 #并发编程系列# 的开篇,首先跟大家分析了锁(Spinlock、Mutex、RWLock等)所代表的真正意义。首先,这些锁要么保证同一时刻只能由一个线程持有(如:Spinlock、Mutex),要么保证同一时刻只能有一个写锁(如:RWLock);其次,锁的加锁操作带有Acquire语义,解锁操作带有Release语义。通过这两个条件,保证了加锁/解锁之间,构成了一个完整的临界区,全局资源的更新操作,可以在临界区内完成,而不必担心并发读写冲突。而这正是并发程序设计的基础:构建一个Data-Race-Free的多线程系统。

本站声明: 本文章由作者或相关机构授权发布,目的在于传递更多信息,并不代表本站赞同其观点,本站亦不保证或承诺内容真实性等。需要转载请联系该专栏作者,如若文章内容侵犯您的权益,请及时联系本站删除。
换一批
延伸阅读

LED驱动电源的输入包括高压工频交流(即市电)、低压直流、高压直流、低压高频交流(如电子变压器的输出)等。

关键字: 驱动电源

在工业自动化蓬勃发展的当下,工业电机作为核心动力设备,其驱动电源的性能直接关系到整个系统的稳定性和可靠性。其中,反电动势抑制与过流保护是驱动电源设计中至关重要的两个环节,集成化方案的设计成为提升电机驱动性能的关键。

关键字: 工业电机 驱动电源

LED 驱动电源作为 LED 照明系统的 “心脏”,其稳定性直接决定了整个照明设备的使用寿命。然而,在实际应用中,LED 驱动电源易损坏的问题却十分常见,不仅增加了维护成本,还影响了用户体验。要解决这一问题,需从设计、生...

关键字: 驱动电源 照明系统 散热

根据LED驱动电源的公式,电感内电流波动大小和电感值成反比,输出纹波和输出电容值成反比。所以加大电感值和输出电容值可以减小纹波。

关键字: LED 设计 驱动电源

电动汽车(EV)作为新能源汽车的重要代表,正逐渐成为全球汽车产业的重要发展方向。电动汽车的核心技术之一是电机驱动控制系统,而绝缘栅双极型晶体管(IGBT)作为电机驱动系统中的关键元件,其性能直接影响到电动汽车的动力性能和...

关键字: 电动汽车 新能源 驱动电源

在现代城市建设中,街道及停车场照明作为基础设施的重要组成部分,其质量和效率直接关系到城市的公共安全、居民生活质量和能源利用效率。随着科技的进步,高亮度白光发光二极管(LED)因其独特的优势逐渐取代传统光源,成为大功率区域...

关键字: 发光二极管 驱动电源 LED

LED通用照明设计工程师会遇到许多挑战,如功率密度、功率因数校正(PFC)、空间受限和可靠性等。

关键字: LED 驱动电源 功率因数校正

在LED照明技术日益普及的今天,LED驱动电源的电磁干扰(EMI)问题成为了一个不可忽视的挑战。电磁干扰不仅会影响LED灯具的正常工作,还可能对周围电子设备造成不利影响,甚至引发系统故障。因此,采取有效的硬件措施来解决L...

关键字: LED照明技术 电磁干扰 驱动电源

开关电源具有效率高的特性,而且开关电源的变压器体积比串联稳压型电源的要小得多,电源电路比较整洁,整机重量也有所下降,所以,现在的LED驱动电源

关键字: LED 驱动电源 开关电源

LED驱动电源是把电源供应转换为特定的电压电流以驱动LED发光的电压转换器,通常情况下:LED驱动电源的输入包括高压工频交流(即市电)、低压直流、高压直流、低压高频交流(如电子变压器的输出)等。

关键字: LED 隧道灯 驱动电源
关闭