Distinction between `@isolated(any)` and `@_inheritActorContext`

I've noticed quite a lot of confusion between @isolated(any) and @_inheritActorContext. Because they are used together by Task, but the underscored attribute is not visible in documentation, many people reach the conclusion that the isolation inheritance actually comes from @isolated(any).

And lately, this has got me thinking. I'm having a hard time coming up with real problems that are only solvable by one but not the other. Are there any?

This is particularly relevant for Closure isolation control, because that proposal would make isolation inheritance public and easier tool to use. It seems like the only motivation for this is a path to change the sematics around capturing isolation.

This is a round-about way of me contemplating fusing these two concepts into one. I like the idea of simplying the language in this way, but I'm having trouble figuring out if this is safe and/or reasonable thing to do.

7 Likes

I agree that these two attributes are easy to confuse, especially with @_inheritActorContext not having been formalized / hidden from documentation / etc, but @_inheritActorContext and @isolated(any) have different effects.

@isolated(any) allows you to recover the isolation of a function value as an (any Actor)? value. It has no impact on the inferred isolation of a closure whose type includes @isolated(any), and that's where I think people get confused. If you take @isolated(any) away, isolation inference behaves exactly the same way. For non-@Sendable/sending closures, isolation is already effectively "inherited" from the enclosing context where the closure is formed.

@_inheritActorContext is only useful when you have a closure that either @Sendable or passed to a sending parameter, and it applies the same isolation inference behavior that you would get if that closure didn't have those other concurrency annotations.

I think the need for @isolated(any) is fairly rare; the task creation APIs use the isolation value to enqueue the operation directly on the isolated actor to resolve the issue that tasks always (used to) begin on the generic executor. That problem wasn't caused by closures having the wrong static isolation, it was because the implementation of the task creation APIs didn't have the actor value to enqueue on.

13 Likes

I think @isolated(any) is generally the right tool for when you accept a function from the user that you're happy to invoke with any arbitrary isolation. If you have a subscription API which takes a callback, for example, and you don't specifically need to deliver events in some specific way for synchronization/ordering purposes, you should probably take the callback as an @isolated(any) function so that users can provide a function with whatever isolation they like.

I think we should also offer a similar feature with protocols, although that would take more effort to design.

6 Likes

Thanks @hborla and @John_McCall for the clarifications. But I find myself still slightly hung up here.

Task uses both these annotations, and I get it. The task group family addTask method uses only @isolated(any), but does not inherit isolation. I sometimes think of task groups as conceptually similar to:

for _ in count {
  Task {
  }
}

But, groups have totally different semantics because they do not inherit the enclosing scope's isolation like Task does. This actually makes them quite hard to use in some circumstances compared to a collection of Task values.

I'm not (currently anyways :grimacing:) arguing for a change these APIs. But I'm really having hard time understanding why these two concepts a) must be separate and b) should also be so easy for developers to use separately.

Given that @isolated(any) allows the creator of the closure to specify any isolation, it kinda feels like fusing that with @_inheritActorContext would just give it an unsurprising default, one that could still be changed if needed just like you can with Task.

Aside from the obvious impact on task groups, are there any other concrete negative implications I'm not thinking of?

3 Likes

It sounds like you’re really arguing that more closures should maintain the isolation of the surrounding context — that that should be more like a default behavior of closures, overridden only by specific contextual requirements that the closure should use a different isolation.

1 Like

I think I'm arguing that it's weird to have just @_inheritActorContext or @isolated(any) but not both? I'm definitely not suggesting that closures should change in the general case.

Well, I mean, you've already identified a case, task groups, where we want to take an @isolated(any) function but explicitly don't want @_inheritActorContext because we don't want the tasks to all potentially get serialized just because you're starting them from a serial domain.

3 Likes

Ok yes you're right.

How do you feel about the reverse? Make the default of an @isolated(any) closure to inherit context, and provide a mechanism to prevent it for uses like task group.

I have one observation that touches upon @_inheritActorContext.
I try to make a convenience function to also understand more about currency annotations

@discardableResult
func withLifetime<Instance, OperationError, Stream>(
    isolation: isolated (any Actor) = #isolation,
    of object: sending Instance,
    consuming stream: Stream,
    @_inheritActorContext forEach operation: @Sendable @escaping (
        _ object: Instance,
        _ element: Stream.Element
    ) throws(OperationError) -> Void
) -> Task<Void, any Error>
    where Instance: AnyObject,
    OperationError: Error,
    Stream: AsyncSequence & Sendable,
    Stream.Element: Sendable
{
    print(isolation) // -> output: BluetoothActor - my custom actor 
    return Task { [weak object] in
        for try await element in stream {
            guard !Task.isCancelled,
                  let object
            else { return }

            try operation(object, element)
        }
    }
}

If I call this function from my custom serial actor:

withLifetime(
    of: self,
    consuming: self.state.values
) { this, element in
    print(this, element)
}

this will crash with _dispatch_assert_queue_fail.

If will remove @_inheritActorContext from the withLifetime forEach definition it won't crash but the operation closure will run on the globalActor:

// com.apple.root.user-initiated-qos.cooperative (concurrent)
print(this, element)

If I change the declaration of the function to (changes in comments), I get a compilation error:

Passing closure as a 'sending' parameter risks causing data races between 'isolation'-isolated code and concurrent execution of the closure
Working version but on globalActor
@discardableResult
func withLifetime<Instance, OperationError, Stream>(
    isolation: isolated (any Actor) = #isolation,
    of object: Instance,
    consuming stream: Stream,
    forEach operation: @escaping @isolated(any) ( // <-- @isolated(any) instead of @_inheritActorContext
        _ object: Instance,
        _ element: Stream.Element
    ) throws(OperationError) -> Void
) -> Task<Void, any Error>
    where Instance: AnyObject & Sendable, // <-- Sendable
    OperationError: Error,
    Stream: AsyncSequence & Sendable,
    Stream.Element: Sendable
{
    print(isolation)
    return Task { [weak object] in
        for try await element in stream {
            guard !Task.isCancelled,
                  let object
            else { return }

            try await operation(object, element)
        }
    }
}

On the other hand, I use the version that does not crash and compiles

@discardableResult
func withLifetime<Instance, OperationError, Stream>(
    isolation: isolated (any Actor) = #isolation,
    of object: sending Instance,
    consuming stream: Stream,
    forEach operation: @Sendable @escaping (
        _ object: Instance,
        _ element: Stream.Element
    ) throws(OperationError) -> Void
) -> Task<Void, any Error>
    where Instance: AnyObject,
    OperationError: Error,
    Stream: AsyncSequence & Sendable,
    Stream.Element: Sendable
{
    print(isolation)
    return Task { [weak object] in
        for try await element in stream {
            guard !Task.isCancelled,
                  let object
            else { return }

            try operation(object, element)
        }
    }
}

and I would like to enforce the isolation on which the closure should be called, I cannot also do that:

withLifetime(
    of: self,
    consuming: self.state.values
// Converting function value of type '@BluetoothActor @Sendable (BluetoothPeripheral, AsyncStateStream<CBPeripheralState>.Element) -> ()' (aka '@BluetoothActor @Sendable (BluetoothPeripheral, CBPeripheralState) -> ()') to '@Sendable (BluetoothPeripheral, AsyncStateStream<CBPeripheralState>.Element) -> Void' (aka '@Sendable (BluetoothPeripheral, CBPeripheralState) -> ()') loses global actor 'BluetoothActor'
) { @BluetoothActor this, element in
    print(this, element)
}

I also wonder if there is a way to run the unstructured Task with the isolation that comes from the caller so the whole for loop would run on the same isolation as operation and from where the instance parameters come from.

I think this is related to how @_inheritActorContext interacts with isolated parameters. You might be interested in:

But for now, you can try explicitly capturing the isolated param value within the Task body like this:

return Task { [weak object] in
        _ = isolation // a capture affects the static isolation
        for try await element in stream {
            guard !Task.isCancelled,
                  let object
            else { return }

            try operation(object, element)
        }
    }
1 Like

Thanks for the hint, it works!

1 Like

Or you can do

typealias IsolatedClosure<each Input, Output> = ((isolated Actor?, repeat each Input) async throws -> Output)

Then one can defined closure which can isolated the body of that closure to a particular actor

Example

@discardableResult
func withLifetime<Instance, OperationError, Stream>(
    isolation: isolated (any Actor) = #isolation,
    of object: sending Instance,
    consuming stream: Stream,
    forEach operation: @Sendable @escaping (
        _ object: Instance,
        _ element: Stream.Element
    ) throws(OperationError) -> Void
) -> Task<Void, any Error>
    where Instance: AnyObject,
    OperationError: Error,
    Stream: AsyncSequence & Sendable,
    Stream.Element: Sendable
{
    let isolatedClosure: IsolatedClosure<Instance?, Stream, Void> = { isolation, object, stream in 
        //   _ = isolation 
        // this is no longer needed
        for try await element in stream {
            guard !Task.isCancelled,
                  let object
            else { return }
            try operation(object, element)
        }
    }

    print(isolation)
    return Task { [weak object] in
        // Here is nonisolated
        try await isolatedClosure(isolation, object, stream) // Here is isolated to the isolation argument
       // Here is nonisolated again
    }
}

One can make the IsolatedClosure typealias async, throwing or even both async & throwing depending on the use case

1 Like