SE-0316 (second review): Global Actors

Several thoughts after reading the proposal:

  • We may need a formal definition of the nonisolated modifier. Previously this modifier was only used to annotate properties and methods of an actor type, but since we use this modifier in global actor isolated types, I think we should define its semantics and usage in a formal way.

  • From the semantics perspective, the global actor attribute is used to annotate a function/property/type so that it's isolated to that global actor. For more generality (at least from my view), have the authors consider using @isolated(SomeGlobalActor) instead of @SomeGlobalActor?
    With this approach we can gain several advantages:

    1. @isolated(SomeGlobalActor) express the isolation-to-global-actor idea more clearly (although with several more characters).

    2. When we use words like isolated and nonisolated consistently and frequently, it would be easier to educate and learn from the isolation model in the whole community.

    3. We can express generics over global actors more elegantly. In the current proposal we have this:

      @T
      class X<T: GlobalActor> {
        func f() { ... } // constrained to the global actor T
      }
      

      The biggest issue I see here is that from the first line of code, we can't decide whether T is an attribute name or generics parameter. Its ambiguity can only be resolved when we read the code after that, which means the generics parameter definition goes after its first usage. If we choose the @isolated(SomeGlobalActor) approach, we can write like this:

      @isolated<T: GlobalActor>(T)
      class X<T> {
        func f() { ... } // constrained to the global actor T
      }
      

      IMO this reads much smoother and aligns with style of generics property wrappers.

  • The author should explain in more detail the related behaviors about the implicit conversion from global-actor-qualified functions to async functions. It says

    the async function will first "hop" to the global actor before executing its body

    So from this sentence, I guess that the compiler implicitly creates a new async function that wraps the original one. But what if we call the converted function on the same global actor? For instance, see the code below:

    @MainActor
    func foo(_ closure: () async -> Void) async {
      await closure()
    }
    
    let callback: @MainActor () -> Void = ...
    let callbackAsynchly: () async -> Void = callback
    foo(callbackAsynchly)
    

    Will there be additional hops when we execute closure inside foo? Will this behavior decided by the corresponding global actor type? I hope these questions could be answered.

2 Likes

I really like the addition of the GlobalActor protocol and appreciate the ability to isolate data to GlobalActor-constrained generic parameters. It's definitely a +1 from me!


Also, I wonder what the compiler uses to differentiate global actors. I imagine it’s type identity, which would mean that super- and sub-classes are treated as different global actors. Is this correct?

A witness that is not inside an actor type infers actor isolation from a protocol requirement that is satisfies, so long as the protocol conformance is stated within the same type definition or extension as the witness:

Are default implementations considered witnesses? If not, it seems reasonable that they — like witnesses — inherit the corresponding requirement’s isolation.

The accessors of a variable or subscript declared with a global actor attribute become isolated to the given global actor. (This includes observing accessors on a stored variable.)

Could this rule be relaxed in the future, allowing multiple accessors to be isolated to different global actors? This would be useful for UI frameworks that both get and set state through a single property declaration, and that require state to remain constant during updates. For example, SwiftUI’s @State could declare wrappedValue as:

var wrappedValue: Value {
    @MainActor get
    @PostUpdateActor get
    @PostUpdateActor set
}

Then, event handlers, such as onTapGesture, would provide an @PostUpdateActor-isolated closure:

func onTapGesture(
    count: Int = 1,
    perform action: @PostUpdateActor @escaping () -> Void
) -> some View

Nits:

  1. In my opinion, callbackAsyncly and callbackAsynchronously read better than callbackAsynchly.
  2. The AppKit sample code uses UIViewController instead of NSViewController.

Being able to separate the global actor type from the actor implementation allows for multiple global actor instances to share the same implementation, which could be useful for common global actor patterns such as a set of global variables guarded by a mutex.

I don't understand the writing in the proposal, this is probably just a bug in the writing: Emphasis mine:

" A global actor is a type that has the @globalActor attribute and contains a static property named shared that provides a shared instance of an actor."
...

@globalActor public struct SomeGlobalActor { 
  public static let shared = SomeGlobalActor() 
}

The global actor type need not itself be an actor type; it is essentially just a marker type that provides access to the actual shared actor instance via shared . The shared instance is a globally-unique actor instance.

However, "shared" has struct type in the example, not actor type.

-Chris

5 Likes

I have some question about Global actor-constrained generic parameters section. What are the benefits of writing a type that has a global-actor-constrained generic parameter? If the type's instances are allowed to run on different global actors, why not change this type into an actor type? Swift concurrency runtime could do more optimization I suppose.

From the proposal..

The global actor type need not itself be an actor type;
it is essentially just a marker type that provides access to the actual shared actor instance via shared. 

Whether class based globalActor support inheritance?

@globalActor
class Base{
    static let shared = Base()
    // shared mutable states...
}
//OR
@globalActor
class Base{
    actor SomeActor {}
    static let shared = SomeActor()
    // shared mutable states...
}
class Sub:Base{...}//propagate @Base globalActor attritube

We have class and actor as separate ref type, but how about @globalActor class ...? Is it a class or (global)actor, or a class as @globalActor with class-inheritance capability?

In other words, @globalActor class is a special type in-between with class+actor duality?

  • class
  • @globalActor class
  • actor

but this should definitely work, right?
@GA
class Base{...}

class Sub:Base {...}

Both Sub and Base share the same @GA globalActor, and support actor-like inheritance without problem.

1 Like

I'm +1 on this proposal.

-Chris

I'll fix the proposal. It should look like this:

@globalActor
public struct SomeGlobalActor {
  public actor MyActor { }

  public static let shared = MyActor()
}

MyGlobalActor is providing the type identity for the global actor. There's an underlying actor instance that handles the coordination, which is accessed via shared.

Hmm, I wonder what this would look like. nonisolated disables the implicit propagation of actor isolation, whether from an actor type or a global actor.

This would be different from the other kinds of custom attributes (property wrappers, result builders), so I think we'd need to convince ourselves that something like @MainActor is insufficiently clear to both deviate from other custom attributes and make its use significantly longer.

To me it looks like it's parameterizing the attribute, not the whole declaration. Are the generic requirements of the @isolated required to exactly match those of T? If there is a where clause, where does it go?

This issue of referencing generic parameters before they are declared (but in the same declaration) isn't totally new. We do it with the underscored @_specialize attribute:

@_specialize(exported: true, where T==UInt)
func increment<T: BinaryInteger>(_ t: T) -> T {
    let incremented = t + 1
    return incremented
}

Let's say I had to put both a global actor and an @_specialize on a declaration. Would I have to declare T three times now, and all consistently? I don't think this syntax scales, and I don't think it's clearer.

Yes, it's effectively this:

let callbackAsynchly: (Int) async -> Void = {
  await callback() // `await` is required because callback is `@MainActor`
}

An actor "hop" is a no-op if you're already running on that actor.

Yes, that's correct. Global-actor-ness isn't inherited, either, so you could have a @globalActor superclass and then use one of its subclasses as an attribute.

Default implementations aren't really a construct in the language. They're declarations in a protocol that can be used to witness a requirement if there is no better option. So. they don't infer isolation.

Hmm. I don't think it's technically impossible to implement this, but it's a lot of extensions: we don't permit overloading of accessors today, nor do we permit overloading based on the global actor.

It's a bug in the example. I've fixed it in the proposal (see above).

@anandabits provided some rationale here. Global actors let you pull in declarations from all of your program to synchronize on a single actor, which you can't do with actor types.

Doug

2 Likes

The @_specialize syntax is part of implementation detail instead of public language feature, so it's less convincing as a precedent for the new syntax introduced in this proposal.

The rationale provided is "I have a library that encodes concurrency context in the type system and limits some functionality to objects (each managing a piece of state) that share the same context. ". I suggest we can provide more use case around the generic parameter of global actor types, so that we could discuss around these use case instead of pure functionality. If we concludes after discussion that we don't need this generics functionality, I think we can use this syntax:

@isolated(MyActor.shared_2)
class SomeClass { ... }

@isolated(MyActor.shared_2)
class AnotherClass { ... }

With this approach we no longer need the protocol definition for global actors (even the ad-hoc one in previous rounds), and have the flexibility to define multiple global actor instances in a single namespace. I also admit @MainActor is easier to use than @isolated(MainActor.shared), but we can make an exception for the main actor in this case.

You can have this flexibility with the current design:

enum MyNamespace {
    @globalActor enum GlobalActor1 { static let shared = SomeActor() }
    @globalActor enum GlobalActor2 { static let shared = SomeOtherActor() }
}

With the current design we need to add an additional layer (GlobalActor1 and GlobalActor2) around those underlying actor instances. Which seems some unnecessary boilerplate to me. With the version I suggested we could just write:

enum MyNamespace {
    static let shared_1 = SomeActor()
    static let shared_2 = SomeOtherActor()
}

I disagree that the proposed design is unnecessary boilerplate. It is an approach that is consistent with the rest of Swift whereas your suggestion looks like ad-hoc magic.

Further, global actor declarations will be extremely rare relative to global actor usage. Your approach increases boilerplate at the usage site which will result in significantly more total "boilerplate" than the proposed design.

Unrelated, I noticed that the text in the proposal just mentions a static property shared while the GlobalActor protocol goes further and specifies: "The value of this property must always evaluate to the same actor instance."

Normal Swift protocols cannot express a constant requirement, but given that this is a magic ad-hoc protocol in the compiler I think it would be a good idea to require shared to be a constant. Are there any valid use cases where it would not be @Douglas_Gregor?

I'm NOT saying the proposed design is unnecessary boilerplate. I'm just comparing the code snippet you provided with mine, and in THIS specific case I regard declaring an additional layer to enclose global actor instances seems unnecessary boilerplate (at least it contains more code). I'd be happy if we could solve this redundancy if possible.

And by saying "ad-hoc magic" can you be more specific on this?

I agree that with my version there is more text in the use site. It's somewhat longer function names versus deeper nested namespaces. I hope we could come up with a solution with moth succinct attribute strings and shallow declarations.

Just to point out that this is the review thread of a Swift proposal. If you assert that "It is an approach that is consistent with the rest of Swift" while saying "your suggestion looks like ad-hoc magic", what is the point of the review and discussion here? I raise concerns and give some suggestions in this thread, and those suggestions don't need to be the polished alternatives I think. We could discuss pros and cons of each approach and make the language better through this process.

2 Likes

That's what I am attempting to do. To elaborate on the previous points, Swift already uses @propertyWrapper and @resultBuilder for ad-hoc protocols that create user-defined attributes. As far as I know, there is no precedent for an attribute argument referencing an arbitrary declaration that takes a very specific shape (such as identifying a global actor).

Aside from breaking the proposal's consistency with property wrappers and result builders, this means we need to establish a notion of identity based on expressions like MyActor.shared_2. If I were to write

@isolated(MyModule.MyActor.shared_2)
class AThirdClass { ... }

would that be the same "global actor" as SomeClass and AnotherClass? Keep in mind that we also have to do this evaluation at runtime, e.g., for dynamic casting:

typealias Fn = @isolated(MyModule.MyActor.shared_2) (Int) -> Int
if let fn = anyValue as? Fn { ... }

so we need to encode the entire expression in runtime metadata.

The great thing about using types for identity is that their notion of identity is well-defined and well-understood throughout the entire compiler and runtime. Your approaches has to create a new notion of identity.

I don't think this is a good idea. It's reasonable for someone to want to create a global actor that uses a computed property (that produces a consistent value) in lieu of a let.

Doug

3 Likes

It seems pretty unlikely to me that there would be a computed property that does something more than a constant initialization could do while still consistently returning the same instance. On the other hand, I can easily imagine people violating the global actor contract unintentionally.

It seems pretty unlike to me that anyone will use anything other than static let here in the first place, so when we're talking about adding a special ad-hoc type-checking rule for a rare case that might be useful. Heck, you need it today if you want a generic global actor, because static variables aren't supported in generic types:

protocol DefaultInitializable {
  init()
}

@globalActor
struct GA<ActorType: Actor & DefaultInitializable> {
  static let shared = ActorType() // error: static stored properties not supported in generic types
}

Doug

Thanks everybody for your help in the review. The core team has decided to accept this proposal with modifications.

2 Likes

I'm late to the review, seeing as it's already been accepted (congratulations!). I wonder if @Douglas_Gregor or another author could give a clarification that seems unanswered in the proposal text--

The proposal gives the following example:

What happens if I define two global actor-annotated protocols, P and Q (distinct global actors), and then write struct X: P, Q? Which is the implicit global actor then, and is there a warning?

In the same vein, but perhaps more likely to occur in real-world code, the proposal states:

What if I define a struct with two members, one annotated with @UIUpdating and another annotated with a custom property wrapper called @BackgroundActorUpdating with a different global actor-qualified wrapped value? What is the inferred global actor for my struct, and is there a warning?

Terms of Service

Privacy Policy

Cookie Policy