Exploration: Type System Considerations for Actor Proposal

I have a bunch of comments throughout, but not a whole lot of structure to them---so I'll go linearly. This also turned out to be rather long, so I'll summarize here:

  • I don't consider duplication of protocols for sync/async to be a significant problem, and where it is a problem it's better solved by a reasync counterpart to rethrowing protocols.
  • Actor isolation is not and cannot be "type-directed", because it is a property of values, but it is reasonable to want to be able to tag a parameter other than self as being "the actor-isolated parameter"
  • nonisolated is a fundamental part of working with actors and cannot be removed or replaced by "unsafe" mechanisms
  • nonisolated(unsafe) would be better replaced by nonisolated (on the declaration) and your proposed withUnsafeActorInternals (in the implementation)

On to the details...

Motivation

In broad strokes, I disagree with much of the provided motivation:

  • "Async requirements require duplication of protocols": I think you're extrapolating from two examples (Iterator and Sequence) to "many" without any reason to do so. Asynchronous code isn't just synchronous code with await sprinkled around; there should be a reason to use asynchrony. If not for ABI constraints, Iterator and Sequence could be better handled by something like a reasync counterpart to rethrowing protocols. The key point is that, with something like "reasync" (as with "rethrows"), you implement your generic operation based on the more complex model (async/throws) and it collapses down to the simpler one (synchronous/non-throwing) when arguments dictate. You can't go the other way.

*" nonisolated members are another "color" for your functions": I end up using nonisolated all the time when porting actor code. When one creates an instance of an actor, it's fairly common to have some bit of identifying and configuration state in the actor---the bank account number, the ID of a player, the address of a database, etc.---and that data is immutable by nature. It gets referenced a lot from places that don't want to be asynchronous: code wants to be able to access the bank account number to record it in a transaction, the ID of a player needs to be communicated with a server, and so on. Protocol conformances like Equatable and Hashable follow from this as well. This motivation section proposes that we don't need nonisolated, so long as (1) actors can fulfill sync protocol requirements and (2) we can unsafely poke into the mutable implementation. The solutions in this proposal don't really work safely for (1). And (2) is not good enough: it would mean throwing away the safety that comes with actors for something as simple as a computed property that derives its value from the aforementioned identity or configuration state.

  • "Actor reference handling is syntax directed, not type directed": You are absolutely right that cases like (self as IntActor).doSyncThing() could be permitted but aren't, and we could consider extending the rules to anything that makes a copy of self without modifying it. However, the result would have to be value-directed, not type-directed. Type-directed implies that all values of the given type have the same behavior, but this cannot be true: a function such as
    func f(a: @sync IntActor, b: @sync IntActor) { }
    
    can only make sense if a === b or a and b share the same serial executor. That's not a type system property, unless you're willing to say that every instance of IntActor shares a single, global serial executor. I can't imagine that's what we want, so we don't have a type-based property, we have a value-based property. It's fine to want to have the ability to tag a non-self parameter as the actor-isolated parameter, but that has to be a property of the parameter itself---it's not part of the type, and there cannot be two of them.

@async and @sync Actor types

@async actor types are just actor references, spelled IntActor or BankAccount or whatever. @sync actor types are really types; they need to be values. Ignoring that issue, it is not unreasonable for something like your useSyncActor(a2:) example to work with @sync as an indicator on the parameter that this is the isolated actor. I'll reproduce the example in part here:

func useSyncActor(a2: @sync IntActor) {
  print(a2.x) // okay, no `await` needed
  let a3 = a2
  print(a3.x) // okay, if we note that a3 is an exact copy of a2 by applying some flow-sensitive analysis
}

I don't find the above too motivating, but I something like the proposed withUnsafeActorInternals would be a reasonable extension. Earlier versions of the actors proposal had a "run a closure on an actor" operation that looked like this:

extension Actor {
  func run<T>(body: () async throws -> T) async rethrows -> T
}

but we removed it, in part, because it would be a lot more useful if we could say statically that we're working on the actor instance, e.g., to use your proposed syntax:

extension Actor {
  func run<T>(body: (self: @sync Self) async throws -> T) async rethrows -> T
}

That's a genuinely useful operation we could add whenever. Right now, you have to extend the actor to write code that's within its isolation domain.

I like withUnsafeActorInternals a lot, and it seems like a good replacement for nonisolated(unsafe), but it does not eliminate the need for nonisolated. Wherever one would write something like:

nonisolated(unsafe) func doSomething() { /* poke at mutable actor state */ }

it would be replaced with

nonisolated func doSomething() {
  withUnsafeActorInternals(self) { self in 
    /* poke at mutable actor state */ 
  }
}

That has the benefit of moving the unsafe code into the body, so we can have fewer concepts at the declaration level.

Revising actor protocol conformance

This whole section rests on the assumption of "type-directed", but it doesn't work. This example shows Equatable:

extension MyIntActor : Equatable {
  // Go and synchronously poke into mutable state, perfectly safe.
  static func ==(lhs: @sync MyIntActor, rhs: @sync MyIntActor) -> Bool {
    (lhs.x, lhs.y) == (rhs.x, rhs.y)
  }
}

As I noted previously, this cannot be safe unless all MyIntActor instances share the same serial executor. You have a later example of @asyncPromoted Equatable being ill-formed; that same reasoning applies even without generics or existentials.

@asyncPromoted protocol types

This feature projects a protocol with synchronous requirements into a corresponding protocol with asynchronous requirements. As noted previously, I think you've over-extrapolated from Iterator and Sequence, and I think that something like reasync protocols are a lighter-weight way to address the (IMO relatively rare) set of cases where a single protocol needs no changes whatsoever to become asynchronous.

Doug

8 Likes