Isolation and async let

I recently stumbled over the fact that async let is its current form always spins up a nonisolated child task. Unfortunate, but swift's isolation control story was/is still patchy in general, so it kind of makes sense as a status quo.

I was happy to see that it was also brought up in the discussion for @isolated(any) function types by @ktoso, but not so happy to see that is was kind of side-tracked for later.

The features to control isolation inheritance (or whatever ends up being the correct terminology) are quite essential in my mind, so I very much appreciate the efforts in Closure isolation control and `@isolated(any)` function types.

Without these, I feel we are in this very uncomfortable state of having tasted the sweet, sweet nectar of compile-time isolation/sendability/region checking, but still having to write error-prone, non-structured-concurrency code like a cave-person for cases where isolation needs cannot be expressed in the language.

I understand the authors of these proposals are all fully aware of this, so let me summarize by saying: This work matters a lot, thank you!

Back to the topic of async let:
I think this use case I brought up is a good example of why it is important to allow async let to infer isolation and make it behave like the proposed task APIs.

Both of the pitches above mention how they will be applied to all task APIs, but neither addresses the async let story.

Can we just "fix" async let as part of one of these proposals?
Does "fixing" async let even need an evolution proposal as one could interpret this as a "improve behavior"?

2 Likes

Thanks for bringing up the topic, although I don’t read this specific case as being dismissed but rather “we should and can just fix it” while at the same time just not relying on the details of the proposal thread where this was posted.

Here’s a quote from @John_McCall on that from the quoted thread:

So in other words, we should just fix it and it’s not really relying on the proposals, so there’s not much to discuss about it there. Single stament async lets can be made to debate as expected by immediately ensuring an enqueue.

Timing wise it’d be good if we can make all those enqueue changes at the same time but we can’t guarantee any schedule decisions.

3 Likes

Thanks for the clarification, that is the best possible answer to hear ; )

Just to be sure: Does that also imply that the region-checking stuff would understand that no transferring needs to occur and these async let expressions can safely share non-sendable things from the calling context (since they all stay in the same isolation)?

My opinion that there is an issue(? - not the best choice here probably) with non-Sendable types itself. Having on them nonisolated async methods has no sense currently, unless these methods are isolated to some actor - like the Context from your original post not isolated to any actor. With or without async let it will raise a warning in Swift 5.10. So without an ability to express isolation on the caller side for such cases, non-Sendable types that have no isolation at the declaration side in most cases will end up with a warning when being used. Swift right now encourages to express isolation as a part of type itself, yet I would prefer to have more dynamicity in expressing isolation and allow to defer to the caller, if it is possible.

I hear you and I agree that this is still quite a rough area of the concurrency story, but at least using the isolation: isolated any Actor = #isolation magic phrase makes it possible to spell out at all.

The future direction in the SE-0420 proposal addresses this head on, so I very much hope we get more "syntax sugar" for it (which I think is underselling the effect of it a bit - the current spelling makes you feel like you do something very complicated and naughty, when all you want to say is "isolate to caller").

I don't think it is necessarily bad that this is on the type/function declaration itself, as the other part of the story should be auto-addressed by the region analysis stuff.

It not a bad, definitely. Having isolation expressed in such way is actually nice in many cases, making it easier to reason about program behaviour. It just too limiting right now I think.

That's somewhat unrelated? It depends™ on what the target of the call is.

actor Worker { 
  var state: State

  func daDooRonRonRonDaDooRonRon(other: Worker) async {
    async let other = other.ping(state)
  }

  func other(state: State) {} 
}

let bob = Worker()
let charlie = Worker()
await bob.daDooRonRonRonDaDooRonRon(other: charlie)

if the async let would become such that it immediately enqueues on the target there's ofc still async boundaries being crossed here -- we're sending it to another, potentially concurrent, actor after all.

The usual transferring semantics would apply here. We just want to change that the enqueue on other happens immediately rather than first onto global pool, and then onto the other actor.

Edit: Perhaps you mean an async let calling a method on self?

  func daDooRonRonRonDaDooRonRon(other: Worker) async {
    async let other = self.ping(state)
  }

Yeah I can see this raising the interesting question of such task actually never being concurrent - and since it's not crossing isolation boundaries there'd be no transfering/sending over there.

Sorry, my question was not very clear. I am in the headspace of tying together async functions that allow "sharing" non-sendables (by explicitly being isolated to the same actor)

My hope is that something like this would work:

final class MyNonSendable {}
func workItHarder(_ it: MyNonSendable, isolated isolation: any Actor = #isolation) async -> Int { 1 }
func makeItBetter(_ it: MyNonSendable, isolated isolation: any Actor = #isolation) async -> Int { 2 }

actor DP {
    func play() async {
        let it = MyNonSendable()
        async let first = workItHarder(it)
        async let second = makeItBetter(it)
    }
}

The idea being that you want to have non-sendable, async "utilities" that you use in isolated contexts (like actors) - these utilities would internally branch out and do async work, but they themselves should have access to the isolated, shared state.

Let me clarify my point from the other thread. We should definitely have some way to determine the formal isolation of the async let initializer that's better than always treating it as non-isolated. Doing that will require a language proposal; we can't just fix it in the implementation, we have to actually come up with a rule. That proposal won't depend on @isolated(any) in any way: while it's totally reasonable to think of async let as putting the expression in an implicit closure that's passed to a specialized task-creation function, which ought to take an @isolated(any) function like all the other ones do — and it is indeed implemented that way — none of that is actually exposed in the language. As a result, the actual proposal would just be "the initializer is now potentially isolated, and here's the rule for determining that".

That doesn't mean it wouldn't have any proposal dependencies. I personally think the right rule is something like "if the initializer is a call expression, then the initializer is isolated the same way as the callee of that call."[1] That rule stands alone, but it would be better if it built on top of the closure-isolation proposal, since explicit closure isolation would provide an explicit way to specify the isolation of an async let initializer in cases where the implicit rule wasn't good enough, e.g.:

async let x = { [isolated myActor] in ... }()

Of course, the way I've written the @isolated(any) proposal also builds on top of the closure isolation proposal in the same way: isolated closures gives you a consistent way to write things that otherwise can only happen in special cases.


  1. This rule doesn't quite work — in general, we need the isolation to be knowable without running any code in advance, which means that an initializer like returnAnActor().isolatedMethod() must be non-isolated. It would also be defeated by something like a type coercion or a cast on the call result, which seems very questionable. But still, something in that general area seems like the most natural thing to do. ↩︎

5 Likes

That makes sense, thanks for clarifying John.

I misunderstood the earlier post to mean we wouldn't need a proposal -- either way is fine ofc; and behavior dovetailing nicely with the closure isolation is how I was thinking about this indeed :slight_smile: