Actor Inferencing?

I was wondering how SwiftUI inferences that a view should be @MainActor (my question isn't about SwiftUI specifically, but about the concurrency system). For example, consider the following view:

struct Counter: View {
    @Binding var value: Int
    
    var body: some View {
        Button("\(value)") {
            increment()
        }
    }
    
    func increment() {
        Task { value += 1 }
    }
}

The body of View is marked as @MainActor, yet View itself isn't. The Task inside increment doesn't inherit the @MainActor context from its lexical scope and actually triggers a warning about not being on the main thread when changing value. Inlining increment solves this.

Aside: When I enable the concurrency warnings (-Xfrontend -warn-concurrency -Xfrontend -enable-actor-data-race-checks) the increment function shows a warning about Counter not being Sendable. That makes sense, but I guess a Binding would never be Sendable?

Where it gets very interesting is when you change from a @Binding to @StateObject or @ObservedObject. All of a sudden, it appears like the Counter is now implicitly marked as @MainActor. Only when I'm on the main actor, I can initialize the counter without await (or call any of its methods). Notably, this also means that increment is now always executed on the main actor and updating state is safe.

Why is Counter marked as @MainActor when it contains an @ObservedObject or @StateObject property? I've looked at the involved protocols and types (View, @StateObject, etc.) but can't figure it out. Is this compiler magic? I found this thread but that doesn't explain why this happens.

Aside: I think it could be useful to have better ways to figure out on which execution context asynchronous code will run. For example, in my first example, inlining the increment method changes. As it's a purely lexical thing, I guess it could/should be something that you can see in the editor.

3 Likes

I don't have an answer but...

My guess is that those types have @MainActor somewhere we can't see. Xcode interfaces don't show the entire truth. Like when you look at Task.init there is no way to know why it inherits the actor and your own closures don't! You need to look at the stdlib source code for that. So maybe the same is happening here. Maybe is not even annotated at the Swift level but somehow on the internals of SwiftUI.

Hopefully somebody can bring more light into this!

This recent pitch helps in some scenarios ([Pitch] Clarify the execution of non-actor-isolated `async` functions) but not in this one. But codifying more rules around this is good fur understanding. Worth looking into it ;)

I wish Xcode showed more things inline. Android Studio does this for variables and is very handy, specially since what we call "context" is actually an object in Kotlin, a coroutine scope, which lets the IDE show it like any other variable overlayed in source. Very handy.

Thanks for the reply @Alejandro_Martinez. I looked at the .swiftinterface files. The wrappedValue and projectedValue properties of e.g. StateObject are marked as @MainActor. Yet the init isn't. But still, I don't understand how that would change the type of the surrounding view. I understand that Task inherits from its lexical context, but somehow I can't believe that the compiler has a special rule for SwiftUI views. Or does it?

(To look at the .swiftinterface file, go to /Applications/Xcode.app and do a find . -path "*/SwiftUI\.framework*swiftinterface")

1 Like

I found this explanation from a blog post. Sounds like it comes from an unofficial source. Good question BTW.

any struct or class using a property wrapper with @MainActor for its wrapped value will automatically be @MainActor. This is what makes @StateObject and @ObservedObject convey main-actor-ness on SwiftUI views that use them – if you use either of those two property wrappers in a SwiftUI view, the whole view becomes @MainActor too. At the time of writing Xcode’s generated interface for those two property wrappers don’t show that they are annotated as @MainActor, but I’ve been assured they definitely are – hopefully Xcode can make that work better in the future.

https://www.hackingwithswift.com/quick-start/concurrency/understanding-how-global-actor-inference-works

3 Likes

It seems to me that View protocol should be @MainActor and it would be less confusing

Thanks @technikyle. That helped, I now found it in the global actors proposal as well:

A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper: [...]

3 Likes

Oh that’s even better!

Do the task's context inheritance rules (link) exclusively refer to the lexical context? In other words, whenever that section mentions context, do they mean lexical context?

In his initial post, Chris mentioned that determining the execution context of async code is a purely lexical thing. Is this mentioned anywhere in the official documentation, an empirical observation, or derived from source code inspection?

My initial thought was that since the same task is used when calling another async function, the system was capable at runtime of inferring the current task so that newly created unstructured tasks could inherit some of its traits.

I don't have a source in the official documentation, but here's a confirmation from @Douglas_Gregor in the Structured Concurrency review thread:

Great. Thanks for digging that out!