Asynchronously mutating structs in an actor-isolated type

I'm trying to define a struct which has a mutating function which is also async, for use inside an actor or a type isolated to a global actor. I'm running into the following error:

Cannot call mutating async function 'foo' on actor-isolated property 'bar'

I understand this is because we need to somehow guarantee that mutations are isolated to the correct actor. Is there a way I can have the mutating method inherit the isolation of the containing type?

For context, I'm trying to implement something like this to deal with actor reentrancy:
struct AsyncSerialQueue {
  
  mutating func execute<T: Sendable>(_ body: () async throws -> T) async rethrows -> T {
    if isExecuting {
      let (stream, continuation) = AsyncStream<Never>.makeStream()
      continuations.append(continuation)
      for await _ in stream {
        
      }
    } else {
      isExecuting = true
    }
    defer {
      if let next = continuations.first {
        continuations.removeFirst()
        next.finish()
      } else {
        isExecuting = false
      }
    }
    return try await body()
  }
  
  private var isExecuting: Bool = false
  private var continuations: [AsyncStream<Never>.Continuation] = []
  
}

In this example you have execute as nonisolated and body doesn’t carry any isolation. So you might want something like

mutating func execute<T>(
    _ body: @isolated(any) () async throws -> sending T,
    isolation: isolated (any Actor)? = #isolation
) async rethrows -> sending T {
}

(I used sending to have more relaxed constrains on types)

Not sure if that’s 100% achieves the goal in that form, but you definitely need to carry on isolation. I’m also not sure how it plays with mutating…

As a side question, why don’t you use an actor for this queue?

Hmm I've tried some variations of this and am still unable to get this to work... I haven't tried with the as-yet-unapproved closure isolation control yet which may be the missing piece. In the meantime, I just pasted the code directly into the body of the actor, which isn't great but does work.

I have had doubts that mutating might affect this and seems that's the case: compiler cannot reason that it is safe to call mutating async method here, because such method can be unsafe in general (see this thread). Simpler case:

struct Value {
    private(set) var wrappedValue: Int?

    mutating func load() async {
        wrappedValue = 1
    }
}

actor Demo {
    private var value = Value()

    func test() async {
        await value.load()
    }
}

I thought that adding isolated parameter should fix that as now compiler can reason that mutation happens within the same context, but either I'm wrong in this assumption or this is a bug. I think I was wrong: because of re-entrancy, which you are trying to handle in the first place, test in my example can be invoked several times while awaiting, violating exclusivity law with or without isolation. So the struct is simply wrong tool for the job.

EDIT. You can try utilize custom executor with actor AsyncSerialQueue so that it won't be introducing additional hops:

actor AsyncSerialQueue {
    nonisolated var unownedExecutor: UnownedSerialExecutor {
        parent.unownedExecutor
    }
    private nonisolated unowned let parent: any Actor

    init(parent: any Actor) {
        self.parent = parent
    }

    func execute<T: Sendable>(_ body: () async throws -> T) async rethrows -> T {
    }
}
1 Like

The custom executor idea is a good one… I will try that out. Thanks for the detailed explanation!