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