Swift實現多執行緒map函式

bestswifter發表於2017-12-14

SequenceType協議中定義的map函式功能很強大,這個函式起源於函數語言程式設計,能夠很方便的對陣列中的每個元素進行變換處理,關於它的實現原理和使用方法可以參考我的這篇文章:Swift陣列變換。今天突然想到,如果陣列非常大,map方法會不會出現效能問題?如果使用多執行緒技術是否可以提高map方法的執行效率?帶著這樣的問題,我開始了本次實(zuo)驗(si)。專案的demo在我的github:ParallelMap,如果覺得不錯還請隨手點一個star?

第一次嘗試

雖然問題有些複雜,不過解決複雜的問題總是從處理簡單問題開始的,我嘗試直接使用非同步多執行緒來處理:

extension Array {
    func kt_map<T>(transform: (Element) -> T) -> [T] {
        let asyncQueue = dispatch_queue_create("com.gcd.kt", DISPATCH_QUEUE_CONCURRENT)
        var result: [T] = []

        dispatch_async(asyncQueue) { () -> Void in
            result = self.map(transform)
        }
        return result
    }
}

let test = [1,2,3,4,5,6,7,8,9]
let result = kt_map { $0 * 2}
print(result) 	// 輸出結果: []
複製程式碼

這顯然是一個錯的離譜的實現,最主要問題在於非同步呼叫self.map方法後,沒有等result變數被賦值就直接返回了(其實就是水平差)。不過,從第一次嘗試中還是大概明確了方向,得出以下幾個結論:

  1. 方法內部肯定要用到多執行緒,但整體來說必須是同步的。必須等所有變換都執行結束,才能返回結果,Swift的map方法也是這麼做的。

  2. 因為方法是同步的,即使在方法內部新開一個執行緒,呼叫self.map,也不會節省任何時間,反而會因為執行緒切換浪費大量的時間。

  3. 解決問題的思路應該是把陣列拆成若干部分,在不同的執行緒中對不同的部分進行變換,最後再合併起來作為返回值。

第二次嘗試

帶著第一次嘗試的收穫,我開始了第二次嘗試,這次程式碼比之前複雜一些,先上程式碼,標記數字的地方下面會有詳細介紹:

func ktMap<T>(transform: (Element) -> T) -> [T] { // 1
    guard !self.isEmpty else { return [] }  // 如果陣列為空就直接返回空陣列

    var result: [(Int, [T])] = []   // 2
    let group = dispatch_group_create()

    // 3
    let subArrayLength: Int = max(1, self.count / NSProcessInfo.processInfo().activeProcessorCount / 2)

    for var step = 0; step * subArrayLength < self.count; step++ {
        var stepResult: [T] = []
        // 4
        dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
        for i in (step * subArrayLength)..<((step + 1) * subArrayLength) {
            stepResult += [transform(self[i])]
        }
        result += [(step, stepResult)]
    }
}

    dispatch_group_wait(group, DISPATCH_TIME_FOREVER)    // 5
    return result.sort { $0.0 < $1.0 }.flatMap { $0.1 }    // 6
}
複製程式碼
  1. 如果可以的話,我肯定仿照Swift對map方法的實現,把transform閉包宣告為@noescape,可惜在GCD中要呼叫這個閉包,只能作罷。關於@noescape關鍵字的優點和限制,可以參考我的這篇部落格:自動閉包和noescape

  2. result是一個元組的陣列。元組中第二個元素是self的子陣列,第一個元素是這個子陣列的序號。比如陣列[1,2,3,4,5,6]有可能在result中表示為[(0, [1,2,3]), (1, [4,5,6])]。之所以這樣拆分,是因為我要在多執行緒中分別處理每個子陣列,最後再把他們合併成原來的陣列。

  3. subArrayLength表示每個子陣列的長度。在整個除法表示式中,我們首先除以當前活躍的處理器核數,以iPhone6的A8處理器為例,它是雙核的。因為一個核心對應兩個執行緒,我希望能夠在每個執行緒中處理一個子陣列,所以又除以了2。其實這樣做沒有必要,因為不管我們在程式中開了多少個非同步執行緒,真正對應到CPU的執行緒的過程是由GCD控制的。這就是作業系統中的“多對多”執行緒模型。不管怎麼說,我們算出了每個子陣列的長度。

  4. 接下來是一個迴圈,在每一步中我們非同步的執行一些操作(這些操作會在新執行緒裡執行)。stepResult是一個子陣列,用於儲存這一段元素的變換結果。變換結束後,把stepstepResult分別作為元組的第一、二個元素,存入result陣列中

  5. 呼叫dispatch_group_wait,這樣我們會一直等到所有group中的方法結束後,才執行下面的程式碼。因為之前所有的任務都是放在group中執行的,所以這就保證了整個ktMap方法是同步的。

  6. 首先根據元組的第一個元素,也就是子陣列序號排序,接著呼叫flatMap方法把其中的陣列提取出來。關於sortflatMap方法的用法,同樣可以參考我的這篇文章:Swift陣列變換

方法實現完了以後,再次呼叫它,檢視結果。可憐的是還不如以前,這次直接崩潰了。根據崩潰提示,問題主要出在這一段:

dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
    for i in (step * subArrayLength)..<((step + 1) * subArrayLength) {
        stepResult += [transform(self[i])]
    }
    result += [(step, stepResult)]
}
複製程式碼

第三次嘗試

我在非同步方法中向陣列result新增新的元素,這樣是很危險的。準確的說是程式幾乎必定崩潰,具體原因可以參考這篇文章:Swift陣列擴容原理。正確的做法是加一個鎖,保證每次只有一個新增元素的操作在執行。另一種常見的方案是使用同步佇列,它可以保證佇列中所有的任務依次進行。

另一個問題在於,在最外層的for迴圈中,step變數表示當前是第幾步。它隨著迴圈的進行,不斷自增。如果在迴圈內部總是使用step,就有可能獲取到錯誤的step值。這一點也要格外重視,在平時的程式設計中,for迴圈總是同步的,當前迴圈不結束就不會開始下一個迴圈。在多執行緒程式設計中就完全不是這樣,迴圈內部呼叫了非同步方法,所以每個迴圈會非常快的結束。正確的做法是在迴圈內部用一個臨時常量儲存step的值。

最後一個問題是處理每一段子陣列時,它的長度總是固定的,也就是subArrayLength的值。但是在處理最後一個子陣列時可能導致下標越界,即使沒有越界,這些操作也是多餘的。

解決這三個問題後,我完成了最終的map方法實現,具體的解釋在下面:

func ktMap<T>(transform: (Element) -> T) -> [T] {
    guard !self.isEmpty else { return [] }  // 如果陣列為空就直接返回空陣列

    var result: [(Int, [T])] = []
    let group = dispatch_group_create()
    let mutex = dispatch_semaphore_create(1)    // 1
    let syncQueue = dispatch_queue_create("com.gcd.kt", DISPATCH_QUEUE_SERIAL)    // 2

    let subArrayLength: Int = max(1, self.count / NSProcessInfo.processInfo().activeProcessorCount / 2)

    for var step = 0; step * subArrayLength < self.count; step++ {
        let localStep = step	// 3
        var stepResult: [T] = []

        dispatch_group_async(group, dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) {
        // 4
        for i in (localStep * subArrayLength)..<min(((localStep + 1) * subArrayLength), self.count) {
            stepResult += [transform(self[i])]
        }

        // 5
        dispatch_semaphore_wait(mutex, DISPATCH_TIME_FOREVER)
        result += [(localStep, stepResult)]
        dispatch_semaphore_signal(mutex)

        // 6
        // dispatch_group_async(group, syncQueue) {
        // result += [(localStep, stepResult)]
        // }
    }
}

dispatch_group_wait(group, DISPATCH_TIME_FOREVER)
    return result.sort { $0.0 < $1.0 }.flatMap { $0.1 }
}
複製程式碼
  1. 使用互斥鎖保證不會有多個陣列新增元素的操作同時進行
  2. 同步佇列也是一種解決方式
  3. 在for迴圈體中用一個常量儲存下來當前的step值,後面用localStep替代所有的step
  4. 陣列的右邊界進行一個判斷,最多不超過self.count
  5. 這是使用互斥鎖的解決方案
  6. 這裡被註釋了,可以替換掉上面那部分。必須使用dispatch_group_async,因為下面還用到dispatch_group_wait,必須確保group中所有操作執行完了才能排序。

測試

首先進行正確性測試,我使用了一些測試用例,目前來看沒有問題,這裡列出一個:

let test = [1,2,3,4,5,6,7,8,9]
let result = test.ktMap { $0 * 2}
print(result)	//輸出結果:[2, 4, 6, 8, 10, 12, 14, 16]
複製程式碼

為了進行效能測試,首先我定義了兩個輔助函式:

func ktTimer<T>(description: String, @autoclosure task performTask: () -> T) -> Void {
    let start = NSDate().timeIntervalSince1970
    performTask()

    let duration = NSDate().timeIntervalSince1970 - start
    print("Mission '\(description)' completed in \(duration * 1000) ms")
}

func transformGenerater(duration duration: Float) -> Int -> Int {
    return {
        NSThread.sleepForTimeInterval(NSTimeInterval(duration))
        return $0
    }
}
複製程式碼

第一個函式用於計時,以毫秒為單位輸出引數performTask的執行時間。

第二個函式用於模擬耗時的計算,它的返回值型別是Int -> Int,可以作為map函式的引數。它的引數delay可以模擬計算所需要的時間。

根據我的猜測,在處理少量資料時,多執行緒map的效能應該不如Swift自己實現的map,因為有一些額外的建立、切換執行緒以及同步操作。在資料量較大時,因為電腦上是八執行緒處理器(iPhone是4執行緒),理論上map方法的耗時應該是自定義的ktMap方法的8倍。不過因為有這些固定的時間開銷,實際上並不能達到理論上的優化效果。

為了驗證猜測,我進行了下面八組測試:

let littleArray: Array<Int> = [1,2,3,4,5,6,7,8,9,10]   // 模擬資料量小的情況
var largeArray: Array<Int> = []   // 模擬資料量大的情況

for i in 0..<1000 {
    largeArray.append(i)
}

ktTimer("1.少量資料,多執行緒map方法,耗時計算", task: littleArray.ktMap(transformGenerater(duration: 0.01)))
ktTimer("2.少量資料,普通map方法,耗時計算", task: littleArray.map(transformGenerater(duration: 0.01)))
ktTimer("3.少量資料,多執行緒map方法,不耗時計算", task:  littleArray.ktMap(transformGenerater(duration: 0)))
ktTimer("4.少量資料,普通map方法,不耗時計算", task: littleArray.map(transformGenerater(duration: 0)))
print("")
ktTimer("5.大量資料,多執行緒map方法,耗時計算", task: largeArray.ktMap(transformGenerater(duration: 0.01)))
ktTimer("6.大量資料,普通map方法,耗時計算", task: largeArray.map(transformGenerater(duration: 0.01)))
ktTimer("7.大量資料,多執行緒map方法,不耗時計算", task: largeArray.ktMap(transformGenerater(duration: 0)))
ktTimer("8.大量資料,普通map方法,不耗時計算", task: largeArray.map(transformGenerater(duration: 0)))
複製程式碼

輸出結果如下:

Mission '1.少量資料,多執行緒map方法,耗時計算' completed in 15.394926071167 ms
Mission '2.少量資料,普通map方法,耗時計算' completed in 112.390041351318 ms
Mission '3.少量資料,多執行緒map方法,不耗時計算' completed in 0.3509521484375 ms
Mission '4.少量資料,普通map方法,不耗時計算' completed in 0.0109672546386719 ms

Mission '5.大量資料,多執行緒map方法,耗時計算' completed in 717.829942703247 ms
Mission '6.大量資料,普通map方法,耗時計算' completed in 11682.7671527863 ms
Mission '7.大量資料,多執行緒map方法,不耗時計算' completed in 2.37083435058594 ms
Mission '8.大量資料,普通map方法,不耗時計算' completed in 0.446796417236328 ms
複製程式碼

每次執行的具體結果不同,通過對比可以發現:

  1. 對比1和2,假設transform的閉包執行時間是0.01秒,ktMap方法的耗時只有同步方法的7.3分之一。基本符合我之前的猜想。
  2. 對比3和4,如果只是把原來的元素翻倍,多執行緒方法的耗時反而是同步方法的35倍。而且即使資料量達到1000,對比7、8也可以發現同步方法快了好幾倍

於是得出一個結論:

是否需要使用多執行緒map方法的依據不是陣列元素的數量多少,而是元素變換操作的複雜度

我簡單的測試了幾次,發現duration的值為0.00001的時候,兩種函式耗時相似。而現在的CPU處理速度非常塊,加減乘除運算用時幾乎可以忽略不計。

寫在最後

一定要找到實際使用中的效能瓶頸再進行優化,不要盲目臆造需求,重要的事說三遍:

過早的優化是萬惡之源!

過早的優化是萬惡之源!

過早的優化是萬惡之源!

不僅map可以多執行緒實現,filterreduceexists等方法也可以使用多執行緒,實現原理與本文類似,有興趣的讀者可以自己研究研究。

專案的demo在我的github:ParallelMap,如果覺得不錯還請隨手點一個star?

相關文章