ABA 问题在多线程编码过程中往往容易让人忽略,以此篇文章来记录我的一些思考,以期未来在并发安全的编码过程中尽可能考虑周全。
什么是 ABA problem ?
先来看看维基对 ABA problem 的描述:In multithreaded computing, the ABA problem occurs during synchronization, when a location is read twice, has the same value for both reads, and “value is the same” is used to indicate “nothing has changed”.
However, another thread can execute between the two reads and change the value, do other work, then change the value back, thus fooling the first thread into thinking “nothing has changed” even though the second thread did work that violates that assumption.
我拆分成两段来解读 wiki 的描述,第一段内容主要描述了 ABA 问题出现的场景和条件,在多线程环境中,某个location(或可以理解为某内存地址指向的变量)会被一个线程连续重复读取两次,那么只要第一次读取的值和第二次读取的值一样,那么这个线程就会认为这个变量在两次读取时间间隔内没有发生任何变化;
但是第二段告诉我们这种判定值是否发生变更的方式是有问题的。当然,在单线程环境下,确实可以保证说当一个线程对同一个内存地址连续读取两次,如若取值没有变化,就可以认为内存地址的值没有被修改过;而在多线程环境下,在两次读取的时间间隔内,其他线程很可能对这个值做了修改,然后又改回原值,这似乎给此时正在重复读取变量的线程造成了该内存变量没有发生变化的错觉。
这就是 ABA problem。
归结起来,构成 ABA 问题有三个重要的条件:某个线程需要重复读某个内存地址,并以内存地址的值变化作为该值是否变化的唯一判定依据;
重复读取的变量会被多线程共享,且存在『值回退』的可能,即值变化后有可能因为某个操作复归原值;
在多次读取间隔中,开发者没有采取有效的同步手段,比如上锁。
以上三个关键点构成了 ABA 问题的充要条件,我们只需要打破其中一个条件就可以解决 ABA 问题。
虽貌似构成这三个条件相当苛刻,因此 ABA 在日常编码中较为少见,但是这里我需要特别提及 CAS(Compare And Swap)。很多人误以为 CAS 是造成 ABA 的pgddxh,或者说 CAS 会导致 ABA 问题,这些说法有失偏颇。正确说法我认为是,错误使用 CAS 才是导致 ABA 的pgddxh,往往不能正确理解并熟悉 CAS 的用法才容易导致 ABA 问题。
在使用 CAS 的过程中,稍不注意就同时满足上述三个充要条件,导致 ABA。首先,CAS 本身就要拿新值和旧值做对比,所谓新旧就是两次不同时间点对同一内存地址的读取而已,如果开发者以新旧值对比作为判定值是否变更的唯一依据,那么就满足了条件 a;另外,如果该值共享给了多个线程,且存在值回退的可能,则满足了条件 b;最后,条件 a 和 b 条件都达成,开发者却并没有在多次读取该内存地址的间隔中使用有效的同步手段,而这就达成了条件 c。三条件满足,ABA 问题就势必存在。
经典转账案例
假设yydsb银行卡有 100 块钱余额,且假定银行转账操作就是一个单纯的 CAS 命令,对比余额旧值是否与当前值相同,如果相同则发生扣减/增加,我们将这个指令用 CAS(origin,expect) 表示。于是,我们看看接下来发生了什么:yydsb在 ATM 1 转账 100 块钱给开放的煎蛋;
由于ATM 1 出现了网络拥塞的原因卡住了,这时候yydsb跑到旁边的 ATM 2 再次操作转账;
ATM 2 没让yydsb失望,执行了 CAS(100,0),很痛快地完成了转账,此时yydsb的账户余额为 0;
安静的发夹这时候又给yydsb账上转了 100,此时yydsb账上余额为 100;
这时候 ATM 1 网络恢复,继续执行 CAS(100,0),居然执行成功了,yydsb账户上余额又变为了 0;
这时候安静的发夹微信跟yydsb说转了 100 过去,是否收到呢?yydsb去查了下账,摇了摇头,那么问题来了,钱去了哪呢?
关于钱的去向,有一种可能就是安静的发夹给yydsb的 100 大洋,因为 ATM 1 网络恢复再次被转给了开放的煎蛋,毕竟yydsb尝试了两次转账,出现这种情况虽不合理,但情有可原。假设我们作为银行系统设计者和开发者,不接受这种情况存在,那我们就需要着手处理这种 ABA 问题了。
解决思路
我们从 ABA 达成条件分析上述转账场景:银行系统以 CAS 指令操作扣款 / 加款,并以旧值是否与账户余额当前值一致作为判断转账是否成功的唯一标准,通俗地说,如果账户余额旧值与当前值相同,系统会认为可以继续转账,这满足了条件 a;
账户信息被多个 ATM 终端共享,且不同 ATM 终端均可操作转账,且余额存在『值回退』的可能,如案例描述,yydsb转 100 给开放的煎蛋,而后安静的发夹转 100 给yydsb,yydsb的账户余额被回退到了 100,这满足了条件 b;
ATM 上操作转账,先是读取账户余额,然后执行 CAS(origin,expect) ,但是这两步操作不具备原子性和事务性,这满足了条件 c。
综上分析,我们为了解决 ABA 问题,关键就是打破三个达成条件中任意一个均可,那么解决思路就很简单了:为了打破条件 a,我们引入 epoch ,这个 epoch 是一个正向递增的值,我们只需要将 CAS(origin,expect) & CAS(origin_epoch,current_epoch) 两个命令封装成一个事务,那么就意味着,如果同属于一个 epoch 下的账户加款/扣款的所有操作,只会有一个成功,这个很好地杜绝了条件 a 判定单一性;
同理,引入 epoch 这个变量是不存在『值回退』的风险的,因为他是个定向递增的值,因此这又可以打破条件 b;
如果我们不引入额外的 epoch 变量,我们只需要在每次账户余额变更的过程里加上锁,将读取余额和 CAS 执行的过程封装到一个事务里,就能打破条件 c。然而,这种做法会大大增加系统可用性的风险,就上述案例而言,yydsb在发现 ATM 1 网络拥塞的时候,即便转到 ATM 2 去操作转账,也需要等待 ATM 1 执行完,完全释放锁才可以。
总结如上思考过程,我依旧坚持认为 ABA 问题并非什么深奥特殊的问题,说到底就是错误的并发控制导致程序异常,可以根据上述三个条件达成状况,自查自己的程序是否存在这样的问题。