[Pitch] API to run a closure off of an actor (on the concurrent executor)

While easy to spell, that block doesn’t immediately say to me that it must run off the current actor executor.

Thinking about this more, as @hborla pointed out there isn’t really a clean way to immediately await on an async let and maybe that’s the direction we should solve this problem from. If we fixed that with slightly new syntax like:

func someLongExpensiveOperationWithResult() -> SomeReturnType { ... }

actor MyActor {
  func waitForLongOperation() async {
    /*
    Sugar for:
    ```
    async let result = someLongExpensiveOperationWithResult()
    await result
    ```
    */ 
    await async let result = someLongExpensiveOperationWithResult()

    // Result could even be discarded after awaiting with `await async let _ = …`
  }
}

Then it seems natural to solve the problem covered by this pitch with a new async do spelling:

func someLongExpensiveOperation() { ... }

actor MyActor {
  func waitForLongOperation() async {
    /*
    Sugar for:
    ```
    await async let _ = do {
      someLongExpensiveOperation()
      
      …
    }
    ```
    */ 
    await async do {
      someLongExpensiveOperation()
      
      …
    }
  }
}

This also has great approachability benefits in that people tend to learn about async let pretty early on in their concurrency journey, and both immediately awaiting on that and async do feel like natural extensions. It involves no new keywords or standard library additions, just some extra desugaring by the compiler.

2 Likes

Something that may not be obvious to everyone, so I’m gonna spell it out, is that “run asynchronously but NOT concurrently” is actually a very useful operation. Back in the day the primary way we ensured responsiveness in Mac apps wasn’t running on multiple threads, it was splitting work into small async chunks on the main thread. Even today with libdispatch “async to the serial queue I’m already running on” is a very efficient strategy for many problems.

So “this is async” indeed doesn’t obviously imply that it’s running somewhere else.

2 Likes

You’re 100% correct, but that ship has sailed and “run elsewhere” (on the global concurrent executor) is the guarantee provided by async let already and that people already learn when coming up to speed on Swift concurrency - this would just be extending that to solve a particular pain point that has come up since introducing that construct.

And besides, we still have actors for “run asynchronously but not concurrently”.

Swift has an optional single-threaded concurrency model. And the implicit @MainActor can result in accidentally single threaded programs that otherwise appear to be multithreaded.

I don't think this is a fundamental building block that needs custom syntax; it should fall out of improvements how we think about execution control, and be one of the applications of it.

I agree with this direction in common, while I don't know the way you are suggesting to express it syntactically.

This pitch addresses a practical problem of syntax burden for running a workload on a global pool.

The way I personally imagine it is to make new Task init, which looks like the following:

actor MyActor {
  func waitForLongOperation() async {
    await Task.onGlobalExecutor { 
      someLongExpensiveOperation() 
    }
  }
}

Such API is explicit, easily discovered and similar to Task.detached. If new ways to express it will appear (e.g. new keywords), this initializer will be deprecated.

But see above:

1 Like

I agree with @ktoso overall feedback that I don't think the pitched method pulls its weight to be included in the standard library. So far I personally haven't come across the need to write such a method but I understand that with SE-0461 this might change but I would suggest to wait a bit how that change plays out before we add such a method to the standard library. It is in the end a pretty trivial method that doesn't require any new language features.

I don't see how this is sound. If an actor specifies a custom executor and a method inherits the isolation of the caller's actor then that method must execute on the actor's custom executor. Otherwise invariants upheld by the custom executor may be broken by accessing isolated state from the wrong executor. It is a pretty common use-case to back an actor's custom executor by a single thread to guarantee that all isolated state is local to that thread.

Now I also don't think we need to differentiate between preferred and required task executors. In my opinion, there really can only be preferred task executors since custom actor executors always need to take priority over them. However, we can make the current withTaskExecutorPreference APIs more useful by providing a variant that takes a @execution(concurrent) closure instead. This way the closure won't inherit the isolation of the surrounding context right away and will eagerly hop off the actor. The tricky bit is how we would spell such an API without overloading withTaskExecutorPreference.

A task executor "requirement" would have to make sure the closure can cross an isolation boundary so that no state is shared, which is exactly what you observed here:

What Konrad and I were suggesting, I think, is to introduce a new method that drops the "preference" terminology. Because yes, we cannot simply change the existing withTaskExecutorPreference method; that's a source breaking change.