[iOS 10 day by day] Day 2:線程競態檢測工具 Thread Sanitizer

本文介紹了 Xcode 8 的新出的多線程調試工具 Thread Sanitizer,能夠在 app 運行時發現線程競態。做者用經典的銀行存取錢爲例子,示例使用這個工具發現線程不安全的問題。html

《iOS 10 day by day》是 shinobicontrols 公司編寫的系列博客,介紹開發者須要瞭解的 iOS 10 新特性,每週更新。本系列翻譯(文集地址)已取得官方受權。倉薯翻譯,歡迎指正:)ios

Shinobicontrols 爲 iOS 和 Android 開發者提供高性能、響應式的 UI 控件 SDK,尤爲是圖表方面的控件。 官網 : shinobicontrols.com twitter : @shinobicontrolsgit

想一想一下,你的 app 已經近乎大功告成:它通過精良的打磨,單元測試全覆蓋。只剩下一個問題:有一個很嚴重的 bug,可是是偶發的,你已經花了好幾個小時嘗試修復它卻一無所得。問題到底出在哪裏呀?程序員

這種狀況常常是多個線程訪問同一塊內存形成的。我能夠大膽猜想,多線程的 bug 是許多程序員的夢魘。這類 bug 很是難定位,並且只有特定條件下才能重現:因此找出問題的緣由確實困難重重。github

而問題的緣由經常是所謂的『線程競態』。對這個名詞咱們再也不多費筆墨去解釋了,如下摘自 Google 的 ThreadSanitizer 文檔算法

兩個線程同時訪問同一個變量,並且其中至少一個線程要作的是寫操做,這種狀況就叫競態。編程

調試競態問題曾經讓程序員們大爲頭疼;不過值得慶幸的是,Xcode 發佈了一個新的線程調試工具叫作 Thread Sanitizer 能夠檢測出這類問題,甚至比你發現得還早。swift

建工程

咱們作了一個簡單的應用,能讓用戶存錢、取錢,每次 $100。跟以前同樣,最終版的工程放在 Github 上了。xcode

銀行帳戶

銀行帳戶的數據模型很簡單,名爲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 倉薯翻譯

譯者:戴倉薯

相關文章
相關標籤/搜索