教你写一个自旋锁

1,164 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第3天,点击查看活动详情

简介

        程序开发过程中,我们难免会遇到各种各样的并发场景,这时候我们通常都会在开发过程中引入锁用来防止各种场景下出现的并发问题。比如:悲观锁、乐观锁、自旋锁、偏向锁、轻量级锁、重量级锁、共享锁、排他锁、公平锁、非公平锁、可重入锁、不可重入锁等。那么今天我们重点就来讲解一下自旋锁

        自旋锁其实也是一种无锁的思想,它是通过在每一次自旋的过程中去做判断来确认次此共享数据是否能够更新,从而达到类似锁住的概念。在这种情况下线程在运行过程中不会进入阻塞也能够同步共享区域的数据。(非阻塞同步)

CAS自旋锁

对于CAS算法相信大家应该都是比较熟悉的吧,它的全名是Compare And Swap(比较并替换)。也是一种乐观锁的实现方式,通过自旋的方式实现无锁并发处理。 在JDK中就有很多采用CAS实现的并发工具包,java.util.concurrent.atomic在atomic包下的这些类就是通过CAS算法实现的,在更新时去跟旧值比较是否一致,如果一致则以原子操作的方式去更新共享变量的值,否则继续自旋。

image.png

AtomicInteger类的自旋处理逻辑 image.png

ABA问题

从CAS处理看似没有什么问题,但是我们试着想一下如果有多个线程同时在并发更新一个共享变量,比如说线程T读取到的变量值为A,那么线程T的内存空间里就记录当前值为A,然后线程T准备对变量做更新操作,在更新期间其他线程对该共享变量值更新为B,然后再被更新回A,那么此时线程T在做变量更新时去比较的旧值就是A,那就会任务共享变量没有被更新过,此时线程T就会发起更新操作,这种情况下实际共享变量已经经过(ABA)的一个变化过程,而线程T是无法感知到的,那么这种情况就会存在一些潜在的风险。

image.png

如上图所示,我们可以利用atomic包下的AtomicStampedRefence类进行处理,它的实现原理其实就是利用版本号进行控制,在共享变量每次更新的时候版本号(stamp)都会做相应的变化,只要在下一次自旋处理时发现版本号不一致,即可认为被其他线程更新过,此时会重新同步版本号再次进入自旋处理。

自旋锁在分布式场景下的运用

一般比较规范的企业大多数都会规定不能直接使用数据库记录锁(for update),防止记录锁失败导致锁表。那么我们就会采取其他方式来防止记录更新的并发问题,比如使用Redis、Zookeeper做分布式锁,或者使用有序队列做记录有序更新(实时性要求较低,适合数据可延迟刷新的方案),还有在这里我们要讲就是自旋锁方案。

那么为什么我们不直接使用分布式锁,这个我们也需要根据真实场景去分析。如果并发可能性较大,那么可能每次都需要加锁,所以就会采用分布式锁;如果并发可能性很小,那么场景相对就比较乐观,我们就可以使用自旋的方式来处理,这种情况下就不需要进行加锁,唯一担心的是其他分布式服务在更新此记录时占用的时间,所以最好还是要加上一个自旋次数来防止长时间等待(也可以使用最长等待时间来控制),当达到最大时间或者最大自旋次数均无结果抛出异常即可,此种方案保证了结果的强一致性(要么成要么败)。

@Service
class BillingService {
    @Autowired
    BillingMapper mapper;    // mapper接口

    @Transaction(isolation = Isolation.READ_COMMITTED)
    void modifyBilling(int value) {
        // 自旋10次
        int spins = 10;
        while(true) {
            // 查询记录
            BillingData billingData = mapper.query(1);
            billingData.setValue(billingData.getValue() + value);
            if (mapper.update(billingData) == 1) {
                break;
            }

            if (--spins <= 0) {
                throw new RuntimeException("Sorry, please try again later.");
            }
        }
    }
}

// mapper层伪代码
interface BillingMapper {
    // update记录版本号+1
    // update billing_data set value = #{value}, version = version + 1 where id = #{id} version = #{version}
    int update(BillingData data);

    BillingData query(int id);
}

class BillingData {
    private int id;
    private int version;
    private int value;

    public BillingData() {
    }

    public BillingData(int id, int value, int version) {
        this.id = id;
        this.value = value;
        this.version = version;
    }
    // getter、setter方法
}

模拟并发调用 1654571747958.jpg

注意:使用数据库做自旋锁的情况下需注意数据库的隔离级别必须是读已提交(Read Commit),要注意mysql默认隔离级别是可重复读(Repeatable Read)使用前需先进行数据库隔离级别的修改或者在开启事务时使用注解@Transaction(isolation = Isolation.READ_COMMITTED)进行事务隔离级别的设置。原理很简单,读已提交的隔离级别就是在每一次自旋的时候都能读到其他事务提交的数据,而可重复读从事务开启只读取一次,而后续自旋都是取第一次读取的视图就会导致读取的数据一直不变无法做自旋版本号校验。

自旋锁的优缺点

  • 优点:不会使线程进入阻塞,即自旋过程不发生线程切换,减少了CPU线程切换的时间损耗,在并发粒度小的场景相当于无所操作,执行速度快。

  • 缺点:如果有个线程执行时间较长,就会使其他线程在自旋的过程中一直占用CPU处于active状态,对CPU消耗较大,所以不适合并发粒度大的场景,建议增加自旋次数或最长等待时间做限制,防止长时间占用CPU资源。自旋锁是不公平的,请求过来的线程都会直接进入自旋处理,无法做到线程执行顺序优先级的处理。

总结

自旋锁就是利用自旋的方式,通过循环不断的去刷新记录,同时在每一次循环进行CAS操作,直到CAS结果与预期一致。