你应该知道的乐观锁-高效控制线程安全的手段

1.背景

最近在修改Seata线程并发的一些问题,把其中一些经验总结给你们。先简单描述一下这个问题,在Seata这个分布式事务框架中有个全局事务的概念,在大多数状况下,全局事务的流程基本是顺序推动不会出现并发问题,可是当一些极端的状况下,会出现多线程访问致使咱们全局事务处理不正确。 以下面代码所示: 在咱们全局事务commit阶段,有一个以下代码:数据库

if (status == GlobalStatus.Begin) {
        globalSession.changeStatus(GlobalStatus.Committing);
    }

代码有些省略,就是先判断status状态是否Begin状态,而后改变状态为Committing。安全

在咱们全局事务rollback阶段,有一个以下代码:多线程

if (status == GlobalStatus.Begin) {
            globalSession.changeStatus(GlobalStatus.Rollbacking);
        }

一样的也省略了部分代码,这里先判断status状态是否为begin,而后改变为Rollbacking。这里再Seata的代码中并无作一些线程同步的手段,若是这两个逻辑同时执行(通常状况下不会,可是极端状况下可能会出现),会让咱们的结果出现不可预料的错误。而咱们所要作的就是解决这种极端状况下来的并发出现的问题。并发

2.悲观锁

对于这种并发出现问题我相信你们第一时间想到的确定是加锁,在Java中咱们咱们通常采用下面两个手段进行加锁:框架

Synchronized
ReentrantLock
咱们能够利用Synchronized 或者 ReentrantLock进行加锁,能够将代码修改为下面的逻辑:分布式

synchronized:ide

synchronized(globalSession){
            if (status == GlobalStatus.Begin) {
                globalSession.changeStatus(GlobalStatus.Rollbacking);
            }
        }

ReentrantLock进行加锁:性能

reentrantLock.lock();
 try {
    if  (status == GlobalStatus.Begin) {
    globalSession.changeStatus(GlobalStatus.Rollbacking);
        }
    }finally {
            reentrantLock.unlock();
    }

对于这种加锁比较简单,在Seata的Go-Server中目前是这样实现的。可是这种实现场景忽略了咱们上面所说的一种状况,就是极端状况下,也就是有可能99.9%的状况下可能不会出现并发问题,只有%0.1的状况可能致使这个并发问题。虽然咱们悲观锁一次加锁的时间也比较短,可是在这种高性能的中间件中仍是不够,那么就引入了咱们的乐观锁。学习

3.乐观锁

一提起乐观锁,不少朋友都会想到数据库中乐观锁,想象一下上面的逻辑若是在数据库中,而且没有利用乐观锁去作,咱们会有以下的伪代码逻辑:优化

select * from table where id = xxx for update;
if(status == begin){
    //do other thing
    update table set status = rollbacking;
}

上述代码在咱们不少的业务逻辑中都能看见,这段代码有两个小问题:

1,事务较大,因为咱们一上来就对咱们数据加锁,那么一定在一个事务中,咱们的查询和更新之间若是穿插了一些比较耗时的逻辑那么咱们的事务就会致使较大。因为咱们的每个事务都会占据一个数据库链接,那么在流量较高的时会很容易出现数据库链接池不够的状况。

2,锁定数据时间较长,在咱们整个事务中都是对这条数据加了行锁,若是有其余事务想对这个数据进行修改那么会长时间阻塞等待。

因此为了解决上面的问题,在不少若是竞争不大的场景下,咱们就采用了乐观锁的方法,咱们在数据库中加一个字段version表明着版本号,咱们将代码修改为以下所示:

select * from table where id = xxx ;
if(status == begin){
    //do other thing
    int result = (update table set status = rollbacking where version = xxx);
    if(result == 0){
        throw new someException();
    }
}

这里咱们的查询语句再也不有for update,咱们的事务也只缩小到update一句,咱们经过咱们第一句查询出来的version来进行判断,若是咱们的更新的更新的行数为0,那么就证实其余事务对他进行了修改。这里能够抛出异常或者作一些其余的事。

从这里能够看出咱们使用乐观锁将事务较大,锁定较长这两个问题都解决,可是对应而来的成本就是若是更新失败咱们可能就会抛出异常或者作一些其余补救的措施,而咱们的悲观锁在执行业务以前都已经限制住了。因此咱们这里使用乐观锁必定只能在对某条数据并发处理的状况比较小的状况下。

3.1 代码中的乐观锁

咱们上面讲述了在数据库中的乐观锁,不少人就在问,没有数据库,在咱们代码中怎么去实现乐观锁呢?熟悉synchronized的同窗确定知道synchronized在Jdk1.6以后对其进行了优化,引入了锁膨胀的一个模型:

1,偏向锁:顾名思义偏向某个线程的锁,适用于某个线程能长期获取到该锁。

2,轻量级锁:若是偏向锁获取失败,那么会使用CAS自旋来完成,轻量级锁适用于线程交替进入临界区。

3,重量级锁:自旋失败以后,会采起重量级锁策略咱们线程会阻塞挂起。

上面的级种锁模型中轻量级锁所适用的线程交替进入临界区很适合咱们的场景,由于咱们的全局事务通常来讲不会是某个单线程一直在处理该事务(固然也能够优化成这个模型,只是设计会比较复杂),咱们的全局事务再大多数状况下都会是不一样线程交替进入处理这个事务逻辑,因此咱们能够借鉴轻量级锁CAS自旋的思想,完成咱们代码级别的自旋锁。这里也有朋友可能会问为何不用synchronized呢?这里通过实测在交替进入临界区咱们本身实现的CAS自旋性能是最高的,而且synchronized没有超时机制,不方便咱们处理异常状况。

class GlobalSessionSpinLock {

        private AtomicBoolean globalSessionSpinLock = new AtomicBoolean(true);

        public void lock() throws TransactionException {
            boolean flag;
            do {
                flag = this.globalSessionSpinLock.compareAndSet(true, false);
            }
            while (!flag);
        }

        public void unlock() {
            this.globalSessionSpinLock.compareAndSet(false, true);
        }
    }
  // method rollback  
  void rollback(){
    globalSessionSpinLock.lock();
    try {
        if  (status == GlobalStatus.Begin) {
        globalSession.changeStatus(GlobalStatus.Rollbacking);
            }
    }finally {
        globalSessionSpinLock.unlock();
    }
  }

上面咱们用CAS简单的实现了一个乐观锁,可是这个乐观锁有个小缺点就是一旦出现竞争不能膨胀为悲观锁阻塞等待,而且也没有过时超时,有可能大量占用咱们的CPU,咱们又继续进一步优化:

public void lock() throws TransactionException {
            boolean flag;
            int times = 1;
            long beginTime = System.currentTimeMillis();
            long restTime = GLOBAL_SESSOION_LOCK_TIME_OUT_MILLS ;
            do {
                restTime -= (System.currentTimeMillis() - beginTime);
                if (restTime <= 0){
                    throw new TransactionException(TransactionExceptionCode.FailedLockGlobalTranscation);
                }
                // Pause every PARK_TIMES_BASE times,yield the CPU
                if (times % PARK_TIMES_BASE == 0){
                    // Exponential Backoff
                    long backOffTime =  PARK_TIMES_BASE_NANOS << (times/PARK_TIMES_BASE);
                    long parkTime = backOffTime < restTime ? backOffTime : restTime;
                    LockSupport.parkNanos(parkTime);
                }
                flag = this.globalSessionSpinLock.compareAndSet(true, false);
                times++;
            }
            while (!flag);
        }

上面的代码作了以下几个优化:

引入了超时机制,通常来讲一个要作好这种对临界区域加锁必定要作好超时机制,尤为是在这种对性能要求较高的中间件中。

引入了锁膨胀机制,这里没循环必定次数若是获取不到锁,那么会线程挂起parkTime时间,挂起以后又继续循环获取,若是再次获取不到,此时咱们会对咱们的parkTime进行指数退避形式的挂起,将咱们的挂起时间逐渐增加,直到超时。

总结

从咱们对并发控制的处理来看,想要达到一个目的,要实现它方法是有多种多样的,咱们须要根据不一样的场景,不一样的条件,选择合适的方法,选择最高效的手段来完成咱们的目的。本文没有对悲观锁的原理作太多的阐述,这里有兴趣的能够下来自行查阅资料,读完本文若是你只能记住一件事,那么请记住实现线程并发安全的时候别忘记考虑乐观锁。

学习更多Java基础知识能够加入个人:Java学习园地,更适合小白。

相关文章
相关标签/搜索