Crashes because main actor isolated closures are called on a background thread with `DispatchGroup.notify`, but no compiler warnings

Hello! We are in the progress of migrating a large Swift 5.10 legacy code base over to use Swift 6.0 with Strict Concurrency checking.

We have already stumbled across a few weird edge cases where the "guaranteed" @MainActor isolation is violated (such as with @objc #selector methods used with NotificationCenter).

However, we recently found a new scenario where our app crashes accessing main actor isolated state on a background thread, and it was surprising that the compiler couldn't warn us.

Minimal reproducible example:

class ViewController: UIViewController {
    var isolatedStateString = "Some main actor isolated state"

    override func viewDidLoad() {
        exampleMethod()
    }

    /// Note: A `@MainActor` isolated method in a `@MainActor` isolated class.
    func exampleMethod() {
        testAsyncMethod() { [weak self] in
            // !!! Crash !!!
            MainActor.assertIsolated()

            // This callback inherits @MainActor from the class definition, but it is called on a background thread.
            // It is an error to mutate main actor isolated state off the main thread...
            self?.isolatedStateString = "Let me mutate my isolated state"
        }
    }

    func testAsyncMethod(completionHandler: (@escaping () -> Void)) {
        let group = DispatchGroup()
        let queue = DispatchQueue.global()

        // The compiler is totally fine with calling this on a background thread.
        group.notify(queue: queue) {
            completionHandler()
        }

        // The below code at least gives us a compiler warning to add `@Sendable` to our closure argument, which is helpful.
//        DispatchQueue.global().async {
//            completionHandler()
//        }
    }
}

The problem:

In the above code, the completionHandler implementation inherits main actor isolation from the UIViewController class.

However, when we call exampleMethod(), we crash because the completionHandler is called on a background thread via the DispatchGroup.notify(queue:).

If were to instead use DispatchQueue.global().async (snippet at the bottom of the sample), the compiler helpfully warns us that completionHandler must be Sendable.

Unfortunately, DispatchGroup's notify gives us no such compiler warnings. Thus, we crash at runtime.

So my questions are:

  1. Why can't the compiler warn us about a potential problem with DispatchGroup().notify(queue:) like it can with DispatchQueue.global().async?
  2. How can we address this problem in a holistic way in our app, as it's a very simple mistake to make (with very bad consequences) while we migrate off GCD?

I'm sure the broader answer here is "don't mix GCD and Concurrency", but unfortunately that's a little unavoidable as we migrate our large legacy code base! 🙂

Answered by DTS Engineer in 862966022

The behaviour you’re describing doesn’t surprise me for Swift 6.0. It’s very common to find legacy completion handler-based APIs that call the completion handler on a secondary thread but don’t annotate that completion handler as sendable. We’ve taking steps to improve that in Swift 6.2, as explained in SE-0463 Import Objective-C completion handler parameters as @Sendable.

There’s a couple of wrinkles here. First, you’re not actually benefiting from this because Dispatch isn’t directly imported from C, but rather has a Swift friendly wrapper. Except that it’s not really that friendly, because it doesn’t mark the execute parameter as being sendable )-:

Second, lots of these problems go away when you set Default Actor Isolation to MainActor. In that case Swift will just bounce to the main actor from the callback automatically.

So, the best option kinda depends on where you’re writing code like this. If it’s an app, like the view controller demo you posted, then I think moving to Swift 6.2 and enabling default main actor isolation is the right choice. If it’s in code that’s not obviously tied to the UI, or some other main-actor-only framework, then you


Oh, one last thing:

Why can't the compiler warn us … like it can with DispatchQueue.global().async?

That’s because the execute parameter of the async(group:qos:flags:execute:) method is marked as @Sendable. That doesn’t show up in the docs, but if you command click on it you’ll see it declared like so:

@preconcurrency public func async(… execute work: @escaping @Sendable @convention(block) () -> Void)

I think it’d be reasonable to file a bug requesting that notify(qos:flags:queue:execute:) get the same treatment. If you do, please post your bug number, just for the record.

Oh, and note that there’s special case code related to Dispatch queues within the Swift compiler itself O-: Now, where did I put that info… oh, right, here it is:

https://oleb.net/2024/dispatchqueue-mainactor/

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

The behaviour you’re describing doesn’t surprise me for Swift 6.0. It’s very common to find legacy completion handler-based APIs that call the completion handler on a secondary thread but don’t annotate that completion handler as sendable. We’ve taking steps to improve that in Swift 6.2, as explained in SE-0463 Import Objective-C completion handler parameters as @Sendable.

There’s a couple of wrinkles here. First, you’re not actually benefiting from this because Dispatch isn’t directly imported from C, but rather has a Swift friendly wrapper. Except that it’s not really that friendly, because it doesn’t mark the execute parameter as being sendable )-:

Second, lots of these problems go away when you set Default Actor Isolation to MainActor. In that case Swift will just bounce to the main actor from the callback automatically.

So, the best option kinda depends on where you’re writing code like this. If it’s an app, like the view controller demo you posted, then I think moving to Swift 6.2 and enabling default main actor isolation is the right choice. If it’s in code that’s not obviously tied to the UI, or some other main-actor-only framework, then you


Oh, one last thing:

Why can't the compiler warn us … like it can with DispatchQueue.global().async?

That’s because the execute parameter of the async(group:qos:flags:execute:) method is marked as @Sendable. That doesn’t show up in the docs, but if you command click on it you’ll see it declared like so:

@preconcurrency public func async(… execute work: @escaping @Sendable @convention(block) () -> Void)

I think it’d be reasonable to file a bug requesting that notify(qos:flags:queue:execute:) get the same treatment. If you do, please post your bug number, just for the record.

Oh, and note that there’s special case code related to Dispatch queues within the Swift compiler itself O-: Now, where did I put that info… oh, right, here it is:

https://oleb.net/2024/dispatchqueue-mainactor/

Share and Enjoy

Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"

Crashes because main actor isolated closures are called on a background thread with `DispatchGroup.notify`, but no compiler warnings
 
 
Q