Inheriting Isolation from the caller

I've read a number of pitches and looked at @ _unsafeInheritExecutor, but I'm still confused about the best way to solve a particular problem I'm having, both today and in Swift 6 when SE-0420 is available.

I am trying to predictably bridge AsyncSequence to RxSwift. For those unfamiliar, RxSwift has similar features to the asynchronous parts of Swift Concurrency; Observable is basically an AsyncSequence, if you ignore the concurrency aspects of AsyncSequence they're almost isomorphic.

By "predictably", I mean "the actor you start on in Swift Concurrency corresponds roughly to the thread you receive it on. In particular, if you call convertToObservable on MainActor, then call subscribe (the equivalent to a for-await in Rx), I want the subscription callback to be run on the main queue.

Here, then, is the implementation from the RxSwift library. It does not meet my requirements, because it uses an unstructured Task, and because the Observable is not isolated, the Task runs on a random thread.

func asObservable() -> Observable<Element> {
        Observable.create { observer in
            let task = Task {
                do {
                    for try await value in self {
                        observer.onNext(value)
                    }

                    observer.onCompleted()
                } catch {
                    observer.onError(error)
                }
            }

            return Disposables.create { task.cancel() }
        }
    }

I seem to be able to make this function safe by adding @_unsafeInheritExecutor, but that requires me to mark the function as async. As an aside, I'm confused as to why an annotation that seems to promise that it always runs on the same actor needs to be awaited. Perhaps the point this annotation is making is that exeuctor inheritance is not the same as actor inheritance? I do not fully understand the implications, if that is the case.

On Swift 6

The above only has to work for a few months, so I think I'm fine with it being async. But when I try to use the tools in SE-0420, I immediately run into an issue. Here's a program that demonstrates it:

class FakeObservable {

    func thingThatShouldInheritIsolation(
        isolation: isolated (any Actor)? = #isolation
    ) {
        isolation?.assertIsolated() // fine, and is mainactor
        Task {
            MainActor.assertIsolated() // no longer main actor
        }
    }
    
    
}
@MainActor
func ff() {
    FakeObservable().thingThatShouldInheritIsolation()
}

await MainActor.run {
    ff()
}

try! await Task.sleep(for: .seconds(3)) // wait long enough for the task to start in normal conditions

But what's exceptionally odd is that by capturing the isolation parameter in the Task, the assertion stops failing:

class FakeObservable {

    func thingThatShouldInheritIsolation(
        isolation: isolated (any Actor)? = #isolation
    ) {
        isolation?.assertIsolated() // fine, and is mainactor
        func internallyIsolated(
            isolation: isolated (any Actor)?
        ) {
            MainActor.assertIsolated()
        }
        Task {
            MainActor.assertIsolated() // no longer main actor
            internallyIsolated(isolation: isolation)
        }
    }

}

This does solve my problem, but it looks a lot like a compiler bug of some kind. Can anyone offer any insights into what's happening?

There was pitch — https://forums.swift.org/t/closure-isolation-control — some time ago, that was aimed to address this behavior, yet for some reason there were no updates for a while.

I believe this is called out in SE-0420 (emphasis mine):

According to SE-0304, closures passed directly to the Task initializer (i.e. Task { /*here*/ }) inherit the statically-specified isolation of the current context if:

  • the current context is non-isolated,
  • the current context is isolated to a global actor, or
  • the current context has an isolated parameter (including the implicit self of an actor method) and that parameter is strongly captured by the closure.

The third clause is modified by this proposal to say that isolation is also inherited if a non-optional binding of an isolated parameter is captured by the closure. A non-optional binding of an isolated parameter is defined in the generalized isolation checking section.

So the act of capturing the isolated parameter in the closure is required to have the isolation inheritance kick in.

1 Like

Thanks! Not sure how I missed that.

A followup question based on Ole's explanation: if I only need to capture the isolated var to ensure isolation, how can I be sure the compiler won't try to optimize the capture out, and with it the isolation?

Would either or both of these work?

Task { [isolation] in 
 
}
Task {  
    _ = isolation
}

I believe that currently only the latter works. The former really should work, but does not today.

You don't need to worry about optimization modifying this. This is a high-level semantic rule specifying the isolation of the closure; once it applies, the compiler has to emit code consistent with that. If the compiler finds a way to avoid capturing that value, hooray for the compiler, but that doesn't change the isolation back.

3 Likes