[Pitch #6] Actors

I do understand the concept quite well. I just find the terminology confusing, and trying to express the confusion must sounds confusing. It does feel to me like the meaning is reversed from what it should be, half the time, depending on whether you're inside or outside of the actor. My opinion is that using a different keyword would help alleviate the confusion, even though it adds one more word.

I don't, because that proposal is a bit further down the dependency chain. We can riff on this over in that thread. But...

So are isolated parameters. Given that much of the discussion in the last few iterations has been about isolated parameters, I've separated out isolated parameters and nonisolated declarations into a separate proposal on improved control over actor isolation. It's not actually a significant simplification to the proposal, in complexity or word count, because the same concepts have always existed in the model---we just delay naming them.

EDIT: added a link to the separate proposal. The actors proposal has also been updated accordingly.

Doug

While the isolation of the subscript would matter, so do the effects exhibited by the process of performing the lookup for the given KeyPath. Consider this example:

actor A {
  var next : A?  // an actor-isolated member

  func doLookup(_ kp : KeyPath<A, A?>) -> A? {
    // this context has same isolation as self, 
    // so no await needed here, right?
    return self[keyPath: kp]
  } 
}

func f() async {
  let chainedLookup = \A.next?.next
  //                    ^     ^
  //                  sync*  async
  //
  //   *depending on actor-isolation of context and actor instance
  //    we start the lookup from. Could also be async.

  // this await only covers the call to enter `doLookup`
  await A().doLookup(chainedLookup)
}

The un-awaited call to subscript(keyPath:) in doLookup is problematic in this example, even though it is on self, because the chainedLookup it receives will access an actor-isolated member from one of its members, which is an async operation. We don't track this fact because KeyPaths (and its type-hierarchy friends) do not carry information about the effects that may be exhibited during the process of performing the lookup (since effectful property accesses did not exist before).

For background, when performing a chained member lookup that involves actor values, each individual component of the lookup might be an async operation. So, even ignoring KeyPaths, we would have three async operations in this example (marked by ^):

await A().next?.next?.next
//       ^     ^     ^

because each component of the lookup is performed on a different actor instance.

So, without creating another hierarchy of KeyPaths, such as AsyncKeyPath, there's no nice way to allow key-paths to actor-isolated members. This chaining problem goes away once we take on that restriction, because it would become invalid to form \A.next?.next because next is a var-bound (and thus isolated) property. If it were let-bound, the chain is OK because actors conform to Sendable.

3 Likes

While I agree with this point for the Sendable versus @sendable debate, I think the inout-versus-mutating, owned-versus-consuming duality is somewhat unavoidable when it comes to naming the method versus naming the parameter.

An intuitive modifier for func is simply not going to mean the same thing for self or another parameter, and I think that's what's prompted so many comments in pitches #4-6 about understanding the concept of an "isolated" parameter. In fact, I struggle to think of a situation even in English generally where this would work well without totally diluting the modifier:

@Douglas_Gregor is making a valiant effort to tackle this difficult design problem.
... is not interchangeable with ...
@Douglas_Gregor is making a difficult effort to tackle this valiant design problem.
... although, if we water it down to an anodyne word...
@Douglas_Gregor is making a big effort to tackle this big design problem.

I get the idea of not using different wordings for the same concept unnecessarily, but where plainly a wording just doesn't fit, trying to stick to the same word is (as the feedback to pitches #4-6 have proved) likely to create problems of its own--and maybe more than it solves. Better that someone understand how to use the same concept two different ways but not make the connection between them, than to have someone not quite grasp the concept at all because the wording is totally unintuitive (since not understanding the word in one context sows doubt about one's understanding of the word in other contexts).

With that in mind, how about something like: isolated func / origin[ating] self.

Let's try to unify isolated/non-isolated into single sync term and explain it in different position/scenarios.

actor Actor {

    sync func f ( v: sync someActor, _: otherActor) async {...}
//  ----*----        -------*------                 -----*-----
//      ^                   ^                            ^
// call me in-sync | access me in-sync            await·able body

}
  • prefix sync-func-call means outside caller call func in sync way

  • infix sync-param-access means switching executor to access actor parameter like inside in sync way

  • suffix async-body-await means func body can call other async stuff in async way, await·able body.

From outside world, f is sync; from inside world {...} the body is async.

Turn back to the example:

any problems?

insync instead of just sync. A letter difference is too little. Synchronized? Synchronous?

Sounds like this part of proposal is broken out into a different one now though.

Thank you for the example of how key-paths can be problematic when used with actor-isolated properties. I think I understand, but I want to poke at this a bit more to see if we can limit the restrictions. It seems that the crux of the problem is that using a key-path may take what looks like a synchronous call and require it to be async, as shown in your example above:

let chainedLookup = \A.next?.next
await A().doLookup(chainedLookup)

You called out that the initial lookup (\A.next) is either sync or async depending on the actor-isolation state of our access to the actor performing the key-path lookup. On the other hand, the next segment of the key-path is unconditionally async, even when the calling context would be synchronous. It seems to me that that is where the problem is.

If I have isolated access to an actor instance (e.g. on self within the actor), any of its immediate properties are be safe to access synchronously; doing so can never change the access from synchronous to asynchronous. Likewise, I can access the chained properties of any struct/enum* properties, because they can never force the access to become asynchronous.

The problem arises when applying a key path that forces an otherwise-synchronously-accessible key path to become asynchronous. It seems to me that that happens when the key-path goes through an actor instance. So for example:

actor A {
  var next: A?
  var identifier: String
  var size: CGSize

  func doLookup<T>(_ kp : KeyPath<A, T>) -> T {
    return self[keyPath: kp]
  } 
}

let a = A()

// This keyPath should be safe; it cannot force an async lookup
let id = a.doLookup(\.identifier)

// A chained lookup can be safe too
let height = a.doLookup(\.size.height)

// Retrieving a reference to another actor is also safe;
// it cannot force an async lookup
let next = a.doLookup(\.next)

// Things to wrong when we try to go _through_ an actor
let problem = a.doLookup(\.next.identifier)

This suggests to me that it might be possible to only restrict key-paths that go through an actor-isolation change; that is, they start in one isolation context but end in a different one.

There's also a potential issue with appending key paths, though:

// This never causes an isolation change, so it's okay
let structPath =  \A.size.height

// This never causes an isolation change, so it's okay
let pathToActor = \A.next

// This is a problem; now we're going _through_ an actor.
let combinedPath = pathToActor.appending(structPath)

Since we can append key-paths together, it seems that individually-isolated key paths cannot be combined safely. That's a problem. If we prohibit key-paths to actors, though, then it seems safe? So a key-path from an actor is fine, so long as it never goes to (or through) another actor.

Incidentally, it's not clear to me that removing Sendable conformance from KeyPath would resolve the safety problem:

extension A {
  func doBadStuff() {
    let asyncPath = \A.next?.next

    // We have isolated access to `self` here, but using 
    // badPath would still cause an asynchronous lookup.
    self[keyPath: badPath]
  }
}

So yes, thank you for helping me to see the potential problems with key-paths. I do think that it may be safe to form a key path so long as it never goes to an actor. But there is sufficient complexity here that it may be better to accept the full restriction for now; we can always relax the restriction later.


* I think this applies to classes too, but I haven't fully digested the implications of classes within actors, and I don't want to get distracted on that point because I don't think it's relevant to my argument here.

1 Like

Yes this is the general problem: if the actor isolation of the accessed member does not match the actor-isolation of the context in which the subscript(keyPath:) is initially invoked, then it would require an async operation. Actors are currently the only area where async effects may arise while performing a lookup, but they may not be the only one in the future.

1 Like

I have merged this pitch as proposal SE-0306, and scheduled it for review starting next Monday.

7 Likes

I completely agree. Swift walked into this by making self implicit for methods, which requires that the modifiers for self be specified on the func. Rust and C++ take a different approach, the former by making self be explicit and the later by putting the modifiers after the signature. The rough equivalent in Swift syntax would be something like this:

struct MyStruct {
  mutating func foo(a: Int) {  // Current swift
  func foo(self: inout MyStruct, a: Int) {  // rust-like
  func foo(a: Int) inout {  // C++ like
}

The ship sailed a long time ago, but I'll explain why we went with the current approach: it avoids the boilerplate of the rust approach, and it is more fluent/obvious than the C++ approach.

The downside is that it does require two keywords for one concept, but I don't think we should try to artificially "fix" this problem with actors, particularly given how rare the @sync modifier is going to be. Such a move would introduce inconsistency with the rest of the language and reduce clarity for the rare cases this thing occurs in.

Instead of tersifying the parameter modifier, I think we should look to use a more verbose and googleable keyword, perhaps a multiword conjunction.

Sounds like a great approach, thanks!

-Chris

1 Like

Thanks Joe. Here are some comments on the revised proposal ahead of the review in case it is helpful. I'll make a more detailed pass when the review is run:

The reference to other.accountNumber is allowed based on this rule, because accountNumber is declared via a let and has value-semantic type Int .

I'd recommend pulling direct cross-actor references to immutable state into the same proposal as the nonisolated discussion. It is the data equivalent of nonisolated (and, indeed, the fundamental thing that nonisolated builds upon) and raises similar semantic concerns because it providing sync access to cross-actor state.

I've mentioned this in passing before, but the actor model throughout academia has strong benefits from forcing all cross-actor references to be async through the mailbox. One example where this comes up is with distributed actors (which seem like a pretty important goal for us to enable and build towards).

The issue here is that distribution benefits from making all cross-process/machine accesses be async, since I/O is naturally a suspension point. This naturally aligns with the classic actor model, but allowing and encouraging cross-actor sync accesses for immutable data as a key design point will encourage design patterns that don't scale to distributed scenarios. This will make distributed actors second class and will require a different set of design patterns to support them.

This is one simple example of enabling cross-actor sync isolated state access undermines the actor model -- and I'm sure there are many others.

  • If the closure is @escaping or is nested within an @escaping closure or a local function, it is non-isolated.

I've mentioned this before, but the issues pointed out in this proposal have nothing to do with actors: The same concerns occur in structured concurrency. I'd recommend investigating ObjC completion handlers as @Sendable and marking dispatch_sync and friends as requiring @Sendable closures. Has this alternative been investigated? If so, it would be great to mention it in the proposal, at least in the alternatives considered section.

Such approaches are much better because they will fade out of the ecosystem over time, whereas this rule will be with Swift forever, and is known to break modeling of certain APIs (those that want @escaping/non-@Sendable closures) like classic non-thread-crossing callbacks.

All actor types implicitly conform to a new protocol, Actor:

It seems like we should name this AnyActor to align with AnyClass and other type erasers.

Other random comments:

  • Since actors support inheritance, it would be good to mention how actors interact all the ugly bits of classes: required/designated/delegating/convenience initializers? class methods (or are they actor methods)? etc. It would also be good to include a short mention/rationale for why they support inheritance in the main proposal (but thanks for including the discussion in the alternatives considered section!)

  • It would be nice to mention the two other supportable models for protocols, and explain why the proposed one is superior. The claims up thread don't really make sense to me (restating this is the right model without doing a tradeoff comparison), though I agree this approach looks sound, thank you!

-Chris

2 Likes

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?

After pondering on this more, I am pretty supportive of the proposal's model for protocol conformance. I appreciate its simplicity. Also, while I think it will have a pretty big impact on the library design community, it is also true that there are no actors currently in the system, and thus we will be building all new patterns and abstractions anyway, so it will probably work out ok.

To reiterate Doug's points, unifying across actors, classes, and other types seems like a useful thing, but it is "nice to have". The most important case of this is the client side problem, and this can be handled by letting actors conform to "will always be async" protocols:

@i_will_never_introduce_a_sync_requirement_in_the_future
protocol P {
  func f() async
  func g() async
}
actor MyActor : P {...} // ok

and maybe we can talk ourselves into not needing the attribute for protocols that currently only have async requirements. In any case, this is a refinement above and beyond the base actors proposal.

Thank you for pushing this forward Doug,

-Chris

1 Like

The key thing about distributed actors is that they may be remote, but there is also a pretty important use case for local instances of potentially distributed actors. Once you have that, people will want to write special optimizations for the "it is actually local" case, and it will be useful to be able to model that with a dynamically checked cast sort of thing.

-Chris

1 Like

Just getting around to reading this proposal (looks good so far), this isn't related to anything important in the proposal, but I noticed one of my favorite traps in one of the examples:

Unnecessary blocking with non-reentrant actors

Consider an actor that handles the download of various images and maintains a cache of what it has downloaded to make subsequent accesses faster:

// assume non-reentrant
actor ImageDownloader { 
  var cache: [URL: Image] = [:]

  func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      return cachedImage
    }
    
    let data = await download(url)
    let image = await Image(decoding: data)
    return cache[url, default: image]
  }
}

The serialized execution of partial tasks on the actor ensures that the cache itself can never get corrupted.

That is quite the understatement, since the cache will actually never be modified in this example!

The Trap: Dictionary subscript with default value does not insert the default value into the dictionary.

6 Likes

I've been reading the actor proposals since the first and I've experimented with them on my own as well. I've come to a simple conclusion: the model pitched here is too complex and too limited for it to be understandable, teachable, or useful enough to most Swift users. I believe this for several reasons.

  1. Exposure of the isolation model directly to creators and users of actors significantly increases the learning curve for this feature. Removal of the explicit isolated and nonisolated keywords in this latest proposal simplifies the proposal but not the model itself.
  2. Using async for safety is unnatural, leading to the overuse of async functions, infecting everything that tries to use actors.
  3. The intersection of actors and the Sendable types of SE-0302 increases the learning curve even further.

Compiler-verified and powered thread-safety would be a huge boon to Swift. However, the value is severely diminished if using the feature leads to unnatural APIs, difficult to understand code, or a model that actually increases the learning curve over the current manual safety patterns. That is, actors should enable simpler development of thread-safe types and shouldn't require different usage than the thread-safe types we currently have.

We can see this in practice by looking at Alamofire's Request type. It protects itself in two ways: the use of an internal serial queue (targeted on another) and a state lock (the @Protected wrapper) around a MutableState struct which stores state which may be mutated between threads.

public class Request {
    @Protected
    fileprivate var mutableState = MutableState()
}

Part of the Request's job is to manage updates to an internal State enum as well as the currently active URLSessionTask. It exposes a cancel() method which mirror's the task's cancel() while also performing other internal bookkeeping. It's critically important all of this work is done synchronously and in a blocking manner. Therefore we use a closure API provided by Protected to do it atomically.

@discardableResult
public func cancel() -> Self {
    $mutableState.write { mutableState in
        guard mutableState.state.canTransitionTo(.cancelled) else { return }

        mutableState.state = .cancelled
        underlyingQueue.async { self.didCancel() }

        guard let task = mutableState.tasks.last, task.state != .completed else {
            underlyingQueue.async { self.finish() }
            return
        }

        // Resume to ensure metrics are gathered.
        task.resume()
        task.cancel()
        underlyingQueue.async { self.didCancelTask(task) }
    }

    return self
}

An actor implementation of this functionality would have several deficiencies, at least as I understand the proposal.

public actor Request {
    fileprivate var mutableState = MutableState()

    @discardableResult
    public func cancel() -> Self {
        guard mutableState.state.canTransitionTo(.cancelled) else { return }

        mutableState.state = .cancelled

        // No way to enqueue work?
        // underlyingQueue.async { self.didCancel() }

        guard let task = mutableState.tasks.last, task.state != .completed else {
            // underlyingQueue.async { self.finish() }
            return
        }

        // Resume to ensure metrics are gathered.
        task.resume()
        task.cancel()
        // underlyingQueue.async { self.didCancelTask(task) }

        return self
    }
}

There are several issues here.

  1. This method would only be useful in an async context with await due to the actor's isolation. This is an unnatural pattern (there's no actual async work from the user's perspective) that shouldn't be exposed to users of the API. At the very least I should be able to do the work necessary to hide that complexity from the user. Ideally the actor itself would treat my synchronous functions atomically automatically.
  2. This usage exposes me to reentrancy issues where the original does not (and I had to fight hard to fix them in the initial implementation). Again, I should be able to manually replicate this behavior or, better yet, have the actor do it for me. The proposal suggests there could be a solution but provides no example:

Generally speaking, the easiest way to avoid breaking invariants across an await is to encapsulate state updates in synchronous actor functions. Effectively, synchronous code in an actor provides a critical section, whereas an await interrupts a critical section. For our example above, we could effect this change by separating "opinion formation" from "telling a friend your opinion". Indeed, telling your friend your opinion might reasonably cause you to change your opinion!

  1. There doesn't seem to be way to enqueue work off the hot path. I don't want to runDetached here, I want to enqueue on the internal queue to run after my mutations are complete so the state isn't locked too long and the user's call of cancel() returns quickly.
  2. I can't expose a synchronous property at all. Once the user calls cancel() they should be able to check isCancelled == true. That kind of API doesn't seem possible at all.

This is long enough so I'll stop here. Am I way off base here?

5 Likes

Sounds like your Request type is basically a future. I agree that implementing futures with actors doesn't seem like it would lead to a good design.

Not really, no, aside from the fact it can produce asynchronous events and eventually completes. Overall, Requests encapsulate a variety of work, much of its async, including setup, encoding, response behavior customization, retry, response decoding, and completion. To me, this sounds like an ideal use case for an actor: a variety of mutable state that needs to be protected from access on multiple queues while also performing work on still other queues. So if that’s not a good use for an actor, what is?

That doesn’t really make sense to me, as the primary selling point of actors seems to be the protection of mutable state, and the APIs that perform that mutation, from simultaneous access. That’s the example given throughout the proposal at least: BankAccounts, for whatever reason, need to be mutated from multiple threads, and often between each other. That’s almost exactly the same use case as Request: the user mutates the instance to change behaviors, it’s mutated as data is returned and response methods are called, as it retries tasks, and as it communicates with another actor (the session). So no, it is not a data type, and it is not a plain Sendable type (though it likely will be Sendable, since pretty much everything in Swift will need to be).

Although rare, it does make sense to make multiple resume() and suspend() calls, sometimes simultaneously and from separate queues, depending on the complexity of the user’s request handling. We test that exact scenario. Our Session type also makes sense as an actor, though it doesn’t really mutate. It’s more about facilitating safe communication between the in flight requests and their parent Session, so that aspect will be useful.

In any event, my exact example isn’t really relevant unless there are misunderstandings of the actual model evident. My general points still stand, so it would be more productive to address those unless you have something concrete to add to the Request example itself.

1 Like

As to (2) I sort of agree. I would actually love this reversed so async/await was inferred and you'd specify something like "sync" in the rare case where you want to guarantee that this will stay synchronous. E.g.:

sync func f() { ... } // Will stay "sync"
func g() { ... } // Currently "sync" and optimized that way
func h() { ... calls an async function ... } // Inferred async

sync g() // Call g(), but fail at compile time if g() is now async

All method invocations would be potential suspension points, except if prefixed with "sync".

I realize that ship has sailed, though :smiley:

  1. Couldn't you just make cancel() nonisolated (i.e. "sync"), and then use runDetached() in the implementation?
  2. I guess the solution is nonisolated / "sync" functions to guarantee no suspension points (and hence no re-entrancy)?
  3. Why don't you want to use runDetached()? As I see it you want Request to have a mix of an async and a sync interface. Actors are all about async interfaces, so perhaps you should have an Actor with a Sendable "MutableState"? cancel() could then be synchronous, update the mutableState synchronously and then use runDetached() to execute the remaining work asynchronously. And perhaps move as much as possible out of the MutableState.
  4. I think I've seen getter-only properties being pitched?