Compiler ignores `@concurrent` and `nonisolated(nonsending)` attributes when a closure captures `isolated` parameter

While doing experiments, I sometimes use code like the following, but recently I realized the closure is quite misleading.

class NS {}

actor A {
    let ns = NS()

    func test1() {
        let fn: @concurrent () async -> Void = { _ = self.ns }
	// ...
    }
}

Notes:

  • I'll use @concurrent closures in the discussion for simplicity. I believe it applies to nonisolated(nonsending) closures too.
  • I did experiments on Swift 6.2. I believe nightly have the same behaviors.

How does the closure work?

There are three completely different ways to understand the closure:

  1. The closure runs on global executor. self.ns is accessed synchronously.

    This was what I thought when I saw the source code. However, it isn't how the code actually works.

  2. The closure runs on global executor. self.ns is accessed asynchronously.

    This was what I thought during my investigation, even though I was aware that there wasn't allowed by rules in SE-0306.

  3. The closure runs on the actor A's executor. self.ns is accessed synchronously.

    This is how the code actually works. This is confusing too because the @concurrent modifier is just silently ignored. It's hard for user to tell that the closure actually runs in A's executor.

    BTW, a `@concurrent` closure capturing actor by calling its method also runs on the actor's executor.

    It can be verified by looking at SIL that nonisolated synchronous func bar is executed on actor A's executor rather than on global executor in both fn1 and fn2 below.

    class NS {}
    
    actor A {
        let ns = NS()
    
        func foo() {}
    
        func test() async {
            let fn1: @concurrent () async -> Void = { 
                bar()
                _ = self.ns 
            }
            let fn2: @concurrent () async -> Void = { 
                bar()
                await self.foo()
            }
    	// ...
        }
    }
    
    func bar() {}
    

I wonder if the behavior in item 3 is by design? The problem is that @concurrent modifier is silently ignored. In contrast, a @MainActor closure behaviors differently (@MainActor modifier isn't ignored).

Example: a `@MainActor` closure runs on `MainActor`'s executor, not `A`'s executor

This can be verified by checking fn's SIL. bar is executed on MainActor, not A's executor.

class NS {}

actor A {
    let ns = NS()

    func foo() {}

    func test() async {
        let fn: @MainActor () async -> Void = { 
            bar()
            await self.foo()
        }
        // ...
    }
}

func bar() {}

On the other hand, however, if the behavior in item 3 isn't correct, what should be correct? The behavior in item 2 doesn't look like correct, not only because it's confusing but also because it violates the rules in SE-0306.

So how about not allowing such closures? IMO the impact on existing code would be small because a) it's not common to capture actor properties in practical code, and b) in projects that have such code, I believe those closures can be replaced with function calls.

I think a more general underlying question is that, when a closure both captures isolated parameter and is marked with @concurent or @MainActor modifiers, what's the expected behavior? In addition, do implicit self and explicit isolated parameter have different behaviors in these cases? I didn't find discussion about this in Evolution proposals.

Another example in which `@concurrent` is silently ignored

fn closure is actor-isolated. That's why it can't be passed to bar. await fn() runs fn on A's executor, not on global executor.

I also noticed that, unlike closure that capturs A's property, fn's SIL doesn't have a hop_to_executor instruction (IIUC it's because it doesn't capture self).

class NS {}

actor A {
    func test(_ ns: NS) async {
        let fn: @concurrent () async -> Void = { _ = ns }  
        await fn() // OK
        bar(fn) // Not OK
    }
}

@MainActor
func bar(_ fn: @concurrent () async -> Void) async {
    await fn()
}

The closure's sendability

If we reason about the clode at source code level, it makes sense that fn1 isn't Sendable, because the closure captures a actor-isolated non-Sendable value (this is inaccurate. More on this later). That was how I thought about it in the past.

class NS {}

actor A {
    let ns = NS()

    func test() {
        let fn1: @Sendable @concurrent () async -> Void = { _ = self.ns } // Not OK
        let fn2: @Sendable @concurrent () async -> Void = { await self.foo() } // OK
        // ...
    }

    func foo() {}
}

However, if we look at fn1 SIL and find the entire body is effectively wrapped in an asynchrously call, there is no reason why it isn't Sendable. I can even think of an explanation at source code level: the closure captures self, which is an actor and Sendable, so the closure is Sendable too (this is also inaccurate. See below).

But what about self.ns? Doesn't it leak the reference of ns to a different isolation? IMO it would only be an issue if the closure is marked with, say, @MainActor modifier. For nonisolated async closure (let's ignore that fact @concurrent modifier is actually ignored for the moment), it's a non-issue because a) it's impossible for a nonisolated async closure to store reference of ns, and b) all accesses to ns inside the closure body actually happen on A executor (this is guaranteed by the rule in SE-0306 that actor properties shouldn't be accessed across isolation).

So it appears to me that a nonisolated async closure capturing actor properties is Sendable.

Even though the language doens't allow transferring a closure like { _ = self.ns } to a task closure, it can be worked around. The following code compiles.

class NS {}

actor A {
    let ns = NS()

    func foo() async {
        let fn: nonisolated(nonsending) () async -> Void = { _ = self.ns }
        await bar(fn)
    }

    func bar(_ fn: @concurrent () async -> Void) async {
        await fn()
    }

    func test() {
        Task {
            await foo()
        }

        Task {
            await foo()
        }
    }
}

So, IMO either the closure is Sendable or the approach used in the workaround shoudn't be allowed.

3 Likes

FWIW, the same behavior occurs with closure capturing local variable and parameters. The entire closure body is effectively wrapped in an asynchronous call and bar inside the closure runs on A's executor.

class NS {
    var value = 0
}

actor A {
	// Test 1: capturing local variable
    func test1() async {
        let ns = NS()
        let fn: @concurrent () async -> Void = { bar(); ns.value += 1 }  
        await fn() // OK
    }

	// Test 2: capturing function parameter
    func test(_ ns: NS) async {
        let fn: @concurrent () async -> Void = { bar(); _ = ns.value }  
        await fn() // OK
    }
}

func bar() {}

Caveat: don't use { _ = ns} in the above example, because that would be optimized as an empty closure and its SIL doesn't include hop_to_executor instruction. { _ = ns.value }, however, does the trick.

The behavior is misleading because it leads to false RBI issues. For usres who are not aware of the closure's behavior, they may think the following code have data race.

class NS {
    var value = 0
}

actor A {
    func foo() {}

    func test() {
        let ns = NS()
        let fn: @concurrent () async -> Void = { 
            ns.value += 1
        }

        Task {
            foo()
            await fn()
        }

        Task {
            foo()
            await fn()
        }
    }
}

Note: the call of foo inside closure is a required setup to affect task closure isolation inference. Otherwise the task closure would be inferred as nonisolated and passing fn to task closure would be rejected by compiler (as expected, because fn is actually actor isolated).

Again this is an example where a closure has both isolated parameter and @concurrent modifier. I guess the issue was unnoticed in the past probably because the above example is typically written without defining fn closure explicitly:

    func test(_ ns: NS) async {
        Task {
            foo()
			bar()
            _ = ns.value
        }

        Task {
            foo()
			bar()
            _ = ns.value
        }
    }

But if fn is defined , it becomes very confusing.

1 Like

This sort of thing makes my head hurt, but it seems to me that your difficulty is based on a possible mis-assumption about the semantics of something like this:

Remember that Swift's type inference is a "backwards" (or bubble-up) flow, not "forwards" (or filter-down). For example, this:

    let i: Int = <an expression>

doesn't force the type Int onto the expression. Instead, the compiler tries to determine possible types for the expression in isolation, and then picks the one that best "matches" the Int target type (which may involve a conversion of the expression's value to an Int value).

In your example, fn is a variable holding a closure that executes isolated to the global concurrent domain. However, I think the expression on the right is interpreted (by default compiler assumptions) as a closure in actor A's domain.

If that's correct, think about how that might execute. A call to fn's closure would need to hop to the non-actor domain, then immediately hop back into actor A's domain to execute the body of your closure. If in addition the call originates in A's domain, then nothing at all is achieved by the hop to the global domain, and the observed behavior seems reasonable:

By contrast, if the variable is annotated as @MainActor, the hop to the main actor cannot be eliminated (because the closure needs to be serialized w.r.t. other stuff happening on the main actor), so closure's calls back into the A domain have to be async.

Does that sound reasonable to you?

2 Likes

I doubt your explanation. If fn1 in the following example was in non-actor domain, then bar would have run on global executor.

class NS {}

func bar() {}

actor A {
    let ns = NS()

    func foo() {}

    func test() async {
        let fn1: @concurrent () async -> Void = {
            self.assertIsolated() // OK
            bar()
            await self.foo() // `await` isn't needed
        }        
        let fn2: @MainActor () async -> Void = {
            MainActor.shared.assertIsolated() // OK
            bar()
            await self.foo()
        }
        await fn1()
        await fn2()
    }
}

let a = A()
await a.test()

The above code actually produces a warning about await self.foo() line fn1:

warning: no 'async' operations occur within 'await' expression

That indicates compiler thinks fn1 is self-isolated, not nonisolated.

I guess this behavior is an unexpected side effect of SE-0461. Before Swift 6.2, there was no way to explicitly specify a closure is nonisolated. A nonisolated closure could only be inferred and, when the closure captured isolated parameter, it's inferred to have the same isolation as the parameter. All worked perfectly. After SE-0461 it's possible to specify @concurrent and nonisolated(nonsending) explicitly and thus the conflict.

// Try this on releases before 6.2
let fn1: nonisolated () async -> Void = { }  // invalid
let fn2: @MainActor () async -> Void = { }  // ok

BTW, the more I think about it, the more I think a closure capturing actor-isolated values (including properties, local variables, and parameters) should be Sendable, for the same reason why a global actor isolated closure is Sendable. The only difference between them is that the former runs on an instance of a custom actor and the latter runs on the shared instance of a global actor.

I changed the thread's title to reflect the root cause, which can be demonstrated with the example in my previous post. All the other examples (e.g. those in my original or earlier posts) are just how the issue manifests itself in different scenarios. For example, in code like { _ = self.ns }, compiler infers the closure has the same isolation as isolated parameter self and ignores @concurrent. That's why compiler allows synchronous access to actor property. This cost me a lot of time because I had thought synchronous access to actor property was the cause of the issue.

I also found a workaround. The closure in the following code is nonisolated, not self-isolated. It's a hack that takes advantage of a "feature" that putting an actor reference in capture list can remove its isolated parameter role (IIRC it's not a feature by design and @jamieQ asked about this in the forum a while back).

class NS {}

func bar() {}

actor A {
    let ns = NS()

    func foo() {}

    func test() async {
        let fn1: @concurrent () async -> Void = { [actor = self] in
            actor.assertIsolated() // This will crash.
            bar()
            await actor.foo() // `await` is required
        }        
        await fn1()
    }
}

let a = A()
await a.test()

A useful tip when in doubt: if you're not sure about closure's isolation in a similar situation, check if await is required to call the captured actor's method. If it isn't required, it indicates the closure is actor-isolated.

I doubt that there is a simple solution to this issue. IIUC when NonisolatedNonsendingByDefault is enabled, if a closure has neither @concurrent nor nonisolated(nonsending) attribute, it defaults to nonisolated(nonsending). As a result, there would be no way to define a closure which is isolated to isolated parameter.

That's all I found and I'm going to stop here.

My earlier point was that I don't think this is 100% accurate. Yes, the compiler gives the closure the same isolation domain as the actor self.

But no, it does not ignore @concurrent, because there is no @concurrent in the expression on the RHS of the =.

Your declaration was:

        let fn: @concurrent () async -> Void = { _ = self.ns }

What I'm suggesting is that the variable fn has an @concurrent type, but this does not "impose" that attribute on the RHS expression. In fact, it occurs to me now that the RHS closure in this form isn't even async — there are no suspension points.

It just happens that the non-async, non-@concurrent, actor-isolation-domain closure on the RHS is compatible with the different type and attributes of fn, so there's no diagnostic. You may be right that this is worth noticing, and is a potential pitfall for the unwary, but it doesn't seem incorrect.

My understanding of your original objection is that you expected the RHS to be treated as @concurrent because the LHS is declared to be so. Again, this isn't really how type inference works in Swift, AFAIK.

I think there may be a bug here, but I don't think it's related to type checking.

My mental model of how this is supposed to work is that certain function type conversions are allowed. For example, sync-to-async conversions, or in this case converting a self-isolated closure to an @concurrent one. IIUC, how things generally work in both instances, at least conceptually, is that the "source" function value is wrapped in a thunk that has the right calling convention, prologue, etc but which ultimately ends up forwarding to the original function.

If we write out the separate assignments for intermediate conversions, ultimately we should get the same behavior:

let fn: @concurrent () async -> Void = { _ = self.ns }

// Should be effectively equivalent to:

let syncFn: () -> Void = { _ = self.ns }
let asyncFn: () async -> Void = syncFn
let concurrentFn: @concurrent () async -> Void = asyncFn

// Or even more explicitly:

let syncFn: () -> Void = { _ = self.ns }
let asyncFn: () async -> Void = { () async -> Void in
  syncFn()
}
let concurrentFn: @concurrent () async -> Void = { () async -> Void in
  await asyncFn()
}

Since the function type implies certain semantics, even though the "source" function is synchronous, if we want to call asyncFn, it now requires an await. Since the language wants to optimize out unnecessary work, the runtime semantics of await do not necessarily mean any suspensions will occur in practice – the compiler and runtime both try to eliminate unnecessary suspensions in various ways.

Similarly, when calling an @concurrent async function, the semantics are supposed to be that it will run "concurrently with respect to actors"; i.e. if called from an actor-isolated context, we should expect that it "hops off" the actor to execute.

However, it seems that for some reason this "hop", which the model suggests should occur, does not actually happen in some cases. Consider this example:

@MainActor
func f() async {
  let task = Task { @MainActor in
    print("task")
  }

  let fn: @concurrent () async -> Void = { @MainActor in
    print("closure")
  }

  await fn()
  await task.value
}

Based on my understanding of the language model, I'd expect the behavior here to be something like:

  1. task synchronously schedules its work on the main actor, but it cannot yet run since the caller is occupying the main actor
  2. fn is called and suspends the calling task since it is @concurrent
  3. task's body can now be run since the main actor was freed up
  4. fn's body schedules the "real work" to occur on the main actor
    • Note that this is expected to occur on the concurrent executor so in general has no fixed ordering w.r.t. 3.. However, since the "body" of the closure uses the same serial executor, the enqueue of the task's body should always precede the enqueue of the closure.

Given this reasoning, I'd expect the observed output of the program to be:

"task"
"closure"

However, that is not what currently appears to happen. It seems that the @concurrent function type does not result in an executor hop in this case and the closure executes before the task.

The behavior is inconsistent with what happens if you replace fn with a call to an @concurrent function rather than a closure (see this demonstration). In that case, the execution order matches what I outlined above.

I know there are certain optimizations to try and eliminate unnecessary executor hops, so I'm not sure if this is a consequence of one of those, or an unrelated issue.

1 Like

I have yet to read your post more carefully. But I thought along the same lines after reading @QuinceyMorris's post and I found a weird bug when I did experiments to emulate the conversion. See Compiler has an inconsistent view on a closure isolation internally · Issue #87824 · swiftlang/swift · GitHub.

Thanks for pointing it out, though that leads to the question: what's the expected behavior of the conversion? It turns out that the behavior is weird even if one emulates the conversion manually. See the bug above.

fn's SIL indicates it's MainActor isolated and the closure hops to MainActor executor directly. That said, in this specific example an explicit hop to global executor seems unnecessary, because it's impossible to add code to run on global executor. A more revealing example is perhaps the following. But again @concurrent is ignored (though not completely ignored. For example, await is required. This is another example of #87824).

  let fn1: @MainActor () async -> Void = { @MainActor in
    print("closure")
  }
  let fn2: @concurrent () async -> Void = { 
    bar()
    await fn1()
  }

This is true even in the current implementation where @concurrent is "ignored". So I wonder what's the reason for the actual output? Is it because compiler considers fn2``MainActor isolated and run it synchronously? But if so, why does it not do the same on the task closure?

That seems correct and expected – the actual closure certainly should be isolated to the main actor because it's been explicitly annotated as such.

I disagree that it's unnecessary, at least from a certain perspective. If the programming model is to be consistent, I think this example should result in an executor hop in the same manner that using an explicit @concurrent function does. Or if not a full-on executor hop (maybe there are performance concerns), something semantically equivalent to yielding the task running on the actor to other pending work before executing the "body" of the closure.

Speculating slightly, but I believe the reason for the output is that, since the closure is actually isolated to the main actor (once things are lowered to SIL at any rate), when it's invoked while already on the actor, there is no dynamic suspension – i.e. it doesn't result in a runtime call to enqueue work on the actor's executor. The Task initializer on the other hand never runs its body immediately (that's what Task.immediate was added for), so always enqueues the work.


P.S. This discussion may be better suited for somewhere other than the "Evolution" category.

This seems to be less well known, but marking a closure as @Sendable or passing a closure to a sending parameter requires it to be nonisolated. Obviously, this does not apply to @_inheritActorContext parameters.

As I alluded to in our previous conversation, I think this is the intended behavior. @concurrent used outside of modules that don’t have NonisolatedNonsendingByDefault enabled is semantically no different from nonisolated:

actor A {
    func work() {}

    @concurrent
    func f1() async -> Void { // nonisolated
        work() // error
    }

    nonisolated
    func f2() async -> Void { // nonisolated
        work() // error
    }

    func f3() async { // statically isolated
        let c1: @concurrent () async -> Void = { // nonisolated by default, overridden by captured static isolation
            self.work()
        }

        let c2: () async -> Void = { // nonisolated by default, overridden by captured static isolation
            self.work()
        }
    }
}

However, in modules that do have NonisolatedNonsendingByDefault enabled:

/* -enable-upcoming-feature NonisolatedNonsendingByDefault */

actor A {
    func work() {}

    @concurrent
    func f1() async -> Void { // nonisolated
        work() // error
    }

    nonisolated
    func f2() async -> Void { // nonisolated(nonsending)
        work() // error
    }

    func f3() async { // statically isolated
        let c1: @concurrent () async -> Void = { // nonisolated by default, overridden by captured static isolation
            self.work()
        }

        let c2: () async -> Void = { // nonisolated(nonsending) by default, overridden by captured static isolation
            self.work()
        }
    }
}

This is the expected behavior, in my opinion, since it has always been possible to override a closure that is nonisolated by default (non-@Sendable closures or closures formed outside a global-actor context) with static isolation, whereas nonisolated functions cannot be overridden.

The same logic applies to nonisolated(nonsending), since it can be overridden by static isolation. I think of the relationship between nonisolated(nonsending) and @concurrent as similar to that between Copyable and ~Copyable.

@NotTheNHK I see what you meant, but I think it's anti-intuitative to consider
that specifying @concurrent should have the same behaivor as if it's the implciit default.

I created it in Evolution forum because I thought this would require a language change, not only in implementation but also a consensus of the semantic of @concurrent wrapper of an actor-isolated closure, and the change might have other fallout. However, the discuss was more like an issue report than a proposal. So, yes, it should be in "Using Swift".

I don't think the issue should be left as is. But as an user a more pragmatic approach is a) not spending too much on it, b) knowing how to investigate such issues, and c) avoid pitfalls based on one's understanding.