[iOS 10 day by day] Day 2:线程竞态检测

作者: 戴仓薯 | 来源:发表于2016-08-15 19:16 被阅读2274次

    本文介绍了 Xcode 8 的新出的多线程调试工具 Thread Sanitizer,可以在 app 运行时发现线程竞态。

    《iOS 10 day by day》是 shinobicontrols 公司编写的系列博客,介绍开发者需要了解的 iOS 10 新特性,每周更新。本系列翻译(文集地址)已取得官方授权。目录点此。仓薯翻译,欢迎指正:)

    Shinobicontrols 为 iOS 和 Android 开发者提供高性能、响应式的 UI 控件 SDK,尤其是图表方面的控件。 官网 : shinobicontrols.com twitter : @shinobicontrols

    想想一下,你的 app 已经近乎大功告成:它经过精良的打磨,单元测试全覆盖。只剩下一个问题:有一个很严重的 bug,但是是偶发的,你已经花了好几个小时尝试修复它却一无所获。问题到底出在哪里呀?

    这种情况经常是多个线程访问同一块内存造成的。我可以大胆猜测,多线程的 bug 是许多程序员的梦魇。这类 bug 非常难定位,而且只有特定条件下才能重现:所以找出问题的原因确实困难重重。

    而问题的原因常常是所谓的『线程竞态』。对这个名词我们不再多费笔墨去解释了,以下摘自 Google 的 ThreadSanitizer 文档

    两个线程同时访问同一个变量,而且其中至少一个线程要做的是写操作,这种情况就叫竞态。

    调试竞态问题曾经让程序员们大为头疼;不过值得庆幸的是,Xcode 发布了一个新的线程调试工具叫做 Thread Sanitizer 可以检测出这类问题,甚至比你发现得还早。

    建工程

    我们做了一个简单的应用,能让用户存钱、取钱,每次 $100。跟之前一样,最终版的工程放在 Github 上了。

    银行账户

    银行账户的数据模型很简单,名为Account

    import Foundation
    
    class Account {
        var balance: Int = 0
    
        func withdraw(amount: Int, completed: () -> ()) {
            let newBalance = self.balance - amount
    
            if newBalance < 0 {
                print("You don't have enough money to withdraw \(amount)")
                return
            }
    
            // 模仿银行的防伪验证过程
            sleep(2)
    
            self.balance = newBalance
    
            completed()
        }
    
        func deposit(amount: Int, completed: () -> ()) {
            let newBalance = self.balance + amount
            self.balance = newBalance
    
            completed()
        }
    }
    

    里面只包含了这么几个方法,能让我们给账户存钱、取钱。存取的金额写死为 $100。

    其中,deposit方法是立即返回的,而withdraw方法要花一点时间才能执行完。我们名义上说是因为银行要执行防伪验证,背后其实就是让线程 sleep 了 2 秒。这在后面能给我们一个使用多线程的借口。

    另外一点要注意的是 completed block,在存取成功之后执行。

    View Controller

    View Controller 里有两个 button ——一个存钱、一个取钱——还有一个 label,显示当前账户余额。Storyboard 中的布局是这样的:

    Storyboard的界面

    从 Storyboard 中引出显示余额 label 的 IBOutlet,再写几个方法更新余额的显示:

    import UIKit
    
    class ViewController: UIViewController {
    
        @IBOutlet var balanceLabel: UILabel!
    
        let account = Account()
    
        override func viewDidLoad() {
            super.viewDidLoad()
            updateBalanceLabel()
        }
    
        @IBAction func withdraw(_ sender: UIButton) {
            self.account.withdraw(amount: 100, onSuccess: updateBalanceLabel)
        }
    
        @IBAction func deposit(_ sender: UIButton) {
            self.account.deposit(amount: 100, onSuccess: updateBalanceLabel)
        }
    
        func updateBalanceLabel() {
            balanceLabel.text = "Balance: $\(account.balance)"
        }
    }
    

    来试一下吧:

    有延迟地存取

    嗯……取钱的过程有点慢呀。这是因为我们所写的withdraw方法里有严格的『防伪验证』机制,在方法结束前会一直 block 主线程。而我们希望的是用户能快速重复存钱、取钱,把延迟降到最低。

    Dispatch Queue 登场了

    如果要是能把withdraw方法从主线程移出来,就解决这问题了。我们可以用上新出的『Swift 化』的 GCD 库:

    func withdraw(amount: Int, onSuccess: () -> ()) {
        DispatchQueue(label: "com.shinobicontrols.balance-moderator").async {
            let newBalance = self.balance - amount
    
            if newBalance < 0 {
                print("You don't have enough money to withdraw \(amount)")
                return
            }
    
            // 模仿银行的防伪验证过程
            sleep(2)
    
            self.balance = newBalance
    
            DispatchQueue.main.async {
                onSuccess()
            }
        }
    }
    

    再跑一次:

    无延迟地存取

    等一下,我们的钱呢?一开始账户余额是 $100,我们先取了 $100,然后存了 $100,怎么账户余额只剩下 0 了呢?

    存取方法肯定是没问题的(刚才都分别测过了),看起来问题就出在把 withdraw 的任务放到单独线程这一步。

    Thread Sanitizer 来解救我们啦!

    开启 Thread Sanitizer 很简单,只需点击 target 的 Edit Scheme...,然后在 Diagnostics tab 下勾选 Thread Sanitizer。可以选择 Pause on issues,这样比较方便一步步调试问题。我们把它勾上。

    Edit scheme 勾选 Thread Sanitizer

    因为 thread sanitizer 只在运行时工作,我们需要把工程重新编译、重新跑一下。来试试吧。

    在 WWDC 演讲中,苹果推荐在所有的单元测试里都打开 thread sanitizer。Sanitizer 只在运行时有效,而且必须要代码运行到那儿才能检测出线程竞态。如果你的代码单元测试覆盖率很高,那么 Thread Sanitizer 能找出工程里绝大部分的线程竞态(可以参考下我们在 iOS 9 Day by Day 里写过的 Xcode 7 的测试覆盖工具)。

    .

    还要注意的一点是,对于 Swift 这个工具只对 Swift 3 的代码有效(Objective-C 也兼容),而且只能用 64 位的模拟器来跑。

    现在我们再把之前的操作重复一遍,先取钱,再马上存钱。这时候 thread sanitizer 把 app 暂停了,因为它发现了线程竞态。它清晰地展现出了冲突发生时的调用栈。

    调用栈

    而且,它在控制台里打印出了相关信息。

    通过调用栈和打印出的信息,Thread Analyzer 给力地帮我们定位了问题所在: Account.deposit 方法与 Account.widthdraw 方法会访问同一个属性 Account.balance,从而出现了竞态。哎呀,看样子我们应该把存钱和取钱放在同一个线程里进行。

    我们修改一下 Account 类的代码,用一个公共的 queue:

    class Account {
        var balance: Int = 0
        private let queue = DispatchQueue(label: "com.shinobicontrols.balance-moderator")
    
        func withdraw(amount: Int, onSuccess: () -> ()) {
            queue.async {
                // 跟之前一样...
            }
        }
    
        func deposit(amount: Int, onSuccess: () -> ()) {
            queue.async {
                let newBalance = self.balance + amount
                self.balance = newBalance
    
                DispatchQueue.main.async {
                    onSuccess()
                }
            }
        }
    }
    

    再跑一遍代码,发现还是有竞态;只不过这次不是在 Account 类里,而是由 ViewController 类在主线程访问 balance 造成的。

    调用栈

    为解决这个问题,我们可以把 balance 属性改成 private 保护起来,只能在 Account 类内部访问它,然后改用 queue 来返回结果。

    private var _balance: Int = 0
    var balance: Int {
        return queue.sync {
            return _balance
        }
    }
    

    之前所有对 balance 属性的写操作都要改成私有的 _balance

    现在再跑一遍,再怎么重复点击 "withdraw" 和 "deposit" 都不会惊动 Thread Sanitizer 了。太棒啦——我们用这个工具修好了多线程的 bug。

    扩展阅读

    尽管看着不起眼,Thread Sanitizer 还是很有可能会成为 iOS 开发者的一个重要工具。它能在程序运行没出错的情况下就找到线程竞态,可以为你省下大把时间 debug 间歇出现的多线程问题。

    一如既往,苹果的 WWDC 演讲 信息量很大,值得一看。Sanitizer 是 Clang 编译器的一部分,更详细的信息可以参考 LLVM 的官网,还有 Google 开发 sanitizer 的团队编写了许多有趣的 wiki,其中包括对检测多线程问题算法的简单介绍。

    我们用到了一点 Swift 3 新出的 GCD 语法。Apple 在Swift 3 的 GCD 并发编程的演讲中对此有所介绍,可以看一看。另外,Roy Marmelstein 也有一篇短小精悍的博客介绍其中的变化。

    如果有任何问题和评论,我们都很欢迎你的反馈。可以发我 tweet @sam_burnstone,也可以关注 @shinobicontrols 关注最新动态以及 iOS 10 Day by Day 系列的更新。感谢阅读!

    原文地址:iOS 10 Day by Day :: Day 2 :: Thread Sanitizer

    原作者:Sam Burnstone @sam_burnstone

    ShinobiControls 官网:ShinobiControls.com twitter : @shinobicontrols

    文集地址:iOS 10 day by day 仓薯翻译

    本文地址:http://www.jianshu.com/p/358535119e9b

    译者:戴仓薯

    相关文章

      网友评论

      • zenon:很不错的工具
      • Cyandev:笔者取钱的例子有些不太恰当,银行账户余额的变化是一个原子过程,不存在先算出新余额再赋值的问题。当然,笔者可能是为了让 -= 是三个过程变得更明显一点所以在中间用了 sleep,迫使这个竞态更易发生。
      • AKyS佐毅:博主,我和楼上遇到了一样的问题。64 bit 也是有的。
        AKyS佐毅:@戴仓薯 模拟器是可以的,真机无法用!
        戴仓薯:@Blank_佐毅 真机貌似不行,我这边用虚拟机可以
      • ibiaoma:Xcode8,程序真机运行,Thread Sanitizer 还是不能勾选?
        戴仓薯:@ibiaoma 再看下,64-bit macOS and 64-bit simulators~ 没有 iOS 哦。貌似只能用虚拟机
        ibiaoma:@戴仓薯 不是吧,运行在6s上的
        戴仓薯:@ibiaoma 是不是楼上说的问题呢?~
      • Damon4Zhou: Thread Sanitizer 为什么不能勾选
        Xcode8咯
        Damon4Zhou:@Damon4Zhou 找到了Note: Thread Sanitizer is supported on 64-bit macOS and 64-bit simulators.
      • 红烧大鸡腿:请问小编使用的是哪版的xcode啊,我的Diagnostics tab 下并没有 Thread Sanitizer,目前使用的是7.2
        红烧大鸡腿: @戴仓薯 好😊😊😊
        戴仓薯:@HCX_IOS 请看标题呀~ 是 iOS 10~ 当然是 Xcode 8 啦~
      • Pusswzy:您的文章上首页了 请问会提醒么
        戴仓薯:@Pusswzy 啊,会有一个消息『编辑推荐了你的文章xxxxx』

      本文标题:[iOS 10 day by day] Day 2:线程竞态检测

      本文链接:https://www.haomeiwen.com/subject/ayjssttx.html