并发编程系列之一:锁的意义
http://hedengcheng.com/?p=803
背景
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的多线程系统。
接下来,作为 #并发编程系列# 的后续文章,将会就如何实现自己的Spinlock、Mutex、RWLock?各种锁之间的区别及应用场景?以及各种锁使用过程中的注意事项,逐个展开讨论,敬请期待!
注:关于此文中的测试用例,可点击此处下载:global_count 。