iOS 多網絡請求的線程安全

wqxiea 7年前發布 | 29K 次閱讀 線程 iOS開發 移動開發

iOS 網絡編程有一種常見的場景是:我們需要并行處理二個請求并且在都成功后才能進行下一步處理。下面是部分常見的處理方式,但是在使用過程中也很容易出錯:

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

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

并發編程中的挑戰

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

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

理解多線程環境下的動態行為本身就不是一件容易的事,找出導致數據競爭的線程就更為麻煩。雖然我們可以通過互斥鎖機制解決數據競爭問題,但是對于可能修改的代碼來說互斥鎖機制的維護會是一件非常困難的事。

難以測試:并發環境下很多問題并不會在開發過程中顯現出來。雖然 Xcode 和 LLVM 提供了 Thread Sanitizer 這類工具用于檢查這些問題,但是這些問題的調試和跟蹤依然存在很大的難度。因為并發環境下除了代碼本身的影響外,應用也會受到系統的影響。

處理并發情形的簡單方法

考慮到并發編程的復雜性,我們應該如何解決并行的多個請求?

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

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 協議中通過同一個鏈接處理多個請求的特性,而且線性處理也意味著我們沒有好好利用處理器的性能。

關于 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.
}

并行悖論

我發現解決并行問題最簡單最優雅的方法就是盡可能的少使用并發編程,而且我們的處理器非常適合執行那些線性代碼。但是如果將大的代碼塊或任務拆分為多個并行執行的小代碼塊和任務將會讓代碼變得更加易讀和易維護。

 

來自:https://segmentfault.com/a/1190000011591022

 

 本文由用戶 wqxiea 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!