Swift中的Actors重入问题

1,407 阅读8分钟

Swift中的Actors重入问题

hudson 译 原文

当我第一次看到WWDC关于Actor的演示文稿时,我对它的能力以及它将如何在不久的将来改变我们编写异步代码的方式感到兴奋。 通过使用Actor,编写没有数据竞争和死锁的异步代码从未如此简单。

撇开这些不谈,这并不意味着Actor没有线程问题。 如果我们不够小心,在使用Actor时,我们可能会意外引入重入问题。

在本文中,我将向您介绍什么是重入问题,为什么它有问题,以及如何防止它发生。 如果这是你第一次听说重入问题,一定要继续读下去,这样下次你使用Actor时就不会让你措手不及。

现实生活中的例子

展示重入问题的最好方法是使用现实生活中的例子。 考虑以下具有balance余额变量的BankAccount actor 。

actor BankAccount {
    
    private var balance = 1000
    
    // ...
    // ...
}

我们稍后会给这个BankAccount Actor 一个提款功能,但在此之前,让我们给它增加一个私有函数 ,检查账户是否有足够的余额进行提款:

private func canWithdraw(_ amount: Int) -> Bool {
    return amount <= balance
}

在此基础上,我们将定义另一个模拟授权过程的私有函数:


private func authorizeTransaction() async -> Bool {
    
    // Wait for 1 second
    try? await Task.sleep(nanoseconds: 1 * 1000000000)
    
    return true
}

实际上,授权过程应该是一个相当缓慢的过程,因此我们将使其成为一个异步函数。我们不打算实现实际的授权工作流程,相反,我们将等待1秒钟,然后返回true,以模拟交易已授权的条件。

有了这些准备,我们现在可以像这样实现提款功能:

func withdraw(_ amount: Int) async {
    
    guard canWithdraw(amount) else {
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    
    balance -= amount
}

实现非常直截了当。我们将首先检查账户余额。如果余额充足,我们将继续授权交易。一旦我们成功授权交易,我们将从余额中扣除提款金额,表明资金已从账户中提取。

之后,让我们添加一些打印语句,以帮助我们监控整个提款过程的流程。

func withdraw(_ amount: Int) async {
    
    print(“🤓 Check balance for withdrawal: \(amount)”)
    
    guard canWithdraw(amount) else {
        print(“🚫 Not enough balance to withdraw: \(amount)”)
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    print(“ Transaction authorized: \(amount)”)
    
    balance -= amount
    
    print(“💰 Account balance: \(balance)”)
}

以下是实现BankAccount actor功能的完整代码:

actor BankAccount {
    
    private var balance = 1000
    
    func withdraw(_ amount: Int) async {
        
        print(“🤓 Check balance for withdrawal: \(amount)”)
        
        guard canWithdraw(amount) else {
            print(“🚫 Not enough balance to withdraw: \(amount)”)
            return
        }
        
        guard await authorizeTransaction() else {
            return
        }
        
        print(“ Transaction authorized: \(amount)”)
        
        balance -= amount
        
        print(“💰 Account balance: \(balance)”)
    }
    
    private func canWithdraw(_ amount: Int) -> Bool {
        return amount <= balance
    }
    
    private func authorizeTransaction() async -> Bool {
        
        // Wait for 1 second
        try? await Task.sleep(nanoseconds: 1 * 1000000000)
        
        return true
    }
}

模拟重入问题

现在,让我们考虑一下2次提款同时发生的情况。我们可以通过在2个单独的异步任务中触发withdraw(_:) 函数来模拟这一点。

let account = BankAccount()

Task {
    await account.withdraw(800)
}

Task {
    await account.withdraw(500)
}

你认为结果会是什么?

乍一看,您可能会认为第1次提款(800)应该通过,而第2次提款(500)将因余额不足而被拒绝。不幸的是,情况并非如此。以下是我们从Xcode控制台获得的结果:

🤓 Check balance for withdrawal: 800
🤓 Check balance for withdrawal: 500
✅ Transaction authorized: 800
💰 Account balance: 200
✅ Transaction authorized: 500
💰 Account balance: -300

如您所见,两笔交易都已完成,用户能够提取超过帐户余额。如果你是银行老板,你不希望这种情况发生!

现在让我们仔细看看withdraw(_:) 函数的实现,您会注意到我们目前面临的问题实际上是由以下3个原因引起的:

  1. withdraw(_:)函数中存在一个暂停点,这即等待授权交易: await authorizeTransaction()

  2. 第二次交易的银行账户状态(余额值)在暂停点之前和之后有所不同。

  3. withdraw(_:) 函数在其前一次执行完成之前就被调用。

由于withdraw(_:)函数中的暂停点,第2笔交易的余额检查在第1笔交易完成之前进行。在此期间,该帐户仍然有足够的余额用于第2笔交易,这就是为什么第2笔交易的余额检查通过了。

这是一个非常典型的重入问题,当它发生时,Swift actor似乎不会给我们任何编译器错误。如果是这样,我们应该做些什么来防止这种情况发生?

为避免重入设计Actor

据苹果公司称,actor重入可以防止死锁,并保证程序继续运行。然而,这并不能保证actor的可变状态在每次等待中都保持不变。

因此,我们作为开发人员必须始终牢记,每次等待都是一个潜在的暂停点,每次等待后,演员的可变状态可能会发生变化。换句话说,我们有责任防止重入问题发生。

如果是这样,我们有什么预防方法?

在同步代码中执行状态改变

苹果工程师建议的第一种方法是始终在同步代码中改变Actor的状态。正如您在我们的示例中看到的,我们的银行actor状态的时间点是余额扣除发生的时间,我们读取actor状态的时间点是我们检查账户余额时。这2个时间点由一个暂停点隔开。 在这里插入图片描述

因此,为了确保余额检查和余额扣除同步运行,我们需要做的就是在进行余额检查之前授权交易。

func withdraw(_ amount: Int) async {
    
    // Perform authorization before check balance
    guard await authorizeTransaction() else {
        return
    }
    print(“ Transaction authorized: \(amount)”)
    
    print(“🤓 Check balance for withdrawal: \(amount)”)
    guard canWithdraw(amount) else {
        print(“🚫 Not enough balance to withdraw: \(amount)”)
        return
    }
    
    balance -= amount
    
    print(“💰 Account balance: \(balance)”)
    
}

如果我们再次运行代码,我们将获得以下输出:

 Transaction authorized: 800
🤓 Check balance for withdrawal: 800
💰 Account balance: 200
 Transaction authorized: 500
🤓 Check balance for withdrawal: 500
🚫 Not enough balance to withdraw: 500

太棒了!我们的代码现在成功拒绝了第2笔交易。然而,退出工作流程并没有真正意义。如果帐户余额不足,授权交易有什么意义?

如果是这样的话,我们还有什么其他选择,以便在解决重入问题的同时保持原始的退出工作流程?

在暂停点后检查Actor状态

苹果工程师建议的另一种预防方法是在暂停点后对演员状态进行检查。这可以确保我们关于actor可变状态的任何假设在跨越暂停点时保持不变。

就我们而言,我们假设在授权过程后,账户余额是足够的。因此,为了防止重新进入问题,我们必须在交易授权后再次检查账户余额。

func withdraw(_ amount: Int) async {
    
    print(“🤓 Check balance for withdrawal: \(amount)”)
    guard canWithdraw(amount) else {
        print(“🚫 Not enough balance to withdraw: \(amount)”)
        return
    }
    
    guard await authorizeTransaction() else {
        return
    }
    print(“ Transaction authorized: \(amount)”)
    
    // Check balance again after the authorization process
    guard canWithdraw(amount) else {
        print(“⛔️ Not enough balance to withdraw: \(amount) (authorized)”)
        return
    }

    balance -= amount
    
    print(“💰 Account balance: \(balance)”)
    
}

以下是我们从上述withdraw(_:) 函数中得到的结果:

🤓 Check balance for withdrawal: 800
🤓 Check balance for withdrawal: 500
 Transaction authorized: 800
💰 Account balance: 200
 Transaction authorized: 500
⛔️ Not enough balance to withdraw: 500 (authorized)

有了上述改变,我们成功地防止了重入问题的发生,同时保持了最初的退出工作流程。

如您所见,没有灵丹妙药可以防止各种重入问题。我们需要根据我们真正需要的东西来调整我们采取的方法。twitter.com/DonnyWals

如果您想看到更复杂的现实生活中的重入问题,我强烈推荐Donny Wals这篇文章。在这篇文章中,您将看到他如何使用字典来防止他的图像下载器Actor因重入问题而下载两次图像。

线程安全与重入问题

现在您已经了解了导致重入问题的原因以及我们如何防止它,让我们转移我们的重点,谈谈线程安全和重入之间的区别。

众所周知,一个Actor会在自己的上下文中保证线程安全,那么为什么我们仍然会遇到重入问题?

据苹果称,Actor将保证相互排斥地访问其可变状态。这就是为什么在刚才的例子中,amount=800的交易总是发生在第1次。如果一个Actor不是线程安全,我们将无法获得如此一致的结果。我们有时可能会得到结果,amount=500 的交易首先被触发。

尽管重入问题发生在多线程上下文中,但这并不意味着这是一个线程安全问题。重入问题之所以出现,是因为我们假设Actor状态不会在暂停点上发生变化,而不是因为我们试图同时改变Actor可变状态。因此,重入问题并不等同于线程安全问题。

小结

在本文中,您了解到Actor可以保证线程安全,但这并不能防止重入问题。因此,我们必须始终注意,Actor状态可能会跨越暂停点发生变化,我们有责任确保我们的代码即使在Actor状态发生变化后仍然可以正确运行。

如果您想尝试本文中的示例代码,您可以在这里获取。

感谢您的阅读。👨🏻‍💻