SE-0316 (second review): Global Actors

Hi everyone. The second review of SE-0316: Global Actors begins now and runs through June 28th, 2021. The previous review ended on June 7th. Relative to the previous review, the following changes have been made:

  • Added the GlobalActor protocol, to which all global actors implictly conform.
  • Remove the requirement that all global and static variables be annotated with a global actor.
  • Added a grammar for closure attributes.
  • Clarified the interaction between the main actor and the main thread. Make the main actor a little less "special" in the initial presentation.

The core team would like to request feedback on the inference rules for global actors during this review. These did not get much attention in the last review cycle, and with vendors now shipping the proposed feature in beta toolchains, we would like to hear about issues that have arisen from real world experience.

This review is part of the large concurrency feature , which we are reviewing in several parts. While we've tried to make it independent of other concurrency proposals that have not yet been reviewed, it may have some dependencies that we've failed to eliminate. Please do your best to review it on its own merits, while still understanding its relationship to the larger feature. You may also want to raise interactions with previous already-accepted proposals – that might lead to opening up a separate thread of further discussion to keep the review focused.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. If you do email me directly, please put "SE-0316" somewhere in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/master/process.md

Thank you for participating in improving Swift!

Joe Groff
Review Manager

10 Likes

The definition of global actor somehow reverted back to the one in the first pitch:

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

I guess this result from the fact that we now introduce GlobalActor as formal protocol. But shared instance here is a struct instead of an actor.

And from the proposal we now allow associated type ActorType be different from the type conforming to the protocol. I still consider the case confusing when we use @MyGlobalActor to annotate a global variable while MyGlobalActor is just a factory type that provides a shared actor of different type.

2 Likes

Nested functions seem to be outside of a global actor if the outer function is part of a global actor. This is somewhat unhandy.

1 Like

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

6 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.