Understanding how DispatchQueue.sync can cause deadlocks

Published by donnywals on

As a developer, you'll come across the term "deadlock" sooner or later. When you do, it's usually pretty clear that a deadlock is bad (the name alone implies this) and if you've experienced one in your code, you'll know that your application crashes with EXC_BAD_INSTRUCTION (code=EXC_I386_INVOP, subcode=0x0) when a deadlock occurs.

A few weeks ago, I wrote about dispatching code synchronously and asyncronously. Several people pointed out that that post does not mention deadlocks. Instead of making that post longer and more complicated, I decided to make this week's post all about deadlocks and understanding what they are.

If you're not familiar with DispatchQueue.sync and DispatchQueue.async I would recommend that you read the article I linked in the previous paragraph before continuing. It'll make following this week's article much easier.

What happens when a deadlock occurs?

In short, a deadlock can occur when the system is waiting for a resource to free up while it's logically impossible for that resource to become available. You can think of this resource as almost anything. It could be a database handle, a file on the filesystem, or even time to run code on the CPU.

So more broadly speaking you're code is waiting for something to happen before it runs, but that something will never happen.

In my previous article I explained dispatching code on a dispatch queue using a restaurant analogy. Let's continue this analogy to explain deadlocks more clearly.

Remember how I explained that if a waiter dispatches orders to the kitchen synchronously the waiter would have to wait and do nothing while the chef prepares a meal and hands it to the waiter? This means that any tasks that are delegated to the waiter must occur after the chef has prepped the meal.

If one of the restaurant's customers wants to order a drink the waiter will not take this order until the chef has prepared the meal that the waiter asked for.

I know, this restaurant sounds terrible.

Now imagine that our waiter dispatched an order for a soup synchronously but they forgot to note down what kind of soup the customer wanted. The note just says "1 soup". So the chef asks the waiter to go back to the customer and ask them for the type of soup they wanted. The chef dispatches this synchronously to the waiter. This means that the chef won't continue to work on any other meals until the waiter returns and tells the chef what soup should be served.

And the waiter replies: "Sure, I'll ask after you serve the soup".

The restaurant is now in a deadlock. No more meals will be prepared, no orders will be taken, no meals will be served.

Why? You ask.

The waiter dispatched an order to the chef synchronously. So the waiter will not do anything until the order is fulfilled.

At the same time, the chef needs information from the waiter to continue so the chef dispatches a question to the waiter synchronously.

And since the chef did not yet fulfill the order, the waiter can not go and ask the customer for the type of soup they wanted. Without the information, the chef cannot complete the soup.

Luckily, restaurants are asynchronous in how they are usually set up so a situation like this should never occur. However, if you're dispatching synchronously in your code regularly, odds are that your program might end up in a deadlock.

Understanding a deadlock in Swift

One way to deadlock a program in Swift is to dispatch synchronously to the current thread. For example:

let queue = DispatchQueue(label: "my-queue")
queue.sync {
  print("print this")

  queue.sync {
    print("deadlocked")
  }
}

Putting this code anywhere in your app will immediately result in a crash before the second print statement runs. The queue is running code synchronously. The second closure can't run until the first one completes. The first closure can't complete until the second closure is run since its dispatched synchronously.

Or as the official documentation for DispatchQueue.sync states:

Calling this function and targeting the current queue results in deadlock.

This is exactly what the code above does, and the situation is almost identical to the one described in the restaurant analogy earlier.

However, in the analogy, we were dealing with two "queues" (the waiter and the chef). Can we write code that models this? Of course we can!

let waiter = DispatchQueue(label: "waiter")
let chef = DispatchQueue(label: "chef")

// synchronously order the soup
waiter.sync {
  print("Waiter: hi chef, please make me 1 soup.")

  // synchronously prepare the soup
  chef.sync {
    print("Chef: sure thing! Please ask the customer what soup they wanted.")

    // synchronously ask for clarification
    waiter.sync {
      print("Waiter: Sure thing!")

      print("Waiter: Hello customer. What soup did you want again?")
    }
  }
}

The code here is somewhat more complicated than what you saw before but the effect is the same. The waiter will never ask for clarification because the chef's sync work never finishes. The waiter and chef queues are waiting for each other to finish which means that neither of them will finish.

In this case, both queues and all sync calls are close to each other so debugging isn't terrible. However, in practice, you'll find that it's much harder to unravel and untangle your code and figure out how a deadlock occurs. Especially if you use sync a lot (which you generally shouldn't due to its blocking nature).

Resolving the deadlock, in this case, is simple. The waiter could dispatch the order synchronously. Or the chef could prepare the meal asynchronously. Or the waiter could even ask for clarification asynchronously. Making any of the three steps async would fix this deadlock.

The real question is whether any of these three tasks should really be sync. In a real restaurant, all of these tasks would be dispatched async and the restaurant would never deadlock. It would also run much faster and smoother than a restaurant where most things are done synchronously.

Avoiding deadlocks by using sync responsibly

While making things async is an easy fix for virtually any dispatch queue related deadlock, it's not always desirable. One thing to look out for when you're dispatching sync is that you do this from a controlled location, and avoid running any work submitted by an external party. So for example, you wouldn't want to do the following:

func run(_ closure: @escaping () -> Void) {
  myQueue.sync {
    closure()
  }
}

You don't know what the code inside the closure does so it might very well contain another call to run which would mean that your run function is now causing a hard to track down deadlock.

A better example of using sync is the date formatter cache I showed in the previous article about dispatch queues:

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.dateFormat = format
      self.formatters[format] = formatter

      return formatter
    }
  }
}

This formatter object can be used to request (and cache) data formatters in a thread-safe manner. By dispatching access to the formatters dictionary synchronously we can make sure that the formatters dictionary accessed and updated atomically. This means that modifying or accessing the formatters dictionary is isolated from other operations that might also require access to formatters.

Since the queue that the work is dispatched onto is private it's not possible for other actors to dispatch synchronously to this queue. We also know that there is no recursive access possible withing the formatter(using:) function so sync is used appropriately here.

In Summary

In this week's post, you learned what a deadlock is by building upon the restaurant analogy I used earlier to explain dispatching code synchronously or asynchronously. You saw how dispatching synchronously leads to deadlocks when tasks start waiting for each other.

You also learned how you can easily resolve deadlocks (once you've tracked them down) by not dispatching code synchronously. In general you should be very cautious when using sync since it's easy to accidentally cause a deadlock with it. However, sometimes you might need atomic access for a dictionary, array or another resource. In these cases sync can be a useful tool to make operations atomic.

If you have any questions about this post, or if you have feedback for me please reach out on Twitter.


Practical Combine

Learn everything you need to know about Combine and how you can use it in your projects with my new book Practical Combine. You'll get thirteen chapters, a Playground and a handful of sample projects to help you get up and running with Combine as soon as possible.

The book is available as a digital download for just $24.99!

Get Practical Combine

Receive weekly updates about my posts

Categories: Swift