Closure isolation control

Greetings Swift evolvers. For your consideration, I have drafted a proposal allowing for more explicit control over the isolation of closures.

You can read the proposal at nnnn-closure-isolation.md

Please share thoughts and feedback here or as comments on the pull request. Thank you.

15 Likes

I'll just share that this is looking awesome and I can't wait for promoting the actor isolation inheritance to a proper that also works with isolated parameters :slight_smile:

5 Likes

I think it’s probably fine, but the proposal should mention that { nonisolated in … } is technically currently valid code that would parse differently with these changes.

3 Likes

Additionally, the example in the document:

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

This is currently redundant, right? Just an example of what it would mean?

1 Like

The current rule is that closures passed to the Task initializer from an actor-reference-isolated context (i.e. not isolated to a global actor) only inherit the current isolation context if they actually capture the enclosing context’s isolated reference. So that is not currently redundant. However, the proposal includes changing that rule so that those closures will inherit the isolation unless the isolation is explicitly specified otherwise. So it will be redundant under the proposal.

1 Like

In an actor's case that's self, right?

Technically, any function can have an explicit isolated parameter and will then be isolated to that. But yes, sorry, by far the most common situation is an instance method on an actor, which is isolated to its implicit self parameter by default. A closure in an actor method that's passed to the Task initializer will currently only be isolated to self if there's a reference to self in the body of the closure (including an implicit reference to self via a member reference.) Note in particular that putting self in the explicit captures list is not sufficient to make isolation be inherited; you need an actual reference in the body of the closure, such as _ = self.

I think most of us working on the language are in agreement that this rule is pretty baroque and confusing, which is why this proposal aims to change it. But that's the current rule.

6 Likes

And in this contrived case?

actor A {
  func isolate(to actor: isolated OtherActor) {
    Task {
      _ = self
      actor.doThing()
      print("where am I?")
    }
  }
}

I'd guess the explicit isolation takes precedence right now? With this proposal, would the implicit behavior change? And I assume the explicit [isolated self] would reverse the answer?

Right, that closure would currently be isolated to its captured actor value because (1) it's passed to the Task initializer and (2) it captures actor, which is the isolated parameter of its enclosing context. It also captures self from its enclosing context, but that's not an isolated parameter, it's just a value that gets captured. This behavior would not change under this proposal.

And right, this proposal allows the closure to explicitly control that with [isolated self]. It could also do [isolated actor] and set its isolation that way, even if actor were not an isolated parameter.

1 Like

Ah, excellent. Does that mean we may finally have a solution for actor parameters for execution in a particular context?

actor A {
  func doSomething(on actor: some Actor = self, closure: () -> Void) {
    Task { [isolated actor] in
      closure()
    }
  }
}

(I can’t quite recall why the isolated parameter case didn’t work for me before. I think it just wasn’t ergonomic enough.)

That would work to get the closure to have that isolation, and therefore it would make the function passed as closure run with that isolation (since it’s synchronous).

However, the function passed to that parameter wouldn’t know that it was running with that isolation. If it needed the isolation, it would have to reassert that with something like assumeIsolated. A better tool for that is the upcoming proposal for @isolated(any) function types. But that proposal will combine well with this one — instead of passing in the actor separately, you’ll set the isolation in the closure (e.g. with the features in this proposal) and then pass it off as an @isolated(any) function. Any call to that function will then know it has to run it on the right actor, and the function will still internally know that it’s isolated.

3 Likes

A nonisolated closure that takes a parameter would be spelled { nonisolated arg in ... } then, I suppose?

1 Like

Will @inheritsIsolation be usable on all function declarations? Also, will it guarantee the same synchronous, no-suspension behavior that @_unsafeInheritExecutor does today?

1 Like

The isolation inheritance provided by @inheritsIsolation is not the same kind of thing as what's done by @_unsafeInheritExecutor.

@inheritsIsolation can be placed on a parameter of function type and says that, if the argument is a closure, that closure should be inferred to have the same isolation as its surrounding context. That is, the closure "inherits" its isolation from its enclosing context. For example:

func execute(@inheritsIsolation fn: @Sendable @escaping () async -> ())

@MainActor
func callExecute() {
  execute { print("Hi!") }
}

Because the fn parameter of execute is @inheritsIsolation, and the argument used for that parameter is a closure literal ({ print("Hi!") }), the closure will be assigned the same isolation as its enclosing context. The enclosing context is the callExecute function, which is isolated to the MainActor global actor, so the closure will be as well. The attribute does not affect the isolation of execute itself, which is still formally non-isolated.

@_unsafeInheritExecutor doesn't have any real semantic meaning; it's more of an instruction to the compiler to not introduce any extra suspensions for isolation. But a semantically sensible version of it would say that the function is isolated to the same actor as its caller. That is, it "inherits" the isolation of its caller. For example:

@safeInheritExecutor
func execute2(fn: @Sendable @escaping () async -> ())

@MainActor
func callExecute2() {
  execute2 { print("Hi!") }
}

Because execute2 is @safeInheritExecutor, it will be formally isolated to the same thing as its caller. Its caller is callExecute2, which is isolated to the MainActor global actor, so execute2 will be as well. The attribute does not affect the isolation of the closure, which will be formally non-isolated.

We definitely shouldn't use the term "inherit" for both of these. I suspect that a safe version of @_unsafeInheritExecutor would probably be called something like @isolated(caller). Maybe we should avoid "inherit" in this attribute name, too.

6 Likes

yes, just as it is currently with attributes like @MainActor or @Sendable.

1 Like

Thank you @jlukas I have added a note about this in the "Implications on adoption" section.

2 Likes

While the chances of someone having a single parameter named nonisolated are probably rare, I think the proposal should include a "backup plan" of sorts if the source break isn't allowed. Because there are a few possible outcomes here depending on whether the proposal is accepted and when its implementation lands:

  • This proposal is implemented in time for Swift 6, and the language steering group decides that the source break is reasonable.
  • This proposal is implemented in time for Swift 6, but the LSG decides not to allow a source break.
  • This proposal isn't implemented in time for Swift 6.0 but instead some other Swift 6.x, in which case the source break couldn't be considered until Swift 7.

So we can't automatically have the assumption that the source break would be ok.

I think the only case we need to worry about is { nonisolated in ... }, because it can be interpreted as the modifier or as an argument name. The following aren't ambiguous:

  • { nonisolated, otherArg in ... }: since it's followed by a comma, nonisolated here is unambiguously a parameter name.
  • { nonisolated arg in ... }: since it's followed by another identifier, nonisolated here is unambiguously a modifier.
  • { nonisolated (arg: Type) -> ReturnType in ... }: since it's followed by a parenthesized signature, nonisolated here is unambiguously a modifier.

One possibility if a source break wasn't allowed could be that if someone writes { nonisolated in ... }, it warns that it's interpreted as a parameter name instead of a modifier, and offer a suggestion to write it as { (nonisolated) in ... } to silence the warning. Then, if someone truly does want a nonisolated zero-parameter closure, they would write the parameter list explicitly: { nonisolated () in ... }.

4 Likes

We could also land a more minor change in 6.0 to enforce the source break and “reserved” status of the word, even if the rest of the implementation isn’t ready for 6.0.

2 Likes

You're right that this needs careful consideration. I don't think the proposal needs to be changed, though, as long as it's up front about proposing a source break. The LSG will need to decide whether that's acceptable, and if it isn't, we should request changes, ideally before review.

Thanks for bringing it up, I'll make sure we talk about it.

6 Likes

I would like to second this. As it stands, the proposal is source breaking.

The following currently compiles:

let closure = { nonisolated in
    print(nonisolated)
}
closure("hello world")

Before the proposal, closure is inferred to have a single parameter, after the proposal it is inferred to have zero parameters.

While it might be unlikely that somebody has used nonisolated as a parameter name in their code base, I don’t think we should count on that. Furthermore, if we want to introduce more modifiers to closures, we need to make the same assumption of nobody using those names as parameter names in their closures and that doesn’t scale IMO.

1 Like