了解 iOS 開發中的 Data Race (EXC_BAD_ACCESS)

E-Zou Shen
Seekrtech-dev
Published in
15 min readAug 22, 2024
Photo by Taylor Vick on Unsplash

什麼是 Data Race

Data Race(數據競爭)是 Parallel Programming 中的一個常見問題。 在我們的日常開發中,滿足以下狀況時,高機率會伴隨著 Data Race 的產生

  • N (> 1) 個 Thread (執行緒) 同時存取同一塊記憶體位置
  • 至少有一個 Thread 在進行寫操作
  • 這些 Thread 沒有使用任何同步機制

Data Race 可能導致不可預測的行為,包括資料損毀、程式崩潰、資料一致性等影響體驗的結果。在 iOS 開發中,由於 GCD (Grand Central Dispatch) 的易用、普遍性,Data Race 是一個特別需要注意的問題。

這邊有個很常混淆的概念:Data Race (數據競爭) 以及 Race Condition (競爭危害),Race Condition 是指一個系統或者程式的輸出依賴於不受控制的事件出現順序或者時機,多個訊號試著彼此競爭,來影響誰先輸出,其涵蓋的範圍比 Data Race 更加廣泛一些,但由於不是本文重點,就不特別展開解釋,接下來的內容都會聚焦在 Data Race 上。

iOS 開發中常見的 Data Race 情形

在 iOS 開發中,在不同 Thread 中操作共享資源很容易產生 Data Race 的問題,以下情境最容易不小心發生:

在這個例子中,多個 Thread 同時呼叫 downloadedFiles 時,可能導致 Data Race。

class DownloadManager {
var downloadedFiles: [String: Data] = []

func downloadFile(_ file: String, completion: @escaping (Data?, Error?) -> Void) {
DispatchQueue.global().async {
do {
let data: Data = try ... // Downloading...
Thread.sleep(forTimeInterval: 0.1)
// ❌ Data Race might happen here
self.downloadedFiles[file] = data
completion(data, nil)
} catch {
completion(nil, error)
}
}
}
}

let manager = DownloadManager()

for i in 1...100 {
manager.downloadFile("File\(i)")
}

或者 Singleton Lazy Initialization

class Singleton {
static var shared: Singleton?

private init() {}

static func getInstance() -> Singleton {
if shared == nil {
// ❌ Data Race might happen here
shared = Singleton()
}
return shared!
}
}

DispatchQueue.concurrentPerform(iterations: 1000) { _ in
let instance = Singleton.getInstance()
...
}

解決 Data Race 的方法

解決 Data Race 問題主要有兩種方法:使用鎖(Lock)和無鎖(Lock-free)技術,每種方法都有其優缺點以及適用的場景。

Lock

Lock 是解決 Data Race 最直接、最常用的方法。它通過確保在同一時間只有一個 Thread 可以訪問共享資源來防止 Data Race,被保護的程式碼區段也就是常說的 Critical Section (臨界區段)。

在 iOS 開發中我們可以輕鬆使用 NSLock / os_unfair_lock 等方法讓我們好好的操作 Lock 。不過我這邊將使用 Lock 這個 Library 來展示各種 Lock 的實際應用,這個 Library 針對 POSIX、os_unfair_lock、GCD 進行封裝,提供一致的 API 讓開發者使用並自由切換,目前也大量應用在我們公司的產品中。

UnfairLock (os_unfair_lock)

UnfairLock 是一種高效的鎖實現,它在 iOS 開發中廣泛使用。重寫第一個案例可能發生問題的 function:

let lock = UnfairLock()

func downloadFile(_ file: String, completion: @escaping (Data?, Error?) -> Void) {
DispatchQueue.global().async {
do {
let data: Data = try ... // Downloading...
Thread.sleep(forTimeInterval: 0.1)
// 用 lock 保護寫入操作
lock.lock()
self.downloadedFiles[file] = data
lock.unlock()
// 操作結束後解鎖
completion(data, nil)
} catch {
completion(nil, error)
}
}
}

lock 的使用可以替換成不同種的鎖也可以達到相同效果

MutexLock (pthread_mutex)

MutexLock 是對 POSIX 線程互斥鎖的封裝,提供了更多的配置選項。

let lock = MutexLock(type: .default)
lock.lock()
// Critical Section
lock.unlock()

RWLock (pthread_rwlock)

RWLock 提供了讀寫鎖的功能,允許多個讀操作同時進行,但寫操作需要獨佔訪問。

let lock = RWLock()

// Read lock
lock.rdlock()
lock.unlock()

// Write lock
lock.wrlock()
lock.unlock()

ConditionVariable (pthread_cond)

ConditionVariable 提供了一種 Thread 間的同步機制,允許等待特定條件成立才解鎖。

let lock = MutexLock()
let condition = ConditionVariable()
var sharedResource = false
// Thread A
DispatchQueue.global().async {
lock.lock()
while !sharedResource {
condition.wait(mutex: lock)
}
print("Condition met!")
lock.unlock()
}
// Thread B
DispatchQueue.global().async {
lock.lock()
sharedResource = true
condition.signal()
lock.unlock()
}

GCD

我們可以透過 Queue 的特性協助我們做到類似 Mutex 或者 RWLock 的效果。下面這張圖展示了DispatchQueue 如何透過 Serial Queue 達到類似 Mutex 以及使用 Concurrent Queue 搭配 Dispatch Barrier 達到類似 RWLock 的效果

Credit: https://betterprogramming.pub/the-complete-guide-to-concurrency-and-multithreading-in-ios-59c5606795ca

Concurrent Queue 由於是一個 Thread Pool 的概念,因此發起多個 Task 時,在資源充足的狀態下可能會產生並行的機會,這就滿足了 Concurrent Read(必須指定為 sync)的需求,並且在有寫入需求時發起 Barrier Task 來確保同時間內在 Queue 裡沒有並行的任務執行,這樣就提供了併發讀取,順序寫入的功能。

// RWLock-like
let concurrentQueue = DispatchQueue(
label: "concurrent.queue",
attributes: [.concurrent]
)
// Read lock
let value = concurrentQueue.sync {
// Safe to read
return value
}
// Write lock
concurrentQueue.async(flag: .barrier) {
// Safe to write
}

而 Mutex 則只需要確保 Critical Section 同時間只會有一個 Thread 在執行這段程式碼,這本身就符合 Serial Queue 的設計,因此只需要簡單呼叫 queue.async/sync 即可

// Mutex-like
let serialQueue = DispatchQueue(label: "serial.queue")

serialQueue.async {
// Critical Section
}

在使用 Lock 時,我們必須謹慎設計 Critical Section 的範圍。應該精確地保護關鍵共享資源的操作,既不過度擴大範圍導致 Thread Starvation,也不過度縮小以至於無法保證一致性。合理的 Critical Section 設計可以提高並發效能,減少 Thread 等待時間,但並不直接保證 Thread 間的絕對公平性。開發者需要權衡鎖的粒度、持有時間和競爭頻率,以在保護共享資源、提高效能和維持合理的 Thread 調度之間取得平衡。

Lock-free

Lock-free 技術試圖在不使用傳統鎖的情況下實現 Thread Safety (執行緒安全)。這些技術通常能提供更好的性能,特別是在高併發的情況下,但實現起來較為複雜。

Swift Concurrency

Swift 5.5 引入的 Swift Concurrency 模型提供了一種新的方式來處理併發和避免 Data Race。

actor BankAccount {
private var balance: Double

init(initialBalance: Double) {
balance = initialBalance
}

func deposit(_ amount: Double) {
balance += amount
}

func withdraw(_ amount: Double) throws {
guard balance >= amount else {
throw NSError(domain: "InsufficientFunds", code: 1, userInfo: nil)
}
balance -= amount
}

func checkBalance() -> Double {
balance
}
}
// 使用
Task {
let account = BankAccount(initialBalance: 100)
await account.deposit(50)
do {
try await account.withdraw(75)
let balance = await account.checkBalance()
print("Current balance: \(balance)")
} catch {
print("Withdrawal failed: \(error)")
}
}

Actor Model 提供了一種安全且易於使用的併發模型,且編譯器可以在編譯時捕獲潛在的 Data Race,而 Swift Concurrency 透過 Back-deployment 已經可以在 iOS 13 之後的 OS 上使用了,潛在的缺點則是需要使用 await 關鍵字,可能影響程式碼結構。

Credit: https://github.com/uriva/gamlajs

swift-atomics

Swift Atomics 主要依賴兩個關鍵機制:硬體級原子指令和記憶體排序。硬體級指令 Compare-and-Swap(CAS)來保證操作的原子性,而記憶體排序(Memory Ordering)選項如 Relaxed、Acquiring、Releasing、Acquiring and Releasing、Sequentially Consistent 等允許開發者在性能和安全性間取得平衡。這些機制共同實現了高效、安全的無鎖併發操作。

// Value type
let counter = ManagedAtomic<Int>(0)
DispatchQueue.concurrentPerform(iterations: 100) { _ in
counter.wrappingIncrement(ordering: .relaxed)
}
print(counter.load(ordering: .relaxed)) // 100

由於硬體及原子指令涉及硬體層面的設計,Repoistory 中說明 Lock-free & Wait-free 的部分有特別提及會受到 CPU 支援的指令影響:

All atomic operations exposed by this package are guaranteed to have lock-free implementations. However, we do not guarantee wait-free operation — depending on the capabilities of the target platform, some of the exposed operations may be implemented by compare-and-exchange loops. That said, all atomic operations map directly to dedicated CPU instructions where available — to the extent supported by llvm & Clang.

硬體層級的 Lock-free 需要注意的面相相當多,包含到對硬體、記憶體存取對於性能影響的理解,以及需要注意 ABA 問題 (A -> B -> A 看起來數值沒變,但記憶體位置實際已經發生改變),在追求高性能的同時,問題也變得複雜許多和增加出錯的可能性。在實際應用中,應該根據具體需求和場景來權衡是否使用 Lock-free 技術。

(下面這張圖很幽默地表達了開發者們對於 atomics 的情緒 😂)

Credit: CppCon 2016: JF Bastien “No Sane Compiler Would Optimize Atomics

總結

Data Race 是 iOS 開發中一個常見且可能產生嚴重後果的問題。正確理解和處理 Data Race 對於開發穩定、高性能的 iOS 應用至關重要。完整的了解自己的需求(性能、版本支援、對於 Third-Party Library 的依賴、處理併發需求的頻率 ……),選擇使用適當的同步機制(沒有最好的技術,只有最適合的技術),無論是 Lock 或者是 Lock-free 的技術,開發者都可以有效地預防和解決 Data Race 問題。

然而,解決 Data Race 並非一蹴而就的過程。它需要開發者對高併發程式設計有深入的理解,並在設計和實現階段就考慮到 Thread Safety。持續的 Code Review、Unit Testing 也是確保程式免受 Data Race 影響的重要手段。

隨著 Swift 及社群的不斷發展,我們有了更多工具和技術來處理併發問題。保持學習和實踐最適合的併發編程技術,將有助於開發出更加穩健和高效的程式。💪💪💪

回過頭來,不論什麼方式,以併發的方式設計你的程式無疑都為業務邏輯帶來了更高的複雜度,所以也需要檢視是否真的有併發執行的需求,以順序執行幫我們省去很多心力,也讓程式更好維護。

“ 能同步就盡量以同步的方式設計程式 ”

--

--

E-Zou Shen
Seekrtech-dev

Working at Seekrtech as CTO | A passionate full-stack developer.