srk1nn.blog

about iOS development

Published on 17 Jan 2024

Deadlock in Singleton | Swift

Introduction

Projects that support lower iOS 13 or have a legacy codebase, probably use GCD or Operation. I was involved in fixing bugs related to concurrency (especially with GCD) and Singleton. So now, I want to share my experience.

Problem

To understand a problem, let's talk about serial queues and sync methods. I want to remember that deadlocks can happen, when a serial queue sync operation triggers another sync operation on the same queue.

In our project, we encountered this when using the Singleton pattern. We have an AppStateProvider, which can be used from any thread, but initialization must be on the main thread (applicationState requires this). This provider creates during Manager creation. The simplified version is listed below.

final class AppStateProvider {
    private(set) var state: UIApplication.State?

    init() {
        if Thread.isMainThread {
            state = UIApplication.shared.applicationState
        } else {
            DispatchQueue.main.sync {
                state = UIApplication.shared.applicationState
            }
        }
    }
}

final class Manager {

    static let shared: Manager = {
        let provider = AppStateProvider()
        return Manager(provider: provider)
    }()

    private let provider: AppStateProvider

    init(provider: AppStateProvider) {
        self.provider = provider
    }
    
    // other code
}

Remember that static let are guaranteed to be initialized only once, even when accessed by multiple threads simultaneously. So static let is tread-safe.

Everything looks good. But this code may cause a deadlock.

To understand this let's talk about how static let guarantees to be initialized once. Internally it uses dispatch_once. Who is not familiar with Objective-C, it acts like NSLock. It locks a thread until initialization finishes.

So putting it together, imagine the situation:

  • a background thread calls Manager.shared and starts the Singleton creation process
  • the main thread also calls Manager.shared and waits until a background thread finishes creation
  • a background thread calls AppStateProvider initializer. As a result, it calls DispatchQueue.main.sync, so it must wait until the main thread sets UIApplication.State to state property
  • The main thread waits for the static let initialization, the background thread waits for the main thread. Deadlock...

Final thoughts

General rule here, is to use async with serial queues. And design your API with that in mind.