Creating an async "control flow"-like API

I've got a Sendable type, and I'd like to create an API on it which acts "like a control-flow statement" for the caller; it does no async work itself, but wants to allow async work to be done within it, without jumping actor/executor:

struct Resource: Sendable { ... }

final class MyThing: Sendable {
    func doSomething<R, E: Error>(
        _ work: (Resource) async throws(E) -> R
    ) async throws(E) -> R {
        let resource = self.createResource()
        defer { resource.complete() }
        return try await work()
    }
}

// used like

class/actor Example {

    let stored = NonSendable()

    func f() async {
        let local = NonSendable()
        let result = myThing.doSomething { resource in
            // access `stored`
            // access `local`
        }
        // use `result`
    }

}

As declared above, this doesn't work, of course — nothing tells the compiler that doSomething behaves as I've described.

I see three tools in the language that naïvely might help me out here:

  • @_inheritActorContext on work
  • @isolated(any) on work
  • an isolated parameter to doSomething

In my experimentation, @_inheritActorContext alone seemed to do the trick, which surprised me. But are there cases where that might not be sufficient? What would be the differences between (combinations of) these three? Are there other things I've missed?

In trying to compose functions like this out of other such functions, I've also come across cases where the compiler wants the return value R to be sending. Can I make changes to the way doSomething is declared to ensure that R doesn't have any constraints on it?

Is actor isolation inheritance (SE-0420) what you are looking for? E.g.:

final class MyThing: Sendable {
    func doSomething<R, E: Error>(
        isolation: isolated (any Actor)? = #isolation,
        _ work: (Resource) async throws(E) -> sending R
    ) async throws(E) -> R {
        let resource = self.createResource()
        defer { resource.complete() }
        return try await work(resource)
    }

    func createResource() -> Resource {…}
}

If you don't want to define the generic R as conforming to Sendable, you can specify sending for the return value (SE-0430), like I have above. That does not introduce any constraints on R (other than assuring the caller that MyThing isn't doing anything else with it).

2 Likes

Possibly; I'm trying to understand the differences between the various options that seem to exist.

Notably, this function does not (inherently) send the return value, and I'd rather not impose that constraint on its closure. But I'm having trouble avoiding it in some contexts, depending on the implementation of the doSomething.

this is not really an answer to your question(s), but if you haven't yet seen it, you might find this thread of interest regarding the distinction between @_inheritActorContext and @isolated(any).

1 Like

Although that thread is helpful, it doesn't come with any clear advice for what to do in my situation; it contains both assertions that @_inheritActorContext and @isolated(any) may be helpful and unhelpful :confused:

I found an example of "what not to do" in SE-0461, but unfortunately it neither gives an example of how to write this correctly in current Swift 6.1, nor does it revisit the example post-SE to say how it is improved :confused:

sorry if this seems redundant, but just to clarify, what are the specific aims you have for this form of method? from the OP, i inferred they were something like:

  1. inherit the caller's isolation when running the body of doSomething()
  2. don't change actor/executor when invoking the work closure (what about suspension allowing interleaving?)
  3. avoid imposing additional requirements on the generic return value, specifically sending
1 Like

That's a pretty good summary. Really what I want is that the caller doesn't have to care about Sendability when invoking this — it should be exactly as invasive as a do { ... } block (which is to say, not invasive at all). They should be able to use non-Sendable types, and self-isolated properties. They should be able to wrap a call to my function around any preexisting code they have, without having to change any syntax to do so.