Confused about concurrency problem presented in wwdc24 video

In What’s new in Xcode 16 - WWDC24 - Videos - Apple Developer starting at 10:22, the guy talks about a imagined scenario involving an issue of performing I/O on the main thread. At the end of that section, he says that the task modifier is inheriting the mainactor context from the swiftui view, and somehow the robotAVVideoPlayer function is inheriting that (im guessing that its because that function is inside the same swiftui view struct so they both get the same context?), and to resolve the problem he described, he will make that function nonisolated.

The last part I definitely don't get: how does making the function nonisolated ensure that the AVPlayer is not called on main thread? Doesnt nonisolated just mean that anybody can call it from anywhere, i.e. it says nothing about the thread?

No, nonisolated just means that it's not isolated to any actor — which of course isn't any kind of explanation.

To understand what's going on, you need to consider an additional piece of information: the function that's being marked nonisolated is an async function.

A non-isolated async function runs on a special non-actor asynchronous context, and that is similar to the non-main context that concurrent tasks use more generally in Swift. In particular, that's going to involve a non-main thread.

So, it's the combination of nonisolated and async that "forces" execution off the main thread.

1 Like

Is there any documentation about this? What use case was there in mind when they designed this behaviour? It seems to me like it be easier to think about the code if non isolated async functions run on the same thread as if it were non isolated sync.

Full details here swift-evolution/proposals/0338-clarify-execution-non-actor-async.md at main · swiftlang/swift-evolution · GitHub

In short, async functions always define where they run (which is the opposite of how systems like libdispatch work). nonisolated doesn't opt out of that, it just defines it as "the place where I run is not on any actor". In my opinion this is much easier to reason about because you never have to think "some async functions care about where they're called from and others don't", it's just "async functions never care about their caller".

8 Likes

Which isn't true anymore because of isolated parameters with #isolation. I think this bugs everyone at first, and for good reasons. For example, non-sendable types are isolated to the domain where they are used but their async functions will hop to a different isolation. This is really strange behavior I think.

1 Like

Yeah, that does add some complexity unfortunately. I still think the rule of thumb is good to internalize, but thank you for noting the special case.

1 Like

Wouldn’t the compiler prevent this? I think the only allowed way for non-Sendable types with isolated async functions to be used is if they always are called from one isolation, and other uses compiler forbids.

The compiler will force developers to deal with warnings already which seems quite unfortunate at this point. The only thing that seems to make sense here is to use #isolation everywhere.

I don't understand what you mean. I was referring to this part

Either with isolated parameter or without it, you won't be able to call different functions from different isolations on non-Sendable type. Consider this class

class NonSendable {
    var i = 0

    func inc() {
        i += 1
    }

    func dec(isolation: isolated (any Actor)? = #isolation) async {
        await Task.yield()
        i -= 1
    }
}

You wouldn't be then able to use it anywhere but in one isolation

@MainActor
struct Runner {
    let ns = NonSendable() 

    func run() async {
        ns.inc()
        await ns.dec()  // ok, main isolation
    }
}

But we cannot alter it back and forth

extension Runner {
    actor Another {
    }
    
    func anotherRun() {
        let another = Another()
        ns.inc()
        await ns.dec(isolation: another)  // error: potential data race
    }
}

I don't think we should abuse #isolation: it is great for library code in the first place and for generalized code in projects, but not for everything. Non-Sendable types with async methods are useful now as is thanks to region-based isolation:

Task.detached {
    let ns = NonSendable()
    ns.inc()
    await ns.dec() // ok, `ns` in disconnected region
}

I'm sorry but your response seems very academic and contrived to be in line with how the compiler works right now. In real world projects, you will find a lot of non-sendable types that cannot benefit from region based isolation, and inheritance is what makes sense there (and this is also how a lot of developers think it works at first).

That’s exactly how compiler works right now. You can run examples and see.

I say that from a very practical experience, and as much as I would love to have dynamic isolation for non-Sendable types with a greater support, having isolated parameters all over the place are significantly complicate the code, and are more academic. Instead, isolating types to a global actor(s) is much more practical and lead to an easier code.