[Pitch #4] Actors

Hello again, everyone,

After yet more interesting discussion in pitch #3, we've revised the actor proposal for this, pitch #4. Thank you everyone for the great discussions---the model is shaping up nicely.

Changes in the fourth pitch:

  • Allow cross-actor references to actor properties, so long as they are reads (not writes or inout references)
  • Added isolated parameters, to generalize the previously-special behavior of self in an actor and make the semantics of nonisolated more clear.
  • Limit nonisolated(unsafe) to stored instance properties. The prior definition was far too broad.
  • Clarify that super is isolated if self is.
  • Prohibit references to actor-isolated declarations in key paths.
  • Clarify the behavior of partial applications.
  • Added a "future directions" section describing isolated protocol conformances.

As always, comments welcome!

Doug

27 Likes

Is it possible for a nonisolated method to take an isolated argument? This seems safe, but confusing, and the section on nonisolated doesn’t seem to say.

The Reentrancy Summary section includes a link to Alternatives Considered, but the relevant information is in Future Directions.

I think isolated Actor should be spelled nonisolated Actor, given the meaning here is that you can access it synchronously (without await, so without isolation). To illustrate I'm going to propose a new feature...


Seeing this (malformed) code section on cross-actor references (where Person is a class) gave me an idea:

  func primaryOwner() -> Person? { return owners.first }

We could allow forming "isolated values". Something like this:

  func primaryOwner() -> isolated Person? { return owners.first }

where accessing anything from an isolated return value would have to be done with an await through the same actor it came from. Layout-wise, an isolated Person would consist of two references: one to the Person object and a (hidden?) reference to the actor it belongs to.

This basically has the reverse meaning from isolated Actor as currently in the proposal: isolated Actor means you don't need to await; isolated Person means we need to await.. Given nonisolated Person sounds like it can be used synchronously, I guess we should change the spelling from isolated Actor to nonisolated Actor.


(Edit: it comes to mind we might not have to specify isolated in the function signature for this to work. Any call to a function returning a non-ConcurrentValue could produce an isolated value when called from outside the actor context. You'd only need to spell isolated Person to express that type when outside of the actor.)

Hm, yeah that's a weird mix. Let me go through that step by step before jumping to a conclusion.

(actor-)isolated

I think one way to think about it to realize what all these keywords actually cause is this: actor-isolated effectively means has to run on some specific actor.

As such isolated account a free function:

/* free func */ func check(account: isolated BankAccount)

// execution context equivalent to:
// actor BankAccount { func check() } 
// both guarantee that check will run on "a" specific BankAccount;
// - the free function: on the `account` actor
// - the actor instance function: on the `self` actor

As such, "actor functions" are also following the isolation rules: there is exactly one isolated parameter: self (!)

Thus, we can read isolated as "this function must run on the context of the actor that is an isolated parameter of this function (or self in case of actor instance functions)".

This is why there cannot be more than one isolated function parameter - because we can only run "on" one actor at any given moment.

nonisolated

Now, nonisolated is used inside an actor to say "well, that function can be called without actually running inside the actor", i.e. it can be used to annotate an actor function and allow it to be called "without awaiting." Some have called this "allow the actor to be called synchronously" but that's pretty tricky wording to be honest IMHO... Since calling an actor "synchronously from the outside" (other task), actually means that we're actually potentially concurrently executing this function, with the actor itself running through its mailbox). </end of wording nitpick>

So... nonisolated functions exist because inside an already isolated by default context (an actor), we sometimes want to allow these nonisolated calls. An actor instance function is always isolated to self, unless nonisolated.

Conclusions:

  • free, instance or static functions are defined "outside" of actors, and they are effectively nonisolated by default.
  • actor instance functions are automatically isolated to the actor's self

Okey, with that in mind, does it make sense to allow:

  1. A free, instance or static functions (which are effectively nonisolated) with isolated parameter?
func hello(worker: isolated Worker) {}
static func hello(worker: isolated Worker) {}
class/struct X { func hello(worker: isolated Worker) {} }

Yes. This effectively isolates the function to the actor that is marked as isolated. The functions execution is actor-isolated by the actor which was marked isolated. This is pretty natural and makes sense :+1:

  1. An actor instance function, to be nonisolated + accept an isolated SomeActor
actor Worker { 
  // weird, but not really wrong
  nonisolated func ready(other: isolated Worker) { } 
}

While this is slightly weird territory to be honest, it technically speaking is not really wrong. What this could mean is:

  • "strip off the self actor isolation on this actor instance"
  • well, since we only allow one isolated actor parameter for any function, and this function right now is isolated to zero actors... it could indeed be isolated to the other Worker :face_with_monocle:

Okey so this "works" in the model... Is it useful?

It is very weird to be honest... semantically this means allows another worker to call someone.ready(self) and that function will execute in our context. As-if it was a function defined on the our actor itself. This is entering "pretty weird"-land, but it's never really "wrong" in any way I can see...

All rules are respected:

  • nonisolated did what it promised - the function is not isolated to self,
  • and isolated did what it promised as well, we're simply isolated to that actor.

It also draws some parallels to what custom executors allow in a weird way -- but there it is about entire actors. And here it is about a function declared on one actor being guaranteed to execute on the caller's actor execution context. I find it a bit weird to imagine use-cases which would really require this as it's a bit backwards, but it is not wrong model wise :thinking:


*It really is very weird though... and I don't really see any "needs this" use-cases, so all in all...

So summing it all up, and even after writing up that it could just work and fit the model... I think I'd actually say to ban allowing nonisolated functions with an isolated parameter. It's kind of like crossing two opposing concepts on a high level, even if we can make it work by careful reading into the rules. I guess in the spirit "easier to add later than remove later" this seems like a good candidate to prevent for now unless we find some strong reasons to allow it – I think it can work if we needed it to, in the future.

WDYT @Douglas_Gregor ?

7 Likes

Note that there is no mechanism that would permit two actors to be isolated at the same time, because one has to "leave" one actor's isolation domain to enter the isolation domain of another actor. Therefore, we prohibit the definition of a function with more than one isolated parameter:

I know global actors are out of scope for this thread, but I'm curious if this rule still applies to global actors. For example, will it be possible to have a function that has synchronous access to two isolated "main" actors? I'm not looking for detail in this thread, just an indication of the direction the global actor proposal will take.

Fixed, thanks!

As you worked out, there's nothing wrong with a nonisolated method in an actor that takes a different parameter as isolated; it falls out of the model. If it's making folks nervous, I don't mind banning it until a use case comes along.

A declaration with a global actor is isolated to that global actor, but there's no specific "isolated" parameter (and one will not be able to have an "isolated" parameter at all in such a declaration), e.g.,

@MainActor func f() { } // okay, on the main actor
@MainActor func g(a: isolated MyActor) { } // error: cannot be actor-isolated to both MainActor and MyActor
@MainActor func h() {
  f() // okay, f is also on the main actor
}

Doug

3 Likes

I don’t mind it, doesn’t really pollute the model or break anything — it falls out of the other rules naturally. It’s a bit weird but perhaps could be useful.

No strong feelings towards needing to ban it after sleeping on it. Since you also think it just falls out of the model naturally, as I wrote up, and we’re happy with it, might as well just keep it around :+1:

Agreed, this is further great progress!

  • Allow cross-actor references to actor properties, so long as they are reads (not writes or inout references)

Got it, I assume the challenge here is in promoting setters to async? Do you anticipate that this is difficult to add in the future, or just "non-critical" for now? It would be weird if the final model allows one without the other.

Fantastic, thank you for adding this.

I'm also thrilled to see this get limited further, and have further suggestions below.

All great cleanups.

I am concerned about this direction. As proposed, the actor feature doesn't provide the basic protocol abstraction features that Swift provides for every other nominal type. I don't think that shipping Actors 1.0 without this would be acceptable: it would push swift programmers into a lot of bad patterns and habits, cause a bunch of stuff to be marked async that shouldn't be, and lead to the definition of a lot of protocols that are "wrong" for the general design. I urge you to consider pulling a solution to this problem into the base proposal. Further thoughts on this below.

Here are detailed comments from a fresh read-through of the proposal:


Motivation section:

It's completely a wording/positioning thing, but I'd suggest changing the discussion to be less about isolation/data races (structured concurrency provides the same benefits) and more something that talks about abstraction and design patterns. Here's a rough sketch:


  • Super nit, but I'd recommend dropping the private markers from the lead example of actors. They are unnecessary and a bit distracting now, since they syntactically overpower the let/var declarations that matter.

In the cross actor reference discussion at the end of this section it would be good to mention some rationale for the decisions - why aren't cross-actor setters allowed? I think I know, but it would be good to make the proposal self contained and obvious to other readers.


Actor Isolated parameters

I love the addition of this into the proposal, I think it makes the actors design much stronger. Thank you.

  • I can see where you're going here with the reuse of the word isolated (and it is much better than @sync) but it doesn't really read right and convey the right thing. I don't have a concrete suggestion of a better term though - the presence of one of these on a function means that the "function may only be invoked within the concurrency domain of the actor parameter", not that the function has isolated the parameter.

  • Regardless of the keyword, are optionals and IUO's allowed here? e.g. func thing(x: isolated BankAccount?) and func thing(x: isolated BankAccount!)

  • Is it possible to pull the runOnActor method into a protocol extension on AnyActor so it is available everywhere?

  • The currying example is great

  • "Therefore, we prohibit the definition of a function with more than one isolated parameter:" --> Why? This can be narrowly useful (e.g. unsafe casts when two actor instances are known to be on the same executor) and there is no reason to ban it. Why exclude a (narrowly useful) valid case and add the compiler complexity? I don't see any safety benefit to this.


Nonisolated declarations

As you know, I am very concerned about adding "nonisolated" proactively as part of the base actor proposal - it is sugar for one specific case and significantly expands the conceptual model of actors. This may or may not be important for actor 1.0, but it confuses and complicates the core actor model (e.g. protocol support) which is critical to get right independent of nonisolated, and makes the proposal more difficult to understand.

About the feature itself, I think it would be much better if you motivate nonisolated without using protocol conformance as the rationale. We need to solve protocol conformance independently of "nonisolated" declarations to allow basic type abstraction etc, because actors contain mutable state (the entire reason we have actors in the first place ;-) and we need "isolated" protocol conformances to be able to touch that mutable state. While it may be important to have nonisolated as part of the model, it is confusing for readers of the proposal that aren't deep into it to think that this is the solution for protocol conformance in actors (since it doesn't cover the whole problem).

My concern here can be addressed in three ways: 1) Change the introduction of nonisolated to be motivated by non-protocol things, 2) pull proper protocol conformance support comes into the base actor proposal. 3) split nonisolated into a completely additive follow-on proposal.

  • If it stays, then I'd also recommend moving the discussion about nonisolated after the closures/inout section since this is really a different additive language feature, and closures/inout already exist.

Closures

  • Nit: The closure isn't non-isolated. It is @concurrent or @sendable or whatever:
error: actor-isolated property 'transactions' can not be referenced by non-isolated closure
error: actor-isolated property 'lastUpdateDate' can not be referenced by non-isolated closure

"If the closure is @escaping or is nested within an @escaping closure or a local function, it is non-isolated."

This is still highly unprincipled and unnecessary w.r.t. the type system, but this is a lot less onerous now with the introduction of isolated actor references. I don't understand how it is specifically enforced in the compiler though because the type system features don't compose in an obvious way, and your concerns with escaping values aren't specific to actors at all (also apply to structured concurrency).

However, in practice nearly all @escaping closures in Swift code today are executed concurrently via mechanisms that predate Swift Concurrency.

I don't think this is true, there are lots of reasons to use @escaping closures other than concurrency, including basic callback things, main thread UI things, etc.

Overall I'm curious if this is actually required now that the other changes have gone into the model. The original concern was about GCD and other APIs that bottom out in it, but won't those be marked with Clang attributes as taking @concurrent closures? Also keep in mind that this isn't actually solving the problem as Dispatch.sync and other concurrency APIs don't take escaping closures.

It isn't obvious to me that this is "better" for the model (in terms of catching bugs) or if it is just "weird" -- even in the short term.


inout parameters section

This limitation is written to apply the to properties in actors, but this limitation should really apply to any state accessible from the actor, e.g.:

class C {   var state : Double }
struct Pair { var a, b : Double }
actor A {
  let someC : C
  var somePair : Pair
...
  func foo() async {
     // not ok.
     await modifiesAsynchronously(&someC.state)
     // not ok.
     await modifiesAsynchronously(&somePair.a)
  }

I think this needs to be expanded.


ConcurrentValue section

  • Wording nit: I'd replace "A separate proposal introduces the ConcurrentValue protocol" with "SE-0302 introduces the ConcurrentValue marker protocol"

Nonisolated in detailed design section

  • Again, it is weird to me that the first thing introduced after the syntax is something that has nothing to do with the core of the actor model. I'd recommend moving this much later in the section (or out of the proposal entirely) as it is orthogonal to the other mechanics of the actor model.

  • I would love to see more detailed explaination of what nonisolated(unsafed) property declarations are - where do they get used in practice, why do we need language support for this instead of using existing property wrapper functionality, etc. This seems like a completely additive language proposal that only has a brief mention in the design paper, and the introduction of isolated MyActor means that we can introduce casts (ala the unsafeActorInternalsCast in the whitepaper I wrote) that achieves this with library techniques. This nonisolated(unsafe) feature isn't clearly motivated, and is a syntactic sugar proposal. I'd recommend splitting it off.

  • I would also love to see nonisolated in general split out from the base actor proposal as an additive thing that follows on the basic model.

Actor Isolation Checking section

Writing suggestion: I think it would be best to rethink the presentation of this section in general. It came from historical roots, but now has a bunch of stuff (incl how protocol conformance works) that have little to do with isolation and relate to the overall design of actors as a nominal type. I'd recommend changing the "detailed design" section to just list out how all the language features work (not as subsections of isolation) and then have actor isolation be a small peer section that describes the limitations imposed by it.

Furthermore, the section is conflating two different things: 1) how actor isolation works in the general model, and 2) various details of the additive and separable "nonisolated" language feature. For example, the "overrides" section is related to the later not the former.

This is part of why I think it is important to split nonisolated into a follow-on proposal. The core actor model is clearly definable without it, and would be much easier to understand the proposal. The follow-on nonisolated proposal can then carry its own motivation etc to cleanly separate out the concerns.


Partial Applications section

The writing of this section evolved and I think this has made it more confusing to understand than it needs to be (just because it is mixing non-isolated,isolated,concurrent, and escaping together as the terms). I would recommend simplifying this by describing the short rules that compose to provide this emergent behavior. Something like:

  1. Partial application of an isolated actor reference to a method produces a normal function type: self.f -> (Int)->Double
  2. Partial application of a non-isolated actor reference to a method produces an async function type: otherA.f -> (Int)->Double async
  3. You can't pass self.g to runDetatched because it isn't a @concurrent function (per rule 1) and SE-0203 doesn't allow passing non-concurrent functions to runDetatched
  4. You can't pass self.g to runLater because (some rules that I don't actually understand w.r.t. escaping).

Isolated Protocol Conformances in future directions

The discussion here is a reasonable framing, but I think you have the sense of protocol conformance wrong. This draft of the proposal is suggesting that:

  • actor Foo : SomeProtocol means a "non-isolated" conformance
  • actor Foo : isolated SomeProtocol means an "isolated" conformance

However, non-isolated functionality is much more limited (and no core to the actors design as I argue above) than isolated functionality. I think instead you should have:

  • actor Foo : SomeProtocol means an "isolated" conformance
  • actor Foo : nonisolated SomeProtocol means a "nonisolated" conformance

This also allows you to move the "nonisolated" feature in general out to a followup, focusing this proposal on building out the core actor model.


I didn't see it in this proposal, but in previous versions of the draft there was the suggestion of having an AnyActor like protocol that all actors conform to, as an analog to AnyClass. Is this still interesting and inflight or did this get intentionally dropped?


Overall, this is another huge step, thank you for driving this forward!

-Chris

2 Likes

I think that would make the language unnecessarily less uniform. We shouldn't ban this, for example, which is analogous to an isolated method taking an isolated actor reference: func f(x: SomeActor, y: isolated SomeActor).

I think the confusion here is the use of the isolated keyword for the "reference to an actor whose concurrency domain we are inside of". If we can find better keyword for this, I think it will reduce the confusion.

-Chris

3 Likes

I’m not sure if there is any confusion; I brought up the mixing-nonisolated-with-isolated question because I think the composition of semantics is clear, but the words might look confusing. I think this merits a brief mention, perhaps just an example in the nonisolated declarations section.

I have added such section detailing the semantics earlier today: [Actors] explain nonisoalted actor func + isolated param a bit more · DougGregor/swift-evolution@c2f6ae9 · GitHub :slight_smile:

Didn't manage to get to properly replying in the thread yet, will do soon.

1 Like

There is going to be "weird" somewhere with async access to properties. This proposal draws the line at setting. If we allowed setting, we would draw the line at inout. I also feel like piece-meal setting of actor state property-by-property might be something we don't want to support long-term, so this felt safer.

This is a good idea. I've incorporated much of this, thanks!

Sure, they're gone.

I've added some rationale.

No, I don't think so. We could add it later if it's important.

@ktoso was arguing that it's not an API we should actually provide. I'll let him give his reasons.

Yes, I agree that it would be possible to call such a thing by, say, unsafely casting the function type. The restriction is there to point out a potential pitfall... but I'm okay with removing it. Perhaps at some point we'll gain a way to have multiple actor instances that are statically known to share a concurrency domain/executor.

I'm fine with introducing additional motivation first, and the some reshuffling to bring it later, but nonisolated is directly related to isolated and has proven to be an important part of the model, so it will remain in this proposal. I've gone ahead and done this.

Sure, fixed.

Yes, this is absolutely required. There is no code with correctly-annotated @concurrent functions yet, and we can not instantaneously update the world to introduce the appropriate @concurrent annotations. We can start lifting this restriction from code that's getting recompiled under the structured Swift Concurrency model, but it will take time.

Lots of basic callback things happen "later" and would introduce data races, and the main thread is particularly important for safety.

Dispatch.sync is semantically fine (albeit a performance nightmare), because the caller is blocked. You want concurrentPerform to prove your point, and yes: the @escaping approach described in this proposal is not 100% bulletproof. But experience with the model shows that it is extremely valuable in catching concurrent executions when working with the Swift ecosystem we have today.

We can talk about more effective ways to phase out this @escaping or time, but I'll be blunt: that @escaping implies non-isolated capture is not negotiable.

Sure, thanks for the example.

... or Sendable, as it were. Done.

I can look into improving the presentation here, but I think your suggestions here are all deeply influenced by your feeling that nonisolated is unimportant and should not be part of the model.

I see what you mean here; it's effectively applying the principle that one can asynchronously access synchronous methods to partial applications. This can be made to work, although it runs afoul of one of the principles we've maintained thus far, which is that the type of a reference to a declaration (e.g., the type of otherA.f) is not dependent on whether otherA is isolated or not in this context.

The rules here from the need to not escape isolated actor instances. Remember that self.g is @escaping when there is no context forcing it to be noescape, e.g.,

let fn = self.g

produces an @escaping function type.

Isolated conformances are the ones that are different from any other kind of conformance we have in the type system, because they only apply to a subset of the values of a given type, and necessarily have other restrictions on what kinds of protocols can work with them. Non-isolated conformances apply to every instance of the type, like all other conformances in Swift. One of the two must have a qualifier, and isolated conformances---which work only on isolated parameters and are different from all other conformances---should be have the qualifier.

It was called simply Actor, and we dropped it at one point because one couldn't actually do anything with it. However, I'd like to bring it back as an empty protocol (to be filled in by the custom executors proposal) to reserve the name and give a place where we can hang the Sendable conformance. If we add runOnActor, that's where we would do it.

Doug

could you, please, tell: actor is value type or ref type?

Actor base protocol

Yeah as Doug said it keeps appearing and being removed from the proposal... The reason it was removed was that people were not convinced about good uses of it.

With custom executors though, we have good reasons! What used to be an empty protocol should rather become:

protocol Actor { 
  /// Executor which will be used to schedule all of the actor's mailbox processing.
  ///
  /// Unless implemented explicitly defaults to the global actor executor.
  /// The global executor may be configured on a per process basis. See ...
  /// 
  /// If implemented explicitly by an `actor`, it MUST always return the same executor.
  /// It is not allowed to return a different executor instances on subsequent calls to the executor, 
  /// as it would potentially lead to violating the actor threading guarantees.
  var executor: UnownedSerialExecutor { get }
    // TODO: specific name of variable and the executor type to be decided in custom executors
}

I do think it is quite valuable to have this protocol with such properties, to document what you can do with it. Otherwise it'll be just a growing number of magic, not documented properties, which if you happen to provide change execution semantics - without the ability to check those names/types in sources.

Also... consider if we had distributed actor it is very natural to express it as. Those have quite a few more requirements which are increadibly important for their end-users. Thankfully, if we had a DistributedActor protocol, we can easily express and document those requirements on the type, like so:

protocol DistributedActor: Actor { 
  associatedtype Message = Sendable & Codable // or DistributedSendable
  
  /// Specifies the transport mechanism used to send messages to this 
  /// distributed actor, in the case this reference is "remote".
  var actorTransport: ActorTransport { get } 

  /// The globally unique identifier of this distributed actor.
  /// It is assigned at an actor's creation by the ActorTransport and 
  /// remains valid for the lifetime of this specific actor instance.
  var actorAddress: ActorAddress { get } // long names to not use up the word "address" for users
}

Again, we have a perfectly natural place to express these requirements. What is more, we will want to express distributed actors conforming to Codable, which again, is very natural and does not involve any magic beyond plain old Swift code and a specialized implementation there of. The Codable implementation would be synthesized (but can be done so once for the DistributedActor type, rather than for every specific instance, giving us a nice code size save), but the conformance can be stated in plain old Swift -- which is great.

Distributed actors we'll discuss in far more depth in the future... but they are just "a bit more specialized" normal actors, so it makes sense to fit them in the same hierarchy.

Given such Actor and DistributedActor protocols, we can also easily express the following functions:

extension DistributedActor { 
  public nonisolated func whenLocalActor(
    _ body: ("actor" Self) async throws -> T, // it is known to be local, we can invoke non-distributed functions on it
    whenRemote remoteBody: ((Self) async throws -> T)? /* = nil */
  ) -> (re)async rethrows -> T?
}

Which is quite similar to another function that was discussed earlier... I think it was someActor.withUnsafeInternalState { "nonisolated everything" actor in }, which also was quite interesting and would be an alternative to nonisolated(unsafe) pushing the "I'm doing super nasty things but it's safe" into nonisolated functions, rather than having to declare the function as nonisolated(unsafe) func x(){} and therefore informing every user of this function "oh boy, that one is scary" :wink:

Summary:

  • I'd very much like the Actor protocol to exist :+1:
  • It has plenty uses, even though putting extensions on it isn't the main one, but rather the understandability and "less magic" is an useful thing this protocol introduces.

Pre-defined Actor.run { some closure }

Please let's not expose this by default. It's the actor equivalent of breaking into your house to watch TV on your couch, rather than asking you to let me in to watch some TV :stuck_out_tongue:

It leads people to do the wrong thing with actors - just passing around random closures which do stuff "on the actor" is not a scalable programming model for all actors. In day to day programming with actors, you will find yourself needing to debug and trace issues and figure out "which actor function is slow", "some actor seems to be blocking... which function is it..." and similar. You don't always have access or the ability to use a profiler, sometimes you will have to rely on logs.

And logs will tell you "well, this run() function is taking an awful lot of time in this actor" and it's harder to debug who and why is submitting work there.

As an example, consider you have an IOActor, it would be accepting some specific work messages, like "please read a few bytes" etc. We should design our actors such, that people with zero experience on actors, are not led to do some wrong thing, for example, someone new to actors might find the IOActor, and notice it as this (predefined!) run(), and they could decide that "aha, maybe that's where to do my blocking IO", and write this:

// don't do this !
IOActor.run { 
  ... = readFileBlocking(...)
}

while IOActor may have been designed to work on a dedicated thread and handle async events from an async IO system etc... and suddenly, someone was let to write something very bad.

As such, we should NOT offer run { ... } just on any actor. Because it may lead people good intentions, to do very wrong things. And it also complicates debugging when unable to profile and trace properly (e.g. server systems relying only on logs where a web framework developer tries to debug why the server is grinding to a halt, yet it's an issue in the user code putting blocking work on the systems actors).

I do absolutely see the value of offering this for some actors, including the MainActor though!

Since in UI we often have situations where "has to be on main actor" etc. Thankfully, we will be able to do this trivially for specific actors, such as the main one:

// however we end up spelling MainActor (global actors), 
// an extension on *that* one:
extension MainActor { // OK
  func runLater(body: @escaping () async throws -> T) async rethrows -> T {
    try await body()
  }
}

Summary:

  • We should not expose run(body:) on any actor, but people can define them on any actor if they really want to.
  • This extra step matters, because it prevents people accidentally using such run where they should not have.
  • We should offer such run(body:) on the MainActor though, because how popular and normal it is to just throw some execution at the main actor / thread and there it is well understood to never block in there.
  • If we wanted to add withUnsafeInternals { actor in ... } that technically could be a function that we could add to Actor, however it must be a throwing function, because perhaps the "internals" do not exist (because it may be a distributed remote actor)

multiple isolated parameters and Actors sharing executors

It's excluded because it's completely unsafe if we'd just allow it.
The compiler (and model) complexity is actually in allowing this, not in banning this.

Banning it gives us time to land the things needed to in the future support this narrow use-case, if at all necessary.

The actor isolated actor parameter concept as expressing "run on this specific actor context" just works because it simply means to run "on" that actor, which automatically is safe.

There is no way to make this for safely for arbitrary multiple actors.

I don't believe this feature should be the way to enable these unsafe patterns; it is too easy. If you want unsafe code, write nonisolated unsafe functions, or use the withUnsafeInternals that was mentioned in other threads. Both are explicit about the unsafety.

You are right that it is possible to make this safe in the very narrow case where we statically know that both actors share the exact same SerialExecutor. But proofing that statically is hard, and I suggest doing this after we have custom executors, as well as we're happy with the general actor runtime. It is an additive proposal and can be done whenever after those things land.

Here is the complexity explained in depth:

To make this:

func transfer(amount: Double, 
  from fromAccount: isolated BankAccount, 
  to toAccount: isolated BankAccount) { 

safe, we have to statically prove that:

  • both those actors are using the same exact SerialExecutor

How do we do this though...? If the actor is declared as:

actor BankAccount {}

then it's obviously wrong/unsafe, because each actor gets its own unique serial executor, so the function isolated to "two bank accounts" should not compile.

If the actor is declared as

let globalEventLoop = EventLoop() 

actor BankAccount { 
  var serialExecutor { globalEventLoop }
}

then it is known to be safe if we perform the static analysis that both those instances indeed just forward to this global, and that global is a let and never changes. But we have to write this analysis. The function isolated to "two bank accounts" should now compile.

However, if those actors were to be defined as

let globalEventLoop = EventLoop() 

actor BankAccount { 
  let serialExecutor: SerialExecutor
  init(executor: SerialExecutor) { self.serialExecutor = executor }
}

we again cannot prove if they're on the same executor or not statically... so the function would have to not compile again.

So... the statically proving this is a bit of a super narrow use-case, but I do agree it could be useful.

Because we also are going to be working on dynamic hop avoidance, as designed in: https://github.com/rjmccall/swift-evolution/blob/custom-executors/proposals/0000-custom-executors.md in which case two actors, even dynamically on the same executor are able to avoid hops.

So it may not be worth doing this static analysis driven "known exact same executor" feature. Or maybe it is, and we'll do it -- but we should do so later, not in this proposal right away.

We may want to fo this as a future direction thing.

Summary:

  • The situation exists, but this is the wrong proposal to jump onto it
  • This feature should NOT be the unsafe API to randomly access actors internal states without going through their mailboxes; that is nonisolated(unsafe) and/or withUnsafeInternals
  • The complexity lies within supporting the "statically known on same exact serial executor"
  • I think we could lift this restriction and add this edge case support as Future Work, because it is naturally incremental and plays into the same story as Custom Executors and their hop avoidance.

Hope this helps, otherwise thanks for all the great suggestions :slight_smile:

4 Likes

You can think of it as a reference type, similar to a class.

Is nonisolated a type modifier to an actor type (which means nonisolated MyActor is a new type)? What does this approach differs from the @async MyActor in this document for the actor proposal? It seems that we have different ideas about how to express the concurrent access model of actors, but aggregating these ideas and discussing pros and cons with more specific scenarios would make the actor proposal more understandable by app developers like me.

This proposal intentionally makes isolated a modifier on a parameter, but it is not creating a new type the way that @sync Actor did. It fits into the type system the same way that inout does: it provides a specific set of capabilities and restrictions for that parameter, but is not part of the type of the value.

Doug

2 Likes

So if I understand correctly, @escaping is also a modifier right? (we cannot make a stored property of type @escaping () -> () for example, but we do have restrictions of using an @escaping parameter in a function body.

In this case I see some similarities of isolated and @sync: they both at least put some restrictions of using a function parameter. How these approaches affect the type system may be an implementation detail (or is ABI stability also affected?). This may be a common base to discuss more scenarios further.

I also hope that we could put more description and explanation of what can be solved and what complexities will be introduced by using each of these approaches (isolate and @sync), and more use cases and scenarios may help us find a better way towards the final solution.

I am considering how to update my SDK to our customer when i use new concurrent model in the future.
Suppose current interface given to customer is as following:

class DataProvider {
func add(_ query: Query ) { }
}

I want change it from class to actor like following:

actor DataProvider {
func asyncAdd(_ query: Query ) { }
nonisolated func add(_ query: Query ){
await self.asyncAdd(query)
}
}

I think both old call like "provider.add(query)" and new style async call like "await provider.asyncAdd(query)" will work. So customer could leave their codes as before or take their time to port.

Is there something wrong?