This is something I’ve been really interested in recently, isolation hierarchy. Which isolation control mechanisms have a higher “rank” than others and can override them. For example, sending and @Sendable have the second-highest “rank” and are only topped by explicit static isolation, while @concurrent and nonisolated(nonsending) have a very low "rank" and can be overridden by almost anything else that controls isolation.
Yes, there's more going on here. This is an interesting case, because what matters here is the executor used to execute work . However, work is also asynchronous. This arrangement is actually quite subtle. The sending is actually required, because the closure must move into ContextActor, but the executor requirement is not visible to the compiler.
I'm not familiar with CoreData. There was a thread about a special behavior of ModelActor in this thread, though that behavior is invisible to type system so I guess it's irrelevant. That said, person is created inside the closure so it can be accessed synchronously of course. I'm glad the sending hack works for you.
FWIW, I haven't been able to reproduce the issue using regular actors. The following code produces diagnostic rather than compiles with misbehavior. This is the same issue as demonstrated by isolatedToGlobalActor in another thread.
actor DBActor {
func run(_ work: nonisolated(nonsending) () async -> Void) async {
await work()
}
}
class DBObject {
func modify() {}
func save() {}
}
// Note: the code compiles if `buttonPushed` is nonisolated.
@MainActor func buttonPushed() {
let myDatabaseActor = DBActor()
Task {
await myDatabaseActor.run { // error: sending value of non-Sendable type '() async -> Void' risks causing data races [#RegionIsolation::SendingRisksDataRace]
print("\(#isolation as (any Actor)?)")
let dbObject = DBObject()
dbObject.modify()
dbObject.save()
}
}
}
It took me a while to think about this. IMO the closure is sendable is because it's effective a plain old function (it doens't capture any parameter). However, you seemed to reason about it in a different way: it's sendable because its isolation is explicit and static. I assume that's how compiler determines it so your approach is a better.
I had some problem in understanding the term "static" at first. On one hand, it made sense because the isolation is known at run time. On another hand it was rather confusing because where the closure actually runs at runtime depends on the passed parameter. I ended up thinking that the term is about isolation (mainly a compile time concept?) not executor (a runtime concept).
What's the isolation of a @isolated(any) closure? Is it explicit static (it's nonisolated at compile time) or explicit dynamic? Given the fact that it's not Sendable, I suppose it's explicit dynamic. But as you mentioned in another thread, there is no reason why it can't be Sendable. This makes me wonder about the reason behind the current rule ("only closures with explicit and static isolation can be Sendable"). Is it only because other situations haven't been implemented or are there more fundamental reasons?
Does it mean they are silently ignored? I'm afraid that's not user friendly and hurts code readability. How about producing warning? What's more, given the complexity of isolation inferrence, is it possible to help users to better understand or even control isolation inferrence? But I don't have any concrete suggestions yet.
At first, this thread was really bothering me. Because while there are some pretty subtle things going on, fundamentally it felt to me like this scenario should be impossible. But I have also been unable to reproduce this situation. I cannot produce an actor that accepts a non-sending/Sendable closure.
@bjhomer could it be that you've omitted some other code?
Additional information. If the function is nonisolated, it compiles and has the same behavior as @bjhomer expected. Example: Compiler Explorer
Ah right that makes sense. But to be honest, I’m not sure what executor would be used in this case.
Okay, I've managed to extract a fully standalone sample that reproduces the problem I'm talking about. Run with swift /path/to/main.swift
// main.swift
import Foundation
actor QueueActor: Actor {
let executor: QueueExecutor
init(queue: DispatchQueue) {
self.executor = QueueExecutor(queue: queue)
}
// Without 'sending' here, the dispatchPrecondition below fails
func run(_ work: /* sending */ nonisolated(nonsending) () async -> Void) async {
await work()
}
public nonisolated var unownedExecutor: UnownedSerialExecutor {
return executor.asUnownedSerialExecutor()
}
}
final class QueueExecutor: SerialExecutor {
let queue: DispatchQueue
init(queue: DispatchQueue) {
self.queue = queue
}
func enqueue(_ job: consuming ExecutorJob) {
let unownedJob = UnownedJob(job)
let unownedExecutor = self.asUnownedSerialExecutor()
queue.async {
unownedJob.runSynchronously(on: unownedExecutor)
}
}
}
@MainActor func buttonPressed() async {
let dispatchQueue = DispatchQueue(label: "test")
let actor = QueueActor(queue: dispatchQueue)
await actor.run {
print("work work")
// 💥 Boom
dispatchPrecondition(condition: .onQueue(dispatchQueue))
}
}
await buttonPressed()
Interesting. With Swift 6 mode, it actually requires the sending annotation… but it gives a surprising error:
@MainActor func buttonPressed() async {
let dispatchQueue = DispatchQueue(label: "test")
let actor = QueueActor(queue: dispatchQueue)
// ❌: Sending value of non-Sendable type '@concurrent () async -> Void' risks causing data races
await actor.run {
print("work work")
dispatchPrecondition(condition: .onQueue(dispatchQueue))
}
}
It's surprising that it claims the closure type is @concurrent here; I would have expected it to say that it's @MainActor isolated, since it's declared inside a MainActor function. In Swift 5 mode (with complete concurrency checking), I get the same error as a warning… but when I run it, the isolation captured by #isolation is MainActor, and it does run on the main actor.
I assume the compiler believes it's @concurrent because it's being passed to a nonisolated function parameter. But then why does it run on the Main Actor (in swift 5)?
If the error message and #isolation disagree, I think it has to be a bug in how the message is constructed.
Don’t forget about the bug, although this case is slightly different. Here, nonisolated(nonsending) in combination with sending fails to remove the implicit static isolation (@MainActor). This has been fixed in Swift 6.3. Because of the first bug, fixed here, you get a runtime crash instead of the erroneous diagnostic.
However, If you run the code sample on the devsnapshot or nightly build it behaves as expected
Ooo… this is an interesting problem. This is a bit of “wall of text” comment, but it will explain why the OP code works on actor instances, but does not on the global actors. Yes, the type of the actor does matter in this case.
Test code
I slightly modified OP problem, by adding DatabaseState that we provide to the consumers:
// State stored in `DatabaseActor`.
// Private, non-sendable, cannot escape the `DatabaseActor` isolation.
final class DatabaseState { … }
actor DatabaseActor {
let state = DatabaseState()
// Our goal is to make this work.
func runOnThisActor(_ body: (DatabaseState) async -> Void) async {
await body(self.state)
}
}
The whole repository is available at github.com/LiarPrincess/Swift-actor-run-method. Tested on:
Swift version 6.3.2 (swift-6.3.2-RELEASE)
Target: x86_64-unknown-linux-gnu
nonisolated(nonsending)
nonisolated(nonsending) is quite useful, as it means “run within the caller isolation” - in this case DatabaseActor is the caller. The massive advantage is that the callers can capture their disconnected non-Sendable values and use them within the closure.
// This is NOT marked as `Sendable`!
// I added a few methods to check which ones can be called from within the closure.
final class NonSendableArg {
func functionSync() {}
func functionSync_isolationArg(isolation: isolated (any Actor)? = #isolation) {}
func functionAsync() async {}
func functionAsync_isolationArg(isolation: isolated (any Actor)? = #isolation) async {}
nonisolated(nonsending) func functionAsync_nonisolatedNonsending() async { }
@concurrent func functionAsync_concurrent() async {}
}
actor DatabaseActor {
// Without 'sending' the closure for now. I will get to that one later.
func runAsync_nonisolatedNonsending(_ body: nonisolated(nonsending) (DatabaseState) async -> Void) async {
await body(self.state)
}
}
let db = DatabaseActor()
let nonSendableArg = NonSendableArg() // Disconnected, not a part of 'DatabaseActor' isolation.
await db.runAsync_nonisolatedNonsending { state in
nonSendableArg.functionSync()
nonSendableArg.functionSync_isolationArg()
// await nonSendableArg.functionAsync() // 🟥 Compilation error
await nonSendableArg.functionAsync_isolationArg()
await nonSendableArg.functionAsync_nonisolatedNonsending()
// await nonSendableArg.functionAsync_concurrent() // 🟥 Compilation error
}
// 🟥 Following will also not compile, as disconnected non-sendable value 'nonSendableArg'
// was send to 'db.runAsync_nonisolatedNonsending' and is no longer available in this isolation.
await nonSendableArg.functionAsync()
If we required the closure to be @Sendable then all of the arguments (including implicit captures like the nonSendableArg) would have to be Sendable. In a way @Sendable closures are an anti-pattern, because they require everything to be Sendable which limits what our consumers can do.
sending nonisolated(nonsending)
As far as I know this is the most correct answer, even though it looks like some eldrich invocation. The reason why we need this are connected values - those that belong to a specific isolation context. What happens depends on whether the value is Sendable or not:
final class SendableArg: Sendable { }
final class NonSendableArg { }
actor DatabaseActor {
// Added 'sending'.
func runAsync_sending_nonisolatedNonsending(
_ body: sending nonisolated(nonsending) (DatabaseState) async -> Void
) async {
await body(self.state)
}
}
actor TEST_RUNNER {
// Values connected to the `TEST_RUNNER` isolation
private let sendableProperty = SendableArg()
private let nonSendableProperty = NonSendableArg()
func test_async() async {
let db = DatabaseActor()
let nonSendableArg = NonSendableArg()
await db.runAsync_sending_nonisolatedNonsending { state in
// 🟩 This is fine, as 'nonSendableArg' is disconnected.
await nonSendableArg.operation()
// 🟩 'sendableProperty' is 'Sendable', so we can send it to the 'DatabaseActor',
// but the closure has to be marked as 'sending nonisolated(nonsending)'.
await self.sendableProperty.functionAsync_nonisolatedNonsending()
// 🟥 'nonSendableProperty' is not 'Sendable', so we can't send it to the 'DatabaseActor',
// as then it would be available in 2 isolation contexts:
// - TEST_RUNNER.nonSendableProperty - as a property
// - DatabaseActor.runAsync_sending_nonisolatedNonsending - inside closure
await self.nonSendableProperty.functionAsync_nonisolatedNonsending()
}
}
}
Reason why we need sending is that after accessing the connected value, the closure type is inferred to be non-sendable (DatabaseState) async -> () that belongs to the TEST_RUNNER. We cannot pass a non-sendable value to another isolation:
- source:
TEST_RUNNER - destination:
DatabaseActor - value: non-sendable
(DatabaseState) async -> ()closure
There are 2 solutions:
- mark the closure argument as
sendingin theDatabaseActorthis will send the non-sendable closure to theDatabaseActor - mark the closure as
@Sendablein the client
To put is simply:
sendingis a part of theDatabaseActor.runsignature meaning: move this non-sendable value from one isolation context to anothernonisolated(nonsending)is the type of the closure we are sending
Problem with the OP code
After all of this nonsense we finally get to the OP problem with nonisolated(nonsending) without sending. Global actors (like @MainActor) are more viral than the custom defined actors like the actor TEST_RUNNER that I used above:
- global actors will always spread their isolation tentacles into the closure
actorswill only spread it only if they are used within the closure
actor DatabaseActor {
// WITHOUT 'sending' the closure - the OP scenario.
func runAsync_nonisolatedNonsending(_ body: nonisolated(nonsending) (DatabaseState) async -> Void) async {
await body(self.state)
}
}
@MainActor
func test_mainActor_virality() async {
let db = DatabaseActor()
// 🟥 This will not work!
//
// In the OP case this will compile, but because of the global actor virality
// the closure will run on the '@MainActor'.
//
// In my case I specifically added the 'DatabaseState' to get the compilation error:
// - non-sendable 'DatabaseState' belongs to the 'DatabaseActor'
// - because of global actor virality the closure will try to run on the '@MainActor'
await db.runAsync_nonisolatedNonsending { state in print("Hello") }
}
// The same code behaves differently on non-global actors.
actor TEST_RUNNER {
// Connected to the `TEST_RUNNER` isolation.
private let sendableProperty = SendableArg()
func test_virality() async {
let db = DatabaseActor()
// 🟩 Works - we are not using any of the connected values
await db.runAsync_nonisolatedNonsending { state in
let i = #isolation
print(i) // Optional(swift_actor_testing.DatabaseActor)
}
// 🟥 Will not compile - we are using connected 'sendableProperty' value
// |- error: sending value of non-Sendable type '(DatabaseState) async -> ()' risks causing data races [#SendingRisksDataRace]
// `- note: sending 'self'-isolated value of non-Sendable type '(DatabaseState) async -> ()' to actor-isolated instance method 'runAsync_nonisolatedNonsending' risks causing races in between 'self'-isolated and actor-isolated uses
await db.runAsync_nonisolatedNonsending { state in
self.sendableProperty.functionSync()
}
}
}
@Sendable + isolated (any Actor)?
Another option is to mark the closure as @Sendable, but this will not work:
- It is not possible to pass the non sendable
DatabaseStateargument to the closure - it is stored on theDatabaseActorand cannot leave its isolation context. - Closure would run on the
@MainActor. The end result would be that we switch toDatabaseActorwhich goes back to the@MainActorto call the closure. This is the OP problem.
We can fix this by adding the isolated (DatabaseActor) argument to the closure:
actor DatabaseActor {
func runAsync_SendableIsolated(
_ body: @Sendable (isolated (DatabaseActor)) async -> Void
) async {
await body(self)
}
}
actor TEST_RUNNER {
func test_async() async {
let db = DatabaseActor()
await db.runAsync_SendableIsolated { dbActor in
// We can access the state here, as we are on the 'DatabaseActor'.
let state = dbActor.state
}
}
}
This is worse than sending nonisolated(nonsending), as @Sendable closure prevents us from using non sendable values in the closure. The upside is that it can be used on older Swift versions - as far as I know sending nonisolated(nonsending) requires Swift 6.2+.
I think the two failed async calls in your first example are irrelevant. In my understanding a value in a nonisolated(nonsending) closure can be potentially merged to the actor isolation region of its caller, so it shouldn't be transferred to global executor or other actors no matter if the closure is also sending or not.
EDIT: sorry, I later realized I probably misunderstood it. while values in a nonisolated(nonsending) could be merged to the actor isolation region of its caller indeed, it shouldn't be assumed it always happens. Instead it should be analyzed based on closure body by RBI. I created a separate thread asking about why these two async call didn't work.