Is MainActor.assumeIsolated truly necessary in deinit for a @MainActor annotated class?

Hi all,

We are adopting Swift 6 concurrency mode in our project, and I wanted to get a better understanding of how we can better handle deinit for @MainActor annotated classes.

According to answers here: Deinit and MainActor as well as discussion here: swift-evolution/proposals/0327-actor-initializers.md at main · swiftlang/swift-evolution · GitHub deinit cannot be isolated to an actor because the last reference to the actor could go out of scope on an arbitrary thread.

This of course makes sense if deinit is running code that requires an escaped self or if it is attempting to do things on a different actor, but if the scope of the deinitializing class is only ever running code isolated to itself, on the MainActor, why is this a problem, and what is the solution?

For example in iOS development, it is common to do some cleanup related to Notification observers in deinit on a ViewController. UIViewController is @MainActor, and the app itself will be instantiated on the MainActor, so the deinitialization should never go out of scope of the main thread.

Should we just put MainActor.assumeIsolated in all of those areas, or are there cases where we could expect deinit to be run from an arbitrary thread? What is the best practice here?

Thanks!

1 Like

@MainActor doesn’t force all references to the object to be held by the main actor — it’s quite common for such objects to be e.g. captured in closures or held by notification sources.

3 Likes

That makes sense, but what is the correct approach to handle this safely without causing a potential queue assertion failure or escaping self via Task { @MainActor in } ? Conceptually it seems a bit unbalanced that we can make guarantees about initialization but not deinitialization.

1 Like

It's something we'd like to have a cleaner story for, for sure.

First, I'd like to strongly recommend against using assumeIsolated for this purpose. If that blows up in your face eventually, which it seems like it eventually would, you probably won't have any good options left. assumeIsolated is really for use cases where you do know statically that you're being called from that actor, but you just can't express it to the compiler for whatever reason.

The right way to avoid escaping self during deinit is just to pull the values you want to use into local variables and then capture those instead of trying to read them from self. That does require a lot of care; this is something that Swift should be diagnosing for you. That is probably your best option until we have some better language support in place.

Personally, I also try to generally discourage people from relying on doing cleanup in deinit instead of triggering it during some explicit teardown step. I know that's not a universal opinion, though.

13 Likes

Your feedback is very helpful, thank you!

I think for right now we'll try to move things out of deinit and into the latter portions of the view lifecycle where possible.

I would love to see language support for isolation of deinitialization, or even as you stated simply more diagnostics to make the picture clearer. For understandable reasons this is tricky, but I think it would go a long way.

I was able to fix the issue by using dispatch queue to make sure it's on the main thread and executed without delay

    deinit {
        func cleanup() {
            MainActor.assumeIsolated {
                // call to MainActor code
            }
        }
        if Thread.isMainThread {
            cleanup()
        } else {
            DispatchQueue.main.async {
                cleanup()
            }
        }
    }

Doing some kind of async cleanup is a good pattern, just don't forget that is absolutely critical you not extend the lifetime of self when doing it.

This problem is covered, a bit, in the migration guide as well:

https://www.swift.org/migration/documentation/swift-6-concurrency-migration-guide/commonproblems#Non-Isolated-Deinitialization

1 Like

async did extend the life but replacing it with sync fixed the issue but there is a concern that would lock somehow the main thread

This code does not work: if the thread is not main, you dispatch a work item to the main queue but return immediately. Once deinit returns, self (may) point to garbage memory and you will run into issues. Please edit your answer as to avoid anyone using your solution with DispatchQueue.main.async.

1 Like