[Pitch #4] Actors

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