Why No Data Race Warning in the non-isolated async function with non-sendable Type?

public class StockExampleSite {

    private let korea: Korea

    init(korea: Korea) {
        self.korea = korea
    }

    internal func kospi() async -> Double {
        // Warning because of passing non-sendable type 'Korea' between global concurrent executor to MainActor
        let mainActorKospi = await self.korea.stockPriceIndexMainActor()
        
        // No Warning because of Same global concurrent executor
        let nonIsolatedKospi = await self.korea.stockPriceIndexNonIsolatedActor()

        return nonIsolatedKospi
    }

}

public class Korea {

    @MainActor
    internal func stockPriceIndexMainActor() async -> Double {
        return 3000000
    }

    internal func stockPriceIndexNonIsolatedActor() async -> Double {
        return 3000000
    }

}

In the code, there are no data race safety warnings when calling between non-isolated async functions. I understand from Swift Evolution Proposal SE-0338 that non-isolated async functions are executed on a global concurrent executor.

Expected result
• data race safety warnings to occur when calling between non-isolated async functions.
Actual result
•No data race safety warnings occurred when calling between non-isolated async functions.

Does this mean that within the same executor, data race safety for non-sendable types is guaranteed even if it is not a serial executor?

1 Like

I think you might have simplified your example too far: there are no concurrency-related warnings at all for me, probably because Double is a Sendable type.

1 Like

sorry, i change my example again!

1 Like

To the certain point. While you just call one non-isolated async function from another - everything is fine, because at this level you can think only if there are any isolation boundaries to be crossed. In your example, there is no isolation boundaries to be crossed, so the code is OK, as both methods are non-isolated.

Assume at certain point you well need to call kospi method from some task, say:

Task {
    await site.kospi()
}

Now you have an error (in Swift 6 mode) telling that you are capturing non-Sendable type StockExampleSite within @Sendable closure, which is now might cross isolation boundary.

Consider further, you are calling it from several tasks:

await withDiscardingTaskGroup { group in 
    group.addTask { await site.kospi() }
    group.addTask { await site.kospi() }
}

Now you clearly have unsafe async access that might lead to concurrency issues, so this also will be an error.

At some point you are more likely to reach the need to call your method from some sendable context, and that's where you get an error.

2 Likes

I understand that a warning will not occur as long as a non-sendable type does not cross the isolation boundary.

It seems that the Global Concurrent Executor, where non-isolated async functions operate, has its own logic to ensure data race safety. This is why this warning does not occur.

I am very pleased to learn these new facts. Thank you!

Executor does not ensure this, Swift sendability rules do. You simply won’t be able to call such functions safely in Swift without ensuring that involved types are sendable and therefore is safe in concurrent environments. Nonisolated functions will be OK to call if they either freestanding and has no parameters or all parameters are sendable, or if they part of a type, the type should be sendable. In other cases that won’t fly because it is unsafe to run concurrently, so Swift simply guards you from such use by disallowing it.

1 Like

I misunderstood that if there are no warnings in Swift’s concurrency checking, data race safety is guaranteed. While there is some relationship between warnings and data races, the absence of warnings does not mean there are no data races. Data race issues can occur if the thread context is not properly checked during async calls between non-Sendable types

Er, I am not too good in this, but that's not the take-away, I think.
While there are some bugs still, the goal for Swift (6+) is definitely to be able to say "there are no warnings in my code about data races, so there are none".

The fact that you don't see a warning at the callsite of stockPriceIndexNonIsolatedActor does indeed mean there is no data race here. The call happens in the global actor isolation context [1] and that is the same context the function is defined on (inside the Korea class). The only parameter that is passed here (korea, which becomes self inside the method, this is basically passed implicitly as that's the instance the method is called on) is not Sendable, but since you do not cross an isolation context, that does not matter.

The warning at the callsite of stockPriceIndexMainActor is correct as the korea instance here is inside the global actor isolation context, but since the called method is marked to be run on @MainActor (and korea/self is passed like before), is now crossing an isolation context, which is forbidden.

In other words: If a jump happens, it's only important what is passed: Is it Sendable? Fine! Is it not? Dang, the compiler warns!
If there is no switch between isolation contexts at all, this does not matter.

Note that a suspension point (await) does not necessarily mean the isolation context is switched, even if the global actor executor may switch threads internally. Perhaps this is the mechanism you mean?


[1]: See my other post below. Basically all your async functions run inside a task, but it's all structured until you start a Task { ...}. See also the nifty image posted here.

3 Likes

You are looking in a bit of a different direction. No warnings guarantee that there is no data races (putting aside compiler imperfections). The reason why is there no errors is that your code not being called as a part of some task yet. As soon as you do, which is unavoidable, Swift will warn you that this produces data race and in Swift 6 mode won’t compile. But for now, everything fine.

And even when you’ll reach this error calling it in some task, you still can address it making (in this example) type to conform Sendable so that calling nonisolated function on this is safe in a concurrent way.

@Gero made also great example in a different words of what I am saying, maybe it will help you to see the full picture.


As a rule of thumb, I can say, that you can apply the following: if I have async method on a type, it is better to be isolated or type conform to Sendable.

There are exceptions with region based isolation that make sense to have nonisolated async methods on non-Sendable types, but in lot of a cases you'll need an isolation.

Oh, yes, this reminds me that I may have worded it too strictly, in a way, so here's an addendum: What I called "global actor isolation context" should actually be "one specific isolation context that runs on the default global actor".
Because as soon as you start a new unstructured task via Task { ... }, you define a new isolation context that, per definition, now runs concurrently on the default global actor. What is inside that is properly isolated, but not in regard to other (unstructured) tasks, even if they also run on the global actor.

I'll see if I can edit my last post to make this consistent now...

That's true for child tasks as well, as they run concurrently, but are structured.

1 Like