深入淺出 iOS 併發編程 什麼是併發編程 GCD vs. Operation 併發編程中常見問題 Operation 流程化開發 總結

本文是我在上海 T 沙龍4月7日分享內容的文字版總結和拓展。相關視頻和文檔請見鏈接:深入淺出 iOS 併發編程
其中主要內容包括:GCD與Operation的用法、併發編程中常見的問題、使用Operation進行流程化開發示範。

什麼是併發編程

在大多數場景下,我們所寫的代碼是逐行順序執行——在固定的時段內,程序只執行一個任務。而所謂併發編程,就是指在固定的時段內,程序執行多個任務。舉個例子,當我們在微博 App 的首頁滑動瀏覽時,微博也在從網絡端預加載新的內容或者圖片。併發編程可以充分利用硬件性能,合理分配軟件資源,帶來優秀的用戶體驗。在 iOS 開發中,我們主要依靠 GCD 和 Operation 來操作線程切換、異步操作,從而實現併發編程。

在 iOS 併發編程中,我們要知道這幾個基本概念:

  • 串行(Serial):在固定時間內只能執行單個任務。例如主線程,只負責 UI 顯示。
  • 併發(Concurrent):在固定時間內可以執行多個任務。注意,它和並行(Parallel)的區別在於,併發不會同時執行多個任務,而是通過在任務間不斷切換去完成所有工作。
  • 同步(Sync):會把當前的任務加入到隊列中,除非該任務執行完成,線程纔會返回繼續運行,也就是說同步會阻塞線程。任務在執行和結束一定遵循先後順序,即先執行的任務一定先結束。
  • 異步(Async):會把當前的任務加入到隊列中,但它會立刻返回,無需等任務執行完成,也就是說異步不會阻塞線程。任務在執行和結束不遵循先後順序。可能先執行的任務先結束,也可能後執行的任務先結束。

爲了進一步說明說明串行/併發與同步/異步之間的關係,我們來看下面這段代碼會打印出什麼內容:

// serial, sync
serialQueue.sync {
  print(1)
}
print(2)
serialQueue.sync {
  print(3)
}
print(4)

// serial, async
serialQueue.async {
  print(1)
}
print(2)
serialQueue.async {
  print(3)
}
print(4)

// serial, sync in async
print(1)
serialQueue.async {
  print(2)
  serialQueue.sync {
    print(3)
  }
  print(4)
}
print(5)


// serial, async in sync
print(1)
serialQueue.sync {
  print(2)
  serialQueue.async {
    print(3)
  }
  print(4)
}
print(5)

首先,在串行隊列上進行同步操作,所有任務將順序發生,所以第一段的打印結果一定是 1234;

其次,在串行隊列上進行異步操作,此時任務完成的順序並不保證。所以可能會打印出這幾種結果:1234 ,2134,1243,2413,2143。注意 1 一定在 3 之前打印出來,因爲前者在後者之前派發,串行隊列一次只能執行一個任務,所以一旦派發完成就執行。同理 2 一定在 4 之前打印,2 一定在 3 之前打印。

接着,對同一個串行隊列中進行異步、同步嵌套。這裏會構成死鎖(具體原因參見下文),所以只會打印出 125 或者 152。

最後,在串行隊列中進行同步、異步嵌套,不會構成死鎖。這裏會打印出 3 個結果:12345,12435,12453。這裏1一定在最前,2 一定在 4 前,4 一定在 5 前。

現在我們把串行隊列改爲併發隊列:

// concurrent, sync
concurrentQueue.sync {
  print(1)
}
print(2)
concurrentQueue.sync {
  print(3)
}
print(4)

// concurrent, async
concurrentQueue.async {
  print(1)
}
print(2)
concurrentQueue.async {
  print(3)
}
print(4)

// concurrent, sync in async
print(1)
concurrentQueue.async {
  print(2)
  concurrentQueue.sync {
    print(3)
  }
  print(4)
}
print(5)


// concurrent, async in sync
print(1)
concurrentQueue.sync {
  print(2)
  concurrentQueue.async {
    print(3)
  }
  print(4)
}
print(5)

首先,在併發隊列上進行同步操作,所有任務將順序執行、順序完成,所以第一段的打印結果一定是 1234;

其次,在併發隊列上進行異步操作,因爲並行對列有多個線程 。所以這裏只能保證 24 順序執行,13 亂序,可能插在任意位置:2413 ,2431,2143,2341,2134,2314。

接着,對同一個併發隊列中進行異步、同步嵌套。這裏不會構成死鎖,因爲同步操作只會阻塞一個線程,而併發隊列對應多個線程。這裏會打印出 4 個結果:12345,12534,12354,15234。注意同步操作保證了 3 一定會在 4 之前打印出來。

最後,在併發隊列中進行同步、異步嵌套,不會構成死鎖。而且由於是併發隊列,所以在運行異步操作時也同時會運行其他操作。這裏會打印出 3 個結果:12345,12435,12453。這裏同步操作保證了 2 和 4 一定分別在 3 和 5 之前打印出來。

在實際開發中,我們還需要知道主線程的特性、GCD 和 Operation 的 API、如發現並調試併發編程中的技巧。

GCD vs. Operation

在 iOS 開發中,我們一般用 GCD 和 Operation 來處理併發編程問題。我們先來看看 GCD 的基本用法:

// serial queue
let serialQueue = DispatchQueue(label: "serial")

// global queue, gcd defined concurrent queue
let globalQueue = DispatchQueue.global(qos: .default)

// custom concurrent queue
let concurrentQueue = DispatchQueue(label: "concurrent", attributes: .concurrent)

其中,全局隊列的優先級由 QoS (Quality of Service)決定。如果不指定優先級,就是默認(default)優先級。另外還有 background,utility,user-Initiated,unspecified,user-Interactive。下面按照優先級順序從低到高來排列:

  • Background:用來處理特別耗時的後臺操作,例如同步、數據持久化。
  • Utility:用來處理需要一點時間而又不需要立刻返回結果的操作。特別適用於網絡加載、計算、輸入輸出等。
  • Default:默認優先級。一般來說開發者應該指定優先級。屬於特殊情況。
  • User-Initiated:用來處理用戶觸發的、需要立刻返回結果的操作。比如打開用戶點擊的文件、加載圖片等。
  • User-Interactive:用來處理用戶交互的操作。一般用於主線程,如果不及時響應就可能阻塞主線程的操作。
  • Unspecified:未確定優先級,由系統根據不同環境推斷。比如使用過時的 API 不支持優先級,此時就可以設定爲未確定優先級。屬於特殊情況。

在日常開發中,GCD 的常見應用有處理後臺任務、延時、單例(Objective-C)、線程組等操作,這裏不作贅述。下面我們來看看 Operation 的基本操作:

// serial queue
let serialQueue = OperationQueue()
serialQueue.maxConcurrentOperationCount = 1

// concurrent queue
let concurrentQueue = OperationQueue()

Operation 作爲 NSObject 的子類,一般被用於單獨的任務。我們將其繼承重寫之後加入到 OperationQueue 中去運行。iOS 亦提供 BlockOperation 這個子類去方便地執行多個代碼片段。相比於 GCD,Operation 最主要的特點在於其擁有暫停、繼續、終止等多個可控狀態,從而可以更加靈活得適應併發編程的場景。

基於 Operation 和 GCD API 的特點,我們可以得出以下結論:GCD 適用於處理並行開發中的簡單小任務,總體寫法輕便快捷;Operation 適合於封裝模塊化的任務,支持多任務之間相互依賴的場景。兩者之間的區別同 UIAnimation 和 CALayor Animation 差別異曲同工——由此可見蘋果在設計 API 時一以貫之的思路:提供一個簡單快捷的 API 滿足80%的場景,在提供一套更全面的 API 應對剩下20%更復雜的場景。

併發編程中常見問題

在併發編程中,一般會面對這樣的三個問題:競態條件、優先倒置、死鎖問題。針對 iOS 開發,它們的具體定義爲:

  • 競態條件(Race Condition)。指兩個或兩個以上線程對共享的數據進行讀寫操作時,最終的數據結果不確定的情況。例如以下代碼:
var num = 0
DispatchQueue.global().async {
  for _ in 1…10000 {
    num += 1
  }
}

for _ in 1…10000 {
  num += 1
}

最後的計算結果 num 很有可能小於 20000,因爲其操作爲非原子操作。在上述兩個線程對num進行讀寫時其值會隨着進程執行順序的不同而產生不同結果。

競態條件一般發生在多個線程對同一個資源進行讀寫時。解決方法有兩個,第一是串行隊列加同步操作,無論讀寫,指定時間只能優先做當前唯一操作,這樣就保證了讀寫的安全。其缺點是速度慢,尤其在大量讀寫操作發生時,每次只能做單個讀或寫操作的效率實在太低。另一個方法是,用併發隊列和 barrier flag,這樣保證此時所有併發隊列只進行當前唯一的寫操作(類似將併發隊列暫時轉爲串行隊列),而無視其他操作。

  • 優先倒置(Priority Inverstion)。指低優先級的任務會因爲各種原因先於高優先級任務執行。例如以下代碼:
var highPriorityQueue = DispatchQueue.global(qos: .userInitiated)
var lowPriorityQueue = DispatchQueue.global(qos: .utility)

let semaphore = DispatchSemaphore(value: 1)

lowPriorityQueue.async {
  semaphore.wait()
  for i in 0...10 {
    print(i)
  }
  semaphore.signal()
}

highPriorityQueue.async {
  semaphore.wait()
  for i in 11...20 {
    print(i)
  }
  semaphore.signal()
}

上述代碼如果沒有 semaphore,高優先權的 highPriorityQueue 會優先執行,所以程序會優先打印完 11 到 20。而加了 semaphore 之後,低優先權的 lowPriorityQueue 會先掛起 semaphore,高優先權的highPriorityQueue 就只有等 semaphore 被釋放才能再執行打印。

也就是說,低優先權的線程可以鎖上某種高優先權線程需要的資源,從而優於迫使高優先權的線程等待低優先權的線程,這就叫做優先倒置。其對應的解決方法是,對同一個資源不同隊列的操作,我們應該用同一個QoS指定其優先級。

  • 死鎖問題(Dead Lock)。指兩個或兩個以上的線程,它們之間互相等待彼此停止執行,以獲得某種資源,但是沒有一方會提前退出的情況。iOS 中有個經典的例子就是兩個 Operation 互相依賴:
let operationA = Operation()
let operationB = Operation()

operationA.addDependency(operationB)
operationB.addDependency(operationA)

還有一種經典的情況,就是在對同一個串行隊列中進行異步、同步嵌套:

serialQueue.async {
  serialQueue.sync {
  }
}

因爲串行隊列一次只能執行一個任務,所以首先它會把異步 block 中的任務派發執行,當進入到 block 中時,同步操作意味着阻塞當前隊列 。而此時外部 block 正在等待內部 block 操作完成,而內部block 又阻塞其操作完成,即內部 block 在等待外部 block 操作完成。所以串行隊列自己等待自己釋放資源,構成死鎖。

對於死鎖問題的解決方法是,注意Operation的依賴添加,以及謹慎使用同步操作。其實聰明的讀者應該已經發現,在主線程使用同步操作是一定會構成死鎖的,所以我個人建議在串行隊列中不要使用同步操作。

儘管我們已經知道了併發編程中的問題,以及其對應方法。但是日常開發中,我們怎樣及時發現這些問題呢?其實 Xcode 提供了一個非常便利的工具 —— Thread Sanitizer (TSan)。在Schemes中勾選之後,TSan就會將所有的併發問題在 Runtime 中顯示出來,如下圖:

這裏我們有7個線程問題,TSan清晰地告訴了我們這是讀寫問題,展開之後會告訴我們具體觸發代碼,十分方便。16年的WWDC上,蘋果也鄭重向大家宣告,如果有併發問題,請記得用 TSan。

Operation 流程化開發

上文中提到 Operation 特別適合模塊化工作,也支持多任務的互相依賴。這裏我們就來看一個具體的開發案例吧:

實現一個相冊 App,其首頁是個滑動列表(Table View)。列表每行展示加上了濾鏡的圖片。具體實現如下圖:

仔細分析一下相關的操作,實際上就是三步:先加載數據,然後解碼成圖片,最後再給圖片加上濾鏡。所以用 Operation 實現起來如下圖:

對於加載數據,我們可以定義如下的 Operation 子類來進行操作:

class DataLoadOperation: Operation {
  
  fileprivate let url: URL
  fileprivate var loadedData: Data?
  fileprivate let completion: ((Data?) -> ())?
  
  init(url: URL, completion: ((Data?) -> ())? = nil) {
    ...
  }
  
  override func main() {
    if isCancelled { return }
    ImageService.loadData(at: url) { data in
      if isCancelled { return }
        loadedData = data
        completion?(data)
    }
  }
}

這裏我們要注意,DataLoadOperation中的三個變量皆爲私有。這是因爲其實後續圖片解碼操作並不關心數據是如何操作的,它只關心是否能提供解碼圖片的數據,所以我們可以用 Protocol 來提供這個藉口即可:

// 此協議定義應和 ImageDecodeOperation 放在同一文件
protocol ImageDecodeOperationDataProvider {
  var encodedData: Data? { get }
}

// 次擴展應和 DataLoadOperation 放在同一文件
extension DataLoadOperation: ImageDecodeOperationDataProvider {
  var encodedData: Data? { return loadedData }
}

接着再來看看解碼圖片的 Operation 如何實現:

class ImageDecodeOperation: Operation {
  
  fileprivate let inputData: Data?
  fileprivate var outputImage: UIImage?
  fileprivate let completion: ((UIImage?) -> ())?
  
  init(data: Data?, completion: ((UIImage?) -> ())? = nil) {
    ...
  }
  
  override func main() {    
    let encodedData: Data?
    if isCancelled { return }
    if let inputData = inputData {
      encodedData = inputData
    } else {
      let dataProvider = dependencies
        .filter { $0 is ImageDecodeOperationDataProvider }
        .first as? ImageDecodeOperationDataProvider
      encodedData = dataProvider?.encodedData
    }
    
    guard let data = encodedData else { return }
    
    if isCancelled { return }
    if let decodedData = Decoder.decodeData(data) {
      outputImage = UIImage(data: decodedData)
    }
    
    if isCancelled { return }
    completion?(outputImage)
  }
}

extension ImageDecodeOperation: ImageFilterDataProvider {
  var image: UIImage? { return outputImage }
}

最後我們再來看 ImageFilterOperation 及其子類如何實現。這裏由於直接輸出 Image,所以就無需用:

protocol ImageFilterDataProvider {
  var image: UIImage? { get }
}

class ImageFilterOperation: Operation {
  fileprivate let filterInput: UIImage?
  fileprivate var filterOutput: UIImage?
  fileprivate let completion: ((UIImage?) -> ())?
  
  init(image: UIImage?, completion:
 ((UIImage?) -> ())? = nil) {
    ...
  }
  
  var filterInput: UIImage? {
    var image: UIImage?
    if let inputImage = _filterInput {
      image = inputImage
    } else if let dataProvider = dependencies
      .filter({ $0 is ImageFilterDataProvider })
      .first as? ImageFilterDataProvider {
        image = dataProvider.image
    }
    return image
  }
}

// LarkFilter 和 ReyesFilter 的實現也類似
class MoonFilterOperation : ImageFilterOperation {
  override func main() {
    if isCancelled { return }
    guard let filterInput = filterInput else { return }
 
    if isCancelled { return }
    filterOutput = filterInput.applyMoonEffect()
    if isCancelled { return }
    completion(imageFiltered)
  }
}

最後我們用 OperationQueue 將這些 Operation 拼接在一起:

let operationQueue = OperationQueue()
let dataLoadOperation = DataLoadOperation(url: url)
let imageDecodeOperation = imageDecodeOperation(data: nil)
let moonFilterOperation = MoonFilterOperation(image: nil, completion: completion)
let operations = [dataLoadOperation, imageDecodeOperation, moonFilterOperation]

// Add dependencies
imageDecodeOperation.addDependency(dataLoadOperation)
moonFilterOperation.addDependency(imageDecodeOperation)

operationQueue.addOperations(operations, waitUntilFinished: false)

大功告成。從上面我們可以發現,每個操作模塊都可以用 Operation 進行自定義和封裝。模塊的對應邏輯非常清楚,代碼複用率和靈活度也非常之高。如果要繼續改進,我們還可以實現一個 AsyncOperation 的類,然後讓 DataLoadOperation 繼承該類,這樣數據加載由同步變爲異步,其效率會大大提高。

總結

iOS 開發中,併發編程主要用於提升 App 的運行性能,保證App實時響應用戶的操作。主線程一般用於負責 UI 相關操作,如繪製圖層、佈局、交互相應。很多 UIKit 相關的控件如果不在主線程操作,會產生未知效果。Xcode 中的 Main Thread Checker 可以將相關問題檢測出來並報錯。

其他線程例如後天線程一般用來處理比較耗時的工作。網絡請求、數據解析、複雜計算、圖片的編碼解碼管理等都屬於耗時的工作,應該放在其他線程處理。iOS 提供了兩套靈活豐富的 API:GCD 和 Operation。GCD的優點在於簡單快捷,Operation 勝在功能豐富、適合模塊化操作。我們享受其便利的同時,也應該及時發現和處理併發編程中的三大問題。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章