Deadlock Issue When Accessing Thread-Safe Objects within Task+withTaskGroup

Hi, we have encountered an almost 100% reproducible deadlock issue on an iPhone 7 running iOS 15.7.x.

We have an object with a field that is thread-safe, where we use queue.sync {} for reading and queue.async(flags: .barrier) for writing. The queue is a concurrent queue.

When using it externally wrapped in Task(or Task.detached)+withTaskGroup, we experience a deadlock on iPhone 7 (iOS 15.7.x). The code inside queue.async is never executed, and Task.Detached never completes its task.

However, we do not encounter this issue on other devices or simulators.

We are unsure if this issue is related to iOS or Xcode. The code is as follows:

import UIKit

class ViewController: UIViewController {

    override func viewDidLoad() {
        super.viewDidLoad()
        // Do any additional setup after loading the view.
        
        Task.detached {
            let manager = Manager()

            await withTaskGroup(of: Void.self, returning: Void.self) { taskGroup in
                taskGroup.addTask {
                    manager.get()
                }
                taskGroup.addTask {
                    manager.get()
                }
                taskGroup.addTask {
                    manager.get()
                }
            }
            print("OK") // On iPhone 7 + iOS 15.x.x, this won't print "OK"
        }
    }
}

private let managerQueue = DispatchQueue(label: "ManagerQueue", qos: .default, attributes: .concurrent)

class Manager {
    
    private(set) var int: Int = 0
    
    func get() -> Int {
        
        let value = managerQueue.sync {
            return int
        }
        set()
        
        return value
    }
    
    func set() {
        print("Enter Set")
        managerQueue.async(flags: .barrier) {
            self.int += 1
            print("Done Set") // On iPhone 7 + iOS 15.x.x, this won't be executed
        }
    }
}

Please note that this issue occurs consistently on iPhone 7 with iOS 15.7.x but not on other devices or simulators. We suspect that it may be related to either iOS or Xcode.

1 Like

I think the main thing here is that the interaction between GCD/Swift Concurrency is flawed. They're unsafe to use together like this. The likely reason it works on later devices is because of the higher core count, which masks (rather than resolves) the underlying issue.

You'd be better off achieving mutual exclusion within your Manager class by either a) making it an Actor, or b) using a lock to protect your int var.

Check out Swift concurrency: Behind the scenes, especially from around 25 mins in.

(Also, your dispatch queue was set to 'concurrent' when it likely should have been a serial queue anyway, but I would still recommend taking the Lock/Actor approach.)

3 Likes

For what it's worth, even in pure-GCD code this is almost always an antipattern. It breaks priority inversion avoidance, asyncing the setter usually has more overhead than the field change (the threshold where it begins to be worth it is around 1ms of work), and it degrades to non-concurrent readers under surprisingly low %s of writers. There are unusual cases where it could be worth it, but in every single case I've personally observed it was a well-meaning mistake.

6 Likes

I'd do one of these instead (in non particular order):

  1. async getters and setters taking a completion closure for getters (and optionally taking a completion closure for setters). The queue must be serial!
  2. NSLock protected variables. Be careful to do absolute minimum under lock.
  3. actors

I didn't measure it recently, would assume that 2 is the fastest, 1 & 3 would be on par.

1 Like

This is a fine choice for projects that need to deploy to older OSs, but it's worth noting that for iOS16+ (and matching macOS, etc…) OSAllocatedUnfairLock is a slightly lower overhead choice.

3 Likes