背景

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的多线程系统。

转自:http://hedengcheng.com/?p=803

如果想深入研究数据库方面,强推何登成大佬的博客

并发编程系列之一:锁的意义相关推荐

  1. reentrantlock非公平锁不会随机挂起线程?_【原创】Java并发编程系列16 | 公平锁与非公平锁...

    本文为何适原创并发编程系列第 16 篇,文末有本系列文章汇总. 上一篇提到重入锁 ReentrantLock 支持两种锁,公平锁与非公平锁.那么这篇文章就来介绍一下公平锁与非公平锁. 为什么需要公平锁 ...

  2. 并发编程系列之五多线程synchronized是可重复加锁,重入锁

    并发编程系列之五多线程synchronized是可重复加锁,重入锁.对于重入锁的概念就是可以重复的加锁.. 示例1,在同一个类里面进行加锁,不同的方法调用,都一层一层的嵌套进行加锁,示例1演示重入锁的 ...

  3. Java 并发编程系列之带你了解多线程

    早期的计算机不包含操作系统,它们从头到尾执行一个程序,这个程序可以访问计算机中的所有资源.在这种情况下,每次都只能运行一个程序,对于昂贵的计算机资源来说是一种严重的浪费. 操作系统出现后,计算机可以运 ...

  4. 高并发编程系列:NIO、BIO、AIO的区别,及NIO的应用和框架选型

    谈到并发编程就不得不提到NIO,以及相关的Java NIO框架Netty等,并且在很多面试中也经常提到NIO和AIO.同步和异步.阻塞和非阻塞等的区别.我先简短介绍下几个NIO相关的概念,然后再谈NI ...

  5. python并发编程之semaphore(信号量)_Python 并发编程系列之多线程

    Python 并发编程系列之多线程 2 创建线程 2.1 函数的方式创建线程 2.2 类的方式创建线程 3 Thread 类的常用属性和方法 3.1 守护线程: Deamon 3.2 join()方法 ...

  6. Java并发编程系列

    Java并发编程系列 2018-03-08 Java 并发编程:核心理论 Java并发编程:Synchronized及其实现原理 Java并发编程:Synchronized底层优化(轻量级锁.偏向锁) ...

  7. 并发编程系列之AQS实现原理

    并发编程系列之AQS实现原理 1.什么是AQS? AQS(AbstractQueuedSynchronizer),抽象队列同步器,是juc中很多Lock锁和同步组件的基础,比如CountDownLat ...

  8. 并发编程系列之什么是Java内存模型?

    并发编程系列之什么是Java内存模型? 1.什么是Java的内存模型 Java内存模型简称JMM(Java Memory Model),JMM是和多线程并发相关的一组规范.各个jvm实现都要遵循这个J ...

  9. 并发编程系列之volatile关键字详解

    并发编程系列之volatile关键字详解 1.volatile是什么? 首先简单说一下,volatile是什么?volatile是Java中的一个关键字,也是一种同步机制.volatile为了保证变量 ...

最新文章

  1. 27岁华裔小伙一战成名!搞出美国新冠最准预测模型
  2. js中typeof用法详细介绍
  3. 此心拖泥带水,是人生最苦处
  4. 居中符号怎么打_小学语文标点符号的正确书写格式(附图文展示),让孩子牢记考试不丢分!...
  5. 【Java基础】 JQuery的常用操作
  6. 图论学习四之Disjoint set union并查集
  7. 【报告分享】2022年快手新市井商业内循环营销通案:让企业经营走向确定性增长.pdf(附下载链接)...
  8. Spark稀疏向量和稠密向量
  9. 【OpenCL开发入门】01 - 搭建Visual Studio 2022+OpenCL开发环境
  10. 实现支付代付批量转账接口
  11. rx580默认频率1150_不到1300元的RX580 显卡能买吗?!迪兰 RX580 2048SP 4G X-Serial 开箱测试...
  12. seo之html优化,SEO优化技巧之HTML优化
  13. 猜数字游戏c语言提示范围,【游戏编程】猜数字游戏(C语言)
  14. 【项目实战——emos在线办公系统】:组件之间的相互使用、请假页面、添加请假组件之间的关系梳理、model和v-model
  15. 6 月份最火的 10 个 GitHub 项目
  16. 追踪货拉拉:“亡羊补牢”进度缓慢,安全“地雷”何时能拆完?
  17. Win10 添加虚拟打印机
  18. bzoj 1123: [POI2008]BLO(Trajan求割点)
  19. 过滤器(Filer)与监听器(Listenter)
  20. JavaScript系列之详解原型和原型链

热门文章

  1. 【二叉树:3】线索二叉树
  2. 联想拯救者R7000P2021安装Ubuntu双系统
  3. AE(After Effect) 自带抠像插件详解
  4. 计算机英语格式怎么写,26个英文字母,正确的书写格式,孩子真的会吗?
  5. 上传文件资料并生成缩略图
  6. PyCharm 里面的 c、m、F、f、v、p 分别代表什么含义?
  7. 阿里云物联网平台测评:以温度报警器开发为例
  8. 虚拟机类加载机制(类加载过程)
  9. 分享一个颜色渐变的网站
  10. PS初学者实例教程——图层样式的学习[斜面和浮雕]