如何做到资源并发安全

并发安全

何为并发安全,就是多个并发体在同一段时间内访问同一个共享数据,共享数据能被正确处理。

并发不安全的后果

并发不安全最典型的案例就是卖票超售,设想有一家电影院,有两个售票窗口,售票员售票时候先看一下当前剩余票数是否大于0,如果大于0则售出票。
用伪代码就是如下:

# 售票操作(一张票)
# 如果票数大于0
totalNum = getTotalNum()
if totalNum > 0
    # 则售出一张票
    totalNum = totalNum - 1
else
    failedToSold()

看上去也没有什么问题,流程图如下:
jpg
此时票数剩下一张票,两个售票窗口同时来了顾客,两个售票人都看了一下剩余票数还有一张,不约而同地收下顾客的钱,余票还剩一张,但是却售出了两张票,就会出现致命的问题。
jpg

如何做到并发安全

目前最最主流的办法就是加锁就行操作,其实售票的整个操作同时间内只能一个人进行,在我看来归根到底加锁其实就是让查询和售票两个步骤原子化,只能一块执行,不能被其他程序中断,让这步操作变成串行化。下面就介绍一下使查询和售票原子化的常见程序操作:

锁的做法就是每次进入这段变量共享的程序片段,都要先获取一下锁,如果获取成功则可以继续执行,如果获取失败则阻塞,直到其他并发体把锁给释放,程序得到执行调度才可以执行下去。
锁本质上就是让并发体创建一个程序临界区,临界区一次只能进去一个并发体,伪代码示意如下:

lock()
totalNum = getTotalNum()
if totalNum > 0
    # 则售出一张票
    totalNum = totalNum - 1
else
    failedToSold()
unlock()

顺带一提的是锁可以分为写锁与排它锁,一般如无特殊说明,一般锁都是指写锁。

读锁与写锁

读锁也叫共享锁,写锁也叫排它锁,锁的概念被发明了之后,人们就想着如果我很多个并发体大部分时间都是读,如果就把变量读取的时候也要建立临界区,那就有点太大题小做了。于是人们发明了读锁,一个临界区如果加上了读锁,其他并发体执行到相同的临界区都可以加上读锁,执行下去,但不能加上写锁。这样就保证了可以多个并发体并发读取而又不会互相干扰。

队列

队列也是解决并发不安全的做法。多个并发体去获取队列里的元素,然后进行处理,这种做法和上锁其实大同小异,本质都是把并发的操作串行化,同一个数据同一个时刻只能交给一个并发体去处理。
伪代码:

# 第一个获取到队列的元素就可以进行下去
isCanSold = canSoldList.pop()
totalNum = getTotalNum()
if totalNum > 0
    # 则售出一张票
    totalNum = totalNum - 1
else
    failedToSold()

CAS

CAS(compare and swap),先比对,然后再进行交换,和数据库里的乐观锁的做法很相似。

乐观锁

数据库里的乐观锁并不是真的使用了锁的机制,而是一种程序的实现思路。
乐观锁的想法是,每次拿取数据再去修改的时候很乐观,认为其他人不会去修改这个数据,表另外维护一个额外版本号的字段。
查数据的时候记录下该数据的版本号,如果成功修改的话,会修改该数据的版本号,如果修改的时候版本号和查询的时候版本号不一致,则认为数据已经被修改过,会重新尝试查询再次操作。
设我们表有一个user表,除了必要的字段,还有一个字段version,表如下:

id username money version
1 a 10 100
2 b 20 100

这时候我们需要修改a的余额-10元,执行事务语句如下:

while
    select @money = money, @version = version from user where username = a;
    if @money < 10
        print('余额成功')
        break
    # 扣费前的预操作
    paied()
    # 实行扣费
    update user set money = money - 10, version = version + 1 where username = a and version = @version
    # 影响条数等于1,证明执行成功
    if @@ROWCOUNT == 1
        print('扣费成功')
        break
    else
        rollback
        print('扣费失败,重新进行尝试')

乐观锁的做法就是使用版本的形式,每次写数据的时候会比对一下最开始的版本号,如果不同则证明有问题。
CAS的做法也是一样的,在代码里面的实现稍有一点不同,由于SQL每条语句都是原子性,查询对应版本号的数据再更新的这个条件是原子性的。

update user set money = money - 10, version = version + 1 where username = a and version = @version

但是在代码里面两条查询和赋值两个语句不是原子性的,需要有特定的函数让cpu底层把两个操作变成一个原子操作,在go里面有atomic包支持实现,是这样实现的:

for {
    user := getUserByName(A)
    version := user.version
    paied()
    if atomic.CompareAndSwapInt32(&user.version, version, version + 1) {
        user.money -= 10
    } else {
        rollback()
    }
}

atomic.CompareAndSwapInt32需要依次传入要比较变量的地址,旧变量的值,修改后变量的值,函数会判断旧变量的值是否与现在变量的地址是否相同,相同则把新变量的值写入到该变量。
CAS的好处是不需要程序去创建临界区,而是让CPU去把两个指令变成原子性操作,性能更好,但是如果变量会被频繁更改的话,重试的次数变多反而会使得效率不如加锁高。