iOS 多網路請求的執行緒安全

BigNerdCoding發表於2017-10-17

Cover
Cover

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.
}複製程式碼

並行悖論

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

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

相關文章