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() 来判断请求是否全部完成:

  • 第一次执行 continuation() 时因为其中一个请求并未完成结果为 nil 所以回调函数并不会执行。
  • 第二次执行的时候两个请求全部完成,执行回调处理。

接下来我们可以通过简单的请求来测试下这段代码:

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, 如有错误欢迎指出。原文链接