线程安全

Thread safety is a computer programming concept applicable in the context of multi-threaded programs. A piece of code is thread-safe if it functions correctly during simultaneous execution by multiple threads. In particular, it must satisfy the need for multiple threads to access the same shared data, and the need for a shared piece of data to be accessed by only one thread at any given time.

简单地说,线程安全是指一段代码在多线程并发的情况下,仍然能正确执行并得到结果。

严格点说,线程安全必须满足两点:第一,多个线程可以访问同一个数据(共享数据);第二,特定时间点上只能有一个线程来访问这个数据。

不过,如果考虑到共享锁(读写锁中的只读锁),特定时间点上可能就不止一个线程来访问数据了。

两种线程通信机制

多线程有两种通信机制:共享数据和消息传递。只有共享数据机制存在线程安全问题,消息传递机制下不存在这种问题。

共享内存机制如下图所示。Java用的就是这种机制。

共享内存机制

消息传递机制参见下图,这里参考的是Actor模型。印象里,GO用的goroutine channel也是这种机制。

消息传递机制
并发模型通信机制同步机制
共享内存线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式进行通信。同步是显式进行的。程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
消息传递(actor)线程之间没有公共状态,线程之间必须通过明确的发送消息来显式进行通信。由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。 

这两种机制不仅适用于线程通信,也适用于进程、系统间通信。集群服务同时访问同一个数据库中的同一条数据,这实际上就是共享数据方式的通信;多个服务之间通过MQ来收发消息,这也是消息传递机制。

线程安全和内存模型的关系

Java内存模型(Java Memory Model,即JMM)大概是这样子的:

JMM

主内存被所有的线程所共享。对于一个共享变量(比如静态变量,或是堆内存中的实例)来说,主内存当中存储了它的“本尊”。工作内存为每个线程所“独有”,每一个线程拥有自己的工作内存,其它线程不能访问。对于一个共享变量来说,工作内存当中存储了它的“副本”。

二者的关系可以类比计算机组成原理中的CPU、高速缓存和内存。

多核CPU的高速缓存、主内存的关系

JMM的设计给线程安全带来了一个问题:工作内存之间、工作内存和主内存之间的数据可能在多线程环境下不能及时同步。也就是常说的“可见性问题”。

可见性就是一个线程对共享变量做了修改之后,其他的线程立即能够看到修改后的值。如果做不到这一点,就会产生可见性问题。

除了可见性问题之外,线程安全还包括原子性、有序性这两个问题。

原子性就是操作不能被线程调度机制中断,要么全部执行完毕,要么不执行。如果不能保证操作原子性,就容易引发CAS问题。如“j = i++”操作,由于非原子性操作,在多线程环境下就可能出错。

我们在业务系统中,常常会做“先select,如果不存在,则insert”的逻辑处理。这种处理也有可能引发CAS问题。

有序性即程序执行的顺序按照代码的先后顺序执行。单例模式中的double-check问题就是由有序性引发的最广为人知的问题了。

锁是保证线程安全的一种方式。它保证了在同一个时间点上只有一个线程能访问到共享数据。

除了锁之外,还有哪些方式可以保证线程安全?(只读数据、不可变对象、信号量等。)

Java中的所有的锁(synchronized、j.u.c.locks包下的各种锁)都能够保证可见性、原子性和有序性。

可见性方面,可以参见j.u.c.locks.Lock接口的javadoc:

All Lock implementations must enforce the same memory synchronization semantics as provided by the built-in monitor lock, as described in The Java Language Specification (17.4 Memory Model) :
A successful lock operation has the same memory synchronization effects as a successful Lock action.
A successful unlock operation has the same memory synchronization effects as a successful Unlock action.

即,Lock的所有实现类都必须保证与java内建的monitor锁一样的内存同步语义,其中就包括可见性语义。具体的分析可以参考:Java锁是如何保证数据可见性的。

原子性方面是指对共享内存的操作要么全做、要么全不做。Java通过monitor机制来保证原子性。在进入锁时先执行monitorenter指令、退出时执行monitorexit指令,从而保证在进入时从主内存把数据从主内存读取到当前线程的工作内存中、并在退出时把工作内存的值写回主内存中。monitor机制在后面的技术分享中会细说,这里不赘述。

有序性方面,锁通过保证“先释放、后加锁”的顺序来保证多线程之间的顺序执行。不过,如果考虑到共享锁,“加共享锁”的操作就不一定要等到“释放共享锁”了;但是,“共享锁”与“互斥锁”的竞争还是要严格遵循这个顺序的。

乐观锁与悲观锁

乐观锁是指,只在最后关头去竞争共享数据上的锁;悲观锁则是在一切还未发生的时候就去竞争锁。

我们的业务系统里,经常有这样的逻辑:先处理一大堆功能,最后向数据库中update一条数据。如果没有更新到数据(rows < 1),就抛出异常。这就是一种乐观锁。

业务系统里也有这样的逻辑:先加一个分布式锁;如果锁定成功,就继续后面的处理;如果锁定不成功,就抛出异常。这就是一种悲观锁。

什么时候用乐观锁、什么时候用悲观锁呢?可以用一个“四象限”来选择:

一、四象限基本没的说。二、三象限使用哪种锁,需要看具体的场景和应对风险的策略。

比如,在概率低、代价高的场景下(例如用户和客服同时对同一笔借款申请发起操作,引发并发),可以选择乐观锁——这种千年等一回的问题错一次改一次就好了,也可以选择悲观锁——万一错一次就要改几十张表的数据还是谨慎点好。

悲观锁的实现方式比较简单,在执行业务逻辑之前通过synchronized关键字、j.u.c.Lock、或者其它方式竞争到锁即可。

乐观锁的方式一般是通过数据库的方式来实现的。在更新数据库时,通过一定的方式来检查更新结果。如果没能更新到数据,则认为锁竞争失败。

例如,在更新语句中,把更新前数据加入到where子句中:update table set status = 1 where id = 100 and status = 0。这条语句可以保证:如果有其它线程并发地把status字段更新成了其它值,那么这条语句就会更新失败。然后,让整个事务回滚即可。

这种方式受业务场景的局限较大。例如,状态字段也许会有一定的规则,必须是从A更新到B、从B更新到C;但是金额呢?从100更新到101还是99,这就说不定了。

也可以通过增加一个版本号字段来实现乐观锁:update table set status = 1, version = version + 1 where id = 100 and version = 4。这条语句可以保证:如果有其它线程并发更新了这条数据、并且提高了数据的版本号,那么这条更新语句同样会更新失败。

与版本号字段异曲同工的方式,是借助更新时间字段来做版本号:update table set status = 1, update_time = now() where id = 100 and update_time <= '2019-06-26 22:22:10'

使用版本号或者更新时间的方式来实现乐观锁,会导致每次更新前都需要从数据库中查询一次数据,以获取当前版本号或者更新时间。这样一来可能需要额外的数据库操作;但是不受限于具体的业务。

数据库相关锁机制

虽然我们常常用数据库来做乐观锁,但是数据库本身的锁一般是悲观锁:先锁定数据然后再做处理。

MySQL使用的数据库锁是逐代升级的。

在MyISAM中只用到表锁,即锁住整张表。使用表锁不会产生死锁的问题,开销也小,但是显然并发能力很差。

BDB引入了页锁。这个页不是“分页查询”的“页”,而是MySQL用来存储数据时做的分页,每页存储相邻的一组数据。

Innodb在索引的基础上实现了行级锁。行锁每次只锁住若干行,而不是整张表。显然,行锁的粒度更小,并发能力更强,但是使用不当会产生死锁,而且开销更大、算法也更复杂。

例如,为了解决共享行锁(只读数据时)升级为独占行锁(更新数据时)可能产生的死锁问题,innodb引入了更新锁;为了提高行锁和表锁一起使用的效率,innodb又增加了意向锁(意向共享锁和意向排他锁)。

InnoDB实现了以下两种类型的行锁。

  • 共享锁(S):允许一个事务去读一行,阻止其他事务获得相同数据集的排他锁。
  • 排他锁(X):允许获得排他锁的事务更新数据,阻止其他事务取得相同数据集的共享读锁和排他写锁。另外,为了允许行锁和表锁共存,实现多粒度锁机制,InnoDB还有两种内部使用的意向锁(Intention Locks),这两种意向锁都是表锁。
  • 意向共享锁(IS):事务打算给数据行加行共享锁,事务在给一个数据行加共享锁前必须先取得该表的IS锁。
  • 意向排他锁(IX):事务打算给数据行加行排他锁,事务在给一个数据行加排他锁前必须先取得该表的IX锁。

上述锁模式的兼容情况具体如下表所示:

右:请求锁模式;
表内:是否兼容;
下:当前锁模式
XIXSIS
X冲突冲突冲突冲突
IX冲突兼容冲突兼容
S冲突冲突兼容兼容
IS冲突兼容兼容兼容

如果一个事务请求的锁模式与当前的锁兼容,InnoDB就将请求的锁授予该事务;反之,如果两者不兼容,该事务就要等待锁释放。

分布式锁

j.u.c.Lock接口下的实现类,都是在同一个JVM内部进行线程同步的工具类。但是这种锁满足不了集群环境、分布式环境的需求。在这种场景下就需要使用分布式锁了。 虽然都是锁,但是分布式锁比JVM内部锁复杂得多,需要额外处理连接问题、超时、阻塞锁、可重入、以及自身的集群一致性问题。我们目前用得多的,主要是三种:数据库、Redis和ZooKeeper。

更多的可以参见:几种分布式锁