How can I use region-based isolation?

Hello,

Would you please help me understand how to have region-based isolation help me fixing some warnings?

My Swift 6 setup:

  • swift-6.0-DEVELOPMENT-SNAPSHOT-2024-04-22-a

  • Swift settings:

    var swiftSettings: [SwiftSetting] = [
      .enableUpcomingFeature("StrictConcurrency"),
      .enableExperimentalFeature("RegionBasedIsolation"),
      .enableExperimentalFeature("TransferringArgsAndResults"),
    ]
    

The warning I'd like to avoid is the following:

// Assume this function exists
func schedule(_ action: @escaping @Sendable () -> Void) { ... }

// Usage
class NonSendable { }

func run() {
  let ns = NonSendable()
  // ⚠️ Capture of 'ns' with non-sendable type 'NonSendable'
  // in a `@Sendable` closure
  schedule { _ = ns }
}

I would have expected SE-0414 Region based Isolation to detect that this code is safe. I was wrong :-)

The proposal says:

A value in a disconnected region can be transferred to another isolation domain as long as the value is used uniquely by said isolation domain and never used later outside of that isolation domain lest we introduce races.

I suppose that no transfer can happen here, because the schedule function does not declare any isolation domain - its closure argument is nonisolated.

I tried to use SE-0430 transferring isolation regions of parameter and result values, but it just moves the warning to another place:

// Assume this function exists
func schedule(_ action: @escaping @Sendable () -> Void) { ... }

// Transferring to the rescue?
func schedule<T>(
  value: transferring T,
  _ action: @escaping @Sendable (T) -> Void
) {
  // ⚠️ Capture of 'value' with non-sendable type 'T'
  // in a `@Sendable` closure.
  schedule { action(value) }
}

class NonSendable { }

func run() {
  let ns = NonSendable()
  // no warning
  schedule(value: ns) { _ = $0 }
}

OK, let's stop fighting the compiler. This can never work, because the schedule method does not make enough promise, and in particular its closure argument does not define any isolation domain. End of the story.

If I change the definition of schedule so that it defines an isolation domain, region-based isolation can work, right?

Things turn a little more complex, considering schedule is actually defined in a protocol:

protocol Scheduler: Sendable {
  func schedule(_ action: @escaping @Sendable () -> Void)
}

The protocol semantics require that actions are serialized, so I'm very close indeed from a proper isolation domain.

Is it possible to change the protocol so that each instance of a confirming type can declare its own isolation domain (and not only isolation domains of global actors?), so that the following code compiles without any warning?

// The goal
func run(scheduler: some Scheduler) {
  let ns = NonSendable()
  // no warning
  scheduler.schedule {
    // isolation specified by the scheduler instance.
    _ = ns
  }
}

:1st_place_medal: Slight difficulty: I can't force schedulers to be actors, because I code a library that has generous minimum targets, and should run in pre-Swift Concurrency OSes.

4 Likes

This would only be safe if there's some guarantee that the closure can't be called concurrently, or that it can be called only once. Right?

I suppose you're right, but the language does not provide such a feature.

Another point of view on the same problem is the interaction of Sendable with pre-concurrency constructs such as DispatchQueue and Thread:

class NonSendable { }

func run(on queue: DispatchQueue) {
    let ns = NonSendable()
    queue.async {
        // ⚠️ Capture of 'ns' with non-sendable type 'NonSendable'
        // in a `@Sendable` closure.
        _ = ns
    }
}

func spawn() {
    let ns = NonSendable()
    Thread {
        // ⚠️ Capture of 'ns' with non-sendable type 'NonSendable'
        // in a `@Sendable` closure.
        _ = ns
    }.start()
}

I should note that a simple way to remove the warning is nonisolated(unsafe):

class NonSendable { }

// no warning
func run(on queue: DispatchQueue) {
    nonisolated(unsafe) let ns = NonSendable()
    queue.async {
        _ = ns
    }
}

func spawn() {
    nonisolated(unsafe) let ns = NonSendable()
    Thread {
        _ = ns
    }.start()
}

protocol Scheduler: Sendable {
    func schedule(_ action: @escaping @Sendable () -> Void)
}

func run(scheduler: some Scheduler) {
    nonisolated(unsafe) let ns = NonSendable()
    scheduler.schedule {
        _ = ns
    }
}

The language does not allow all variable declarations, though:

// OK
nonisolated(unsafe) let ns = ...

// Compiler error
if nonisolated(unsafe) let ns = ... { }

// Compiler error
guard nonisolated(unsafe) let ns = ... else { }

One can deal with if let without coming up with a new variable name, though:

// OK
if let ns = ... {
  nonisolated(unsafe) let ns = ns
}

But this won't work with guard:

// Compiler error
guard let ns = ... else { }
nonisolated(unsafe) let ns = ns
1 Like

What you have shown here is a result of us not having changed apis yet to take a transferring non-Sendable closure since transferring has not finished going through evolution. As a quick example of this in action, consider the following:

class NonSendable { }

extension Task where Failure == Never {
  // Modified Task.init() converting it to a non-Sendable transferring implementation.
  init(x: (), @_inheritActorContext @_implicitSelfCapture operation: transferring @escaping () async -> Success) {
    /* ... */
  }
}

func spawn() {
    let ns = NonSendable()
    Task.init(x: ()) {
        _ = ns
    }
}
7 Likes

:star_struck: I didn't think about transferring closures! So there's hope :blush: I'll mark my nonisolated(unsafe) with a FIXME, so that I can change them when transferring has its final name :-)

1 Like

Happy to help!

1 Like