Give your simulator superpowers

Give your Xcode
Simulator extra features

Deadlocks in Swift explained: detecting and solving

Deadlocks in Swift can make your app hang, causing a frustrating situation for both you and your users. Your app becomes unresponsive, and you can often only solve it by restarting the app. While features like actors reduce the number of deadlocks you’ll run into, there’s still a high chance of experiencing deadlocks.

Deadlocks can have several causes, and there are multiple techniques to detect them. During my 10+ years of experience, I found a way for myself to indicate deadlocks and narrow down the root cause quickly.

What is a deadlock?

A deadlock is a situation in which two or multiple threads wait for each other indefinitely. Locks are a common cause in Swift in which a serial queue sync operation triggers another sync operation on the same queue.

If you’re new to serial and concurrent queues, I encourage you to read Concurrent vs. Serial DispatchQueue: Concurrency in Swift explained.

Let’s use the above example to demonstrate a deadlock in code by creating an image fetcher:

final class ImageFetcher {

    /// Defaults to a serial queue.
    let lockQueue = DispatchQueue(label: "image-fetcher-lock-queue")

    private var imageCache: [URL: UIImage] = [:]

    func fetchImage(for url: URL, completion: @escaping (UIImage) -> Void) {
        lockQueue.async {
            let image = UIImage() // Imagine being a remotely fetched image.
            self.imageCache[url] = image

            DispatchQueue.main.sync {
                completion(image)
            }
        }
    }

    func hasCachedImage(for url: URL) -> Bool {
        lockQueue.sync {
            imageCache[url] != nil
        }
    }
}

The image fetcher allows us to fetch an image and store it in a local cache. For the sake of this example, I kept the code simple and didn’t handle error handling, reading from the cache, etc.

The fetch method dispatches on our serial queue to ensure we’re the only one writing or reading from the image cache dictionary. Once completed, we’re executing the completion callback from the main thread so that implementors can use the image inside UI elements.

The usage of these image fetcher methods determines whether a deadlock occurs or not. For example, you could start fetching an image and check for a cached image right after:

imageFetcher.fetchImage(for: imageURL, completion: { image in
    imageView.image = image
    self.hideLoadingIndicator()
})

if imageFetcher.hasCachedImage(for: imageURL) == false {
    showLoadingIndicator()
}

When there’s no cached image available, we’re showing a loading indicator as we’re expecting an image fetching request to start. While the code might look fine, it causes a deadlock to occur.

How can I detect a deadlock?

You’ll be able to indicate a deadlock as soon as your app becomes unresponsive. Running the above code will stop your app from updating UI since the image fetcher blocks the main thread. To confirm we’re dealing with an endlessly locking situation, you can pause program execution in Xcode:

Pause program execution to explore running threads for deadlocks.
Pause program execution to explore running threads for deadlocks.

Once paused, you’ll be able to explore current running threads inside the debug navigator of Xcode. You’ll likely see multiple running threads, including the main thread and our own labeled “image-fetcher-lock-queue” serial queue. To find out whether we’re causing a deadlock, we’ll have to explore methods inside these threads. In this case, the fetch image method blocks the main thread:

A deadlock is likely blocking the main thread.
A deadlock is likely blocking the main thread.

While we can also see that the cached image existence check method blocks the main thread (Thread 1):

The main thread is hanging due to a deadlock.
The main thread is hanging due to a deadlock.

We’ve been using the debug navigator to explore currently running threads after pausing program execution. If unsure whether the state is still changing, you could rerun the program and pause it after a few seconds. You’ll notice a deadlock if the state of the threads doesn’t change.

Solving hangs in Xcode

Now that we’ve indicated a deadlock, it’s time to find a solution to our problem. Before doing so, it’s essential to understand our situation exactly. The following happened:

  • The image fetcher dispatches on its lock queue and starts fetching the image
  • On the caller side, code continues to run on the main thread
  • Image fetching completes and requests to execute the completion callback synchronously on the main thread
  • The main thread is still blocked since it’s waiting for the image fetcher to return a cached status for the image URL

In other words, the caller side is waiting for the image fetcher while the image fetcher is waiting for the caller side: a deadlock.

You can solve a deadlock by breaking synchronization in one of the places. In this example, it makes perfect sense to asynchronously dispatch back to the main thread after image fetching succeeds:

func fetchImage(for url: URL, completion: @escaping (UIImage) -> Void) {
    lockQueue.async {
        let image = UIImage() // Imagine being a remotely fetched image.
        self.imageCache[url] = image

        /// We're now dispatching using async.
        DispatchQueue.main.async {
            completion(image)
        }
    }
}

The best way to solve this would be to use async / await in combination with actors. However, that requires rewriting your code in multiple places, which is not always realistic.

Thread explosion as a result of a deadlock

Related to deadlocks are thread explosions. GCD will create another thread to execute work on whenever a thread is blocked. When you’re code results in deadlocks, a new thread will likely be spawned by GCD multiple times.

There’s a limit of 64 available threads which means that your app will potentially hang once there are no threads left. These situations are also often called deadlocks, while they should be seen as thread locks. However, a deadlock is often connected and a root cause of the problem.

Conclusion

Deadlocks can lead to annoying situations for both you and your users. Multiple synchronizations calls on different queues can lead to a deadlock at which multiple threads are waiting for each other. By exploring running threads, you’ll be able to indicate the cause and solve deadlocks accordingly. Thread explosion can result from deadlocks, causing your app to hang.

If you like to improve your Swift knowledge, check out the Swift category page. Feel free to contact me or tweet me on Twitter if you have any additional tips or feedback.

Thanks!