[Pitch #4] Actors

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: swift-evolution/0000-custom-executors.md at custom-executors · rjmccall/swift-evolution · GitHub 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?

It's close. You won't be able to await inside the add function, so you would need something more like this:

nonisolated func add(_ query: Query) {
  Task.runDetached {
    await self.asyncAdd(query)
  }
}

where Task.runDetached is part of the Structured Concurrency proposal under review now.

We've seen this come up a bit when porting code, and have a prototyped feature we call @asyncHandler that automates this a bit. It would allow your example to compress down to:

@asyncHandler func add(_ query: Query) {
  // body of your asyncAdd
}

Doug

2 Likes

Yeah, I think that we'll eventually want to support setters but not inout. I agree that starting conservatively is the right way to go in any case.

Right. To be clear, the reason I'd prefer to remove it from the proposal is more for "reasons of principle" than "practical reasons". I agree that the utility of such things are very narrow, but adding special cases like this to the compiler reduces orthogonality, I don't think it will detect problems in practice.

If people trip on this in the future then we can look at why that is happening in practice and add a warning or whatever the best remedy is based on the details later. This is better done lazily than proactively IMO.

I understand the claim, and I hear your very clear insistence, but you're sort of ignoring my point. The issues involved have nothing to do with actors. The exact same issues arise with structured concurrency. Furthermore, the model proposed doesn't work as far as I understand it. It would be good to clarify the model if it does work, perhaps it just isn't coming out in the proposal.

No, it really isn't. I'll split this out to a separate post because it really is the crux of my concern with this proposal as written.

Ok, it makes sense to split it out of the proposal and add it later if unclear about this, but this is highly precedented with AnyClass and it seems like it will have resilience issues if added after Actors 1.0, so this would benefit from some significant thought.

Right, I agree.

While I understand the concern, Actors already have a good way to deal with this: access control. The utility of something like a "run" method is that it gives a cross-actor caller the ability to work with multiple public properties without the risk of interruption between them. This is a pretty important utility for actors with publicly exposed state. Same thing for access to sync methods (not just state) without interruption.

If you don't want people poking at your state and sync methods like this, then don't mark them public!

Thank you for expanding the issue, but I really do understand how this works. I think you're missing my argument though, which is basically:

  1. This catches no bugs, because even if you define such a method, you can't invoke it using safe swift. There is no hole this closes.

  2. There are narrow but valid use cases for this.

  3. Keeping this uncheck is simpler and more orthogonal in the compiler - it composes out.

The argument for rejecting this is basically that "most uses of this are misguided". While we do diagnose some (very few!) such things with warnings, this is generally not what swift does -- and for good reasons. This is akin to warning on all force unwraps.

-Chris

No, it's not that the uses are misguided, it is that the it is semantic nonsense, unless the extra edge case handling is implemented and we compiler enforce the correct usage.

There is no such thing as "run simultaneously on two actors." It is only because a series of special conditions that make this a thing that can exist: specific exact same, serial executor used by both, which also has to be provable at compile time.

Practically speaking, how would this function be even implemented: where would this function body be scheduled? There's no correct answer for this. A function with 2+ isolated parameters has no idea where to be scheduled, and picking one over the other is arbitrary and wrong in any case -- either one actor will be accessed unsafely, or the other one.

Unless we implement the checking I discussed above of course, but we don't have that and I don't think it's planned in the initial scope.

I guess you mean simply because it should not be possible to get hands on two isolated actors, because if we're in some actor isolated closure, we can't capture the isolated actor anyway etc?

This may be true but is somewhat accidental...

Above you claim you cannot invoke it wrongly using safe swift; how would this be invoked in the narrow use-case that you claim here, and be safe?

I would rather we push unsafe uses to one specific unsafe way to interact with actors: which can be the addition of the withUnsafeActorInternals (which I quite like btw!), or fomr form of nonisolated(unsafe)/@actorIndependent(unsafe).

Rather than allowing definition of these weird functions, we should today push people to "well, this is pretty surely wrong, use unsafe things if you really know it's not", rather than allow defining those double-isolated functions which are pretty much always wrong.

And then in step 2 we can make those be provably safe -- and I definitely see a lot of use cases for these, especially on the server side where reusing the same event-loop for many pieces of a pipeline is a very popular pattern.

Splitting this out, I think there is a very big issue here that is not coming through clearly in my feedback. I strongly believe that nonisolated should be split out to a follow on proposal, but I am /not/ arguing that we drop it from the model. This is exactly how we're handling global actors - split out to a follow-on proposal - even though they are also critical to the model.

Through the evolution of the actors proposal, we've progressively pared back the functionality ascribed to actors, generalizing that functionality and making it orthogonal to actors. This has made actors and structured concurrency more of peers that share the same implementation details (e.g. Sendable).

What are the fundamental nature of actors?

An Actor is a nominal type that collects a bag of state together and allows one to define methods on it, providing isolation through async messaging of its mailbox. This is a simple and beautiful idea!

By analogy, the non-concurrent part of swift has closures and structs/classes. Closures allow one to capture state and provide a single operation against that state. A struct allows you to explicitly declare the state you want to represent, and define multiple operations against it. This is what I meant in the intro rewrite when I said actors are a "state first" design like structs/classes when compared to closures.

Nominal types are great, because they allow a bunch of cool things that closures cannot do: e.g. they can conform to protocols, allow POP abstractions built with them, are the key for resilience, and support for multiple methods that interact with that state.

@sendable closures in structured concurrency and actors provide the same duality for the concurrent world!

The problem with the presentation of the proposal

Getting to my problem, the proposal does not express this simple nature! Where actors should be a simple nominal type with isolated self and the sync/async promotion dance going on, instead the proposal spends most of its words talking about complexity related to nonisolated, which isn't general and isn't the fundamental nature of actors. nonisolated is all about /breaking/ the fundamental property that actors provide - isolation of data.

To reiterate, I agree with you that nonisolated may be an important part of the pragmatic eventual user model, but it is an additive feature, and the presence of all the nonisolated complexity in this proposal is distracting and makes it very difficult to evaluate and reason about the actual actor part of the proposal.

The basic actors proposal (ignoring the nonisolated stuff) is not good enough

Furthermore, the proposed protocol model has a significant number of problems that will cause excessive use of async, does not allow protocol abstraction across actor definitions, doesn't interact well with generics, and has other problems I laid out in the motivation of the whitepaper I wrote. While the introduction of 'nonisolated' parameters is a positive step, there are still major holes in the proposal.

At the end of the day, I hear you state vociferously that things are "non-negotiable" but the model you're proposing is missing basic abstraction facilities that all other nominal types support. I can't see how introduction of actors without these basic features would be considered acceptable.

In short, while it isn't "necessary" to split all the non-isolated complexity out of the proposal, doing so would make it much easier to evaluate the core of actors, land it cleanly, then build on top of it. "non-isolated" members are not an acceptable substitute for proper interaction with the protocol and generics system in Swift.

-Chris

6 Likes

Actor.run { }

Yes but not in the way you're suggesting to use access control. It is the run, defined by specific actors that should be either internal or public, depending on the library's design. If it is predefined on the Actor protocol, we lose this flexibility.

If the function exists on Actor, it will be public/internal depending on what MyActor is. I may want to offer this ability to my library but not to everyone using the library.

Yes and that's fine, but should be opt in by actors which actually do this. Nothing I'm saying is preventing people from defining such function. I specifically argue for offering it on MainActor even after all.

That is not the concern; the concern is operations performed on this actor, please read my IOActor example again -- it is not about any state or any functions of the IOActor being touched. It is purely that someone may throw arbitrary computation at this actor.

And perhaps it has to be public because e.g. it has to be passed somewhere. It is easy to say "so don't make it public" but the reality of API design with actors is that it often is useful to allow passing them around, and if we force every actor to offer this completely arbitrary capability, we can't escape it.

If run exists on every actor ever defined, there's no way to "hide" or prevent people from using this.

If it is not pre-defined, anyone who wants this capability is free to define it for actors which want to support such usage pattern.

The actor model allows defining multiple actors that share an executor, so it is not semantic nonsense. The fact that the type system doesn't allow one to express that doesn't mean it is nonsense. We support unsafe casts specifically to allow Swift programmers to escape the type system. It is perfectly reasonable to touch the mutable state of two actors that share a context, and can be useful in advanced cases. If it weren't important, then we shouldn't allow actors to share a context.

In this case, we could even support a safe cast from an actor type to isolated actor type that dynamically checks that two actors are on the same executor. This would be the actor-cast equivalent of x!.

I think you're missing how these things work - functions aren't "scheduled", they are invoked from existing things (e.g. other actors or sturctured concurrency tasks) that are themselves scheduled.

Consider:

func foo(a: isolated MyActor, b: isolated MyActor) {...}

It's obvious where this gets invoked: it is only invokable from within MyActor's concurrency domain. You can do this trivially by passing self as both arguments, but if you have another actor instance that shares an executor, it is safe to cast that and pass it as well. This is all properly defined (but, as I mention above, I agree this is not going to be widely used ;-).

You seem hung up on whether the compiler's type system can statically enforce that, and I'm making the argument that this is not how Swift treats diagnostics in general and that there is no reason to deviate from the standard approach.

I'm claiming four things:

  1. It is trivially safe to pass self to both arguments to foo above, no unsafety involved.
  2. You can use withUnsafeActorInternals if you know another actor instance is on the same executor as the current actor.
  3. You can probably define a withSafeActorInternals(otherActor, thisActor) cast method that is memory safe by failing/trapping if thisActor and otherActor are on different executors.
  4. Banning this doesn't prevent any bugs in safe code.

Can you explain if you disagree with point #4 and show an example?

I also find this whole discussion very interesting given the proposed safety holes with nonisolated(unsafe) etc which would be a much more appealing footgun to offer people ;-)

-Chris

Hi all, I'm new to both the concurrency pitches and speaking up on Evolution in general, so apologies if some of these questions have obvious answers I'm just not finding in the documents. Maybe they'll at least prompt some example tuning?

I don't see any examples of chained calls in async/await, so I'm not sure if it's possible under the current phase of proposals. I'm trying to understand how something like the following might work:

struct S {
  var balance: Int = 0
  mutating func deposit(_ money: Int) { balance += money }
}
class C {
  var balance: Int = 0
  func deposit(_ money: Int) { balance += money }
}
actor A {
  var vally = S()
  var reffy = C()
}
var accountBook = A()
await accountBook.vally.deposit(10)
await accountBook.reffy.deposit(10)

My first guess is that both of the await lines would fail for one of two reasons: either await doesn't handle chained calls or because they'd decompose as equivalent to

// left A isolation at ;, x-actor ref to setter prohibited
{ var s = await account.vally; s.deposit(10); } 
// left A isolation at ;, x-actor ref to non-sendable ref type prohibited
{ var c = await account.reffy; c.deposit(10); } 

But my more hopeful guess is that one or both of them would decompose to the following:

await account.runOnActor { a in a.vally.deposit(10); }
await account.runOnActor { a in a.vally.deposit(10); }

This would be my first assumption for a fully finished actor model, because Swift IME has spent a lot of effort on devolving a type's interface to supporting member types, but I'm not sure if that's enabled by the current version of await and actor isolation.

I only started reading the concurrency pitches today yesterday, but my first pass of this one left me concerned that refactoring a given class to an actor will implicitly change all my properties to the equivalent of private(set) and force me to declare new setX methods that I already eschewed when designing the original class, because the members' interfaces served as extensions of the owner's interface. I understand the goals of

  1. Cannot pass mutable state across actor boundaries
  2. Prefer not to encourage storms of async a.x += 1; async a.y += 1; ...
  3. Prefer not to encourage large runOnActors of arbitrary computation

But these are all in tension with each other and I don't see explanation of how this design encourages or enables the healthy middle ground of messaging an actor to call one mutable method on one of its children—because you've already been trying to right-size mutation, which is why you grouped some data up in a supporting type. The closest thing to a suggestion I saw was essentially redeclaring the mutable interface of each member. I'm not sure that is adequate to help us use phase 1 refactors to decide what we want out of phase 2.

Which brings me to my second question:

Is isolated only applicable to a function parameter of actor type, or can it be used for any one parameter? I assume not, because I could not find any examples of this use, but IIUC non/isolated is otherwise an attribute of an actor's members so using it to refer to a parameter-of-actor-type made me doubt my understanding for a moment; once I translated it to a: isolator A it was fine. I certainly understand the desire to keep keyword count low. I also can't avoid pitching the flavorful starring A as an alternative though :wink:

That said, I really like the concept of allowing isolated on any parameter type, as a way to opt a function into an arbitrary actor's executor without adding the actor to the parameter list. I'm picturing something like the following:

actor Hero { // freshly retyped from class
  var stats = Stats()
}
func ponder(hero: isolated Hero) { ..; buffAll(hero.stats) }
class Stats {
  var hitting: Int = 10
  var thinking: Int = 10
  var tolerating: Int = 10
}
func buffAll(_ s: Stats) {
  s.hitting += 1
  s.thinking += 1
  s.tolerating += 1
}

// bad; Stats is non-sendable ref type (and maybe ill-formed to wrap async in a sync call?)
await buffAll(someHero.stats);
// signature updated as part of Hero refactor
func buffAll(_ s: isolated Stats) { … }
// ok; semantically similar to
// someHero.runOnActor { hero in buffAll(hero.stats) }
await buffAll(someHero.stats)
await buffAll(someMonster.stats) // actor Monster also benefits!

Is that something this pitch currently admits? If so, I think I'd appreciate an example or two in the pitch document—it certainly clarifies that isolated isn't actually being repurposed in a function signature, but is the same modifier we apply to member decls. Considering Swift often uses multiple related types to support the final interface of a type, it would be nice to see examples of how supporting types might update alongside the newly minted actor.

If this pitch doesn't currently consider that—could it? IIUC the compiler already needs to know the actor isolating each argument at each call site. Here's my reasoning:

  1. There is nothing special about self as of pitch 4; it is just a decl isolated to a Hero
  2. stats is another decl isolated to a Hero
  3. Some compiler operator isolating(nonisolated Actor) exists such that await ponder(someHero) decomposes to await isolating(someHero) { a in ponder(a) }
  4. The compiler uses some sort of isolatorOf() operator to check each of these decls at each call site, to determine things like "is this x-actor ref declared let in its parent?"
  5. buffAll(s: isolating Stats) could decompose to isolating(isolatorOf(someHero.stats)) { a in buffAll(a.stats) }
  6. That decomposition could, possibly as future work, be a projection with a keypath or internal equivalent: isolating(someHero.stats) => isolating(someHero, projecting: \.stats) { s in buffAll(s) }

This would also be another plausible justification for allowing multiple isolated parameters: to support functions that modify two non-actors that share an owning actor/executor.

extension Stats {
  // or global func steal(from: isolated Stats, toPay: isolated Stats)
  // only safe when (isolator(self) == isolator(other))
  isolated func steal(from other: isolated Stats) { 
      other.tolerating -= 1
      tolerating += 1
  }
}
actor HeroOfTime {
  var link = Stats()
  var fairy = Stats()
  // ok with or without non-actor isolated annotations
  func emergency() { link.steal(from: fairy) } 
}
// ok: isolatorOf(fairy) == isolatorOf(link)
await aTimelyHero.link.steal(from: aTimelyHero.fairy); 

I believe (hope) this would be an extension of the previous reasoning plus Chris's first claim:

  1. it is trivially safe to pass self to multiple isolated parameters of a function
  2. this is true because both copies of self trivially share an isolation context
  3. Both link and fairy are also isolated data members of HeroOfTime
  4. Their shared isolation context already needs to be checked in emergency even without the new modifiers.

One more reason this is potentially interesting IMHO: one of the "workarounds" for my first buffAll example would be to re-type Stats as an actor, because actor references are Sendable. Semantically, Stats is just a way to group up some related Ints that a lot of actors use; it's not really appropriate for it to synchronize separately from its owners, who will synchronously manage other properties in response to stat updates. This means I may want actor Stats to be passed its owner's executor upon initialization. Even if I expect all Hero->Stats messages to be optimized onto the same thread, Hero contains many synchronous calls to buffAll and they would have to be rewritten to async, muddying the waters on Stats's supporting role within its owner.

I'm not saying that's the correct architecture, but it's one I can see the current limitations encouraging, with some classes becoming actors out of convenience, who semantically ought to be managed by their owner's executor. Another pitch looks like it will enable that shared executor, so the ability to say "I know I can reference my member actor's members synchronously because I know they're isolated to my context" would be nice.

Okay last question: why are key paths currently disallowed, out of curiosity? Is it just a matter of waiting on Sendable to be accepted before it's workable, or are there some other aspects of actor that would make a readonly IsolatedKeyValue hard?