本文介紹了 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 裡有兩個 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 主線程。而我們希望的是用戶能快速重復存錢、取錢,把延遲降到最低。
如果要是能把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 很簡單,只需點擊 target 的 Edit Scheme...,然後在 Diagnostics
tab 下勾選 Thread Sanitizer
。可以選擇 Pause on issues,這樣比較方便一步步調試問題。我們把它勾上。
因為 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
譯者:戴倉薯
文章轉自 戴倉薯的簡書