iOS 多網絡請求的線程安全

Cover

iOS 網絡編程有一種常見的場景是:咱們須要並行處理二個請求而且在都成功後才能進行下一步處理。下面是部分常見的處理方式,可是在使用過程當中也很容易出錯:html

  • DispatchGroup:經過 GCD 機制將多個請求放到一個組內,而後經過 DispatchGroup.wait()DispatchGroup.notify() 進行成功後的處理。
  • OperationQueue:爲每個請求實例化一個 Operation 對象,而後將這些對象添加到 OperationQueue ,而且根據它們之間的依賴關係決定執行順序。
  • 同步 DispatchQueue:經過同步隊列和 NSLock 機制避免數據競爭,實現異步多線程中同步安全訪問。
  • 第三方類庫:Futures/Promises 以及響應式編程提供了更高層級的併發抽象。

在多年的實踐過程當中,我意識到上面這些方法這些方法都存在必定的缺陷。另外,要想徹底正確的使用這些類庫仍是有些困難。ios

併發編程中的挑戰

使用併發的思惟思考問題很困難:大多數時候,咱們會按照讀故事的方式來閱讀代碼:從第一行到最後一行。若是代碼的邏輯不是線性的話,可能會給咱們形成必定的理解難度。在單線程環境下,調試和跟蹤多個類和框架的程序執行已是很是頭疼的一件事了,多線程環境下這種狀況簡直不敢想象。git

數據競爭問題:在多線程併發環境下,數據讀取操做是線程安全的而寫操做則是非線程安全。若是發生了多個線程同時對某個內存進行寫操做的話,則會發生數據競爭致使潛在數據錯誤。github

理解多線程環境下的動態行爲自己就不是一件容易的事,找出致使數據競爭的線程就更爲麻煩。雖然咱們能夠經過互斥鎖機制解決數據競爭問題,可是對於可能修改的代碼來講互斥鎖機制的維護會是一件很是困難的事。編程

難以測試:併發環境下不少問題並不會在開發過程當中顯現出來。雖然 Xcode 和 LLVM 提供了 Thread Sanitizer 這類工具用於檢查這些問題,可是這些問題的調試和跟蹤依然存在很大的難度。由於併發環境下除了代碼自己的影響外,應用也會受到系統的影響。api

處理併發情形的簡單方法

考慮到併發編程的複雜性,咱們應該如何解決並行的多個請求?安全

最簡單的方式就是避免編寫並行代碼而是講多個請求線性的串聯在一塊兒:網絡

let session = URLSession.shared

session.dataTask(with: request1) { data, response, error in
    // check for errors
    // parse the response data

    session.dataTask(with: request2) { data, response error in
        // check for errors
        // parse the response data

        // if everything succeeded...
        callbackQueue.async {
            completionHandler(result1, result2)
        }
    }.resume()
}.resume()

爲了保持代碼的簡潔,這裏忽略了不少的細節處理,例如:錯誤處理以及請求取消操做。可是這樣將並沒有關聯的請求線性排序其實暗藏着一些問題。例如,若是服務端支持 HTTP/2 協議的話,咱們就沒發利用 HTTP/2 協議中經過同一個連接處理多個請求的特性,並且線性處理也意味着咱們沒有好好利用處理器的性能。session

關於 URLSession 的錯誤認知

爲了不可能的數據競爭和線程安全問題,我將上面的代碼改寫爲了嵌套請求。也就是說若是將其改成併發請求的話:請求將不能進行嵌套,兩個請求可能會對同一塊內存進行寫操做而數據競爭很是難以重現和調試。多線程

解決改問題的一個可行辦法是經過鎖機制:在一段時間內只容許一個線程對共享內存進行寫操做。鎖機制的執行過程也很是簡單:請求鎖、執行代碼、釋放鎖。固然要想徹底正確使用鎖機制仍是有一些技巧的。

可是根據 URLSession 的文檔描述,這裏有一個併發請求的更簡單解決方案。

init(configuration: URLSessionConfiguration,
          delegate: URLSessionDelegate?,
          delegateQueue queue: OperationQueue?)


[…]

queue : An operation queue for scheduling the delegate calls and completion handlers. The queue should be a serial queue, in order to ensure the correct ordering of callbacks. If nil, the session creates a serial operation queue for performing all delegate method calls and completion handler calls.

這意味全部 URLSession 的實例對象包括 URLSession.shared 單例的回調並不會併發執行,除非你明確的傳人了一個併發隊列給參數 queue

URLSession 拓展併發支持

基於上面對 URLSession 的新認知,下面咱們對其進行拓展讓它支持線程安全的併發請求(完成代碼地址)。

enum URLResult {
    case response(Data, URLResponse)
    case error(Error, Data?, URLResponse?)
}

extension URLSession {
    @discardableResult
    func get(_ url: URL, completionHandler: @escaping (URLResult) -> Void) -> URLSessionDataTask
}

// Example

let zen = URL(string: "https://api.github.com/zen")!
session.get(zen) { result in
    // process the result
}

首先,咱們使用了一個簡單的 URLResult 枚舉來模擬咱們能夠在 URLSessionDataTask 回調中得到的不一樣結果。該枚舉類型有利於咱們簡化多個併發請求結果的處理。這裏爲了文章的簡潔並無貼出 URLSession.get(_:completionHandler:) 方法的完整實現,該方法就是使用 GET 方法請求對應的 URL 並自動執行 resume() 最後將執行結果封裝成 URLResult 對象。

@discardableResult
func get(_ left: URL, _ right: URL, completionHandler: @escaping (URLResult, URLResult) -> Void) -> (URLSessionDataTask, URLSessionDataTask) {
    
}

該段 API 代碼接受兩個 URL 參數並返回兩個 URLSessionDataTask 實例。下面代碼是函數實現的第一段:

precondition(delegateQueue.maxConcurrentOperationCount == 1,
      "URLSession's delegateQueue must be configured with a maxConcurrentOperationCount of 1.")

由於在實例化 URLSession 對象時依舊能夠傳入併發的 OperationQueue 對象,因此這裏咱們須要使用上面這段代碼將這種狀況排除掉。

var results: (left: URLResult?, right: URLResult?) = (nil, nil)

func continuation() {
    guard case let (left?, right?) = results else { return }
    completionHandler(left, right)
}

將這段代碼繼續添加到實現中,其中定義了一個表示返回結果的元組變量 results 。另外,咱們還在函數內部定義了另外一個工具函數用於檢查是否兩個請求都已經完成結果處理。

let left = get(left) { result in
    results.left = result
    continuation()
}

let right = get(right) { result in
    results.right = result
    continuation()
}

return (left, right)

最後將這段代碼追加到實現中,其中咱們分別對兩個 URL 進行了請求並在請求都完成後一次返回告終果。值得注意的是這裏咱們經過兩次執行 continuation() 來判斷請求是否所有完成:

  1. 第一次執行 continuation() 時由於其中一個請求並未完成結果爲 nil 因此回調函數並不會執行。
  2. 第二次執行的時候兩個請求所有完成,執行回調處理。

接下來咱們能夠經過簡單的請求來測試下這段代碼:

extension URLResult {
    var string: String? {
        guard case let .response(data, _) = self,
        let string = String(data: data, encoding: .utf8)
        else { return nil }
        return string
    }
}

URLSession.shared.get(zen, zen) { left, right in
    guard case let (quote1?, quote2?) = (left.string, right.string)
    else { return }

    print(quote1, quote2, separator: "\n")
    // Approachable is better than simple.
    // Practicality beats purity.
}

並行悖論

我發現解決並行問題最簡單最優雅的方法就是儘量的少使用併發編程,並且咱們的處理器很是適合執行那些線性代碼。可是若是將大的代碼塊或任務拆分爲多個並行執行的小代碼塊和任務將會讓代碼變得更加易讀和易維護。

做者:Adam Sharp,時間:2017/9/21
翻譯:BigNerdCoding, 若有錯誤歡迎指出。譯文地址原文連接

相關文章
相關標籤/搜索