Isolating an `inout` parameter

I'm playing around a bit more with Structured Concurrency and have run into a case where I would want to hop between two isolation regions to asynchronously update an inout parameter:

actor Foo {
  func foo(value: inout NonSendable) async {
    for _ in 0..<100 {
      /// Do `Foo`-isolated things
      value.mutate() /// <-- needs to hop to `value`'s isolation region
      /// Do more `Foo`-isolated things
    }
  }
}

@MainActor
func doStuff() {
  var mutableValue: NonSendable
  let foo = Foo()
  await foo.foo(&mutableValue)
}

So far, I haven't found a way to express "the region value is isolated to". The closest thing I've found is making value sendable by either making it an actor or associating it with a global actor. Is anyone aware of a different way to achieve this?

I think you are looking for isolated parameter, e.g.

func foo(
    value: inout NonSendable, 
    isolation: isolated (any Actor)? = #isolation
) async {
}

Then you can carry isolation to the great extent (currently you may run into an issue with closures isolation).

If you add an isolated argument, then the /// Do `Foo`-isolated things step won't work -- for example, mutating a property gives an error like "actor-isolated property 'x' can not be mutated on a non-isolated actor instance".

The issue with this is that value is not an Actor, it is simply a value that is isolated somewhere other than the rest of the function body.

You are correct that mutation won't work, but you can call functions (I think even mutating functions) and they will just require moving from one isolation to the other (i.e. synchronous functions will need to be await-ed)

Yes, and you can capture this isolation using #isolation macro.

Oh, yeah, that what I definitely has missed. Maybe then just a closure is enough?

actor Foo {
  func foo(mutate: sending () -> Void) async {
  }
}

The closure would need to take an inout, which may be possible with some ~Escapable / ~Copyable dance.

I actually found an alternative which is you can mark inout parameters as sending. This requires the parameter to be in a disconnected region for the duration of the function call, but may be OK for my purposes.

1 Like

sending on the parameter will work, yes, I just thought it is important for you to call this mutation in the origin isolation.

It is, but inout sending allows the type conforming to the protocol to either be a (disconnected) value type, a type on a global actor, or an actor, which is what I was trying to achieve. Using isolated limits it to the last two and makes calls to Foo's API need to cross an isolation boundary (which may make sense for similar usecases, just not my specific one).

I think you might misunderstand this. Having isolated parameter doesn't require NonSendable to become an actor/actor-isolated:

class NonSendable {
}

actor A {
    func bar(ns: NonSendable, isolation: isolated (any Actor)? = #isolation) async {
    }
}

@MainActor
func foo() async {
    let ns = NonSendable()
    let a = A()
    await a.bar(ns: ns)  // call here will carry main actor isolation
}
1 Like

Ah, I see! It looks like the magic I was missing was #isolation. Thanks for following up!

One weird interaction I'm seeing is that isolated parameters infect sending return values.


Is there any way to have an isolated function return a non-isolated (sending) value?

That’s because of a protocol: for some reason Swift is incorrectly diagnose regions here. It works fine with concrete types.

Here is an issue on GitHub: False-positive diagnostics with sending parameters and use of RBI · Issue #76100 · swiftlang/swift · GitHub

There is also a workaround using Task (taken from issue):

// UseCase is isolated to main actor type
extension UseCase {
    func doSmthWithTask() async throws {
        let factory = factory
        try await Task {
            let command = factory.logger()
            try await command.execute("did it!") // ok
        }.value
    }
}

I think passing an async closure instead of inout parameter is the way to go, sending is not needed:

class Object {
    var k: Int

    init(k: Int) {
        self.k = k
    }
}

struct NonSendable {
    var obj: Object = Object(k: 0)

    mutating func mutate() {
        obj = Object(k: obj.k + 1)
    }
}

actor Foo {
    func foo(mutateValue: () async -> Void) async {
        bar() // Do `Foo`-isolated things
        await mutateValue() /// <-- hops to `value`'s isolation region
        bar() /// Do more `Foo`-isolated things
    }

    func bar() {}
}

@MainActor
func doStuff() async {
    var mutableValue = NonSendable()
    let foo = Foo()
    await foo.foo {
        mutableValue.mutate()
    }
}

I'm a bit surprised this works without any annotations for the closure, I was expecting that I would need to add @MainActor annotation to the closure isolation, or capture [isolation = #isolation], but apparently it works.

Tested using Swift 6 and Xcode 16.0.

1 Like