Is nonisolated(nonsending) on a function *parameter* mostly useless?

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.

1 Like

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.

1 Like

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()
        }
    }
}
1 Like

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

1 Like

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)?

1 Like

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 sending in the DatabaseActor this will send the non-sendable closure to the DatabaseActor
  • mark the closure as @Sendable in the client

To put is simply:

  • sending is a part of the DatabaseActor.run signature meaning: move this non-sendable value from one isolation context to another
  • nonisolated(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
  • actors will 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 DatabaseState argument to the closure - it is stored on the DatabaseActor and cannot leave its isolation context.
  • Closure would run on the @MainActor. The end result would be that we switch to DatabaseActor which goes back to the @MainActor to 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+.

4 Likes

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.