What's the difference between directly using an asyncly returned non-sendable type in .task and indirectly using it with another function?

Consider this code:

class NonSendable {
    var bool: Bool = .random()
}

func makeNonSendable() async -> NonSendable {
    NonSendable()
}

struct Demo: View {
    @State private var bool: Bool?

    nonisolated func foo() async {
        let ns = await makeNonSendable()
        Task { @MainActor in
            bool = ns.bool
        }
    }

    var body: some View {
        Text("Hello, World!")
            .task { // NG
                let ns = await makeNonSendable()
                bool = ns.bool
            }
            .task { // OK
                await foo()
            }
    }
}

In the code above, when we try to call makeNonSendable directly in the .task closure, which is isolated by MainActor, the compilor says "Non-sendable type 'NonSendable' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary" and fails to build. But when we wrap the same logic with a nonisolated foo function, the compilor says nothing and builds successfully.

I assume that these 2 patterns should be doing the same thing: calling a nonisolated async function in a MainActor isolated task closure, and applies the returned value to a MainActor isolated property. So I don't understand why one pattern is OK to build and the other is not, am I missing something here? or is it a bug of Swift compilor?

4 Likes

My understanding is that the reason ns can move across boundaries into the main actor within foo is due to region based isolation analysis.

On the other hand, the reason makeNonSendable cannot be called from the main actor is that a non-sendable return value cannot cross boundaries.

I find these two behaviors inconsistent.

According to the reasoning behind region based isolation, a value returned from a nonisolated function should not be shared with any actor context.

If that is the case, it should be safe to call it from the main actor as well.

3 Likes
Deleted

I also find the error is very confusing. Below I make a minor change to the code (I remove nonisolated before foo(), because I don't think it's relevant) and add the error message.

(For people who don't use SwiftUI, you can reproduce the issue by putting the code in a swift package and compiling it.)

import SwiftUI

class NonSendable {
    var bool: Bool = .random()
}

func makeNonSendable() async -> NonSendable {
    NonSendable()
}

struct Demo: View {
    @State private var bool: Bool?

    func foo() async {
        let ns = await makeNonSendable()
        Task { @MainActor in
            bool = ns.bool
        }
    }

    var body: some View {
        Text("Hello, World!")
            .task { // NG
                let ns = await makeNonSendable() // error: non-sendable type 'NonSendable' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
                bool = ns.bool
        Task { @MainActor in
            bool = ns.bool
        }
            }
            .task { // OK
                await foo()
            }
    }
}

The error messge:

non-sendable type 'NonSendable' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary

My questions:

  1. The compiler thinks it's "implicitly asynchronous call". Is this a bug? makeNonSendable() is defined as async func, so it's an explicit call, I think.

  2. From Apple SwiftUI doc, View.task() takes an async closure, which IIUC should run in global executor. So, considering makeNonSendable() also runs in global executor, it's a bit hard for me to understand why this is considered as "cross actor boundary" (not to mention it shouldn't be an issue due to RBI even if it really crossed actor boundary). I'm aware that global executor might have multiple threads, but my understanding is that it shoudln't be an issue to pass non-senable value between functions running in global executor.

I'd appreciate if anyone can clarify it.

1 Like

I'm using Xcode 16.2 and unfortunately, if I remove nonisolated from foo, let ns = await makeNonSendable() also produces the Non-sendable type blablabla error :innocent:

My understanding is that if I remove nonisolated attribute, foo method becomes exactly the same environment as .task closure, which is isolated by MainActor, so they should produce the same error :thinking:

i think i mostly agree with @omochimetaru's assessment of the underlying cause of the disparity.

the diagnostic in makeNonSendable() occurs due to the fact that non-sendable types cannot generally be passed across isolation boundaries. that particular diagnostic is produced during typechecking, which occurs before the region-based isolation analysis runs (that happens during SIL optimization). as such, it's a bit more conservative about diagnosing patterns that may not be safe. it appears that this diagnostic in particular was removed somewhat recently in favor of region-based isolation analysis, but i'm not sure this has made it into a release compiler yet.

i believe if your function does in fact return a non-sendable instance that is 'disconnected' from any isolation region, then adding the sending keyword to the return value of makeNonSendable() may resolve the behavioral discrepancy. this enables the type system to 'see' that the type should be safe to transfer into an actor-isolated region, so the typechecking diagnostic should be avoided.

2 Likes

I did my experiments with Swift 6.0.1. I'll double check the result tomorrow and upgrade my environment if possible.

I'm not sure about my above understanding now. I remember I read in a draft SE proposal that by default closure has the same isolation as the context where it's defined. In above example, the closure passed to View.task() is defined in View.body, so it should has @MainActor isolation. But if so, what's the point of defining View.task()'s closure argument as async? I mean, if both the caller (View.task() and the callee (the closure passed to it) are in the same isolation (that is, @MainActor), I can't see the reason to run the callee asynchronously. I'm really confused.

EDIT: Please ignore above question. I figure out why. The same question applies to Task.init(), which takes an asynchronous closure and that closure is usually defined in place. The closure needs to be async so that it's possible to call other async functions in closure.

1 Like

Thanks for the response.

adding the sending keyword

Yes, this definitely solves the problem, as long as the person who implements makeNonSendable() is me and the person who calls makeNonSendable() is also me :innocent: One of my concerns is if I'm just the the implementer, I may forget this sending keyword (BTW I actually cannot return a shared non-Sendable object, e.g., a property in a type, no matter weather there's a sending keyword or not, from a nonisolated async func, right?); and if I'm just the caller, there's nothing I can do but implement the foo workaround method.

FYI. I did experiments on both MBP (it has swift 6.0.1 installed) and Linux (it has swift 6.0.3 installed, which is the newest patch release). I can't even get foo() compile no matter I add nonisolated or not. My conclusion is RIB in these releases just doesn't work (now I understand why @jamieQ said RIB hasn't been implemented, although according to SE-0414 it was implemented in swift 6.0).

Test code on MBP
import SwiftUI

class NonSendable {
    var bool: Bool = .random()
}

func makeNonSendable() async -> NonSendable {
    NonSendable()
}

struct Demo: View {
    @State private var bool: Bool?

    // adding 'nonisolated' doesn't help and has the same error
    func foo() async {
        let ns = await makeNonSendable() // error: sending 'ns' risks causing data races
        Task { @MainActor in 
            bool = ns.bool
        }
    }

    var body: some View {
        fatalError()
    }          
}   
Test code on Linux
class NonSendable {
    var bool: Bool = .random()
}

func makeNonSendable() async -> NonSendable {
    NonSendable()
}

actor A1 {
    var bool: Bool?

    // adding 'nonisolated' doesn't help and causes "bool = ns.bool" fail to compile
    func foo() async { 
        let ns = await makeNonSendable()// error: non-sendable type 'NonSendable' returned by implicitly asynchronous call to nonisolated function cannot cross actor boundary
        bool = ns.bool
    }
} 

What's the swift version in Xcode 16.2? I suppose it's swift 6.0.3. Are you really sure you can compile your original example if you remove the part of the code you marked with "NG"? I believe you can't. And that make senses - there are really no behavior difference, neither works because RIB is buggy in current release.

And yes, I can get it working by adding sending. But I don't think it's needed if RIB work properly. In my understandinding sending is only required in scenarioes where compiler don't have enough information (e.g. function parameters, or perhaps return value of a function declaration in protocol, etc), which is not true in this case.

1 Like

Umm that's weird. Yes I checked my environment, the Swift version is the latest 6.0.3, running on an M4 Mac mini with macOS 15.3, and I can compile your code as long as I mark foo as nonisolated, but if I remove the nonisolated attribute, it gets build-time error.



I see the difference. You use "swift <file", but I put the code in a package and run "swift build". I have no idea why the latter reports error but the former doesn't. I just created a new thread asking about the weird behavior.

It fails on my MBP (swift 6.0.1) indeed. While I did experiments, I also observed other concurrency errors I couldn't explain, so I stopped investigating further. I think I had better not do anyting related to concurrency until Swift 6.1 is released.

But I managed to simplify foo() (the original code looks contrived to me). With this simplified version, your original quesiton is still valid - why it doesn't work to call the code in foo() body directly in View.task()?

struct Demo: View {    
    @State private var bool: Bool?

    func foo() async { 
        let ns = await makeNonSendable()
        bool = ns.bool
    }
    
    var body: some View {
        Text("Hello, World!")
             .task { // NG
                 let ns = await makeNonSendable()
                 bool = ns.bool
             } 
            .task { // OK
                await foo()
            }
    }
}

EDIT: I have a hypothesis: while foo() is a method of a SwiftUI View, its isolation isn't '@MainActor', but nonisolated. So adding nonisolated doesn't really matter. That explains why using the same foo() (without @nonisolated) causes "cross actor boundary" in actor, but not in SwiftUI custom view.

On the other hand, in the code you mark with "NG", the closure passed to View.task() is defined in-place, so its isolation is @MainActor. That explains why it fails!

TLDR: the different behavior is due to the fact that foo() and the closure passed to Task.View() has different isolation. The former is nonisolated, the latter is in @MainActor.

No, I don't think so, at least in the case of using swift <file.swift> with Swift 6.0.3. If we don't mark it as nonisolated, it's automatically isolated by MainActor due to Swift 6 View protocol constraint (which is different from Swift 5), and that's why you don't need to wrap bool = ns.bool with a Task { @MainActor in } closure in that simple foo function. If you mark it as nonisolated, you'll have to switch to MainActor explicitly in order to mutate bool property, which is also isolated automatically by MainActor as a View.

EDIT:
BTW actually if you remove nonisolated attribute from foo func, you should get an error before mutating bool property, at the point trying to call makeNonSendable(). Since underneath the non-attributed foo func should be totally the same as .task closure, which is isolated by MainActor.

EDIT 2:
I see the problem. You're marking let ns = await makeNonSendable() with comment // error: sending 'ns' risks causing data races. This is because ns is isolated by MainActor and in your case, foo isn't. This is a Swift 5 feature: only body and @State properties are MainActor isolated, but the whole View isn't, so foo is nonisolated from the very beginning. While in Swift 6 mode, whole View is by default isolated by MainActor, so if you use Swift 6 mode, you should get a totally different error, like in the screenshot above.

EDIT 3:
No ns is not isolated by MainActor. I just made a mistake that I thought ns was a property of Demo... So that means, even in Swift 5 mode, ns should be nonisolated...

Sorry for the confusion. I think you're right. I installed Xcode 16.2 on my own MBP and have seen the same result. It's sad that foo() has to be so ugly (there are so many unnecessary isolation domain switch just to please compiler). That said, I think my following understanding are correct:

  1. The different behavior your originally asked about was because foo() and the closure passed to View.task() have different isolation.

  2. RBI doesn't work properly in 6.0.3. Otherwise my simplified version of foo() should just work. Hopefully this will be fixed in 6.1.

Do you know where I can read more about this? Thanks.

1 Like

I can't find the document (I believe it should be somewhere in Apple's developer website), but some details can be found in last year's WWDC session video:

1 Like

Sorry, my bad. My words were not clear enough.

Actually what I meant was, since makeNonSendable is marked as async, it's running on a random actor due to this behavior. So although nonisolated foo and .task have different isolations, the execution of func makeNonSendable should be the same isolation, if my understanding was correct.

First, the thread you linked is just a pitch. It hasn't been approved or implemented. In current release a nonisolated async function always runs in global executor. That said, this isn't the key to understand my answer.

Second, what you assumed in your question was that, since both foo() and the closure have the same code in their body, they should have the same behavior. The assumption is wrong, foo() and the closure have different isolations, which affect the code running in them and hence different behaviors.

Yes I know, but that's not my point. I'm not saying the thread's goal is currently implemented behavior, actually exactly as you said, currently nonisolated async functions always run on global executor, which makeNonSendable is doing so. That means, no matter where I call makeNonSendable, in MainActor isolated .task or nonisolated foo, as long as it's marked as async, the implementation of makeNonSendable should run on global actor.

Back to my original code, in the NG example, I called let ns = await makeNonSendable(); bool = ns.bool in .task. The 1st line is called from MainActor obviously, but should be running on global actor, which means the value of makeNonSendable should be returned from a global actor, and then assigned to ns on main actor, so we can then assign ns.bool to bool on 2nd line, since bool is isolated from main actor.

On the other hand, in the OK example, I just called foo from .task, which is on main actor yes, but since foo is marked as nonisolated async, the implementation of foo is running on a global actor, so naturally the 1st line let ns = await makeNonSendable() is running on a global actor. But since bool is isolated from main actor, I then need to switch to main actor with Task { @MainActor in } to assign ns.bool to bool.

In conclusion, these 2 examples should be like this:

example called from running on
NG makeNonSendable main actor global actor
NG bool = ns.bool main actor main actor
OK makeNonSendable global actor[1] global actor
OK bool = ns.bool main actor main actor

As you can see, the only difference is the executor which calls makeNonSendable, but both examples should be running on the same executor, and that's why I think they should have the same behavior. And if the error is saying a non-sendable type returned by an async func crosses its actor boundary, both examples passed ns from global actor to main actor. The result of one example is OK but the other example is not makes no sense to me.


  1. Although makeNonSendable is called from global actor, the caller function foo is called from main actor. ↩︎

Your understanding is correct that there are data crossing isolation domain in both cases. It's just that the exact details are different. IMO both should work if RBI works propertly. Unfortunately we all observed that one work and another doesn't. That's why I think RBI is buggy. Since it's buggy (I suppose my understanding is correct), it's a waste of time trying to figure out why one works and another not. That's why I suggested to just understand the issue in a general way.

1 Like

AFAIK the issue you're discussing is known for RBI analysis, where it fails to reason in closures (and some other instance-related contexts) in certain cases. Maybe it has been improved in 6.1 (haven't checked), maybe it is too complex to address. Use of sending should suffice, and for third-party code – you can wrap it in your function with proper sending markers (or fix if that's an open-source dependency, or file a radar if that's an Apple framework).

1 Like

I think OP's example showcases a potential improvement point in RBI.

When we write let ns = await makeNonSendable(), ns is undoubtedly in its disconnected region in all circumstances. However, at the moment, RBI can only kick in when we are passing a disconnected nonsendable to another isolation domain, but not when we are retrieving a returned value from another isolation domain.

class NonSendable {}
nonisolated func makeNonSendable() async -> NonSendable  { .init() }

@MainActor 
func passAcrossDomain(_ ns: NonSendable) {}
nonisolated func foo() async {
    let ns = await makeNonSendable()
    await passAcrossDomain(ns)    // OK
}

@MainActor
func retrieveAcrossDomain() async {
  let _ = await makeNonSendable()  // Not OK
}

For the latter case, currently we need the sending direction.
I believe, theoretically, the return value of any nonisolated async function with no parameters should automatically be a sending one.

2 Likes