Closure isolation control

Is your suggestion to gate the source break behind an upcoming feature / the Swift 6 language mode, or are you arguing to come up with a different syntax for applying modifiers to closures?

As I note in the "implications on adoption" section (using the same example actually), in terms of source breaks, it fortunately will break compilation rather than silently altering meaning.

2 Likes

I’m arguing that it should be a different syntax because the suggested syntax will always require an upcoming feature flag for any additional modifiers that we want to add in the future. I don’t have any concrete solutions though, maybe @nonisolated?

2 Likes

We could just require parentheses in this case, e.g. { nonisolated () in … }.

I asked the same question during code review earlier. I was thinking if we could use either `` or to disambiguate but they don't really help.

Task { `nonisolated` in }
Task { [nonisolated] in }

is what I thought initially, but they don't really help it's as ambiguous as it was without those. [`nonisolated`] actually makes some sense but just to "capture the variable nonisolated" and not in helping the compatibility question we have.

The upcoming @isolated(any) appears in type position, like this:

public func test1(fn: @isolated(any) () -> ()) {}

so I'm wondering if following through with that style and allowing them in closures like:

Task { @isolated(any) in } 
Task { isolated something in } 
Task { @nonisolated in } 

So the question comes up about the need for @isolated(something), which in type position I was expecting to become this:

func test(actor: any Actor, fn: @isolated(actor) () -> ())

Does that imply that perhaps moving towards @isolated(what) in both type and closure positions is something that may make sense?

Just thinking out loud but thought the parallels with the @isoalted() are interesting.

3 Likes

These spellings look so similar but have very different syntax relationships:

{ isolated arg in }     // "isolated arg", as a bound pair of tokens
{ nonisolated arg in }  // "nonisolated" and then "arg", but no connection between them

Maybe I'd get used to it, but scanning the code quickly it just feels like two things should differ in some visual way. This overload also is the cause of the conflict when the arg is named nonisolated.

Konrad's suggestions do help them look different (the second would have an @).

5 Likes

This is the most concerning ambiguity I have seen raised and so we have decided to go with requiring attribute syntax @nonisolated. I have updated the proposal accordingly.

4 Likes

I think it would be confusing to sometimes write nonisolated and sometimes write @nonisolated. Requiring an attribute is also not the only solution. When specifying isolation in closure signatures, we could just require parenthesis like @jlukas suggested upthread:

{ (arg: isolated MyActor) in ... } // syntax that works today, { isolated arg in ... } is invalid
{ nonisolated (arg) in  ... }

For what it's worth, I don't think that people will be writing nonisolated in closures often. When people want to create a nonisolated task, they use Task.detached.

6 Likes

That's good -- and the { isolated arg in } is probably actually a bug that it is even allowed? So no real ambiguity there then.

We should highlight though that Task { nonisolated in } is much better than Task.detached and I expect to be recommending it almost always over detached tasks.

The ability to copy task-locals is hugely important in most real situations. Detaching breaks traces and any other correlation attempts (say, if swift-testing wanted to tell you "a task spawned during execution of has failed and caused the error" etc), a lot of things need context and I just expect their number to grow over time -- and thus the promoting detaching to more of a footgun than it is considered today.

It's an okey spelling though; if we'd really dislike it we could add a new method on task that isn't detached but just Task.nonisolated or something. But the Task { nonisolated in } is probably good enough :+1:

5 Likes

Right, I forgot to address that suggestion. I am concerned that the () requirement will make it quite subtle to recognize that you omitted it if your intention was nonisolated as a specifier, because that will still parse but with a different meaning. Though perhaps in practice something will eventually catch a parameter count mismatch (though if you make a mistake on both of those, this allows the potential for both mistakes to cancel each other out quietly as well). I guess I also question whether the inconsistency of "sometimes you use @ and sometimes you don't" is actually any worse than "sometimes you can omit () and other times you must explicitly write the otherwise implicit ()".

3 Likes

I don't think it's a given that nonisolated () in would be necessary. If I understand your concern here correctly

It's fundamentally about the similarity between { isolated arg in } and { nonisolated arg in }. However, { isolated arg in } is not valid syntax, and we could require parenthesis for nonisolated only in the case where you want to specify the parameter list, allowing you to write { nonisolated in }, but not allowing you to write nonisolated immediately next to a parameter name. That would make these syntaxes valid:

{ [isolated actor] in ... }
{ (actor: isolated MyActor) in ... }
{ nonisolated in ... }
{ nonisolated (actor) in ... }

This is still technically source breaking for { nonisolated in ... }, but we have tools to handle this. If we identify that people are using nonisolated as a closure parameter name in practice through source compatibility testing, then we can gate the change behind an upcoming feature flag. I don't think we'll need this, though, and I personally don't think we should start going down the attribute route before we have any evidence that we'll want to generalize this to other modifiers. I haven't been able to think of other modifiers that make sense to apply to closures, and if we add syntax in the future knowing we will want to apply that syntax to closures, we can proactively make that syntax an attribute from the start.

I think the attribute approach is a worse solution because with the attribute syntax, { nonisolated in ... } is valid syntax where nonisolated is a parameter name, making it extremely easy to make a mistake that leads to confusing error messages. For example, here's the message you get if you write this code today:

func test() {
  let closure = { nonisolated in // error: Cannot infer type of closure parameter 'nonisolated' without a type annotation
    print("uh oh")
  }
}

Here's another misleading example:

func takeClosure(_: () -> Void) {}
func takeClosure(_: @MainActor (String) -> Void) {}

func test() {
  takeClosure { nonisolated in
    print("oh no")
  }
}

The above code is totally valid - overload resolution chooses the @MainActor overload and nonisolated is a parameter of type String.

6 Likes

Thanks Holly for the thorough expanding on your argument and examples. Convinced and agreed on all this! I have updated the proposal to restore the previous (non-attribute) design and add a requirement of parentheses if parameters are combined with nonisolated (and now updating the implementation accordingly).

5 Likes

There's also a pitch up right now that would add @isolated(any) function types: `@isolated(any)` function types - #29 by cal

Have you considered using the @isolated syntax for this functionality as well? It seems to dovetail nicely with the other pitch:

I find the @isolated spelling more appealing than the capture list spelling, because it's more similar to the syntax for isolating a closure to a global actor:

// Isolated to a global actor
Task { @MainActor in
  // ..
}

// Isolated to a specific non-global actor
let myActor = MyActor(...)
Task { @isolated(actor) in
  // ..
}

// Isolated to the same isolation as an `@isolated(any)` function:
func delay(operation: @isolated(any) () -> ()) {
  Task { @isolated(operation) in
    // ...
  }
}

Could it make sense in these cases as well?

actor A {
  func isolate() {
    Task { @isolated(self) in
      print("isolated to 'self'")
    }
  }
}

One topic I don't see covered is how the isolated self capture interacts with implicit self in the closure. Does an [isolated self] capture enable implicit self in the closure body like a [self] capture would? (This seems logical to me, but it should probably be spelled out explicitly in a final proposal).

actor A {
  let instanceProperty: Foo

  func isolate() {
    Task { [isolated self] in
      print(instanceProperty) // is implicit self allowed?
    }
  }
}

I suppose one downside of the @isolated self spelling is that it could lead you to need to write something like:

Task { @isolated(self) in
  print(self.instanceProperty) // no explicit self capture, is implicit self disallowed?
}

Task { @isolated(self) [self] in
  print(instanceProperty) // self is captured explicitly so implicit self is allowed
}

If @isolated(self) doesn't enable implicit self, then a shorthand for both capturing and isolating to self would probably be valuable. Perhaps both @isolated(someActor) and [isolated self] (or [@isolated self] for consistency) are reasonable and could both be supported?

Alternatively, could it make sense for @isolated(self) to also enable support for implicit self? I can see an argument that @isolated(self) already suggests that self will be captured, at which point there is no value in requiring explicit self in the closure body:

Task { @isolated(self) in
  // since self is already explicitly referenced by @isolated(self),
  // which is suggestive of a self capture, so perhaps implicit self 
  // is reasonable here?
  print(instanceProperty) // implicitly self.instanceProperty
}
2 Likes

Using { @nonisolated in … } instead could help avoid this specific issue.

@nonisolated pairs naturally with any potential @isolated annotation, and like with @isolated the @ syntax makes it more similar to the spelling for isolating to a global actor (@MainActor in). Seems like a win-win-win.

There are several different syntaxes for specifying isolation today:

  • nonisolated on actor methods and properties (and properties in global-actor-qualified types),
  • isolated on function parameters, and
  • @MainActor and other global actor attributes.

It's not possible to be consistent with all of them at once. Given how inherently different global actor attributes are — they name user-specified types, so they're both capitalized and have to be written with an @ — I think this proposal makes the right choice.

5 Likes

Can weak be used in conjunction with an isolation parameter?

takesAClosure { [isolated weak myActor] in
    // Isolated to myActor even if it has been deallocated
}
4 Likes

+1.

This syntax would also be consistent with Statically-isolated function types, if we get them one day:

// Isolated to a specific non-global actor
let myActor = MyActor(...)
let action: @isolated(to: myActor) () -> () = { @isolated(to: actor) in
  // ..
}
1 Like

Thank you for raising this unanswered question, @jeremyabannister
It would introduce some significant complexity so will not address it in this proposal but I have added a note about it under "future directions".

3 Likes

Weak actor as “isolation” is pretty tricky from a runtime perspective as well… if the actor was deallocated, where is the task supposed to run? If it’s supposed to NOT run that’s rather weird. So we’d probably want to run somewhere, but if on the actor it means we’d have to retain the actor as we “start running”… :thought_balloon:

I wonder if handling this would take some shape of “guard self” retaining and gaining isolation in a flow control aware way — but that sounds pretty tricky as well.

4 Likes

I know very little about how isolation and actors actually work under the hood, so this may be way off base, but what I had imagined so far in relation to this usage of weak goes like this:

An isolation context can be boiled down to a unique identifier. All data is tagged with some isolation identifier, and all execution contexts are as well. For data to be accessed synchronously from within an execution context, the isolation identifier of the execution context needs to match that of the data being accessed. Writing [isolated weak myActor] sets the isolation identifier of the closure to that of myActor, and there really isn't any problem if the actor gets deallocated, because the isolation identifier is just a simple value type and can live on. The data that was on the inside of the actor is of course gone, but since we can now assign these isolation identifiers to new execution contexts, it may well be the case that new data was created that is external to the actor but which nonetheless shares its isolation identifier**. This data can be accessed whether or not the actor who the isolation identifier originally belonged to is still alive.

(**I'm not sure if this is actually true. Can I escape a local var declaration to the outside of an isolated context?)

The concept sounds messy to me and makes me a bit uneasy imagining a bunch of extra data that is supposedly isolated to an actor that no longer exists, but at least I was imagining that it would be conceptually simple, at least in terms of the mechanics of how it would work.

While it's true that it sounds messy to me, on the other hand, it also seems problematic to me that in order for a closure to synchronously access actor data it must also strongly retain that actor, which was the thought process that led me to ask the question in the first place.

Lastly, I'll say that at first glance it seems weird to me to use the capture list in this way at all. I'm sure this was carefully thought through, and I haven't read all related content thoroughly enough to officially dissent, but the syntax choice already seemed weird to me when I first saw it based on questions like "What happens if I try to apply isolated to multiple captured actors?" and "Can the isolated actor appear at any position in the capture list or does it have to come first?", and then it just got worse when I thought "Wait - does this mean that I'm forced to strongly retain the isolated actor? That seems genuinely problematic..."