Hello Swift!

Thread Safety in iOS

September 17, 2019 • ☕️ 4 min read

大部分在 Swift 中可以 mutate 的東西,都可能不是 thread safe 的。舉一個例子:

假設有一個 Person 的物件,裡面有 name 跟 age 這兩個屬性,且同時有兩個人在不同執行緒中要更改 person 的 name,且在更改的同時有一個人要讀取該 person 的 name 屬性,最後讀取的人會得到什麼值?

不知道。因為我們不知道這些人誰先完成了讀取或者寫入的動作。

最直接的例子就是資料庫,在現實中的 app 裡,我們可能有很多的執行緒同時在讀取跟寫入,在這個情況下同時讀取寫入就會造成資料庫的 exception 進而讓 app 閃退。一個比較好的作法可能是寫入的時候一個一個寫入,讀取等到所有的寫入完成後才從資料庫讀取最新的資料出來,如此一來讀取時就不會讀取到寫入到一半的資料,或者讀取到舊的資料了。

概念雖然簡單,但具體上應該怎麼做呢?

來做一個簡單的 Thread Safe Array 吧

我們先來看一下多個執行緒同時操作同一個 array 會發生什麼事:

var array: [Int] = []

DispatchQueue.concurrentPerform(iterations: 10, execute: { i in
    DispatchQueue.concurrentPerform(iterations: 10, execute: { j in
        array.append(i*j)
    })
})

結果就是閃退:

error: Execution was interrupted, reason: EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0).

因為 Swift Array 底層實作的關係,會去操作 pointer(這裡不贅述,有興趣的朋友歡迎看 Swift source code),多執行緒同時操作 array 就會有這樣的問題。

那我們能做的事情就是讓操作 array 的地方統一在一個執行緒中發生,因此我們可以在操作 array 前,先進到一個固定的執行緒後才執行 array 操作。

var array: [Int] = []
let queue = DispatchQueue(label: "io.some.thread")

DispatchQueue.concurrentPerform(iterations: 10, execute: { i in
    DispatchQueue.concurrentPerform(iterations: 10, execute: { j in
        queue.async {
            array.append(i*j)
        }
    })
})

在同一個執行緒中執行 array 操作後就不會有 exception 了。

Read Write Lock

在上面提到,我們希望所有的讀取發生在寫入之後,如此一來就可以讀取到寫入之後的最新資料。GCD 提供了一些方便的方法讓我們可以做到這樣的效果,就是使用 DispatchWorkItemFlags 中的 .barrier 可以達到這個效果:

let queue = DispatchQueue(label: "io.some.thread", attributes: .concurrent)

func write(_ index: Int) {
    queue.async(flags: .barrier, execute: {
        print("performing write \(index)")
        sleep(3)
        print("performing write \(index) completed")
    })
}

func read(_ index: Int) {
    print("sync read task \(index)")
    queue.sync {
        print("inside read sync task \(index)")
        sleep(2)
    }
    print("sync read task \(index) completed")
}

DispatchQueue.concurrentPerform(iterations: 10, execute: { i in
    DispatchQueue.concurrentPerform(iterations: 10, execute: { j in
        Bool.random() ? write((i+1)*(j+1)) : read((i+1)*(j+1))
    })
})

可以試著自己跑跑看這個 sample code,可以看到讀取寫入一起發生時,write 會優先執行且 block read。

有興趣閱讀更多關於 Read/Write Lock 可以點進連結看

完成 Thread Safe Array

Array 常用的操作不外乎就是 append, subscript 等等:

array.append(newElement)
array[0]
array.first

如果想要讓這個 array thread safe 的話,我們必須對他做一層包裝:

class SafeArray<Element> {
    private let queue = DispatchQueue(label: "io.safe.array.queue", attributes: .concurrent)
    private var array = Array<Element>()
}

接著實作可能會用到的一些操作(包含 read/write):

// For read
extension SafeArray {
    var first: Element? {
        var result: Element?
        queue.sync { result = array.first }
        return result
    }
}

// For write
extension SafeArray {
    func append(_ newElement: Element) {
        queue.async(flags: .barrier) { self.array.append(newElement) }
    }
}

接著就可以放心的直接操作 array 囉:

let safeArray = SafeArray<Int>()

DispatchQueue.concurrentPerform(iterations: 10, execute: { i in
    DispatchQueue.concurrentPerform(iterations: 10, execute: { j in
        safeArray.append(i*j)
    })
})

最後把 array 所擁有的方法全部 expose 出來:

performance measure

measure block:

extension Array where Element == TimeInterval {
    func averageTime() -> TimeInterval {
        return reduce(0, +) / TimeInterval(count)
    }
}

var normalArrayConsumeTimes: [TimeInterval] = []
var threadSafeArrayConsumeTimes: [TimeInterval] = []
var testArrayA: [Int] = []
var testArrayB = ThreadSafeArray<Int>()

func measure(label: String, _ block: @escaping (() -> Void), complete: (TimeInterval) -> Void) {
    print("measuring \(label)")
    let start = Date().timeIntervalSince1970
    DispatchQueue.global().sync {
        block()
    }
    let end = Date().timeIntervalSince1970
    let time = end - start
    print("measuring \(label) time: \(time) seconds")
    complete(time)
}

單純讀取:

for _ in 1...10 {
    let loopCount = 50000

    measure(label: "normal array", {
        var testArrayA: [Int] = []
        for _ in 1...loopCount {
            _ = testArrayA.last
        }
    }, complete: { time in normalArrayConsumeTimes.append(time) })

    measure(label: "thread safe array", {
        let testArrayB = ThreadSafeArray<Int>()
        for _ in 1...loopCount {
            _ = testArrayB.last
        }
    }, complete: { time in threadSafeArrayConsumeTimes.append(time) })
}

// normal array average time: 0.04547381401062012
// thread safe array average time: 0.09168415069580078
// thread safe array is 50.40166302952637% slower

thread safe 讀取效能上降低了 50%。


單純寫入:

for _ in 1...10 {
    let loopCount = 50000

    measure(label: "normal array", {
        var testArrayA: [Int] = []
        for _ in 1...loopCount {
            self.testArrayA.append(1)
        }
    }, complete: { time in normalArrayConsumeTimes.append(time) })

    measure(label: "thread safe array", {
        let testArrayB = ThreadSafeArray<Int>()
        for _ in 1...loopCount {
            self.testArrayB.append(1)
        }
    }, complete: { time in threadSafeArrayConsumeTimes.append(time) })
}

// normal array average time: 0.040544438362121585
// thread safe array average time: 0.3030177116394043
// thread safe array is 86.61977937105864% slower

thread safe 寫入效能上降低了 86%。


如果同時讀寫的話:

for _ in 1...10 {
    let loopCount = 50000

    measure(label: "normal array", {
        var testArrayA: [Int] = []
        for _ in 1...loopCount {
            testArrayA.append(1)
            _ = testArrayA.last
        }
    }, complete: { time in normalArrayConsumeTimes.append(time) })

    measure(label: "thread safe array", {
        let testArrayB = ThreadSafeArray<Int>()
        for _ in 1...loopCount {
            testArrayB.append(1)
            _ = testArrayB.last
        }
    }, complete: { time in threadSafeArrayConsumeTimes.append(time) })
}

// normal array average time: 0.05463418960571289
// thread safe array average time: 0.9117634773254395
// thread safe array is 94.00785500138957% slower

thread safe 讀寫效能上降低了 94%。


雖然效能明顯降低很多,但可以注意到的是每個 iteration 都有 5 萬次,如果單純看一般 array 跟 thread safe array 一次讀寫時間,以下是只讀寫 100 次的時間:

normal array average time:
0.00014765262603759765

thread safe array average time:
0.002648663520812988

兩者的時間都很小,以手機 60 fps 的更新率來算,1 fps 你有 0.016 秒的運算時間可以使用,相比起來 thread safe 的運算時間小很多。如果怕讀取會卡住等寫入完成的話,可以先到背景讀取,等到取得你要的資料時再回到 main 即可。

thread safe array 一次讀寫操作約耗時 0.000026487 秒,在 0.016 秒中可以執行一樣的操作 600 次。但整體速度也要取決於裝置的 cpu,這裡只是大略測試。

補充: 在實機測試時同時讀寫效能略好於 simulator(以 iPhone 8 @ iOS 13.1 為例)使用 debug scheme。 讀:慢 50% 寫:慢 75% 讀寫:慢 91%

補充: 在實機測試時同時讀寫效能略好於 simulator(以 iPhone 8 @ iOS 13.1.3 為例)且使用 release scheme。 讀:慢 99.88% 寫:慢 99.83% 讀寫:慢 99.92%


Gist link: https://gist.github.com/yoxisem544/41811dbb8aaabf22bde02e8610de78ff