锁的相关概念
锁的作用
用来解决"非并发安全"的方案之一就是"锁", 它能保证一些方法在同一时间只能被执行一次, 从而避免并发问题.
死锁
死锁(deadlock),当两个以上 运算单元,双方都在等待对方停止执行,以获取系统资源,但是没有一方提前退出时,就成为死锁。
死锁的四个条件是:
- 禁止抢占(no preemption):系统资源不能被强制从一个进程中退出。
- 持有和等待(hold and wait):一个进程可以在等待时持有系统资源。
- 互斥(mutual exclusion):资源只能同时分配给一个行程,无法多个行程共享。
- 循环等待(circular waiting):一系列进程互相持有其他进程所需要的资源。
死锁只有在四个条件同时满足时发生,预防死锁必须至少破坏其中一项。
消除:终止一个进程的运行
活锁
与死锁相似,死锁是行程都在等待对方先释放资源;活锁则是行程彼此释放资源又同时占用对方释放的资源。当此情况持续发生时,尽管资源的状态不断改变,但每个行程都无法获取所需资源,使得事情没有任何进展。
示例
假设两人正好面对面碰上对方:
- 死锁:两人互不相让,都在等对方先让开。
- 活锁:两人互相礼让,却恰巧站到同一侧,再次让开,又站到同一侧,同样的情况不断重复下去导致双方都无法通过。
悲观锁
悲观锁(Pessimistic Lock),顾名思义,就是很悲观,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿数据就会 block直到它拿到锁,也就是别人解锁后。传统的关系型数据库里面就用了很多这种锁机制,比如行锁、表锁等,读锁,写锁等都是在操作之前先上锁
乐观锁
乐观锁(Optimistic Lock),顾名思义,就是很乐观,每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制。乐观锁使用于多读的应用类型,这样可以提高吞吐量。Redis就是利用这种 check-and-set机制实现事务的。
小tips: 当减少库存或者资金余额,更新条件加上 前面逻辑中查出来用来判断的库存或金额,则可以达到“版本号”的效果,如下
//查出数据
stock_num_old = SELECT stock_num FROM product WHERE id = 100
//比较库存
if (num > stock_num_old ) {
error("库存不足");
}
//更新库存
UPDATE product SET stock_num = stock_num - 1 WHERE id = 100 AND stock_num = stock_num_old
互斥锁
共享资源的使用是互斥的,即一个线程获得资源的使用权后就会将该资源加锁,使用完后会将其解锁,如果在使用过程中有其他线程想要获取该资源的锁,那么它就会被阻塞陷入睡眠状态-空等待(sleep-waiting),直到该资源被解锁才会被唤醒,如果被阻塞的资源不止一个,那么它们都会被唤醒,但是获得资源使用权的是第一个被唤醒的线程,其它线程又陷入沉睡.
自旋锁
自旋锁也是广义上的互斥锁, 是互斥锁的实现方式之一, 它不会产生线程的调度, 而是通过"循环"来尝试获取锁, 优点是能很快的获取锁, 缺点是会占用过多的CPU时间, 这被称为忙等待(busy-waiting).
读写锁
在互斥锁中, 只有两个状态: 加锁和未加锁, 而在一些情况下对于"读"可以并发的进行而不用加锁, 对于读则需要加锁, 比如golang中map的操作.
为了让"读"操作更快的进行(不必加锁), 就诞生了"读写锁"的概念, 它有三个状态: 读模式下加锁状态, 写模式加锁状态和未加锁状态.
规则如下
- 如果有其它线程读数据, 则允许其它线程执行读操作, 但不允许写操作
- 如果有其它线程写数据, 则其它线程都不允许读和写操作
由于这个特性, 读写锁能在读频率更高的情况下有更好的并发性能.
分布式锁
在单机情况下, 在内存中的一个互斥锁就能控制到一个程序中所有线程的并发.
但由于有集群架构(负载均衡/微服务等场景下), 内存中的锁就没用了. 所以我们需要一个"全局锁"去实现控制多个程序/多个机器上的线程并发. 这个全局锁就叫"分布式锁".
互斥锁的实现机制
互斥锁是并发控制的一个基本手段,是为了避免竞争而建立的一种并发控制机制。在学习它的具体实现原理前,我们要先搞懂一个概念,就是临界区。
在并发编程中,如果程序中的一部分会被并发访问或修改,那么,为了避免并发访问导致的意想不到的结果,这部分程序需要被保护起来,这部分被保护起来的程序,就叫做临界区。
可以说,临界区就是一个被共享的资源,或者说是一个整体的一组共享资源,比如对数据库的访问、对某一个共享数据结构的操作、对一个 I/O 设备的使用、对一个连接池中的连接的调用,等等。
如果很多线程同步访问临界区,就会造成访问或操作错误,这当然不是我们希望看到的结果。所以,我们可以使用互斥锁,限定临界区只能同时由一个线程持有。
当临界区由一个线程持有的时候,其它线程如果想进入这个临界区,就会返回失败,或者是等待。直到持有的线程退出临界区,这些等待线程中的某一个才有机会接着持有这个临界区。
你看,互斥锁就很好地解决了资源竞争问题,有人也把互斥锁叫做排它锁。那在 Go 标准库中,它提供了 Mutex (mutual exclusion)来实现互斥锁这个功能。
Mutex 是使用最广泛的同步原语(Synchronization primitives,有人也叫做并发原语。根据英文直译优先用同步原语,但是并发原语的指代范围更大,还可以包括任务编排的类型,所以后面我们讲 Channel 或者扩展类型时也会用并发原语)。关于同步原语,并没有一个严格的定义,你可以把它看作解决并发问题的一个基础的数据结构在这里,同步原语的适用场景。
- 共享资源。并发地读写共享资源,会出现数据竞争(data race)的问题,所以需要 Mutex、RWMutex 这样的并发原语来保护。
- 任务编排。需要 goroutine 按照一定的规律执行,而 goroutine 之间有相互等待或者依赖的顺序关系,我们常常使用 WaitGroup 或者 Channel 来实现。
- 消息传递。信息交流以及不同的 goroutine 之间的线程安全的数据交流,常常使用 Channel 来实现。