[Pitch #2] Global actors

Hi all,

I've posted a new revision of the global actors proposal. It's been a while since the first pitch. The implementation and our understanding of the proposal model has come a ways since then, and we've (separately) settled the actors proposal. Changes from the first pitch include:

  • Clarify that the types of global-actor-qualified functions are global-actor-qualified.
  • State that global-actor-qualified types are Sendable
  • Expand on the implicit conversion rules for function types
  • Require global and static variables to be immutable & non-isolated or global-actor-qualified.
  • Describe the relationship between global actors and instance actors
  • Update inference rules for global actors
    Doug
13 Likes

A non-actor type that conforms to a main-actor-qualified protocol in its primary definition infers actor isolation from that protocol:

..but not via an extension.
Could you expand on the reason behind this? Apologies if I missed it.
I thought - style wise - conforming to a protocol was best done via an extension,. Also different rules for where something is defined, the struct/class definition vs the extension, tend to trip up beginners, Like with the dispatch rules wrt to func defined in the class definition or extension.

In the proposal we define a global actor like this:

@globalActor
public struct MainActor {
  public static let shared = /* unspecified actor type */
}

It make me confused that MainActor is a struct instead of actor type. Would it be nicer if the definition goes like this:

@globalActor
public actor MainActor {
  public static let shared: MainActor = /* private initialization expression */
  
  private init() { ... }
}

Is there any reason why we want to hide (or provide the ability to hide) the actual type behind global actors?

2 Likes

I didn't pay much attention to the first pitch so sorry if I'm repeating questions.

As Zhu_Shengqi points out, it feels a bit weird that we mark @globalActor a type that is not an actor, and the global actor is something that type returns.

The alternatives proposed talks about singleton. I'm fine with not adding that to the language and just using @globalActor, but why not attaching that attribute to the actor itself and just require it to return a shared instance itself?

To eliminate these data races, we can require that every global or static variable do one of the following:

is not clear to me if that's just a suggestion (as in, the proposal allows us to do this in the future and is very nice) or is the proposal adding these new rules to Swift?

Using global actors on a type
It is common for entire types (and even class hierarchies)

When seeing this in UIViewController I'm starting to see the appeal for Revisiting “nonisolated let” with more implementation, usage, and teaching experience ...

A minot nitpick on the text: In Using global actors on functions and data the code example is a UIViewController but the text mentions AppKit. Nothing to do with the topic at hand but may confuse some people.

Overall I'm very excited for this part of the actor system. It will make working with UI frameworks much safer and nicer! <3

Because an extension can be defined in a separate module from the original type. We should not change a type's behavior from outside (we should only extend its behavior).

2 Likes

This, and also: the global actor for a type is a fairly important part of its interface. It should be discoverable from the primary definition of the type.

Doug

1 Like

I made this change to emphasize that the global actor type and shared type need not be the same, but I think it causes more confusion than anything else. I'll revert it back.

Ah, it actually means to add these new rules to Swift. I'll clarify the wording to make that clear!

Ah, thanks.

Doug

2 Likes

Shouldn't this be optional? Developers might want or need to protect globals/statics without actors (e.g. with locks).

[quote="tclementdev, post:8, topic:48332, full:true"]

We probably need to provide some mechanism for opting out. In earlier versions of the actors proposal, we had the notion of nonisolated(unsafe) to treat stored property as not isolated to any actor (therefore, acceptable from anywhere) but without any checking. That's one approach.

Doug

1 Like

Two questions:

Q1:

@MainActor protocol P {
  func updateUI() { } // implicitly @MainActor
}

class D { }

extension D: P { // D is not implicitly @MainActor
  func updateUI() { } // okay, but not implicitly @MainActor
}

asyncDetached { // non MainActor context
	let concrete = D()
	let hiddenType: P = concrete

	concrete.updateUI()
	hiddenType.updateUI()
}

In this example, will concrete.updateUI() or hiddenType.updateUI() require async/await?
Will it be guaranteed that access through protocol will run the function on MainActor?


Q2:

@propertyWrapper
struct UIUpdating<Wrapped> {
  @MainActor var wrappedValue: Wrapped
}

@propertyWrapper
struct DifferentWrapper<Wrapped> {
  @DifferentActor var wrappedValue: Wrapped
}

struct CounterView {
  @UIUpdating var intValue: Int = 0
  @DifferentWrapper var differentValue: Int = 1
}

struct CounterView2 {
  @UIUpdating var intValue: Int = 0
  var height: CGFloat = 10
}

will CounterView cause compilation error?
in case of CounterView2, will read of height also require to be on MainActor?

1 Like

Late to the feedback on this, but I do have some concerns about global actor inference related to protocols--although it goes beyond @Terje's. In the pitched design, you have:

@MainActor protocol P {
  func updateUI() { } // implicitly @MainActor
}

class C: P { } // C is implicitly @MainActor

class D { }

extension D: P { // D is not implicitly @MainActor
  func updateUI() { } // okay, but not implicitly @MainActor
}

I do think that @Terje makes a good point about the inconsistency between C and D.

We used to require synthesized Equatable and Hashable conformance to be stated with the original type declaration also, but we've since relaxed that so that conformance in a same-file extension works just as well. I'd argue that it would be more consistent to follow that precedent, in line with Swift's prevailing direction that allows users to refactor conformances into their own extensions as many users prefer to do.

Even as the global actor is an important part of a type's interface, the importance is bounded by the fact that it's implicit. There's not really any signal to the reader of the code, without consulting a protocol declaration that's likely not in the same file, that a global actor is at play regardless of where the conformance itself is stated. (If we actually wanted to assert the importance of the global actor to the fullest, we wouldn't actually allow such implicit global actor inference.)


Besides that design point, though, I'm struggling to understand the decision to make the inference rule "propagates by default" rather than "propagates mandatorily." What does it mean that D conforms to P but doesn't have a global actor (or might optionally have a different global actor)?

Put another way, if a global actor is an important part of a type's interface, surely it is an equally important part of a protocol's interface. How can D be said to "conform to P" without mandating that D be isolated to @MainActor?

It would seem to me that the reasoning here would be most consistent rather if @MainActor protocol P propagates mandatorily to conforming types. That would also incidentally go some way to solving the problem above where a user could conform to P in an extension mistakenly believing that the type is now actor-isolated when silently it's not: at least they'd get a diagnostic about it.

10 Likes

Yes, I agree that the inconsistency is a concern. I realize as I'm reading this that the implementation has another inference rule that makes this make a bit more sense, which is that a witness infers a global actor context from the requirement is satisfies. So updateUI() would be @MainActor even though D isn't. Here's an extended and corrected example:

@MainActor protocol P {
  func updateUI() { } // implicitly @MainActor
}

class C: P { } // C is implicitly @MainActor

class D { }

extension D: P { // D is not implicitly @MainActor
  func updateUI() { } // okay, implicitly @MainActor because it satisfies updateUI() requirement
  func other() { } // okay, not implicitly @MainActor
}

That means we'll have a more consistent story for protocols.

Some of that was motivated by conditional conformances having to be on (constrained) extensions, but I agree: the precedent is set and it's probably worse to deviate from it.

It could certainly mean that D doesn't actually care what actor it's on. A "nonisolated" witness can satisfy a requirement that's on a global actor, because the global actor is more of a constraint on the user of the protocol/requirement than on the implementor. So clients of the protocol have to promise to call via the right global actor, but types that conform to the protocol can choose to be non-isolated.

A review will be kicking off for this proposal shortly; let's be sure to continue this discussion and we can fold any additional changes (beyond the ones mentioned above) into that review.

Thank you!

Doug

1 Like

This seems at odds with the text:

  • A protocol requirement declared with a global actor attribute requires that a given witness must either have the same global actor attribute or be non-isolated. (This is the same rule observed by all witnesses for actor-isolated requirements).

Would updateUI() need to be explicitly nonisolated to avoid this? I think it would be more consistent to make extension D: P propagate the global actor to the members of the extension by default, or consistently have all its members be nonisolated. Otherwise this wouldn’t be possible from a non-@MainActor context

let d = D()
d.updateUI() // error: updateUI() is @MainActor
d.other() // OK

I think it would be more surprising if members defined in an extension would implicitly have different actor isolation from each other.

1 Like
Terms of Service

Privacy Policy

Cookie Policy