`@isolated(any)` function types

Hi, folks. I'd like to pitch a new feature that lets you make a higher-order function polymorphic over the isolation of its function parameters. Basically, you can mark a function type as @isolated(any); when you convert a function to that type, the isolation will be statically erased, but it can still be recovered at runtime (as an (any Actor)?). Adopting this in the standard library lets us do things like directly enqueue new tasks on the right isolated executor, which both improves performance and eliminates a risk of reordering.

Anyway, please check it out. If you notice any typos or have any other editorial comments, please leave that feedback directly on the PR.

18 Likes

Thanks John! This is looking very good overall!

Left some small editorial change proposals in the PR, (method names of existing APIs, maybe adding one more example).

This is very clearly the right thing to do — to enqueue Task { isolated actor in immediately to that actor, and it’s pretty good that we’re able to get this behavior without having to do a new keyword: I used to think we’d do this with send actor.work(), but this also a good spelling for it, perhaps more consistent with the language.

Ordering

The tradeoff with optimizing away hops and guaranteeing order being dependent on wether or not the annotation was explicit is probably a good tradeoff. As long as the Task { [isolated thing] in } is guaranteed to enqueue we’re good for the cases that need to be careful about the order. It not applying when “implicit” works okey for things like just kicking off an unstructured task because “i don’t want to wait” without too much care about order…

It definitely is a “gotcha” though so we should highlight this in the swift book and improve documentation, including Task.init and friend’s API documentation about enqueue behavior. But if we do that, it sounds good to me :slight_smile:

Distributed

Nothing to complain about in this proposal, for local ones we’re good — and this actually is very important for making IPC/Network messages be received and enqueued in the order as they arrived. So, I’m very happy we’re able to have correct message order now in recipients without resorting to hacks!

It did made me realize though that we’re still missing a way to send uni-directional to remote actors “in specified order” which is common but this will have to be solved some other way. I was thinking if perhaps our default executor for remote actors being the “crash on enqueue” is bad, and instead we should make up a specific default executor for such actor… then somehow make use of it for the sends…

But this isn’t for this proposal; the proposed semantics of just working ok with the “known local” ones are good. Thank you for including them in the discussion!

Async let

I noticed we’re not discussing async let in the proposal, probably just missed it?

The behavior should be the same as with a group’s addTask I think:

actor Worker { func compute() {} }

func go(worker: Worker) async {
  async let one = { [isolated worker] in
    worker.compute() 
  }
}

assumeIsolated

As the proposal mentions this is TODO to figure out… So what we’ll want is:

actor Worker { func test() {} }

func test(worker: Worker) {
  worker.assumeIsolated { worker.test() }
}

While the current assumeIsolated is forced to take a parameter:

worker.assumeIsolated { isolatedWorker in  // current API
  isolatedWorker.test() 
  await worker.test()
}

The way to solve this is somewhat alluded to in the Future Directions: @isolated(parameter) but also explained that it’s a difficult value-dependent types feature we’d need to introduce here…

Is it worth handling the assumeIsolated specifically today, until we’re able to generalize the isolated-to-parameter feature?

assumeIsolated + GlobalActor

The lack of this has made the assumeIsolated APIs unfortunately limited — for example, the API today does exist on MainActor as a static function. But was not able to be expressed for “any global actor”, because we could not write:

// could not write this before
extension GlobalActor { 
  static func assumeIsolated<T>(_ operation: !!@Self!! () throws -> T) rethrows ->T
}

But with recent isolation proposals; we’re able to know that GlobalActor.shared is same as isolating to @GlobalActor… Technically we could offer an assumeIsolated now that uses the same pattern that the existing assumeIsolated on instance actors, but today it’d still need the isolated parameter…

extension GlobalActor { 
  static func assumeIsolated(_ operation: (isolated GlobalActor) throws -> T) rethrows -> T { … }
}

but really we’d want to

extension GlobalActor { 
  static func assumeIsolated(_ operation: @isolated(to: Self.shared) throws -> T) rethrows -> T { … }
}

That’s another quite tricky spelling I think, we’re referring to a property, not even just a parameter of the function, but a property of the global actor.

Long story short — I’m dancing around the question if we should “make assumeIsolated work as expected” even if we don’t have the general feature of @isolated(to:) yet?

I’m not sure about the answer, it definitely would be nice to have it work — but on the other hand, I think given workarounds exist for this — just by doing more isolated parameters everywhere now. So that’s an open question I wanted to add to the discussion.

Thanks again for the work on this, it’s really awesome to extract .isolation out of these functions like that!

1 Like

Just thought about how we currently don’t infer isolation for async let like these while chatting with @hborla:

async let a = actor.do()
async let b = actor.undo()

in today’s world those are — same as Task{} — enqueued to the global pool first, and then hop to the actor, causing opportunity for re-ordering to happen.

If we can now make the implicit closure of async let be @isolated(any) and notice that the first call immediately hops off, we could avoid making the enqueue to global.

2 Likes

Well, async let is built into the language, so if we want to start the initializer on its formal isolation, we'll have to handle that in the built-in logic for async let. I agree that we should! But we don't actually need new language mechanics like @isolated(any) to make that happen — we could always have handled that in the built-in logic for async let. I think the real question here is what the isolation of the async let initializer is, because (I think) we currently always treat it as non-isolated. We probably need a better answer than that in general because of sendability.

I don't know if it makes sense to try to answer that in this proposal, though.

3 Likes

Yeah true, it's not like there's a "signature" of the async let closure spelled out anywhere...

Yes, we indeed always treat async let tasks as nonisolated today, and enqueue on the global pool.

I wonder if this is actually specifically for expressions like this: a single actor call; This doesn't need to first hop to the global pool.

But we also have been saying that async let is a way to get concurrency -- so suddenly having the entire right hand side of it be on the actor may be actually over-correcting behavior here... Is it just a "start on this actor, but come back wherever?" or something like that? Not sure yet, will think more about it hm.

Probably aiming for consistency between a group and async let is another thing to consider here... Do we want to enable the { isolated sss in } on right hand side properly? Today people do workarounds like this: async let x = { one(); two() }() (note the trailing ()) to make multiple statements in an async let; perhaps we should embrace the fact and allow initializing with a closure literal that we'll stuff into the task (get its isolation, and run it)?

1 Like

The proposal looks great and I am glad we were able to solve the actor send problem using the current methods without having to invent new keywords. Especially having to explicitly create an unstructured task was important to me where some of the previous discussions revolved around hiding that task behind a potential new send keyword.

One thing stood out to me that is mentioned w.r.t. the standard library API evolution:

More importantly, I believe Swift has a general policy of declining to guarantee stable types for unapplied function references in the standard library this way.

Is this true and do we think this can be generalized to packages that only provide API stability as well? So far we have been pretty reluctant to make such changes in the server ecosystem due to the chance somebody reference an unapplied method. We are often in the situation where we want to add additional defaulted parameters but duplicate the whole method or put all the parameters into a separate struct which can evolve easier. Would be great if we can provide general API design guidelines here.

1 Like

But actually, I guess John is asking to fix async let in a separate proposal rather than here... We can do that if it helps -- though there is a relationship between that and task groups.

We can split off the thread for those though.

Swift reserves the right to optimize the execution of tasks to avoid "unnecessary" isolation changes, such as when an isolated async function starts by calling a function with different isolation. In general, this includes optimizing where the task initially starts executing. As an exception, in order to provide a primitive scheduling operation with stronger guarantees, Swift will always start a task function on its appropriate executor for its formal dynamic isolation unless:

  • it is non-isolated or
  • it comes from a closure expression that is only implicitly isolated to an actor (that is, it has neither an explicit isolated capture nor a global actor attribute).

I worry about this difference between implicit and explicit closures. If I understand correctly, this means the following:

@MainActor
struct Example {

  // These tasks have no guarantee as to their
  // execution order on the main actor
  func test1() {
    Task { /* implicitly @MainActor via enclosing struct */
      print("Unordered One")
    }
 
    Task { /* implicitly @MainActor via enclosing struct */
      print("Unordered Two")
    }
  }

  // These tasks are guaranteed to execute in 
  // source order:
  func test2() {
    Task { @MainActor in
      print("Ordered One")
    }
 
    Task { @MainActor in
      print("Ordered Two")
    }
}

This means that there is now an observable semantic difference between implicitly inheriting an actor isolation and explicitly declaring the same isolation that would be explicitly inherited. I don't know of anywhere else in the language where explicitly redeclaring an inherited attribute has visible semantic differences. That seems likely to be a source of confusion, and I'd like to understand better the motivation behind this proposal.

(Or, if I'm understanding incorrectly, I'd love to understand better what is being proposed.)

3 Likes

This problem is unique to async let, I think, since there's always an explicit function or closure with every other way of making a task.

I would say that the rule at a minimum ought to make an initializer that does nothing but call an isolated function or closure be isolated the same way as the callee. The somewhat annoying thing about that rule is that it makes async let x = myActor.foo() isolated but async let y = myActor.foo() + 1 formally non-isolated. I'm not sure there's a good way to fix that that doesn't potentially remove a lot of concurrency, though! For all we know, x + 1 could be a very heavyweight operation. For that matter, even making async let z = myActor.foo(x + 1) be isolated to myActor assumes that x + 1 can be efficiently offloaded.

1 Like

The motivation is to not over-interpret Task {} in an isolated context, especially in a main actor context. (This really is specific to Task {}[1], since nothing else in the language makes sendable closures inherit isolation this way.) Forcing the closure to start on its formal isolation even if it, say, immediately makes a network request means that that request can't start until the actor has finished everything else in its queue. In the case of the main actor, it also adds unnecessary work to an already highly-loaded queue. I don't want Task {} to be a performance footgun.


  1. Well, and any libraries using the same underscored attribute as Task. ↩︎

1 Like

Does that mean that closure without explicit isolation cannot be converted to @isolated(any) () -> Void? Or that when converted, operation.isolation returns a different value? What would operation.isolation return in the following example:

actor NetworkingService {
    func getData() async -> String { ... }
}

struct MyView: SwiftUI.View {
    let network: NetworkingService
    @State var data: String?

    func loadData() {
        doLoad {
            let data = await network.getData()
            self.data = data
        }
    }

    func doLoad(_ operation: @isolated(any) () -> Void) {
        let isolation = operation.isolation
        print("isolation = \(isolation as Any)")
        Task(operation: operation)
    }
}

Would it be @MainActor.shared, nil or network?

If it is still @MainActor.shared, then I'm confused by how/why Task initializer would ignore it.

If it is nil or network, then .isolation sounds like a confusing name for the property. Something like initialActorHint would be more honest.

Ah, okay. I can see in the case where it immediately hops somewhere else how starting the task somewhere other than its formal isolation would make sense. But is that the only time when this property is not maintained? If the task does start with synchronous code, is the Task closure always going to be started on its formal isolation? For example:

@MainActor
struct Example {
  func orderingExample() {
    Task { /* implicitly @MainActor via enclosing struct */
      print("One")
    }
 
    Task { @MainActor in
      print("Two")
    }
  }
}

In this example, are these two closures guaranteed to print in "one two" order under this proposal, even though only one of them is explicitly declared to be @MainActor?

1 Like

No. The value of .isolation is always taken from the formal isolation of the function.

The optimization would work by examining the actual body of the function passed in, seeing that it immediately switches to a specific executor, and rewriting the creation of the task to start there instead. That is a straightforward thing to do, and it’s clearly always semantics-preserving if, say, the initial executor was previously nil. The guarantee in this proposal would say that it can only do this in certain cases when the initial executor is not nil.

To check my understanding, would this from Task:

public init(
  priority: TaskPriority? = nil,
  @_inheritActorContext @_implicitSelfCapture operation: __owned @Sendable @escaping () async throws -> Success
) {

become this?

public init(
  priority: TaskPriority? = nil,
  @_implicitSelfCapture operation: __owned @escaping @isolated(any) () async throws -> Success
) {

Additionally, I really appreciate the explicit and concise set of conditions to consider when to use typed throws in that pitch and I think a similar section here about when to use isolated(any) would be really helpful.

1 Like

This is more of a question about the limits of the task execution optimization. Consider this code:

@MainActor func foo() async {
  print("1")
  await myActor.bar()
  print("5")
}
extension MyActor {
  func bar() {
    print("2")
    await baz()
    print("4")
  }
}
func baz() async {
  print("3")
}

Now consider the formal computation history, ignoring control flow and function boundaries, and "color" it with its formal isolation, like so:

// @MainActor starting from here
print("1")
// myActor starting from here
print("2")
// nil starting from here
print("3")
// myActor starting from here
print("4")
// @MainActor starting from here
print("5")

So, first, execution optimization is only allowed to shift the boundaries of these shaded regions around and eliminate empty regions. It can never create new suspension points or make the task run somewhere it wasn't going to run anyway.

Second, execution optimization can only move boundaries when it's semantically okay to do so, which generally requires it to prove that there's nothing in the code that it's moving over that depends on the current executor, depends on being ordered, etc. Just seeing that there's a synchronous call is not sufficient; it has to know what that call does, what anything it calls does, etc.

We do this optimization in general today; what we don't do yet is rewrite the initial executor (but of course that initial executor is always nil at the moment). Doing that would essentially just involve recognizing that the initial region was empty (or boundaries could be legally shifted to make it empty) and changing the task to start on the executor for the first non-empty region instead.

print, specifically, calls functions that the optimizer can't see into, and those functions have order-sensitive side effects (which of course the optimizer has to assume for functions we can't see into). The optimizer therefore cannot move a boundary over it; we would have to specifically annotate it as being okay to move over (which probably wouldn't be a good idea because of those order-sensitive side effects). So I think that we would not be able to optimize your example even without explicit isolation — the prints would be guaranteed to happen in order.

1 Like

Logically, yes. If you follow the patches, though, we'll probably have to keep writing Sendable explicitly for stdlib-specific reasons (having to do with "feature suppression", i.e. our attempts to keep older toolchains able to parse new stdlib .swiftinterface files).

That's a great idea; I can certainly add that. I think the recommendation would be to use it whenever you're taking an arbitrary request for work — essentially, if you wrap task creation, you should probably wrap it to take an @isolated(any) function.

1 Like

Great, thanks for the clarification. With that understanding, I'm quite excited about this approach.

1 Like

If optimization is based on the body of the closure, not it’s type, then what is the role of the @isolated(any)? Is it even needed?

If the only use for propagating function isolation was to get the Task initializer to enqueue directly on the isolation of the function you pass to it, we could definitely have just hard-coded that in the compiler. However, it is more generally useful than that, and providing a general feature lets us guarantee that it happens without API-specific compiler magic.

1 Like

This model looks quite good to me and like @ktoso I'm very happy that this suffices to address the send use case without the need for a yet additional keyword/primitive. A couple thoughts:

  • Spelling-wise, it's interesting to me that for global actors the spelling for "isolation of this function" would take on a noun form (the name of the actor) where as here it's taking on an adjective form "isolated". I wonder if something like @Actor(any) (or @AnyActor?) would read better in context and fit better with existing isolation attributes.
  • This is the first time we're adding a member on function types (right?)--anything to worry about with respect to future evolution which may enable extending non-nominal types? We could ban isolation as a name on function types, but if we also enabled protocol conformances for function types then arbitrary names could be introduced in a generic context. Seems like something that wouldn't be so difficult to work around down the line but curious about the member-like spelling as opposed to something like #isolation(of: f) or a $-prefixed identifier like f.$isolation.
1 Like