[Pitch #4] Actors

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?

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?

Doug

Yes, this is the correct reading of the proposal.

I suspect it is your #3 that would end up being the right answer here, or extending the actor type to add a new method that performs the mutation you need.

isolated can only apply to parameters of actor type. It sounds like we should make that clearer and perhaps have an example with an "error: parameter of type 'Int' cannot be 'isolated'" or similar.

Since Stats is a class, a parameter of type Stats cannot be isolated per this proposal. However, I will certainly add more examples showing how isolation parameters can be used.

If key paths are meant to be Sendable, then you don't want a key path that refers to actor-isolated state but can be evaluated for a non-isolated instance of the actor.

Doug

I disagree with this because of where the unsafe code would be.

If declaring functions with multiple isolated parameters were legal, e.g.:

func x(a: isolated A, b: isolated B) {} 

this gives people the impression that this "totally can somehow work, yay". While it only can be working in a very narrow case. Developers seeing such function are led to just "passing whatever makes this work", which would be obtained via withUnsafeInternalState(a) { a in withUnsafeInternalState(b) { b in x(a: a, b: b) } } which the vast majority of the time is wrong.

I claim, that such unsafe patterns should be done inside the functions which to the unsafe tricks, i.e. that the function should be:

/// Actors A and B must be on the same exact serial executor, or this function will trap.
func unsafelyX(a: A, b: B) {
  precondition(a.executor == b.executor) (and they're known to be a serial executor)
  withUnsafeActor(a) { a in withUnsafeActor(b) { b in ... } }
}

This is less wrong in the promises the function signature makes IMHO. It does not promise things about the function magically being isoalted to "two" actors, which simply isn't a thing. The tricks we can do with more knowlage about execution are valid and useful, but they are unsafe tricks and should remains such, and not pretending in function signatures we can do something that we actually can't.

If there was some way to unsafely obtain such isolated A, it is possible for this function to actually be unsafe.

In other words: It is about the where the unsafety is exposed, and I would never want a function that only uses safe spellings, to suddenly become unsafe - which the "multiple isolated parameters" would cause (unless we do the long, involved static proving stuff).


Alternatively, it could be allowed to say "multiple isolated(unsafe) actor parameters" are ok, since this puts the word unsafe into the function signature again, alleviating the concern about a safe-looking function actually not being safe.

But I'm not a huge fan of making this pattern so easy to begin with. I am very much interested in making such uses efficient, safe, and nice in the long run though -- especially since on the server such pattern appears a lot, esp. when using event loop based IO systems, such as NIO.


No, not really hung up on it, but rather I'm showcaseing how hard it is for this "multiple isolated parameters" to actually be safe. This is why I'm saying we should not allow this, and if we end up really wanting to support this, these are the steps that would need to be taken for it to be statically safe.

It definitely is a lot of analysis, and I'm not pushing for it at all, but it could be done if there is enough need for it. It would be a natural loosening up of the model, because we added more checking and are confident this code can ge safe.

I'm also specifically calling out the executor hop-avoidance work which may render this entirely unnecessary, because perhaps hop-avoidance will be good enough. We don't know yet, and this is why I don't think we should just allow signatures which promise more than they are able to guarantee.

Yeah, very much agreed.

Rather than just slapping a run on all actors, since you're designing your actor to encapsulate some specific work, such "change a bunch of options together" should be e.g. exposed as some:

protocol WorkerSettings { 
  var throttle: Int {get set}
  var max: Int {get set}
}

actor Worker: WorkerSettings { 
  var throttle: Int
  var max: Int  
  func reconfigure(_ body: inout WorkerSettings -> ()) {
    body(&self)
  }
}

worker.reconfigure { settings in 
  settings.throttle = 10
  settings.max = 100
}

I think this is a much cleaner and future proof design, rather than just exposing some pretty much arbitrary "run random stuff here" run function.

As I said before though, you can just expose such run(body) if you really wanted to -- but it's up to specific actors to decide if they want to do this or not. Rather than the entire concurrency story encouraging this.

// or really, I’d have the settings be a var settings then it’s nicer, but to prove the point about mutating multiple things at once without interleaving this is spelled like this.

So, I think we fundamentally disagree about what kinds of protocols are important for actors to conform to. For me, most interactions with actors are naturally asynchronous---they are coordinators and controllers and owners of data models that need to be consistent.

I am not strongly motivated by the arguments in your white paper because I fundamentally don't see actors as being "the data" that you operate on, because "the data for me should usually be a value type. Your motivation provides this example:

actor MyDataActor {
  var data: Data
  func compressData() -> Data { use(data) … details omitted … }
  func doThing() {
    let compressed = compressData()
  }
}

public protocol DataProcessible {
    var data: Data { get }
}
extension DataProcessible {
  func compressData() -> Data {
    use(data) 
    … details omitted … 
  }
}

I don't think I would model things things this way at all. Data is presumably a value type, so compressData would be an algorithm on it that returns a new Data value. It doesn't belong on the actor type. doThing() might be a completely reasonable operation, but I expect it will be something like:

  func doThing() {
    let compressed = data.compressed()
    // do more to the compressed data
    data = resulting-data
  }

I'm trying to come up with another example I've come across to swap in to bolster your argument, but I have not come across one yet. I don't want an actor to be a Sequence: I either want it to store a sequence as a member or I want it to be an AsyncSequence`. I've come across various existing async protocols that are spelled with completion handlers:

protocol Server {
  func send<Message: MessageType>(
    message: Message,
    completionHandler: (Result<Message.Reply>) -> Void
  )
}

And with nonisolated and Task.runDetached, I've taught actors to conform to them:

actor MyActorServer {
  func send<Message: MessageType>(message: Message) async throws -> Message.Reply {
    // actual implementation
  }
}

extension MyActorServer: Server {
  nonisolated func send<Message: MessageType>(
    message: Message,
    completionHandler: (Result<Message.Reply>) -> Void
  ) {
    Task.runDetached {
      do {
          let reply = try await send(message: message)
          completionHandler(.success(reply))
      } catch {
        completionHandler(.failure(error))
      }
    }
  }
}

(Yes, I'll add this to the proposal)

I could perhaps buy that nonisolated can be separated out because it makes the proposal confusing, but I completely disagree with your assessment that the proposal is missing basic abstraction facilities. Actors can interact with asynchronous protocols, which fits well with their nature. They can aggregate other types---usually value types---that can conform to whatever protocols are needed for the synchronous "computation" bit. Actors do not need to themselves be the abstraction for synchronous computation, because we have better types---value types---to cover those cases.

Doug

1 Like