---
title: A Simple Approach to Thread-Safe Networking in iOS Apps
teaser: 'Concurrent programming is hard! But by taking advantage of a lesser-known
  aspect of its design, we can avoid some common pitfalls.

  '
tags: concurrency,parallelism,urlsession,networking,swift,ios
author: Adam Sharp
published_on: 2017-09-21
---

In any iOS app's development, there comes a time when you need to make two
network requests in parallel, and only continue if both succeed. There are a
*lot* of ways to solve this problem, and it's very easy to make mistakes. Here
is a short (non-exhaustive) list of tools and approaches you could use:

- `DispatchGroup` allows you to link related tasks, then use
  `DispatchGroup.wait()` or `DispatchGroup.notify()` to wait for them all to
  complete;

- Networking tasks can be encapsulated in custom `Operation` subclasses,
  executed using an `OperationQueue`, and executed in the appropriate order by
  defining dependencies between them;

- Serial `DispatchQueue`s, `NSLock`s, and other mutex implementations protect
  against data races by synchronising multiple threads that are executing in
  parallel;

- Third-party solutions can provide higher level concurrency abstractions, such
  as Futures/Promises and Reactive Programming.

After quite a few years trying out many of these options, I've consistently
come to the realisation that *this is really hard!*

## The challenges of concurrent programming

**Thinking in parallel is hard.** When we read code, we read it like a story,
from start to finish. The less linear our code, the harder it is to understand.
Have you ever tried to debug a problem and ended up tracing the execution of
your program through dozens of files, classes, frameworks and methods? This
kind of complexity can happen even in single-threaded code. Figuring out what
can happen when the same code is run on multiple CPU cores in parallel is even
more difficult.

**Writing code free of data races is hard.** It's safe for multiple threads to
*read* memory concurrently, provided that none are writing to it. A data race
occurs when multiple threads are accessing the same memory concurrently, but at
least one of those threads is *writing* to it.

Because it can be so difficult to understand the dynamic behaviour of a
parallel system, understanding which code is subject to data races is
difficult. There are some cases where it's straight-forward to wrap code in a
lock and prevent a data race, but this code can be difficult to maintain, and
it's very easy for thread-safety issues to creep back in as code changes over
time.

**Testing concurrent code is hard.** Many concurrency issues don't show up
during development. They can be hard to diagnose, and even harder to debug.
Xcode and LLVM include some really powerful tools to help diagnose and debug
concurrency issues, like the [Thread Sanitizer][].  But even with these tools,
concurrency issues can be difficult to understand or consistently reproduce,
because they are influenced by the dynamic and emergent behaviour of the whole
operating system, not just your app.

[Thread Sanitizer]: https://developer.apple.com/documentation/code_diagnostics/thread_sanitizer

## Is there a simpler way to architect applications for concurrency?

Given the vagaries of concurrent programming, how should we approach solving
our problem of waiting for multiple requests to complete?

One way, possibly the simplest, would be to avoid parallelism altogether, and
chain our network requests one after the other:

```swift
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()
```

I've intentionally left a lot of details out in order to keep this example
short, but real code would potentially need to deal with error handling,
cancellation and more. And what about performing the requests in parallel?
Serialising these two unrelated requests is potentially wasteful. For example,
if our server supports HTTP/2, the networking subsystem could have multiplexed
these requests over the same connection, resulting in a big win for performance
and battery life.

## Everything I thought I knew about `URLSession` was wrong

(OK… maybe just one thing!)

In the above example I avoided parallelism because I was concerned about data
races and thread-safety. Let's say I restructured the requests to run in
parallel: they could no longer be nested, meaning they would need to write to
shared memory in order for the results to be collected and processed together.
Then, what if both requests completed at the same time? They'd both write to
that shared memory, and we'd have a hard to reproduce and hard to debug data
race on our hands.

One way to solve this problem would be to use a lock: this would ensure that
only a single thread could ever access the shared memory at a time. Locks have
a fairly simple interface — acquire the lock, perform your code, then release
it — but there are quite a few pitfalls to avoid in order to ensure you're
using them correctly.

But it turns out there's a simpler way! Here's an
[excerpt from the `URLSession` documentation][urlsession-docs]
(emphasis added):

> ```swift
> 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-docs]: https://developer.apple.com/documentation/foundation/urlsession/1411597-init

This means that for any unique instance of `URLSession`, you can trust that
**completion handlers will never execute in parallel,** as long as you haven't
passed in a custom, concurrent `OperationQueue`. Importantly, this also
includes the commonly used `URLSession.shared`!

## Extending `URLSession` to perform requests in parallel

With our newfound understanding of `URLSession`'s threading model, let's sketch
out how we might extend it to easily support running two requests in parallel.
(The [source code for this section is available on GitHub][example-source],
ready for you to drop into a Playground.)

[example-source]: https://gist.github.com/sharplet/37210c02aa9e525b55f823bb67712725

```swift
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
}
```

First we introduce a simple `URLResult` enumeration to model the different
results we can get in a `URLSessionDataTask` callback.  This will greatly
simplify our higher-level code that combines the results of multiple requests.
The implementation of `URLSession.get(_:completionHandler:)` is omitted for
brevity, but the method is a simple way to GET a URL, automatically `resume()`
the data task, and wrap things up in a `URLResult`.

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

This new API takes two URLs, calls its completion handler with two results, and
returns two data tasks. Let's start building it:

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

Given that it's still possible to configure `URLSession` with a concurrent
`OperationQueue`, we first guard against that possibility.

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

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

Next, we declare a variable to gather the results of our two requests. We then
define a local function, `continuation()`, whose job it is to check if both
requests are complete, and if so call the completion handler.

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

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

    return (left, right)
  }
```

Finally, we kick off both requests, and then return their associated data tasks
to the caller. Each request's completion handler is really simple: stash the
result of the request, and call `continuation()`. The continuation will run
exactly twice:

1. When the first request completes, `continuation()` will find that one of the
   results is `nil`, and will not call the completion handler.

1. When the second request completes, it will run again, find that both results
   are populated, and call `completionHandler` with the unwrapped results.

That's it! Now we can request double the zen:

```swift
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.
}
```

## A parallel paradox

I find it elegantly unintuitive that one of the most effective ways to solve
parallel programming problems is to actually use *less* parallelism. Computers
are really good at running code sequentially! If we can split up a task into
multiple smaller tasks that can be run concurrently, but serially, it's often
possible to improve the understandability and maintainability of our code.
