[Pitch #6] Actors

Yes, I believe so

_ = A().h() // not ok: must be awaited because suffix-async

This is the antonym problem I was talking about: the prefix sync misleads the API consumer into believing they don’t need to await the call even though the suffix async still requires it.

To state my priors: I assume “an outsider can call this in-sync” means the opposite of “must be called async -> must be awaited”. The prefix sync cannot make this promise as long as it can share a signature with the suffix async because the prefix only considers one reason there may be a suspension point but the suffix flags the function as being an asynchronous context in which any number of suspension points (including 0) may occur for any reason. Calls to this "must occur within the operand of an await expression (async/await: await expressions)"—no matter what the other portions of the signature claim.

In other words, suffix async is not so much awaitable body as it is call mustAwait. This is parallel to throws representing call mustTry instead of tryable body. In this context, prefix sync is like trying to spell rethrows as noThrow func f(() throws -> R) throws -> R. One side of that signature is lying to me but I don't "know" which until the compiler finishes static analysis.

This conceptualization is why I personally doubt there's much of anything to "unify" into sync, which itself is just the special case async<Never> (to borrow from typed throws). The more I think about it, the more I want to treat Isolation (really, Message) as just a subtype of suspension point, alongside Subtask, Yield, Continuation, etc. Specifically, Message points can be elided statically, conditional on the static context of each call. A function that is async<Message>, that is one whose only suspension points are of type Message, might be able to determine it can merge or even strip all the suspensions in the call once it sees the call site and knows whether the executor hosting the call also hosts the actor(s) referenced. Therefore both suffix async and (the implict) suffix sync are incomplete descriptions of the signature, because either could be wrong.

As I hinted above, this seems like a job for a spelling similar to rethrows and reasync.

actor A {
  // synchronous within A's **executor**, async across it
  // can message other actors w/o await if they statically share execs
  // can also handle x-exec msgs, so `await self.f` still common?
  // for this reason I might recommend `message` as an alternative
  // except that makes less sense as a parameter modifier
  func f() isolated -> R
  // always synchronous (nonisolated)
  func g() -> R
  // awaits a "type-erased" set of suspension points
  func h() async -> R
  // asserts body doesn't need to await B (and so awaits A)
  // but call may be sync if caller.exec == a.exec == b.exec
  func i(o: isolated B) isolated -> R
  // as above but some other task type is being awaited
  func j(o: isolated B) async -> R
}

// as f
func k(a: A) isolated -> R
// as g
func l(a :A) -> R
// body will need to await both
// but call may be sync if caller.exec == a.exec == b.exec
func m(a: A, b: B) isolated -> R
// as m, but assert that body doesn't need to await A
func n(a: isolated A, b: B) isolated -> R
// as n, but assert body treats A and B as same
func o(a: isolated A, b: isolated B) isolated -> R

From a previous thread, it sounds like this kind of executor analysis may be hard. This probably would result in more await operators than the current isolation pitch but that may not be a bad thing, as mentioned upthread:

In fact, maybe asserting a non-self actor as isolated is an anti-pattern in a distributed world, unless we can ship the whole body over the wire?