Dispatching async or sync? The differences explained
Published on: August 31, 2020When writing iOS apps, we regularly run into code that is asynchronous. Sometimes you know you're writing something that will run asynchronously and other times you're passing a completion handler to code that may or may not run asynchronously on a different dispatch queue.
If you're familiar with using DispatchQueue.main, you have probably written code like this:
DispatchQueue.main.async {
// do something
}
And while writing this, you may have encountered a second method on DispatchQueue.main
called sync
.
In this week's post I will explain the difference between sync
and async
, and you will learn when you might want to use these different methods.
Note that this article assumes a little bit of knowledge about DispatchQueue
and asynchronous programming as I won't be covering the basics of these topics in this post.
Understanding DispatchQueue.async
Every DispatchQueue
instance has an async
method. Regardless of whether you're using DispatchQueue.main.async
, DispatchQueue.global().async
, or if you create a custom queue.
Every dispatch queue has an async
method that schedules a chunk of work that's in the closure it receives to be executed at a later time (asynchronously).
When you use a dispatch queue's async
method you ask it to perform the work in your closure, but you also tell it that you don't need that work to be performed right now, or rather, you don't want to wait for the work to be done.
Imagine working in a restaurant as a waiter. Your job is to accept orders from guests and relay them to the kitchen. Every time you take a guest's order, you walk to the kitchen and pass them a note with the dishes that they need to prepare and you move on to take the next order. Eventually the kitchen notifies you that they have completed an order and you can pick up the order and take it to the guest's table.
In this analogy, the waiter can be considered its own dispatch queue. You might consider it the main dispatch queue because if the waiter is blocked, no more orders are taken and the restaurant grinds to a halt (assuming that it's a small restaurant with only a single waiter). The kitchen can be thought of as a different dispatch queue that the waiter calls async
on with an order every time it asks for an order to be made.
As a waiter you dispatch the work and move on to do the next task. Because you dispatch to the kitchen asynchronously nobody is blocked and everybody can perform their jobs.
The above analogy explains dispatching asyncronously from one queue to another, but it doesn't explain dispatching asyncronously from within the same queue.
For example, there's nothing stopping you from calling DispatchQueue.main.async
when you're already on the main queue. So how does that work?
It's quite similar, really. When you dispatch asynchronously within the same queue, then the body of work that should be executed is executed right after whatever it is the queue is currently doing.
Coming back to the waiter analogy, if you walk past a table and tell them "I'll be right with you to take your order" and you're currently delivering drinks to another table, you would essentially be dispatching asyncronously to yourself. You've scheduled a body of work to be done but you also don't want to block what you're doing now.
To summarize, DispatchQueue.async
allows you to schedule work to be done using a closure without blocking anything that's ongoing. In most cases where you need to dispatch to a dispatch queue you'll want to use async
. However, there are most certainly cases where DispatchQueue.sync
makes sense as you'll see in the next section.
Understanding DispatchQueue.sync
Dispatching work synchronously can be a slippery slope. It can be quite tempting to simplify an asyncronous API and replace it with sync
. Especially if you're not too familiar with DispatchQueue.sync
, you might think that even though you're dispatching synchronously you're not blocking the queue you're on since the work runs on a different queue.
Unfortunately, this isn't entirely true. Let's go back to the restaurant from the previous section.
I explained how a waiter dispatches meal preparation to the kitchen asyncronously. This allows the waiter to continue taking orders and bringing meals to guests.
Now imagine that the waiter would ask the kitchen for an order and then stood there waiting. Doing nothing. The waiter isn't blocked by work they're doing themselves but instead, they're blocked because they have to wait for the chef to prepare the dish they asked for.
This is exactly what DispatchQueue.sync
does. When you dispatch a body of work using sync
, the current queue will wait for the body of work to complete until it can continue doing any work.
I wrote about DispatchQueue.sync
briefly in my post on an @Atomic property wrapper where I explained why that property wrapper doesn't quite work for types like Array
and Dictionary
.
By far the most common case where I've used DispatchQueue.sync
is for a purpose similar to the @Atomic
property wrapper, which is to ensure that certain properties or values can only be modified synchronously to avoid multithreading problems.
A very simple example would be this DateFormatterCache
object:
class DateFormatterCache {
private var formatters = [String: DateFormatter]()
private let queue = DispatchQueue(label: "DateFormatterCache: \(UUID().uuidString)")
func formatter(using format: String) -> DateFormatter {
return queue.sync { [unowned self] in
if let formatter = self.formatters[format] {
return formatter
}
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = format
self.formatters[format] = formatter
return formatter
}
}
}
Every time the formatter(using:)
function is called on an instance of DateFormatterCache
, that work is dispatched to a specific queue synchronously. Dispatch queue are serial by default which means that they perform every task that they're asked to perform one by one, in the same order that the tasks were scheduled in.
This means that we know for sure that there's only one task at a time that accesses the formatters
dictionary, reads from it, and caches a new formatter if needed.
If we would call formatter(using:)
from multiple threads at the same time we really need this synchronization behavior. If we wouldn't have that, multiple threads would read from the formatters
dictionary and write to it which means that we'd end up creating the same date formatter multiple times and we might even run into scenarios where formatters go missing from the cache entirely.
If you prefer to think of this in our restaurant analogy, think of it as all guests in a restaurant being able to write their order on a piece of paper. The waiter is only allowed to pass one piece of paper to the kitchen and that piece of paper must contain all orders. Every time a guest asks the waiter for the ordering paper the waiter will give a copy of its currently known list of orders to the guest.
So if two guests come in at the same time, they both get an empty piece of paper. And when they come back to return the paper to the waiter, the waiter can only keep the last one they receive. The next guests do the same and ultimately the waiter will have a very messy and incomplete order for the kitchen.
This is obviously not what you want so the waiter should process requests synchronously to make sure that every order is written down, and no orders go missing.
In Summary
In this week's post you learned about two very interesting DispatchQueue
methods: sync
and async
. You learned what each of these two methods do, and how they are used in practice.
Keep in mind that async
is often the method you want when dispatching work to a queue, and that sync
can be very important when you want to have atomic operations and ensure a level of thread safety for dictionaries, arrays, and more.
If you have questions about this post, or if you have feedback for me, I'd love to hear from you on Twitter.