[Pitch #2] Actors

Hi all,

Thank you all for the wonderful discussion so far on concurrency and on actors. We've revised the actor proposal for a second "pitch", incorporating lots of feedback from the first pitch and other related discussions. Here are the changes from the first version of the proposal:

  • Added a discussion of the tradeoffs with actor reentrancy, performance, and deadlocks, with various examples, and the addition of new attribute @reentrant(never) to disable reentrancy at the actor or function level.

  • Removed global actors; they will be part of a separate document.

  • Separated out the discussion of data races for reference types.

  • Allow asynchronous calls to synchronous actor methods from outside the actor.

  • Removed the Actor protocol; we'll tackle customizing actors and executors in a separate proposal.

  • Clarify the role and behavior of actor-independence.

  • Add a section to "Alternatives Considered" that discusses actor inheritance.

  • Replace "actor class" with "actor".

    Doug

37 Likes

Well done, also nice improvements.

3 Likes

The addition of @reentrant is very welcome.

The pitch has this to say about await:

The potential for interleaved execution at suspension points is the primary reason for the requirement that every suspension point be marked by await in the source code, even though await itself has no semantic effect.

It seems to me that within a @reentrant(never) context this raison d'ĂŞtre of the await keyword is no more. Instead it signals a potential deadlock happening if you're not careful.

I suggested in the past that a different keyword should be used instead of await when the effect is to block the current actor. I still believe it'd make the code easier to analyze if the spelling was different whether you have to care about interleaving or deadlocks. @reentrant can be located quite far from the code you are reading, where its effect becomes important.


The search is as follows:

  1. The declaration itself.
  2. If the declaration is within an extension, the extension.
  3. If the declaration is within a type (or extension thereof), the type definition.

If there is no suitable @reentrant attribute, an actor-isolated function is reentrant.

Does 3 include type nesting?

@reentrant(never)
actor X {
    actor Y {
       func something() {
           // is this reentrant or not?
       } 
    }
}
1 Like

A few mechanical points:

  • Under "Existing practice," the first sentence is unfinished ("There are a number of existing actor implementations that have" is the entire sentence), and in the sentence beginning "Note that Orleans" the text misspells the word "add[s]" as "ad."

  • The internal link in the table of contents to the newly inserted section "Proposal: Default reentrant actors[...]" does not work; it also seems odd to have a specific subsection in a proposal titled "Proposal" and the section heading works without that word.

  • Under "Task-chain reentrancy," the discussion of the async example (the second example in that section) says that "Under the current proposal, this code will deadlock," but under the current proposal, actors are reentrant by default, and I thought the point was that this design decision all but eliminates deadlocks? Either the example should use @reentrant(never) attributes or it should explain how a deadlock is possible despite using reentrant actors.

Points of clarification/text revision suggestions:

  • The @reentrant and @reentrant(never) annotations are said to be supported not just for actors, but actor methods and extensions: there are no examples of the latter two uses. I imagine (it seems to be the only logical design) that a @reentrant(never) extension defaults all the methods it contains to non-reentrancy, which can then be overridden on specific methods by @reentrant to restore the original default. Can the proposal specifically show some examples to describe this behavior (or, alternatively, explain and justify what the intended behavior is if not that)?

  • The section discussing actor inheritance summarizes the arguments for and against inheritance well. It states in concluding that "actor inheritance has use cases just like class inheritance does": this would be strengthened by providing or describing one such use case that you have in mind--or is that statement specifically referring to the first bullet point about porting existing class hierarchies? (By the way, typo in the word "hierarches [sic].")


Design suggestion:

It seems to me that @actorIndependent refers to a concept that's as intimately tied to actors as final, override, convenience, etc. are tied to classes. It also seems to me that the underlying concept is just as fundamental to the use of actor methods as inheritance concerns are to class methods.

Further, like those keywords for class methods, @actorIndependent can only be used on member declarations in actors or their extensions [edit: or on an actor or extension itself].

Attributes, while lately often used for new features added to the language, give (at least to me) the suggestion that a feature can be used in multiple different places, and also that it is a somewhat advanced and/or optional feature, a "decoration." Indeed, the authors' impulse to name the attribute @actorIndependent and not just @independent suggests that they also intuit that the latter spelling would make the attribute seem too divorced from actors, reinforced perhaps by the style where longer attributes are often written on a line above the rest of the declaration.

I note that, in the proposal, "actor-independent" is contrasted with "actor-isolated" as its antonym.

Taking all of this into account, I would suggest consideration of using the contextual keyword nonisolated (and, of course, nonisolated(unsafe) as the corresponding unsafe feature) instead of @actorIndependent. I think nonisolated placed in front of func (or subscript or var) inside or on an actor or its extension adequately describes the feature (without having to clarify further that it's not actor-isolated), particularly as it'd be a contextual keyword which manifestly is not supported anywhere else but in the context of such a declaration. And visually, this spelling elevates isolation and non-isolation to be more of a co-equal to actor itself, which after all is the reality ("An actor is a form of class that protects access to its mutable state.... This is enforced statically by...a set of limitations...collectively called actor isolation").


Final points of clarification:

What are the protocol conformance rules, class inheritance rules, and ABI stability rules for "actor-independency/non-isolation"?

The proposal writes that @actorIndependent can be "used on a class." Do the authors mean "used on an actor"? And given this feature (which is not illustrated by examples), do we then need an @actorDependent or @actorIsolated attribute to override the default for members declared in such actors (which attribute, if there is favorable reaction to the above design suggestion, would naturally be spelled as the contextual keyword isolated)?

9 Likes

By offering developers the tools to pick which reentrancy model they need for their specific actor and actor functions, we allow users to pick the safe good default most of the time, and allow opt-ing into the more tricky to get right reentrant mode when developers know they need it.

This part seems like it came from an earlier version where reentrant(never) was the default.

3 Likes

actor X.Y should be @reentrant by default, since nested actors are independent each other.

but for actor inheritance

@reentrant(never)
actor X {}
actor Y : X {} // should be @reentrant(never) from base actor X

hope I understand correctly.

My interpretation would be the opposite for both cases.

The @reentrant(never) attribute applied to a type affects all contained members. I would expect that to apply to contained types just as it does to functions.

The @reentrant(never) attribute applied to a type is just a shorthand for applying the same to all contained members, and there is no reason that an inheriting type needs to have reentrant or non-reentrant members by default just because a parent type has used a shorthand. It would be strange if a stylistic choice of the author of the parent actor (to use the shorthand on an actor instead of an actor extension) affects the defaults for an inheriting class, and it would be undesirable because an apparently innocuous refactoring of a class into separate extensions could have behavior-changing impacts on inheriting types.

A different keyword for reentrant await and non-reentrant await would be beneficial here: no silent change in behavior.

3 Likes

Proposal Questions

  1. In "Actor Reentrancy" it's stated:

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!

Could an example be provided of "encapsulate[ing] state updates in synchronous actor function"? How is this any different than the already synchronous state updates taking place in the example?

  1. In "Deadlocks with non-reentrant actors":

Deadlocked actors would be sitting around as inactive zombies forever. Some runtimes solve deadlocks like this by making every single actor call have a timeout (such timeouts are already useful for distributed actor systems). This would mean that each await could potentially throw , and that either timeouts or deadlock detection would have to always be enabled. We feel this would be prohibitively expensive, because we envision actors being used in the vast majority of concurrent Swift applications. It would also muddy the waters with respect to cancellation, which is intentionally designed to be explicit and cooperative. Therefore, we feel that the approach of automatically cancelling on deadlocks does not fit well with the direction of Swift Concurrency.

Should we take this to mean Swift Concurrency will have no solution for deadlock detection or mitigation? Are there any solutions for developers to detect or otherwise avoid them? Or will this be the domain of tools outside the language like the Thread Sanitizer?

  1. In "Unnecessary blocking with non-reentrant actors":

With a reentrant actor, multiple clients can fetch images independently, so that (say) they can all be at different stages of downloading and decoding an image. The serialized execution of partial tasks on the actor ensures that the cache itself can never get corrupted. At worst, two clients might ask for the same image URL at the same time, in which there will be some redundant work.

As someone with a keen interest in adopting Swift Concurrency for networking, I can tell you it's key to have a solution for avoiding redundant work like network requests and image processing. This is especially true for libraries like AlamofireImage which offer extensible image processing pipelines for downloaded images where it's important to avoid reprocessing large images through CoreImage and other systems. Does (or will, in other proposals) Swift Concurrency offer any solution for avoiding these issues? (This may also relate to my later questions.)

General Design Questions

Actors' ability to verify thread safety will be extremely valuable to the community. Combined with async / await, it should greatly simplify the encapsulation of shared mutable state. I look forward (:grimacing:) to rewriting Alamofire yet again with this vastly simpler model. I spent quite a lot of time in Alamofire 5 getting synchronous properties and methods to play well with internally asynchronous actions while maintaining safe mutation and serial execution on shared DispatchQueues.

That said, I'm extremely wary of the async requirement for all outside actor access, especially without async properties or subscripts (yes, it's being pitched). This both makes actors less useful in non-async contexts and, perhaps more importantly, prevents developers from designing their ideal APIs. That is, it prevents them from offering synchronous APIs and properties even if they're perfectly safe.

More concretely, Alamofire's current design seems like an ideal application of actors. Requests are modeled as subclasses of the Request classes, largely mirroring the hierarchy of URLSessionTask (Request -> DataRequest -> UploadRequest, Request -> DownloadRequest, Request -> DataStreamRequest, and (eventually) Request -> WebSocketRequest). Request encapsulates all of the shared state, including mutable state, as well as internal API common to all Requests. Each subclass maintains whatever additional mutable state is necessary, and all mutable state that has public API is protected by a wrapper using os_unfair_lock. State that isn't expected to be publicly accessed is unprotected but safe access is ensured by the fact that all intercommunicating classes share an underlying serial DispatchQueue. So Requests communicate back to their originating Session, and vice versa, without locks.

Part of the API of Request I'm most concerned about are the various synchronous APIs for state changes and access to the various bits of state itself. For instance, like URLSessionTask, Request exposes resume(), suspend(), and cancel() methods which operate synchronously. Actions like adding a response handler are also synchronous and return the Request instance, allowing users to attach multiple handlers. Additionally, various bits of the Request's state is available publicly, like all of the URLRequests or URLSessionTasks its performed. So while the actions makes sense asynchronously, access to state properties really don't. Of course we could just expose requests() instead of requests, and maybe we'll get async read-only properties, but this seems to be exposing implementation details and deviates from typical Swift design. So it leads to a basic question.

How pervasive are async contexts intended to be? That is, what is the UX impact of having to make every method (and maybe property) async when using actors? Will there be a default, global actor that everything starts on and allows global awaiting?

Relatedly, a large amount of the API I've mentioned doesn't need to be async by definition and would only have the requirement due to being an actor. This seems to expose unnecessary underlying implementation details, just like it would if I exposed the various state properties or handler methods using completion handlers rather than making them externally synchronous while encapsulating internal locking or async work. So it certainly seems like this proposal will make a lot of APIs async when they don't otherwise need to be. That is, they could be safely exposed synchronously if only it was made possible. Will such capabilities be exposed for actors? If not, while I disagree with those who say try or await are overused, it will certainly be the case that async will suddenly become far more widespread than seems necessary, leading to a proliferation of await even for simple APIs.

Finally, there was nothing in the proposal about the intended performance or other system impact of actors. Users often want to enqueue perhaps thousands of asynchronous actions, so what are the limitations of actors in that scenario? In Alamofire 5 we took great pains to move away from the previous "one DispatchQueue per Request" model to a shared, serial DispatchQueue that underlies all Requests made by the same Session, eliminating one major bottleneck for users wanting to enqueue thousands of network requests at the same time (it's still not a good idea). It would be a shame for actors to reintroduce such limitations.

2 Likes

Is there a way for an actor to guarantee that actors it contains share a concurrency context with it to allow it to call their methods synchronously? e.g.

actor ThirdPartyThingA {
	func checkSomething() -> Bool
}

actor ThirdPartyThingB {
	func checkSomething() -> Bool
}

actor MyThing {
	@usesMyContext
	let thingA: ThirdPartyThingA
	@usesMyContext
	let thingB: ThirdPartyThingB

	init() {
		thingA = @makeWithMyContext ThirdPartyThingA()
		thingB = @makeWithMyContext ThirdPartyThingB()
	}

	func checkAll() -> Bool {
		// No await / queue hopping needed
		return thingA.checkSomething() && thingB.checkSomething()
	}
}

(Similar to the idea of a multiple dispatch queues sharing a target queue but with the added benefit that the compiler can remove any unnecessary dispatch_async calls)

Hello. I am new here and want to ask how what is being pitched is different than this:

Hand made async
import Foundation
@propertyWrapper
public final class Async<Result> {
  private var result: Optional<Result> = nil
  private var thread: Thread? = nil
  public var wrappedValue: Result {
    while !thread!.isFinished {}
    return result!
  }
  public init (performing action: @escaping () -> Result) {
    thread = Thread { [weak self] in 
        if self == nil { return }
        self!.result = action ()
    }
    thread!.start()
  }
}
struct Test {
    static let produce_value: () -> [Int] = {
        sleep(1)
        return (0..<1000).map{_ in Int.random(in: 1...10)}
    }
    @Async(performing:produce_value) var thing
    @Async(performing:produce_value) var thing2
    var thing3: [Int] { thing + thing2 }
}

I use it in many places. And wait for attributes to be applicable inside functions.

Your question appears to be about async/await rather than actors, but in short:
Your property wrapper busy waits.
await does not. It gives up it’s execution context and allow other things to process on that queue/thread until at some point the execution is resumed when the async function returns.

4 Likes

Well done, this version is clearer.

I’m still unsure about reentrant being the default, but I understand it better with the pattern of “synchronous method you can call asynchrounously from outside” for critical path.
I expect that part to be in the future “actor” section of Swift documentation. I wonder if compiler diagnostics could help devs take that direction too.

2 Likes

This revision is a huge step forward. I am very excited to see this, and I'm also happy to see that some of the more complicated topics are split out to their own subsequent discussion. This makes it much easier to understand the base proposal and make progress on things in steps. Really this makes a big difference, and the design refinements you are making here are a great improvement.

Some high level comments before detailed ones:

  • I know that many will find it a superficial decision, but I think that actor Foo is much more clear than actor class Foo and will align with how people refer to these in practice, so +1.

  • I know that these proposals are all happening concurrently and there are some race conditions between them (ooh, sorry, I just had to :-), but I think that this proposal logically builds on the ConcurrentValue and @concurrent closure discussion. It is great to make progress on these in parallel, but I think it would be beneficial to nail that one down before actors goes to official review.

  • I appreciate the addition of the actor inheritance discussion in the "Alternatives Considered" section. I still personally disagree with this choice, but I respect your rationale, and am happy to see it included in the proposal in a first class way. Thank you.

  • I still think the @actorLocal and "lets are implicitly accessible across actors" design points are suboptimal and think it is critically important to continue discussing this. I think those discussions will be easier after the @concurrent discussion settles (see below), but I'll add some thoughts on @actorLocal to a follow-on post to this one (just to separate the discussion a bit and provide more space for exploration) when I have time.

Here are some more points from a detailed read through:


Minor writing thing

For actors, the primary mechanism for this protection is by only allowing their stored instance properties to be accessed directly on self

Super nit pick but actor local state can be passed through inout arguments, which means that g touches actor local state even though it doesn't go through self:

func g(_ x : inout Int) { x += 1 }
actor X {
  var a = 42
  func f() {
    g(&self.a)
  }
}

I'm not sure if this is worth pulling into the early part of the writing or would just be confusing.


Async Properties FYI

Synchronous actor functions can be called synchronously on the actor's self , but must be called asynchronously from outside of the actor.

Fantastic, thank you for pulling this in. I think this approach will define away a lot of boilerplate.

However, note that this will/should also directly affect property access, so we'll need to figure out what the semantics of await otherActor.state += 42 means, or explicitly forbid it. There is another proposal exploring effect-ful properties, so that naturally dovetails in.


Class methods and other stuff?

Since actors support inheritance, do they have class-like methods? Are they called actor methods? How about convenience initializers etc? How many of the class-specific attributes (e.g. the core data ones) work on actors?

Please add this to the proposal with a description of their semantics (or the omission).


Intersection of @actorIndependent and @concurrent closures

I don't want to get into whether @actorIndependent is a good idea of not in this post, but I do want to observe that the design point hinges quite heavily on the details of how the @concurrent closure proposal shakes out. In particular, I expect the fallout of it to be that @concurrent closures are actor isolated (since they can run in different concurrency domains) but non-@concurrent ones are not, and you don't need any attributes or additional type system machinery.

Notably, this means that you get this behavior by default, and don't need @actorIndependent at all:

actor SomeThing {
  var x = 42

  func accumulate(arr : [Int]) {
    // Sequence.forEach takes non-concurrent closure, so self reference is perfectly fine.
    // Non-concurrent closures are always guaranteed to be run in the actors concurrency domain.
    arr.forEach { self.x += $0 }

    // Similarly ok.  `sum` is actor local state, even though `self` is not involved, but the closure isn't
    // concurrent, so this is fine.
    var sum = 0
    arr.forEach { sum += $0 }
  }

  func bad(arr : [Int]) {
     // Error: use of x from concurrent closure must be marked await since it is from a different concurrency domain.  "self" is captured by value.
     Task.runDetatched { print(self.x) }

    // Ok because @concurrent closures capture as by value copies.
    var sum = 0
    Task.runDetatched { print(sum) }

    // Error: sum is an immutable let capture because closure is @concurrent.
    Task.runDetatched { sum += 1 }

     // Ok, sum captured by copy.
     arr.pararallelMap { $0 + sum }

     // Error, sum is immutable.
     arr.pararallelMap { sum += 42; return something... }
   }
}

This behavior isn't spelled out in the @concurrent proposal because it is logically underneath the actor proposal in the dependency tree, but I can add some details about this if you think it is helpful. This is one of the major things that shakes out of modeling @concurrent closures correctly, and I think this is really important to make sure that higher order programming works in actor contexts.

Also worth keeping in mind is that @concurrent is the thing that unifies structured concurrency and actors, while @actorIndependent is an actor specific concept. It doesn't really make sense for global functions like runDetatched to know about actors - they should know about one thing that is common to structured concurrency and actors.


@escaping shouldn't imply @concurrent

This is best discussed on the other thread but I think that conflating concurrency with escaping is the wrong way to go, and your example illustrates on strong reason for this: parallelForEach doesn't escape the closure!

It is much cleaner in my opinion to mark runDetatched as an escaping and concurrent function, and parallelForEach to be a concurrent but non-escaping function.

In any case, once the @concurrent thing gets figured out and nailed down, much of the discussion around @actorIndependent will need to be reconsidered. As proposed, it seems like a decl modifier that makes the underlying declaration have @concurrent function type or something.


Actor-isolated stored properties can be passed into synchronous functions via inout parameters, but it is ill-formed to pass them to asynchronous functions via inout parameters.

+1, good call.


Interleaving/reentrancy:

The discussion about interleaving and actor reentrancy is really fantastic. Clear and to the point.

async let shouldBeGood = person.thinkOfGoodIdea() // runs async
async let shouldBeBad = person.thinkOfBadIdea() // runs async

I'd recommend not using the async let syntax in this proposal to keep the proposal dependency graph clean.

Deadlocks with non-reentrant actors can be detected and diagnosed with tools that can identify cyclic call graphs or through various logging/tracing facilities. With such deadlocks, the (asynchronous) call stack, annotated with the actor instances for each actor method, should suffice to debug the problem.

Yes, in the general case, this is pretty equivalent to detecting heap cycles that cause leaks. It is possible that some tools can catch some bugs, but only dynamic tools can reliably diagnose deadlocks that have happened, and no tool (as far as I'm aware) will ever be able to show that deadlocks aren't possible in the general case. It would be good to clarify the writing to avoid giving people false hope.

Therefore, we feel that the approach of automatically cancelling on deadlocks does not fit well with the direction of Swift Concurrency.

I completely agree and would go further: If possible, it would be great for the runtime to do a "best effort" attempt at detecting deadlocks and abort if they happen to "fail fast". I don't think that this can be efficiently implemented in full generality, but detecting this dynamically and failing fast in some cases will be a very useful debugging aid in practice.

As noted previously, we propose that actors be reentrant by default, and provide an attribute ( @reentrant(never) ) to make specific actors or actor-isolated functions non-reentrant.

I completely agree that reentrancy should be the default --- but are you sure we should enable this new thing at all in the base proposal? From my perspective, I have seen a lot of people be concerned about the reentrancy behavior in the discussion, but we as a community have very little experience building large scale code for the Swift actor model -- and we won't until it stabilizes and the APIs around it build out. Would it be reasonable to define this model, say it is available for consideration in future proposals, but intentionally defer it until we get more usage experience?

Rationale:

  1. This attribute introduces a source of deadlocks and other nasty performance issues that we'd like to define away.
  2. This attribute hopes that it reduces other kinds of logic bugs, but we cannot measure the balance between the bugs it introduces and removes today.
  3. No existing language has precedent for await marking, so we can't extrapolate from their experience.
  4. Once this is in the language, we can't take it out, and this clearly adds complexity to the language and runtime.
  5. This attribute is effectively syntactic sugar for moving stuff to sync functions.

Unless there is a really good reason to include this in the base actor proposal, I'd recommend deferring it until we decide we "need" this. Such a need is best shown with evidence that the bugs introduced are less bad than the bugs resolved.


Actors subclassing NSObject

As a special exception described in the complementary proposal Concurrency Interoperability with Objective-C, an actor may inherit from NSObject .

Do they need to inherit from the NSObject class, or should @objc actors be automatically made to subclass NSObject in their ObjC metadata? It seems much cleaner to sweep this special case into the @objc attribute instead of allowing actors to subclass NSObject directly. The different affects member lookup on the Swift side of things, and I don't think we want actors to have self access to all the NSObject stuff.

I think it would also be good to have a description of how @objc works with this proposal. If it is best to handle that as a separable proposal, then I think the discussion of NSObject subclassing should similarly be moved out.


Wording/specificity nitpick

Partial applications of synchronous actor-isolated functions are only well-formed if they are treated as non-concurrent.

I'm not sure what "concurrent" means here, please revise this to be more specific and tie into the type system features defined by and used by this proposal.


Tie into ConcurrentValue

Once the ConcurrentValue discussion converges, it would be great to refer to it from the actor proposal, since it affects can methods and properties can be used across actor boundaries.


Overall

This proposal revision is a huge step forward. At some point I'll write up some thoughts on alternative approaches to the @actorIndependent thing. I see what you're going for here, but there is at least one alternative that achieves the same thing with a different set of tradeoffs that would be good to discuss. Thank you for the hard work on the actor model!

-Chris

10 Likes

The "Task-chain reentrancy" section in "Alternatives Considered" similarly makes it confusing what the default is.

Under the current proposal, this code will deadlock, because a call from EvanEvan.isEven to OddOddy.isOdd will then depend on another call to EvanEvan.isEven , which cannot proceed until the original call completes. One would need to make these methods reentrant to eliminate the deadlock.

I believe under the now current proposal, this code would not deadlock.

Also, from the same section:

If we can address the above, task-chain reentrancy can be introduced into the actor model with another spelling of the reentrancy attribute such as @reentrant(task) , and may provide a more suitable default than non-reentrant ( @reentrant(never) ).

1 Like

I'm also very concerned about reentrancy being configurable. IMO if this is allowed at all, it should be very rare, to solve specific problems, and this should be made clear in the docs. If any given block of code might or might not be reentrant, this makes it very hard to code review. (This has been a problem IMO with Swift value/reference variables having the same spelling, and I don't want that problem repeated with reentrancy.)

If reentrancy is going to be configurable, IMO it needs to change the spelling of await. "This is a point where actor state may change" is very different from "this is a point where a deadlock may occur." And if you change an actor between reentrant and non-reentrant, you're going to need to reevaluate every place you call await. A spelling change would let the compiler find those for you.

6 Likes

I should explain the confusion I had when I first read the draft. We can "identify cyclic call graphs" statically, that is the compiler can see some bare function A (might) call B, B (might) call A. So if that's all there were to it, the compiler can see even long cycles straightaway and can prove your code (can) deadlock, and such a proof if reliable would make @reentrant(never) very safe in practice.

However, for actor methods the implicit queue is an instance property of the actor. So for i,j,k instances, we have callgraph Aᵢ -> Bⱼ-> Aₖ. This deadlocks for i==k, not but not otherwise.

This is formally equivalent to the leaks problem, since the strong reference Aᵢ -> Bⱼ-> Aₖ is a cycle if i==k, not otherwise. So, just like leaks it is tough to do anything in the compiler.

However, unlike leaks, I think there may be useful cases where the i==k problem is trivial. I have a lot of singleton types that could be actors (database, cache, etc.), and with singletons any 2 references are the same instance (and usually you do not even have 2 references, you have a let at global scope). In such cases I suspect we could prove deadlocks during compile in a similar manner to traversing a graph of bare function calls.

If so, it would introduce an option between reentrant and nonrentrant actors, e.g. avoiding the highlevel races of reentrancy while guaranteeing no deadlocks. Of course it is not a replacement for either flavor of instanced actor but for my purposes anyway it would be a very useful actor flavor.

1 Like

I think we should add @actorIsolated (and @concurrent) to the unescaped non-async closure arguments.

func foo(_: @actorIsolated () -> ()) { ... }
func bar(_: @concurrent () -> ()) { ... }

foo { /* can read/write actor members */ }
bar { /* can read actor members */ }

With the type hyerarchy:

(-)  -> @concurrent -> @actorIsolated -> async
 |                                         |
 V                                         V
@escaping ------------------------> @escaping async

I want to discuss more non-reentrant functions. It doesn't really give up the actor that isolates it. Furthermore, it doesn't care if the function suspends (which is the point). I think it would work better if non-reentrant applies only to non-async actor-isolated functions, and allows for a call to async functions without await keyword.

extension Actor {
  @nonReentrant
  func foo() {
    self.value = 1
    asyncTask1() // no await needed
    // self.value is preserved
  }

  func bar() {
    foo() // can be called from non-async
  }
}

I know this sounds a little crazy, but I couldn't find a good reason to dismiss it.

I don't agree. The await signals a potential suspension point: your code may be suspended, and any kind of global state (unprotected globals anywhere, clocks/timers, etc.) might change out from under you before you resume. The "reentrancy" discussion is about whether your actor state might also change out from under you before you resume.

It's reentrant. I've clarified the specification and added an example.

Fixed, thanks!

I've switched it to use @reentrant(never) and dropped a stale reference to a since-removed example, thanks!

As noted above, I've added an example and clarified the wording a bit.

I've added a simple example and reworded this a bit.

Ah, I see how you came to this conclusion, and it is somewhat of a side effect of us splitting global actors out of the proposal. With global actors, any declaration anywhere can be actor-isolated with the appropriate annotation. For example,

@MainActor var dataOnlyAvailableOnTheMainActor: String

@MainActor
extension MyViewController {
  func f() { ... }                                    // actor-isolated to the global actor MainActor
  @actorIndependent func g() { ... }  // actor-independent
}

On re-reading, I noticed that a bit of the discussion on @actorIndependent describes rules that are too general and don't make sense without also considering global actors. At a minimum, we need to get a pitch out there for global actors. I don't have a strong opinion on whether we should narrow the definition of @actorIndependent in this proposal down to the definition that makes sense in this proposal, then widen the definition again in global actors... but it seems like that will make the proposals harder to understand.

This is an interesting point. Actor isolation is certainly an important part of the declaration, with far more semantic impact than one would normally expect from an attribute. I like this suggestion. The only wrinkle I see is the use of @actorIndependent on closures, which isn't quite as amenable to contextual keywords. That said, we have the contextual keyword async in closures already, so we can probably handle this.

Hmm, the rules are spelled out for overrides and conformance, but I suppose we could use more examples here.

Actor isolation isn't part of the ABI, so it's not ABI-breaking to change actor isolation... but it's certainly source-breaking and could lead to unfortunate runtime effects if (say) your client was compiled when your function was actor-independent and now it is actor-isolated.

Ah, we meant "on a type", as in my example above.

Yes, it's reasonable to want to specifically state that something is intended to be isolated. Closures could probably benefit from this as well. (Again, I get slightly worried about the parsing of something like { isolated in ... }, because changing that meaning would be source breaking)

Thank you!

Doug

3 Likes

It's in the domain of tools, like (for example) finding reference cycles.

Sure, you can build it out of the image downloader example + detached tasks from structured concurrency. Instead of the cache being this:

var cache: [URL: Image] = [:]

turn it into something that either stores an image or a Task.Handle that creates the image:

enum DelayedImage {
  case image(Image)
  case handle(Task.Handle<Image, Never>)
}

var cache: [URL: DelayedImage] = [:]

Then in your getImage(), you check which case you're in: if you're the first access to that URL, you create a detached task to do the work and stash it in the cache. Otherwise, you can wait until the task completes. Here's a sketch:

func getImage(_ url: URL) async -> Image {
    if let cachedImage = cache[url] {
      switch cachedImage {
        case .image(let image): // already retrieved
          return image

        case .handle(let handle): // someone else is doing the work now
          return await handle.get()
      }
    }

    // okay, we need to do the work ourselves. Spawn off a task to do it
    let handle = Task.runDetached {
      let data = await self.download(url)
      let image = await Image(decoding: data)
      return image
    })

    // Let everyone else know how to get the image
    cache[url] = .handle(handle)

    // Wait for our task to complete
    let image = await handle.get()

    // Update the cache before we return
    cache[url] = .image(image)
    return image
}

I don't really know how to answer this question: code that involves actors will need to use async. One can spawn off detached tasks from synchronous code to perform the asynchronous interaction with the actor, much like one would put code to interoperate with asynchronous completion-handler methods inside the completion closure.

In the upcoming "global actors" proposal, there will be a @MainActor that is an actor representing the main thread, to cope with tasks that must run on the main thread (hello, UI libraries). But it's opt-in, not a default for non-isolated code.

To be clear, the APIs themselves can be synchronous, but access to them from outside the actor will require an asynchronous call. In the defined model, it is safe to access immutable actor instance data (e.g., a let), but not any mutable data. You can suppress the checking with @actorIndependent(unsafe), which could also be used (say) if you wanted to do your own locking inside an actor for some of the state.

We've generally avoided trying to make performance claims in a proposal (ever), because they're not something readily verifiable and would tend toward being vacuous. To be clear, an actor instance does not hold its own DispatchQueue: it has a light-weight queue of partial asynchronous tasks that are cooperatively scheduled. We expect an outer (concurrent) DispatchQueue to provide the threads themselves, and the actor runtime will select an actor that has pending tasks to run. Feel free to check out the actor runtime implementation.

Doug

5 Likes