SE-0327: On Actors and Initialization

Thanks for thinking about distributed actors here, Chris! :slight_smile:
As Kavon outlined (thanks! Sorry, I was having such a slow Monday...) we really do not need (or want) "remote initializers" but the systems are built around the concept of resolving remotes. An initializer is always local; this is important and powerful because it allows us to have such actor contain things which only could exist locally -- you can ask a remote "Boss" to get you a "Worker", and the boss can pass non-serializable values to the from itself to the worker, and return to you a reference to that worker etc.

This is also what enables advanced "virtual" actor patterns (popularized by orleans), where you don't ever really know if the remote actor is alive or not at the moment of resolve, but the implementations either give you back the existing one, or could even create a "fresh" one for it -- this relates to the passivation patterns Kavon mentions :slight_smile:

Summing up: distributed actor inits are indeed tricky, but because of the injection of the transport.actorReady(self) call, not because any remoteness of them.

I'd love to catch up in detail by the way, hope to set up something soon :slight_smile:


I need to write up a review here about the proposed semantics and the calls we need to inject in these inits, but I worked with Kavon and we're confident the two mesh well together. We didn't want to muddy this proposal with a lot of details about distributed init "actor ready" call injection though -- it'll fit the rules though, so we're fine :+1:

4 Likes

In the last paragraph of "Initializer Races", the authors said:

The existing implementation makes it impossible to write a correct init for the example above, because the init is considered to be entirely isolated to the @MainActor . Thus, it's not possible to initialize self.status at all . It's not possible to await and hop to self 's executor to perform an assignment to self.status , because self is not a fully-initialized actor-instance yet!

I don't understand why we can't initialize self.status. Can there be more detailed explanation on this?

The "existing implementation" that the proposal is referring to is quite old now (the proposal was originally started before Swift 5.5 released). Previously, the typechecker would prevent access to self.status because it is considered isolated state for an actor instance, which is not the same as the MainActor. Given that it's a synchronous initializer, there was no way to gain isolation either. It's still debatable whether global-actor isolation should actually be permitted on an actor's initializer. I didn't see any downsides to simply permitting the access. The fix was simple, had no downsides I could think of, and if programmers actually need to access MainActor-isolated state when initializing an actor, the workarounds are annoying:

init() {
  self.status = nil
  Task.detached { await self.setStatus(await mainActorFn()) }
}

Something like the above would be needed even if the caller is MainActor-isolated.

After reading through the proposal a second time, from my view the new rules for problem 1 (initializer data races) and problem 3 (initializer delegation) are overly complicated. Two main points I am thinking:

  1. The proposal tries to interweave sync/async semantics with isolated/nonisolated traits of initializers. While we can write down the details in some diagrams, I am not sure if it is worth for engineers to understand and memorize these.
  2. While there may be some concern about ABI or source-level compatibility (I am not sure at this), this proposal tries to unify the initializer delegating rules in the actor world. But the convenience keyword from classes seems to put limit on our design.

Here is my modified version for problem 1 (initializer data races) and problem 3 (initializer delegation):

  1. sync/async trait don't have any restriction on an actor's initializer. Whether an actor's initializer is async only means whether it may give up the current thread (same as a normal function or method).
  2. There are only two types of initializer in actor: isolated init() and nonisolated init().
  3. In the body of an isolated init(), the initializer is either isolated to the actor's internal queue, or it is isolated to a global actor's queue (if there is some annotation like @MainActor).
  4. We can only initialize stored properties in an isolated init(), while calling other methods of the actor is prohibited.
  5. In the body of a nonisolated init(), we can not initialize any stored properties. We must call isolated init() to do the stored properties initialization.
  6. In the body of a nonisolated init(), we can call any other methods of the actor while treating self as nonisolated self.

As for the convenience keyword, my suggestion is to treat it as an alias for nonisolated. We can deprecate convenience in actors in later Xcode versions.

2 Likes

Aha, this makes a lot of sense to me.

-Chris

1 Like

Can someone explain to me what it would mean if global actors are generally disallowed on stored properties for the following examples?

@MainActor
struct S {
   var value: Int // implicitly @MainActor
}

@MainActor
class C {
   var value: Int // implicitly @MainActor
}

// The current proposal mentions:
// "The global actors proposal explicitly excludes actor types from
// having stored properties that are global-actor isolated. "
//
// From the global actors proposal:
// "An actor type cannot have a global actor attribute. Stored instance
// properties of actor types cannot have global actor attributes."

// This does not seem to be enforced though?! 🤨
actor A {
   @MainActor // does COMPILE in Swift 5.5
   var value: Int
   ...
}

The explicit global actor protection on an actor stored property would be disallowed, fine it'll be protected by the actor itself. What about other types, what's going to protect the properties if the implicit actor is gone?


Last but not least, was it ever decided that actors will never gain inheritance? Not that I want/need that feature, but it feels a bit odd trying to eliminate convenience keyword on actors when the answer to that question might be undecided. If we'd ever introduce inheritance to actors, wouldn't they need convenience to have similar but complex delegation rules like classes?

I think the rule we really need here is that a stored property has the same isolation as its enclosing type: a stored property of an actor is isolated to that actor, a stored property of class is isolated to the global actor of that class (if it has one) or has no isolation.

Actors do not have inheritance today. If they were to gain inheritance in the future, we would not necessary have initializer inheritance, which came from Objective-C and is the root of the complex delegation patterns. Without initializer inheritance, the designated/convenience split is unnecessary and the whole model becomes simpler. So, I would prefer that we eliminate convenience from actors, and leave it as an oddity for classes.

Doug

5 Likes

Thanks everyone for the productive discussion here! We're returning this proposal for revision so the authors can explore the "flow-sensitive" semantic model.

Doug

4 Likes

While I don't disagree, what about value types without value semantics? It feels like that those are made unsafe. Sure you could argue that the entire object types they care around need actor protection, but what about the case where only the stored properties on the value type need actor protection but not the entire class as it might need to remain unsafe in other places for whatever design reasons?! I know this a very muddy question, but I don't think it's that unreasonable.

I think you're arguing that on a type that needs actor protection, there might be some stored properties that don't need that protection, e.g., because they use some other synchronization mechanism. If that's so, I agree---but I think it should be a specific opt-out that's clearly unsafe/unchecked, e.g.,

@MainActor class C {
  nonisolated(unchecked) myLockedState: ManagedBuffer<...>
}

Doug

A quick and dirty example would be:

// not actor protected by design
// may not even be in our control (e.g. 3rd party class)
class C { ... }

// no value semantics by design
@MainActor
struct S {
  // no implicit actor protection after this proposal
  // unless I still misunderstand something
  let c: C = ... 

  func foo() {} // implicit MainActor
}

Can you clarify on that scenario please?


I think there are a few options but they seem to be somewhat forced on the developer:

  • make the struct obey value semantics (may or may not be possible or even desirable)
  • leave it in an seemingly, implicit unsafe state
  • search for a different solution

Do we really need the "no global actor protection on value type's stored properties"? There is no deinit in that case, at least nothing from the developers standpoint. It feels like this rule punches a hole here and creates such a strange edge case.

I don't think anything ever said "no global actor protection on stored properties". The point is that a stored property of a type should not be on a different actor from the type itself.

S is @MainActor. c is a stored property of S, so it can only be accessed on the main actor. The fact that C is not Sendable means that you won't be able to take the value from c and pass it into another task or to another actor.

Doug

2 Likes

That clears it for me, however I think the wording in the proposal got me slightly confused:

In summary, the most straightforward solution to the problems described earlier is: global-actor isolation should not apply to the stored properties appearing within any nominal type.

So in short besides actors themself (they'll protect their stored properties), any other nominal type will have its stored properties protected by a global actor (if any), which also happens to protect the whole type.

If that's somewhat correct, can we potentially relax this rule and just say that on any nominal type every stored property must be protected by one and the same actor (if any).

  • on an actor this is trivial
  • on other types the stored properties either implicitly gain protection from the actor which protects the entire type, or if the type has no explicit global actor applied to it, all stored properties must share the same global actor or none
// For the last example.
// Would that be still valid protection?
// No explicit actor protection on the type itself.
struct T {
  @MainActor
  var x: Int 

  @MainActor
  var y: Int 

  @MyActor
  var somethingElse: Foo { /* computed property */ }
}

Don't get me wrong, I'm all supporting unifying and simplifying the rules on actors and the rest of the concurrency. ;) It's still such a beast to tame.

Would this be allowed still?

actor MyActor {
    @MainActor weak var delegate: MyActorDelegate?
    …
}

I use this pattern in my app so the UI can set the delegate, and all the callbacks happen on the main actor, but processing doesn’t block the main thread.

Sorry for getting to this bonus discussion a little late. :slight_smile:.

In the existing implementation, the actor-isolation of the property does not extend until the end of a chained access that involves that property. So, in this example access:

let s: S = ...
await s.c.anotherField.doSomething()

The load of anotherField and the call to doSomething is not guaranteed to be run on the MainActor, unless if those declarations are isolated to the main actor too. With solely the one marking of MainActor on the struct, only the load of the property c from s is performed on the MainActor. Since c is not only a let-bound property, but one that is part of a struct, having that load happen with synchronization has no benefits. Subsequent accesses are still memory-safe, as Doug brings up, only because of Sendable restrictions. In light of that, I think discussion of global-actor isolation on stored properties in this version of the proposal is half-baked. I hope to have a new version ready soon that covers this in more depth.

1 Like

Can you share a bit more of how you're using this pattern, Jacob? Currently, the existing actors proposal says that global-actor isolation should not apply to an actor's stored properties, so it's a bug that the compiler is permitting that. I'd like to consider your use-case as I prepare a new version of the actor-initializer proposal.

I have an actor that manages a server access token. My network code awaits on the actor to get the current token, but the UI needs to know when the token is invalid (and not renewable). The delegate property is only written from the main thread by the interested UI component, and in the actor, I hop to the main thread before reading the delegate and calling any methods.

There are probably lots of ways to design this, but this way seemed the most natural to me (and mirrors how the component worked before it was an actor), and I didn’t realize it wasn’t allowed.

1 Like

Here are other examples which ultimately got me thinking about all this. This code is part of a book on async / await in Swift:

Making some ObservableObjects an actor seems to be very tempting, however most of those stored properties would still need to obey the MainActor.

I really hope to see some clear rules for all of this. And I also would like to bump the issue Chris Lattner mentioned upthread. In the book the author eventually bumped into an error messages like the following one:

Call to global actor 'ImageDatabase'-isolated initializer 'init()' in a synchronous actor-isolated context

This forced the introduction of a IUO-stored-actor-property and some deferred initialization via a setUp method. Reading this felt unnecessarily painful to me, especially as I personally would like to avoid IUOs as much as possible. There must be a better solution to such problems.

I agree. In fact, I think the definition of EmojiArtModel here has no reason I can think of to be an actor type, because most of the code doesn't rely on being isolated to it, except to update an unused integer counter. It should just be a MainActor-isolated class. For example, the only instance-isolated method verifyImages could be marked nonisolated without changing its body, since it only accesses a MainActor-isolated property before launching child tasks that will await to access the private method anyway.

Based on that error message alone, I think the problem is that they're trying to call an init that is isolated to a global actor, from outside of that global-actor, so an await is required. But, that call appears in a synchronous function. That diagnostic could definitely be improved in this situation.

For the actor ImageLoader, the setUp method here could be changed to be an async initializer, since it is already defined to be an async method. The same goes for the other setUp method being async throws.

The async actor initializers that Chris and I have been discussing for this proposal already work in released versions of Swift 5.5 in the way we all agree on (barring some small bugs I recently fixed). That is, there are no extra restrictions on what you can do with self in an async initializer (i.e., you're effectively isolated to self). The main contention in this proposal is on synchronous actor initializers, because the escaping-use restriction is an unnecessary pain and doesn't match up with async initializers. The new version of this proposal that I'm working on will remove that pain and simplify things a lot, I think.

1 Like

I appreciate hearing about how you designed it, even if it's currently relying on a mistake in the implementation :slight_smile: .

I can understand the desire for the UI to have the ability to access that delegate property on a reference type without giving up the main thread. Let's just ignore my proposed solution to stored properties and global-actor isolation in this version of the proposal. I'll make sure the new one strikes a better balance and considers more scenarios.

3 Likes
Terms of Service

Privacy Policy

Cookie Policy