并发控制始终是程序员头疼的问题,不管是单机事务还是分布式SOA事务都是如此。就像家里有6个小孩同一时间争抢一篮筐苹果的情形,并发控制的游戏规则如何建立?通常的办法是两个:对竞争资源(苹果)加锁(悲观锁lock/synchronized,乐观锁)、分布式事务控制器(协调中间人,类似家长的角色)。(关于分布式事务,分布式锁,非常复杂,因为分布式事务的数据一致性还依赖网络状态:成功,失败,超时,而timeout、两将军问题等是分布式事务管理器设计的噩梦)。什么是乐观锁和悲观锁?
悲观锁
悲观锁(独占锁)它假设最坏的情况,并且只有在确保其它线程不会造成干扰的情况下执行,会导致其它所有需要锁的线程挂起,等待持有锁的线程释放锁。而另一个更加有效的锁就是乐观锁。Java中synchronized关键字,C#中lock关键字就是独占锁。
悲观锁的代价
悲观锁是用来做并发最简单的方式,当然其代价也是最高的。内核态的锁的时候需要操作系统进行一次上下文切换,加锁、释放锁会导致比较多的上下文切换和调度延时,等待锁的线程会被挂起直至锁释放。在上下文切换的时候, cpu之前缓存的指令和数据都将失效,对性能有很大的损失。操作系统对多线程的锁进行判断就像两姐妹在为一个玩具在争吵,然后操作系统就是能决定他们谁能拿到玩具的父母,这是很慢的。用户态的锁虽然避免了这些问题,但是其实它们只是在没有真实的竞争时才有效。此外,锁还存在大量的存储开销。
Java在JDK1.5之前都是靠synchronized关键字保证同步的,这种通过使用一致的锁定协议来协调对共享状态的访问,可以确保无论哪个线程持有守护变量的锁,都采用独占的方式来访问这些变量,如果出现多个线程同时访问锁,那第一些线线程将被挂起,当线程恢复执行时,必须等待其它线程执行完他们的时间片以后才能被调度执行,在挂起和恢复执行过程中存在着很大的开销。锁还存在着其它一些缺点,当一个线程正在等待锁时,它不能做任何事。如果一个线程在持有锁的情况下被延迟执行,那么所有需要这个锁的线程都无法执行下去。如果被阻塞的线程优先级高,而持有锁的线程优先级低,将会导致优先级反转(Priority Inversion)。
由于这些原因,高性能系统都尽量避免悲观锁,转而用巧妙的数据结构、算法和乐观锁来解决资源争用的一致性问题。如果必须用悲观锁,尽量减小锁定资源的粒度和时间。
乐观锁
所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。具体做法就是把上次的值保存下来,当尝试去更新该值的时候,检查当前值是否与上次保存的旧值一样,如果一样就没有冲突,可以更新;否则,获得新值并继续重试,直到成功。
在现实应用中,我们不必保存上次的旧值,而只需要检查数据版本号(revision)、时间戳(timestamp)、状态(state)等即可判断数据是否是脏数据。
例如:Entity Framework和Hibernate都可以配置并发模型(ConcurrencyMode),默认是乐观并发模型以提升性能,即更新记录的时候不加锁,当尝试去updateRow的时候加上where条件检查上次保存的旧的值、状态、数据版本号或时间戳,这样如果RecordsAffected=0,即受影响的行数是0,表示在这段时间内值已被修改,乐观锁冲突被检测到,则刷新新的值再次尝试。Entity Framework和Hibernate也可以配置为悲观并发模型,即使用数据库默认的锁机制,以保证操作最大程度的独占性。例如: Hibernate使用session.lock() 锁定对象来实现悲观锁(本质上就是执行了SELECT * FROM t FOR UPDATE语句)。 但随之而来的就是数据库性能的大量开销,特别是对长事务而言,这样的开销往往无法承受。
Wiki上乐观并发控制(Optimistic Concurrency Control)的定义
Optimistic concurrency control (OCC) is a concurrency control method applied to transactional systems such as relational database management systems and software transactional memory. OCC assumes that multiple transactions can frequently complete without interfering with each other. While running, transactions use data resources without acquiring locks on those resources. Before committing, each transaction verifies that no other transaction has modified the data it has read. If the check reveals conflicting modifications, the committing transaction rolls back and can be restarted.[1] Optimistic concurrency control was first proposed by H.T. Kung.[2]
OCC is generally used in environments with low data contention. When conflicts are rare, transactions can complete without the expense of managing locks and without having transactions wait for other transactions' locks to clear, leading to higher throughput than other concurrency control methods. However, if contention for data resources is frequent, the cost of repeatedly restarting transactions hurts performance significantly; it is commonly thought that other concurrency control methods have better performance under these conditions. However, locking-based ("pessimistic") methods also can deliver poor performance because locking can drastically limit effective concurrency even when deadlocks are avoided.
CAS(比较并交换)
最著名无锁机制是CAS(比较并交换),其原理是乐观锁的。即:我认为V的值应该为A,如果是,那么将V的值更新为B,否则不修改并告诉V的值实际为多少。当多个线程尝试使用CAS同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。CAS有3个操作数,内存值V,旧的预期值A,要修改的新值B。当且仅当预期值A和内存值V相同时,将内存值V修改为B,否则什么都不做。CAS比较与交换的伪代码可以表示为:
do{
备份旧数据;
基于旧数据构造新数据;
}while(!CAS( 内存地址,备份的旧数据,新数据 ))
CAS无锁算法的C实现如下:
int compare_and_swap (int* reg, int oldval, int newval)
{
ATOMIC();
int old_reg_val = *reg;
if (old_reg_val == oldval)
*reg = newval;
END_ATOMIC();
return old_reg_val;
}
就是指当两者进行比较时,如果相等,则证明共享数据没有被修改,替换成新值,然后继续往下运行;如果不相等,说明共享数据已经被修改,放弃已经所做的操作,然后重新执行刚才的操作。容易看出 CAS操作是基于共享数据不会被修改的假设,采用了类似于数据库的 commit-retry的模式。当同步冲突出现的机会很少时,这种假设能带来较大的性能提升。)
CAS CPU指令 -在大多数处理器架构,包括IA32、Space中采用的都是CAS的CPU指令,如Intel的XCHG指令即可以一个时钟周期内完成数据的交换(寄存器和内存的数据交换)。x86架构下其对应的汇编指令是lock cmpxchg,如果想要64Bit的交换,则应使用lock cmpxchg8b。在JDK和Windows API/.NET Framework中,都提供了很多原子类型和原子操作(Atomic Operatoration),如.NET的InterlockedCompareExchange等一系列InterLocked函数,JDK的AtomicLong等,它们的实现源码都可以看到CAS CPU指令的踪影。Windows API可参考InterlockedCompareExchange的反汇编代码。JDK的原子操作可以参考AtomicLong源码,背后是用native call直接调用汇编后面是CASCPU指令。CPU指令的好处是显而易见的:避免了请求操作系统来裁定锁的问题,不用麻烦操作系统,直接在CPU内部就搞定了。(关于CAS,也可以参考:酷壳coolshell的一篇文章《无锁队列的实现》,后面还有很多姿势:内存屏障、MESI协议等,越说越感觉像IT姿势科普员了...)
来看看JDK中AtomicLong自增(AtomicLong.incrementAndGet)的源码(片段):
public final int getAndIncrement() {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return current;
}
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
由此可见,AtomicLong.incrementAndGet的实现用了乐观锁技术,调用了sun.misc.Unsafe类库里面的 CAS算法,用CPU指令来实现无锁自增。所以,AtomicLong.incrementAndGet的自增比用synchronized的锁效率倍增(5-10倍以上,取决于写入竞争程度)。
乐观锁的应用例子
事实上到处都是乐观锁,因为悲观锁(特别是大粒度的)是性能杀手:
- JVM/.NET framwork/GC: 垃圾收集器使用非阻塞算法加快并发和平行的垃圾搜集;调度器使用非阻塞算法有效地调度线程和进程,实现内在锁....
- .NET Dataset / Entity framework: 设计都是基于乐观并发的原则,详见msdn: Optimistic Concurrency,在EntityFramework里面还有一个OptimisticConcurrencyException
- Oracle/SQLServer/Hibernate - 基于版本号的乐观锁应用很多
- Google App Engine,见下wiki图
Lock-free的设计实现例子
服务端编程的3大性能杀手是:1、大量线程导致的线程切换开销。2、锁。3、非必要的内存拷贝。所以古往今来,大家都绞尽脑汁解决这个问题。抛开单线程,我认为比较牛逼的lock-free的实现有:
- JDK中的ConcurrentHashMap源码实现,设计巧妙,用桶粒度的锁和锁分离机制,避免了put和get中对整个map的锁定。尤其在get中,只对一个HashEntry做锁定操作,性能提升是显而易见的(详细分析见《探索 ConcurrentHashMap 高并发性的实现机制》)。只有你仔细阅读了源码才明白里面的精妙。
- LMAX Disruptor, 通过基于定长数组的环形队列数据结构巧妙做到了lock-free,性能异常高。
何时使用乐观锁?乐观的人用乐观锁?!
乐观的人觉得这个数据我就默认这段时间没人更新,尽管用,发现不行就回滚重试一下!悲观的人觉得不管三七二十一,先锁住再说,让别人用不了,我操作完了再释放;如果在长事务中,将导致数据一直被锁住,在数据库级别将导致长连接保持不释放,结果是并发性能大大降低。如果把乐观锁看作是关于冲突检测的,那么悲观锁就是关于冲突避免的。应用或SQL的事务处理逻辑有点类似于乐观并发机制:提交事务,如果不成功就回滚。
那么到底如何选择呢?一般情况下,更新写入的并发不大的情况下,使用乐观锁可以大大提升性能,否则则会出现大量冲突而写入失败事务回滚。在并发更新很大、冲突机率高的情况下,使用悲观锁。但什么是并发写入很大,十万次并发是大还是小?这个问题比较难回答,要视情况而定,测试看看冲突提交失败回滚多不多,其实大部分应用乐观锁足够了。可以参考看看JDK里面ConcurentQueue等的源码,其思路是:结合乐观并发的逻辑减少锁的使用、同时使用尽量小粒度的悲观锁,从而既保证数据一致性、减少冲突,又能获得高性能;事实上现在越来越多的开发者倾向于乐观并发机制来减少锁的使用,你呢?