并发编程与锁的底层原理

admin 4个月前
   <h2>背景:</h2>    <p>并发编程,多核、多线程的情况下,线程安全性问题都是一个无法回避的难题。虽然我们可以用到CAS,互斥锁,消息队列,甚至分布式锁来解决,但是对于锁的底层实现,这次分享,我们想更深入的来分析和探讨锁的底层原理,以便更好地理解和掌握并发编程。</p>    <h2>大纲:</h2>    <p>1.并发编程与锁</p>    <p>2.缓存和一致性协议MESI</p>    <p>3.CPU/缓存与锁</p>    <p>4.常见锁总结</p>    <p>1 并发编程与锁</p>    <p>我们写的各种应用系统,像网络编程,基本上都是并发编程,不论是多进程还是多线程,亦或是协程、队列的方式,也都是并发编程的范畴。并发编程中,在多核操作系统中,多线程的时候,就会出现 <strong>线程安全性</strong> 问题,有的也说 <strong>并发安全性</strong> 问题。这种问题,都是因为对共享变量的并发读写引起的数据不一致问题。所以,在并发编程中,就会经常用到锁,当然也可能使用队列或者单线程的方式来处理共享数据。</p>    <p>我们先来还原一下具体的问题,然后再用不同的方法来处理它们。</p>    <p>线程安全性问题1</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/f21ffebe6b27ead18a23678b6123cca0.jpg" alt="并发编程与锁的底层原理" width="340" height="439"></p>    <p>代码中共享变量num是一个简单的计数器,main主线程启动了两个协程,分别循环一万次对num进行递增操作。正常情况下,预期的结果应该是1w+1w=2w,但是,在并发执行的情况下,最终的结果只有10891,离2w差的好多。</p>    <p>典型应用场景:</p>    <p>1 库存数量扣减</p>    <p>2 投票数量递增</p>    <p>并发安全性问题:</p>    <p>num+ +是三个操作(读、改、写),不满足原子性</p>    <p>并发读写全局变量,线程不安全</p>    <p>线程安全性问题2</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/653ff31a7c1133dd1443567a0ac8b1cd.jpg" alt="并发编程与锁的底层原理" width="389" height="438"></p>    <p>代码中共享变量list作为一个数据集合,由两个协程并发的循环append数据进去。同样是每个协程执行一万次,正常情况下,预期的list长度应该是2w,但是,在并发执行下,结果却可能连1w都不到。</p>    <p>具体的原因,大家可以思考下,为什么并发执行的情况下,2个协程,竟然list长度还小于1w呢?</p>    <h2>典型应用场景:</h2>    <p>1 发放优惠券</p>    <p>2 在线用户列表</p>    <p>并发安全性问题:</p>    <p>append(list, i) 内部是一个复杂的数组操作函数</p>    <p>并发读写全局变量,线程不安全</p>    <p>问题修复</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/e021857cfa2aa223c3c8ad510e386aba.jpg" alt="并发编程与锁的底层原理" width="335" height="407"></p>    <p>方法一: 通过WaitGroup将两个协程分开执行,第一个执行完成再执行第二个,避免并发执行,串行化两个任务。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6ba2fcad0a4ea15de4261b4bbb576f1c.jpg" alt="并发编程与锁的底层原理" width="335" height="423"></p>    <p>方法二: 通过互斥锁,在数字递增的前后加上锁的处理,数值递增操作时互斥。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/a5d10f78934e1ff5ef2cd737e836d87d.jpg" alt="并发编程与锁的底层原理" width="377" height="403"></p>    <p>方法三: 针对int64的数字指针递增操作,可以利用atomic.AddInt64原子递增方法来处理。</p>    <p>当然还会有更多的实现方法,但是内部的实现原理也都类似,原子操作,避免对共享数据的并发读写。</p>    <h2>并发编程的几个基础概念</h2>    <p>概念1:并发执行不一定是并行执行。</p>    <p>概念2:单核CPU可以并发执行,不能并行执行。</p>    <p>概念3:单进程、单线程,也可以并发执行。</p>    <p>并行是 <strong>同一时刻</strong> 的多任务处理,并发是一个 <strong>时间段</strong> 内(1秒、1毫秒)的多任务处理。</p>    <p>区别并发和并行,多核的并行处理涉及到多核同时读写一个缓存行,所以很容易出现数据的脏读和脏写;单核的并发处理中因为任务内部的中间变量,所以有可能存在脏写的情况。</p>    <p>锁的作用</p>    <ul>     <li> <p>避免并行运算中,共享数据读写的安全性问题。</p> </li>     <li> <p>并行执行中,在锁的位置,同时只能有一个程序可以获得锁,其他程序不能获得锁。</p> </li>     <li> <p>锁的出现,使得并行执行的程序在锁的位置串行化执行。</p> </li>     <li> <p>多核、分布式运算、并发执行,才会需要锁。</p> </li>    </ul>    <p>不用锁,也可以实现同样效果?</p>    <p>单线程串行化执行,队列式,CAS。</p>    <p>——不要通过共享内存来通信,而应该通过通信来共享内存</p>    <h2>锁的底层实现类型</h2>    <p>1 <strong>锁内存总线</strong> ,针对内存的读写操作,在总线上控制,限制程序的内存访问</p>    <p>2 <strong>锁缓存行</strong> ,同一个缓存行的内容读写操作,CPU内部的高速缓存保证一致性</p>    <p>锁 ,作用在一个对象或者变量上。现代CPU会优先在高速缓存查找,如果存在这个对象、变量的缓存行数据,会使用锁缓存行的方式。否则,才使用锁总线的方式。</p>    <p>速度 ,加锁、解锁的速度,理论上就是高速缓存、内存总线的读写速度,它的效率是非常高的。而出现效率问题,是在产生冲突时的串行化等待时间,再加上线程的上下文切换,让多核的并发能力直线下降。</p>    <p>2 缓存和一致性协议MESI</p>    <p>英文首字母缩写,也就是英文环境下的术语、俚语、成语,新人理解和学习有难度,但是,掌握好了既可以省事,又可以缩小文化差距。</p>    <p>另外就是对英文的异形化,也类似汉字的变形体,“表酱紫”,“蓝瘦香菇”,老外是很难懂得,反之一样。</p>    <p>MESI“生老病死”缓存行的四种状态</p>    <ul>     <li> <p>M: modify 被修改,数据有效,cache和内存不一致</p> </li>     <li> <p>E: exclusive 独享,数据有效,cache与内存一致</p> </li>     <li> <p>S: shared 共享,数据有效,cache与内存一致,多核同时存在</p> </li>     <li> <p>I: invalid 数据无效</p> </li>     <li> <p>F: forward 向前(intel),特殊的共享状态,多个S状态,只有一个F状态,从F高速缓存接受副本</p> </li>    </ul>    <p>当内核需要某份数据时,而其它核有这份数据的备份时,本cache既可以从内存中导入数据,也可以从其它cache中导入数据(Forward状态的cache)。</p>    <p>四种状态的更新路线图</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/6ba2fcad0a4ea15de4261b4bbb576f1c.jpg" alt="并发编程与锁的底层原理" width="475" height="295"></p>    <p>高效的状态: E, S</p>    <p>低效的状态: I, M</p>    <p>这四种状态,保证CPU内部的缓存数据是一致的,但是,并不能保证是强一致性。</p>    <p>每个cache的控制器不仅知道自己的读写操作,而且也要监听其它cache的读写操作。</p>    <p>缓存的意义</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/0a7de17c9404fd9c804dc75350068eb0.jpg" alt="并发编程与锁的底层原理" width="398" height="382"></p>    <p>1 时间局部性:如果某个数据被访问,那么不久还会被访问</p>    <p>2 空间局部性:如果某个数据被访问,那么相邻的数据也很快可能被访问</p>    <h2>局限性:空间、速度、成本</h2>    <p>更大的缓存容量,需要更大的成本。更快的速度,需要更大的成本。均衡缓存的空间、速度、成本,才能更有市场竞争力,也是现在我们看到的情况。当然,随着技术的升级,成本下降,空间、速度也就能继续稳步提高了。</p>    <p>缓存行,64Byte的内容</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/21b8c95533ad06dd41bf65d89d54da82.png" alt="并发编程与锁的底层原理" width="550" height="222"></p>    <p>缓存行的存储空间是64Byte(字节),也就是可以放64个英文字母,或者8个int64变量。</p>    <p>注意伪共享的情况——56Byte共享数据不变化,但是8Byte的数据频繁更新,导致56Byte的共享数据也会频繁失效。</p>    <p>解决方法:缓存行的数据对齐,更新频繁的变量独占一个缓存行,只读的变量共享一个缓存行。</p>    <p>3 CPU/缓存与锁</p>    <p>锁的底层实现原理,与CPU、高速缓存有着密切的关系,接下来一起看看CPU的内部结构。</p>    <p>CPU与计算机结构</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c151ca2d18bcec01a94e2f640a63501f.jpg" alt="并发编程与锁的底层原理" width="454" height="310"></p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/37c177a2b47ac3f0fe3766ef202b31c7.jpg" alt="并发编程与锁的底层原理" width="406" height="269"></p>    <p>内核独享寄存器、L1/L2,共享L3。在早先时候只有单核CPU,那时只有L1和L2,后来有了多核CPU,为了效率和性能,就增加了共享的L3缓存。</p>    <p>多颗CPU通过QPI连接。再后来,同一个主板上面也可以支持多颗CPU,多颗CPU也需要有通信和控制,才有了QPI。</p>    <p>内存读写都要通过内存总线。CPU与内存、磁盘、网络、外设等通信,都需要通过各种系统提供的系统总线。</p>    <p>CPU流水线</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/109d23b7ec17d1db9893237419ce1cd6.jpg" alt="并发编程与锁的底层原理" width="501" height="346"></p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c7d9c69d6eb4e5cb05ac8f5703ad9862.png" alt="并发编程与锁的底层原理" width="297" height="309"></p>    <p>CPU流水线,里面还有异步的LoadBuffer,</p>    <p>Store Buffer, Invalidate Queue。这些缓冲队列的出现,更多的异步处理数据,提高了CPU的数据读写性能。</p>    <p>CPU为了保证性能,默认是宽松的数据一致性。</p>    <p>编译器、CPU优化 <img src="https://simg.open-open.com/show/4b22e7331206277c2eea8d22b88e13a2.png" alt="并发编程与锁的底层原理" width="480" height="129"></p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/fb68c77ab035468af38e0c28891e69e9.png" alt="并发编程与锁的底层原理" width="474" height="179"></p>    <ul>     <li> <p>编译器优化:重排代码顺序,优先读操作(读有更好的性能,因为cache中有共享数据,而写操作,会让共享数据失效)</p> </li>     <li> <p>CPU优化:指令执行乱序(多核心协同处理,自动优化和重排指令顺序)</p> </li>    </ul>    <p>编译器、CPU屏蔽</p>    <ul>     <li> <p>优化屏蔽:禁止编译器优化。按照代码逻辑顺序生成二进制代码,volatile关键词</p> </li>     <li> <p>内存屏蔽:禁止CPU优化。防止指令之间的重排序,保证数据的可见性,store barrier, load barrier, full barrier</p> </li>     <li> <p>写屏障:阻塞直到把Store Buffer中的数据刷到Cache中</p> </li>     <li> <p>读屏障:阻塞直到Invalid Queue中的消息执行完毕</p> </li>     <li> <p>全屏障:包括读写屏障,以保证各核的数据一致性</p> </li>    </ul>    <p>Go语言中的Lock指令就是一个内存全屏障同时禁止了编译器优化。</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/c04095e00338c8f27945a0749a58786a.png" alt="并发编程与锁的底层原理" width="550" height="97"></p>    <p>x86的架构在CPU优化方面做的相对少一些,只是针对“写读”的顺序才可能调序。</p>    <p>加锁,加了些什么?</p>    <ul>     <li> <p>禁止编译器做优化(加了优化屏蔽)</p> </li>     <li> <p>禁止CPU对指令重排(加了内存屏蔽)</p> </li>     <li> <p>针对缓存行、内存总线上的控制</p> </li>     <li> <p>冲突时的任务等待队列</p> </li>    </ul>    <p>4 常见锁总结</p>    <p>最后,我们一起来看看常见的自旋锁、互斥锁、条件锁、读写锁的实现逻辑,以及在Go源码中,是如何来实现的CAS/atomic.AddInt64和Mutext.Lock方法的。</p>    <p>自旋锁</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/7a7c1ba417333b3a91b3ed90a926b558.png" alt="并发编程与锁的底层原理" width="242" height="57"></p>    <p>只要没有锁上,就不断重试。</p>    <p>如果别的线程长期持有该锁,那么你这个线程就一直在 while while while 地检查是否能够加锁,浪费 CPU 做无用功。</p>    <p>优点:不切换上下文;</p>    <p>不足:烧CPU;</p>    <p>适用场景:冲突不多,等待时间不长的情况下,或者少次数的尝试自旋。</p>    <p>互斥锁</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/ec5307216b23e21d2648448dcfa50342.png" alt="并发编程与锁的底层原理" width="408" height="66"></p>    <p>操作系统负责线程调度,为了实现「锁的状态发生改变时再唤醒」就需要把锁也交给操作系统管理。</p>    <p>所以互斥器的加锁操作通常都需要涉及到上下文切换,操作花销也就会比自旋锁要大。</p>    <p>优点:简单高效;</p>    <p>不足:冲突等待时的上下文切换;</p>    <p>适用场景:绝大部分情况下都可以直接使用互斥锁。</p>    <p>条件锁</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/d899de946eb34691d667c592929a808a.jpg" alt="并发编程与锁的底层原理" width="422" height="239"></p>    <p>它解决的问题不是「互斥」,而是「等待」。</p>    <p>消息队列的消费者程序,在队列为空的时候休息,数据不为空的时候(条件改变)启动消费任务。</p>    <p>条件锁的业务针对性更强。</p>    <p>读写锁</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/532edc8b615e18700f7e8703946be882.jpg" alt="并发编程与锁的底层原理" width="301" height="540"></p>    <p>内部有两个锁,一个是读的锁,一个是写的锁。</p>    <p>如果只有一个读者、一个写者,那么等价于直接使用互斥锁。</p>    <p>不过由于读写锁需要额外记录读者数量,花销要大一点。</p>    <p>也可以认为读写锁是针对某种特定情景(读多写少)的「优化」。</p>    <p>但个人还是建议忘掉读写锁,直接用互斥锁。</p>    <p>适用场景:读多写少,而且读的过程时间较长,可以通过读写锁,减少读冲突时的等待。</p>    <p>无锁操作CAS</p>    <p>Compare And Swap 比较并交换,类似于将 num+ + 的三个指令合并成一个指令 CMPXCHG,保证了操作的原子性。</p>    <p>为了保证顺序一致性和数据强一致性,还需要有一个LOCK指令。  </p>    <p>源码,参见 runtime/internal/atomic/asm_amd64.s</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/15c00f92bed7f5e4f55e1663814cf67b.jpg" alt="并发编程与锁的底层原理" width="530" height="300"></p>    <p>LOCK指令的作用就是禁止编译器优化,同时加上内存全屏障,可以保证 <strong>LOCK指令</strong> 之后的一个指令执行时的数据强一致性和可见性。</p>    <p>数字的原子递增操作 atomic.AddInt64</p>    <p>在原始指针数字的基础上,原子性递增 delta 数值,并且返回递增后的结果值。</p>    <p>源码1,参见sync/atomic/asm.s</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/4315dfcb9a487c2453a83d4602d46616.png" alt="并发编程与锁的底层原理" width="415" height="46"></p>    <p>XADDQ 数据交换,数值相加,写入目标数据</p>    <p>ADDQ 数值相加,写入目标数据</p>    <p>在XADDQ之前加上LOCK指令,保证这个指令执行时的数据强一致性和可见性。</p>    <p>源码2,参见runtime/internal/atomic/asm_amd64.s</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/18f04fd0007941aa175529a77d3c9fda.jpg" alt="并发编程与锁的底层原理" width="550" height="183"></p>    <p>互斥锁操作 sync.Mutex.Lock</p>    <p style="text-align:center"><img src="https://simg.open-open.com/show/bd81d634ded49f73e0fd11a06f92c06c.jpg" alt="并发编程与锁的底层原理" width="550" height="393"> <img src="https://simg.open-open.com/show/074f9c26482a63f14e5c1de41c2427a4.jpg" alt="并发编程与锁的底层原理" width="527" height="491"></p>    <p>源码,参见 sync/mutex.go</p>    <p>大概的源码处理逻辑如下:</p>    <p>1 通过CAS操作来竞争锁的状态 &m.state;</p>    <p>2 没有竞争到锁,先主动自旋尝试获取锁runtime_canSpin 和 runtime_doSpin (原地烧CPU);</p>    <p>3 自旋尝试失败,再次CAS尝试获取锁;</p>    <p>4 runtime_SemacquireMutex 锁请求失败,进入休眠状态,等待信号唤醒后重新开始循环;</p>    <p>5 m.state等待队列长度(复用的int32位数字,第一位是锁的状态,后31位是锁的等待队列长度计数器)。</p>    <p>以上便是这次分享的全部内容,有不足和纰漏的地方,还请指教,谢谢 ~</p>    <p> </p>    <p>来自:https://mp.weixin.qq.com/s/DX_GsBq31-cemADrqQKfqw?utm_source=tuicool&utm_medium=referral</p>