当前位置:首页 > 芯闻号 > 充电吧
[导读]背景 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的多线程系统。

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

我们手里每天基本都有多个事情要做,很多人为了在短时间内完成任务,于是,开启了“多线程”工作模式。比如:一边写代码,一边写工作总结,同时还在回复着工作群里的消息。

关键字: 多线程 工作阻力 代码

摘要:针对计算机端口扫描技术的优缺点,采用多线程技术,结合TCP全连接扫描,实现了基于C语言编程的网络端口扫描及危险端口关闭程序,旨在使端口关闭操作简单化。

关键字: 多线程 危险端口 简单化

摘要:阐述了一种基于GPRS和嵌入式Linux的远程图像监控系统设计和实现方法。该系统主要由嵌入式视频采集终端 和监控中心服务器组成。其中,嵌入式视频采集终端主要由摄像头视频采集模块、ARM模块、SIM900模块组成,监...

关键字: 通用分组无线业务 实时图像采集 多线程 信号量

一、前言二、MichaHofri算法三、测试代码四、总结一、前言在上一篇文章中,介绍了一种纯软件算法,用来实现临界区的保护功能,文章链接:C语言边角料2:用纯软件来代替Mutex互斥锁。首先明确一下:如果利用操作系统提供...

关键字: C语言 多线程 软件

作 者:道哥,10年嵌入式开发老兵,专注于:C/C、嵌入式、Linux。关注下方公众号,回复【书籍】,获取Linux、嵌入式领域经典书籍;回复【PDF】,获取所有原创文章(PDF格式)。目录单片机中常用的环形缓冲区多线程...

关键字: 多线程 异步

|前言前两天做了一个导入的功能,导入开始的时候非常慢,导入2w条数据要1分多钟,后来一点一点的优化,从直接把list怼进Mysql中,到分配把list导入Mysql中,到多线程把list导入Mysql中。时间是一点一点的...

关键字: 多线程

大家好,我是闪客,感谢喵哥提供的平台让我在这里给大家介绍自己,这是我的公众号卡片。为了防止大家看到这里就点击了返回按钮,我先放一张图勾引一下您。这是我公众号做的第一张动图,好多读者当时说被这张图的魔性所吸引。这个动图,来...

关键字: 并发编程

近期看到有读者在公众号留言问有没有C多线程的学习方法,我这里特意总结了下,希望能对大家有所帮助。目录什么是多线程?为什么使用多线程?如何创建线程?joinable()?多线程参数传递方式锁原子变量条件变量async多线程...

关键字: 多线程

直接进入正题,发车!简述java内存模型(JMM)java内存模型定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都...

关键字: 多线程

直接进入正题,发车!简述java内存模型(JMM)java内存模型定义了程序中各种变量的访问规则。其规定所有变量都存储在主内存,线程均有自己的工作内存。工作内存中保存被该线程使用的变量的主内存副本,线程对变量的所有操作都...

关键字: 多线程
关闭
关闭