并发中的 ABA 问题

并发中的 ABA 问题

原文
https://www.baeldung.com/cs/aba-concurrency

编程
如果您在计算机科学或研究方面有几年的经验,并且有兴趣与社区分享这些经验,请查看我们的贡献指南。
一、介绍
在本教程中,我们将介绍并发编程中 ABA 问题的理论背景。我们将看到它的根本原因以及解决方案。
2. 比较和交换
要了解根本原因,让我们简要回顾一下Compare 和 Swap的概念。
比较和交换 (CAS) 是无锁算法中的一种常用技术,可确保在另一个线程同时修改相同空间时,一个线程对共享内存的更新会失败。
我们通过在每次更新中使用两条信息来实现这一点:更新后的值和原始值。然后compare and swap将首先将现有值与原始值进行比较: 如果相等,则将现有值与更新的值交换。
当然,这种情况也可能发生在引用中。

  1. ABA 问题
    现在,ABA 问题是cas的一个异常问题,这有点令人挫败。
    例如,假设一个线程读取一些共享内存 (A),以准备更新它。然后,另一个线程临时修改该共享内存 (B),然后将其恢复 (A)。之后,一旦第一个线程执行了cas,它将看起来好像没有进行任何更改,从而使检查的完整性无效。
    虽然在许多情况下,这不会导致问题,有时,是不是等于一个象我们想象的。让我们看看这在实践中是怎样的。
    3.1. 示例部分
    为了通过一个实际示例演示该问题,让我们考虑用一个简单的银行帐户类,其中一个整数变量 保存实际余额的金额。我们还有两种功能:一种用于取款,一种用于存款。这些操作使用 CAS 来减少和增加账户余额。
    3.2. 哪里有问题?
    让我们考虑当线程 1 和线程 2 在同一个银行账户上操作时的多线程场景。

当线程 1 想要提取一些钱时,它会读取当前实际余额(expected)以使用该值来比较 CAS 操作中的金额(exchange)。然而,出于某种原因,线程 1 有点慢——也许它被阻塞了。
同时,线程 2 在线程 1 挂起时使用相同的机制对帐户执行两个操作。首先,线程2更改已被线程 1 读取的原始值,但随后又将其更改回原始值。
之后,一旦线程 1 恢复,它会看起来好像什么都没有改变,并且 CAS 会成功:

  1. Java 示例
    为了更好地形象化这一点,让我们看一些代码。在这里,我们将使用 Java,但问题本身并与语言无关。
    4.1. 帐户
    首先,我们的Account类在AtomicInteger 中保存余额balance,它为我们提供了 Java 中整数的 CAS。此外,还有另一个AtomicInteger来计算成功交易的数量transactionCount。最后,我们有一个ThreadLocal 变量来捕获给定线程的 CAS 操作失败数currentThreadCASFailureCount。
public class Account {
    private AtomicInteger balance;
    private AtomicInteger transactionCount;
    private ThreadLocal<Integer> currentThreadCASFailureCount;
    ...
}

4.2. 存款
接下来,我们可以为Account类实现存款方法:

public boolean deposit(int amount) {
    int current = balance.get();
    boolean result = balance.compareAndSet(current, current + amount);
    if (result) {
        transactionCount.incrementAndGet();
    } else {
        int currentCASFailureCount = currentThreadCASFailureCount.get();
        currentThreadCASFailureCount.set(currentCASFailureCount + 1);
    }
    return result;
}

请注意,AtomicInteger.compareAndSet(…)只不过是AtomicInteger.compareAndSwap()方法的包装器,用于反映 CAS 操作的布尔结果。
4.3. 提款
同样,提款方法可以创建为:

public boolean withdraw(int amount) {
    int current = getBalance();
    maybeWait();
    boolean result = balance.compareAndSet(current, current - amount);
    if (result) {
        transactionCount.incrementAndGet();
    } else {
        int currentCASFailureCount = currentThreadCASFailureCount.get();
        currentThreadCASFailureCount.set(currentCASFailureCount + 1);
    }
    return result;
}

为了能够演示 ABA 问题,我们创建了一个mayWait()方法来保存一些耗时的操作,为其他线程对余额执行修改提供一些额外的时间。
现在,我们只是暂停线程 1 两秒钟:

private void maybeWait() {
    if ("thread1".equals(Thread.currentThread().getName())) {
        // sleep for 2 seconds
    }
}

4.4. ABA 情景
最后,我们可以编写一个单元测试来检查 ABA 问题是否可能。
我们要做的是有两个线程,我们之前的线程 1 和线程 2。线程 1 将读取余额并延迟。线程 2 在线程 1 休眠时将更改平衡,然后将其更改回来。
一旦线程 1 醒来,它并没那么聪明,它的操作仍然会成功。

在一些初始化之后,我们可以创建线程 1,它需要一些额外的时间来执行 CAS 操作。完成后,它不会意识到内部状态已经改变,因此在 ABA 场景中,CAS 失败计数将为零而不是预期的 1:

@Test
public void abaProblemTest() {
    // ...
    Runnable thread1 = () -> {
        assertTrue(account.withdraw(amountToWithdrawByThread1));

        assertTrue(account.getCurrentThreadCASFailureCount() > 0); // test will fail!
    };
    // ...
}

同样,我们可以创建线程 2,它会在线程 1 之前完成并更改帐户的余额并将其更改回原始值。在这种情况下,我们不期望任何 CAS 失败。

@Test
public void abaProblemTest() {
    // ...
    Runnable thread2 = () -> {
        assertTrue(account.deposit(amountToDepositByThread2));
        assertEquals(defaultBalance + amountToDepositByThread2, account.getBalance());
        assertTrue(account.withdraw(amountToWithdrawByThread2));

        assertEquals(defaultBalance, account.getBalance());

        assertEquals(0, account.getCurrentThreadCASFailureCount());
    };
    // ...
}

运行线程后,线程 1 将获得预期的余额,尽管对于来自线程 2 的额外两个事务并不期望:

@Test
public void abaProblemTest() {
    // ...

    assertEquals(defaultBalance - amountToWithdrawByThread1, account.getBalance());
    assertEquals(4, account.getTransactionCount());
}
  1. 基于价值与基于参考的场景
    在上面的例子中,我们可以发现一个重要的事实——我们在场景结束时返回的AtomicInteger与我们开始时使用的完全相同。除了未能捕获线程 2 进行的两个额外事务外,在此特定示例中没有发生异常。
    这背后的原因是我们基本上使用的是值类型而不是引用类型。
    5.1. 基于引用的异常
    为了重用引用类型,我们可能会遇到 ABA 问题。在这种情况下,在 ABA 场景结束时,我们取回了匹配的引用,因此 CAS 操作成功,但是,引用可能指向与最初不同的对象。这可能会导致歧义。
  2. 解决方案
    现在我们已经对问题有了一个很好的了解,让我们深入研究一些可能的解决方案。
    6.1. 垃圾收集
    对于引用类型,垃圾收集 (GC) 可以在大多数情况下保护我们免受 ABA 问题的影响。
    当线程 1 在我们使用的给定内存地址处有对象引用时,线程 2 所做的任何事情都不会导致另一个对象使用相同的地址。该对象仍然存在,并且它的地址不会被重用,直到没有对它的引用为止。
    所以,当我们在无锁数据结构中可以依赖 GC 时,可以解决引用类型的ABA问题。
    当然,某些语言不提供 GC 也是事实。
    6.2. 危险提示
    危险指针与前一个指针有些相关——我们可以在没有自动垃圾收集机制的语言中使用它们。
    简而言之,线程在共享数据结构中跟踪有问题的指针。这样,每个线程都知道指针定义的给定内存地址上的对象可能已被另一个线程修改。
    现在,让我们看看其他几个解决方案。
    6.3. 不变性
    当然,不可变对象的使用解决了这个问题,因为我们不会在整个应用程序中重用对象。每当发生变化时,都会创建一个新对象,因此 CAS 肯定会失败。
    然而,我们的最终解决方案也允许可变对象。

6.4. 双重比较和交换
双比较和交换方法背后的想法是跟踪另一个变量,即版本号,然后在比较中也使用它。在这种情况下,如果我们有旧版本号,CAS 操作将失败,这只有在同时另一个线程修改我们的变量时才有可能。
在 Java 中,AtomicStampedReference和AtomicMarkableReference是该方法的标准实现。
7. 结论
在本文中,我们了解了 ABA 问题以及一些防止它出现的技术。
与往常一样,完整的源代码以及更多示例都可以在 GitHub 上找到。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值