旧游无处不堪寻
无寻处,惟有少年心

锁在并发编程中扮演着非常重要的角色,本篇,我将梳理各种锁分类的概念以及各种锁实现类之间的区别与联系。

为什么要有锁


因为在应用层面,不可避免地会出现并发操作,就会出现资源竞争,因此程序代码中就需要锁机制来进行同步。同样的,在数据库层面,不可避免地多用户同一时刻对数据进行读写操作,因此也需要锁机制保证数据准确性。

锁操作


我们对锁的操作可分为加锁(lock)或者解锁(unlock),学术一点地说法也称为获取(acquire)和释放(release)。

锁的种类


开发过程中,我们常听到以下这些名词,悲观锁乐观锁互斥锁信号量读写锁自旋锁等,我们来具体介绍一下这些基础概念。

悲观锁与乐观锁

锁的一种宏观分类方式是悲观锁和乐观锁。悲观锁与乐观锁并不是特指某个锁。只是在并发情况下的两种不同策略。
我们说的所有的锁均属于悲观锁或乐观锁。

  1. 悲观锁(Pessimistic Lock): 即每次读写数据时都认为数据会被修改。所以每次读写数据时都会加锁
  2. 乐观锁(Optimistic Lock): 每次读数据时都认为不会被修改。所以不会上锁,但是如果更新数据,则会在更新前检查在读取至更新这段时间数据是否被修改过。如果修改过,则重新读取,并尝试更新,循环上述步骤直到更新成功或超时放弃

一句话记忆: 悲观锁阻塞事务,乐观锁回滚重试
这两种策略各有优劣:

  1. 乐观锁适用于写少读多的情况,即冲突极少发生,这样可以省去锁的开销,加大了系统的整个吞吐量。
  2. 悲观锁适用于冲突经常发生的情况,防止不断的进行重试,降低性能。

CAS

CAS 即 CompareAndSwap(比较并替换):

  1. Compare: 读取到了一个值 A,在将其更新为 B 之前,检查原值是否仍为 A(未被其他线程改动)
  2. Swap: 如果是,将 A 更新为 B。否则,循环重试

以上两步为一个不可分割的原子操作,即 CPU 的一条指令。

有了 CAS,就可以实现一个乐观锁,因为整个过程中并没有”加锁”、”解锁”操作,因此乐观锁策略也被称为无锁编程。

互斥锁

互斥锁(Mutex)无疑是最常见的多线程同步方式。其思想简单粗暴,多线程共享一个互斥量,然后线程之间去竞争,得到锁的线程可以进入临界区执行代码。
互斥锁是睡眠等待(sleep waiting)类型的锁,当线程抢互斥锁失败的时候,线程会陷入休眠。优点就是节省 CPU 资源,缺点就是休眠唤醒会消耗一点时间。

读写锁

读写锁(Read-Write Lock),顾名思义”读写锁”就是对于临界区区分读和写。在读多写少的场景下,不加区分的使用互斥量显然是有点浪费的。读写锁有一个别称叫共享-独占锁,其读共享,写独占。
读写锁的特性:

  1. 当读写锁被加了写锁时,其他线程对该锁加读锁或者写锁都会阻塞(不是失败)。
  2. 当读写锁被加了读锁时,其他线程对该锁加写锁会阻塞,加读锁会成功。

自旋锁

自旋锁(SpinLock),更为通俗的一个词是忙等待(busy waiting),这是与互斥锁最大的区别。自旋锁不会引起线程休眠,当共享资源的状态不满足的时候,自旋锁会不停地循环检测状态。这既是优点也是缺点,不休眠就不会引起上下文切换,但是会比较浪费 CPU 资源。自旋锁的意义在于优化一些短时间的锁。

锁与数据库隔离级别的关系

数据库中有三种并发控制的技术:

  • 严格的两段锁(2PL): 事务在读任何数据之前需要一个共享锁,而在写之前需要一个排他锁。一个事务所拥有的锁,会一直保持到事务结束时才自动释放。
  • 多版本并发控制(MVCC): 事务不使用锁控制机制,我们为过去某一时间点的数据库状态保存一个一致的副本,即便在某一固定时间点之后数据库状态发生了改变,我们也可以读到数据库的一个过去的状态。
  • 乐观并发控制(OCC): 该方法允许多个事务在无阻塞的情况下读或者更新一个数据项。事务会保存自身的读写历史,在一个事务提交前,必须通过检查其读写历史来判断是否发生了隔离性冲突。若发生,则发生冲突的其中一个事务必须回滚。

两段锁

同应用锁一样,数据库锁中最基本的也有读写锁:

  1. 共享锁: 又称 S 锁、读锁,事务 A 对一个资源加了 S 锁后其他事务仍能共享读该资源,但不能对其进行写,直到 A 释放锁为止。
  2. 排它锁: 又称 X 锁、写锁,事务 A 对一个资源加了 X 锁后只有 A 本身能对该资源进行读和写操作,其他事务对该资源的读和写操作都将被阻塞,直到 A 释放锁为止

2PL 又分为两个阶段:

  1. Growing: 仅获取锁或者拒绝获取锁的请求
  2. Shrinking: 只允许释放锁

MVCC

要解决冲突,可以使用完全基于锁的并发控制,比如 2PL,这种方式开销比较高,而且无法避免死锁。因此出现多版本并发控制(MVCC)。是一种用来解决读写冲突的无锁并发控制,在读操作不阻塞写操作,写操作不阻塞读操作的同时,避免了脏读和不可重复读。

OCC

乐观并发控制(OCC)是一种用来解决写写冲突的无锁并发控制,先进行修改,在提交事务前,检查一下事务开始后,有没有新提交改变,如果没有就提交,如果有就放弃并重试。
由于 OCC 避免了锁等待,因此,当事务之间真正发生冲突时将会产生很高的惩罚代价。当冲突频繁发生时,过多的回滚和重试会严重降低性能,这时,OCC 就不是一个好的选择了。

隔离级别的使用

对于 READ UNCOMMITTED 和 SERIALIZABLE,我们使用极少,因为对于 READ UNCOMMITTED,可以读到未提交的读,与我们应用事务的初衷不符,基本上不会使用该隔离等级。而对于 SERIALIZABLE 完全基于锁的并发控制,很少有文章会详细介绍 SERIALIZABLE 隔离级别,真相是:对很多数据库来说,Serializable就是个形象工程,在 RC、RR 隔离级别下,不带 for share (lock in share mode) / for update的 select 称为 plain select,从快照中读取数据的同时,其它线程可以并发写数据到数据库,读写不冲突。而在 SERIALIZABLE 隔离级别下,所有的 select 都会加读写锁,不存在 plain select,读写无法并行。此时数据库的吞吐量和响应时间相比其它弱隔离级别下降很多,访问延迟很难确定。

不同 SQL 语句对加锁的影响

不同的 SQL 语句当然会加不同的锁,总结起来主要分为五种情况:

  • SELECT … 语句正常情况下为快照读,不加锁;
  • SELECT … LOCK IN SHARE MODE 语句为当前读,加 S 锁;
  • SELECT … FOR UPDATE 语句为当前读,加 X 锁;
  • 常见的 DML 语句(如 INSERT、DELETE、UPDATE)为当前读,加 X 锁;
  • 常见的 DDL 语句(如 ALTER、CREATE 等)加表级锁,且这些语句为隐式提交,不能回滚。

锁的算法

  • Record Lock: 单个行记录上的锁
  • Gap Lock: 间隙锁,锁定一个范围,但不包含记录本身
  • Next-Key Lock: Gap Lock+Record Lock,锁定一个范围、索引之间的间隙,并且锁定记录本身,目的是为了防止幻读

MVCC 实现 READ COMMITTED 和 REPEATABLE READ

之前我们也说过,最早的数据库系统实现中,只使用了锁来实现隔离级别,因此只有读读之间可以并发,读写,写读,写写都要阻塞。
引入多 MVCC 后,只有写写之间相互阻塞,其他三种操作都可以并行,这样大幅度提高了并发度。不同数据库的 MVCC 实现方式不同。

RC 隔离级别总是读取记录的最新版本,而 RR 隔离级别是读取该记录事务开始时的那个版本,虽然这两种读取的版本不同,但是都是快照数据,并不会被写操作阻塞,所以这种读操作称为快照读(Snapshot Read)。

MySQL 还提供了另一种读取方式叫当前读(Current Read),它读的不再是数据的快照版本,而是数据的最新版本,并会对数据加锁,根据语句和加锁的不同,又分成三种情况:

  • 隔离级别为未提交读(RN)时读取都是当前读,默认 SELECT 为当前读
  • SELECT … LOCK IN SHARE MODE: 加共享(S)锁
  • SELECT … FOR UPDATE: 加排他(X)锁
  • INSERT / UPDATE / DELETE: 加排他(X)锁