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 useDispatchGroup.wait()
orDispatchGroup.notify()
to wait for them all to complete;Networking tasks can be encapsulated in custom
Operation
subclasses, executed using anOperationQueue
, 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.
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:
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
(emphasis added):
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.
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,
ready for you to drop into a Playground.)
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
.
@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:
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.
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.
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:
When the first request completes,
continuation()
will find that one of the results isnil
, and will not call the completion handler.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:
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.