[Pitch][Concurrency] Starting tasks synchronously from caller context

Hello everyone,
Continuing the trend of a few proposals improving the usability of Swift concurrency, especially focusing on task execution semantics...

Recently we shared the [Pitch] task priority escalation handler APIs, which should be entering review sometime soon.

And today I'd like to propose a way to synchronously start tasks from caller context.

The proposed API allows for creating an unstructured task (or TaskGroup child task) such that it immediately starts running on the calling thread/actor/executor, and only executed asynchronously once it hits an actual suspension. This allows for initiating a call to asynchronous code from the context of a synchronous function, and more. A simple example might look like this (but please do read the whole proposal for a nuanced explanation):

// (N) signify execution order

func synchronous() { // synchronous function
  // executor / thread: "T1"
  let task: Task<Void, Never> = Task.startSynchronously {
    // executor / thread: still "T1"
    guard keepRunning() else { return } // synchronous call (1)
     
    // executor / thread: still "T1"
    await suspend() // potential suspension point #2 // (2), suspend, (4)
    // executor / thread: "other"
  }
  
  // (3) continue execution
  // executor / thread: "T1"
} 

Please check out the complete proposal for an in depth explanation over here: SE-NNNN: Starting tasks synchronously from caller context.

:question: For what it's worth I'm not so sure about the naming of the methods in this proposal, so if someone has good ideas especially about how to better name TaskGroup/startSynchronously and Task/startSynchronouslyDetached I'd love to hear them.

This is not yet implemented, however we'll be working on it shortly and will provide an early build to play around with as soon as possible.


Please leave any typo or editorial notes on the pull request itself, and use the forums thread for any review and detailed discussion of the proposal. Thank you!

43 Likes

I can't help but ask if we're giving an extra point of flexibility in the mechanism for running asynchronous tasks, why not extend the interface of the Job object (or something close to it) so it can be run synchronously? That is, essentially move __swift_run_job into the interface, and shift the responsibility for synchronous launch or scheduling to a TaskExecutor (and SerialExecutor) implementation. And then provide a wrapper implementation of TaskExecutor that runs the first Job synchronously, and schedules the rest to the underlying TaskExecutor.

The high level question here is reasonable -- e.g. in Akka and Scala this is done by a "parasitic execution context" (it sounds nasty to prevent people from using it... long story). So... Can we just make this an executor? Scala provides no compile time safety or trying to bind isolation guarantees to what parameter was passed somewhere.

It would have to be a specific serial executor, and the isolation of the closure would have to be the same as the passed executor... So... this is kindof asking for the future direction that we say is too complex to implement currently:

  isolation: isolated (any Actor)? = #isolation,
  operation: @escaping @isolated(to: isolation) sending async throws(Failure) -> Success,

See here: swift-evolution/proposals/NNNN-task-start-synchronously-on-caller-context.md at wip-task-synchronously-start · ktoso/swift-evolution · GitHub

Regarding the Job idea -- I don't see this changing anything here...? a) Job is not user API, so that's off the table for that reason to begin with. b) You can write an executor which just runs the first job inline, in fact, we'll basically have to write such thing to service this job.

If we were to pursue this idea we'd need... have this work only with passing a specific actor, like (some ancient discussions may have seen that mentioned somewhere): Task(startingOn: actor) but that:

  • a) boils down to the same operations really (and the @isolated(to:) need, which we can't do right now)
  • b) goes against the language direction of "specify the isolation of the closure" that closure isolation control is all about Closure isolation control

So we've been trying to lean into this "closure isolation control" proposal, and the fact that we are not able to express the API we'd actually want: @isolated(to: someParameter).

If you have a more specific writeup that could be useful, because I don't think I was able to fish out something actionable so far :thinking: I could have missed your point perhaps as well, examples would be great - thank you!


edit: I wonder if the startingOn could make a comeback... maybe it would then be addTask(startingOn: #isolation) but we hit the @isolated(to:) problem sadly then... :cry:

2 Likes

If @isolated(to: isolation) is not yet available, how does compile-time checking even work? Is there some new underscored attribute?

Regarding this example:

actor Caplin {  
  var num: Int = 0
  
  func check() {
    Task.startSynchronously {
      num += 1 // could be ok; we know we're synchronously executing on caller
      
      try await Task.sleep(for: .seconds(1))
      
      num += 1 // not ok anymore; we're not on the caller context anymore
    }
    
    num += 1 // always ok
  }
}

Why is the second increment not ok? Aren't we hopping back to the actor after await?
Since this is in the "Future directions", does that mean that under current proposal even the first increment is not ok?

If @isolated(to: isolation) is not yet available, how does compile-time checking even work? Is there some new underscored attribute?

A limited form of it, specialized to this method, using an underscored attribute until we can do the real @isolated(to:).


That should be a startSynchronouslyDetached to showcase the issue, I'll fix the proposal.

The point of that example is that we're no longer on the caller there anymore and we have not passed any specific isolation -- i.e. a detached task.

2 Likes

Ran into the lack of this a lot in the early days of async/await, trying to move from code like this:

func doSomethingAsync(_ completion: @escaping () -> Void) {
    doSomeSetup()
    DispatchQueue.main.async {
        doSomeWork()
        completion()
    }
}

func doSomething() {
    doSomethingAsync {
        completion()
    }
    doSomethingThatReliesOnSetup()
}

To something more like:

func doSomethingAsync() async {
    doSomeSetup()
    await doSomeWorkAsync()
}

func doSomething() {
    task = Task {
       await doSomethingAsync()
    }
    doSomethingThatReliesOnSetup()
}

With the problem that doSomeSetup() is called synchronously in the original case, and therefore guaranteed to have happened before doSomethingThatReliesOnSetup(), whereas in the async case, there's no guarantee.

Been a while since I've come across it, but I think it's a good tool to have available.

3 Likes

This pitch is much appreciated! In order to make testing async code more reliable we have had to resort to workarounds of varying effectiveness and flakiness, like inserting Task.yields and sleeps, or instrumenting code with extra continuations, just to wait for an async unit of work to "begin" before proceeding.

Is it possible to write a helper that abstracts around Task.startSynchronously and Task.init with the isolation limitations of Task.startSynchronously? I'm wondering if it's possible to limit synchronous scheduling to some testing tools while allowing code to be scheduled normally outside of tests.

3 Likes

I've found myself wanting to reach for the SPI version of this primitive numerous times in the past (the previous thread has some great motivating examples) so I'm very excited to see this finally being pitched as an official API.

Regarding the name, my only concern is that using the term "synchronously" might make people think that the API blocks for the entire duration of task execution. Another option might be startImmediately but I'm not sure if that's as descriptive (though it's worth noting that the proposal draft does use the word "immediately" 6 times!)

7 Likes

That makes me think these could be referred to as "immediate tasks", from which the logical API names would be Task.immediate { ... } and Task.immediateDetached { ... }[1].


  1. Not "immediately detached," mind you, but "immediate, detached." ↩︎

6 Likes

Will this API be available in the "I have but one thread to give to the executor" execution model?

1 Like

Yes, I think so.

1 Like

I love this! It’s something I’ve wanted from the very beginning of swift concurrency and I think the design you’ve laid out makes the most sense.

A couple of clarifying questions:

Will this work with async sequences? That’s been one of the biggest limitations moving from Combine is that with a publisher, if there’s a value available immediately sink will get called right away, but converting that to an async sequence will force you to create a task and delay the first event. I think this would work with the proposal but want to make sure.

How will nonisolated tasks and methods work? In theory a nonisolated task should be able to be started synchronously from any context, and a nonisolated async method should be able to run synchronously if it never suspends, but I wasn’t sure from the wording.

As for the name, Task.immediate makes more sense to me.

1 Like

This would be an incredibly welcome addition! I have also reached for the SPI version of this several times, but having it generalized to start on the caller context is also welcome.

My one request: would we possibly be able to have a deprecated back-deployable version using the existing SPI for the main actor use case? This behavior exists for the main actor and I suspect even a main actor-specific version of this would be welcome.

2 Likes

So there's two layers here:

  • the function from which you call Task.startSynchronously
    • since it's an unstructured task, if you never await task.value the outer task never suspends at all
  • the task inside Task.startSynchronously
    • if that task needs to suspend, that task will suspend but the outer function just continues synchronously running
    • at this point you may or may not hit point 1.1. from this list -- if you, yourself, suspend the outer task/function then yeah it'd suspend, but if you don't then it would not.

So this doesn't cause any suspension to the outer task.

It does in the TaskGroup version though, because the await on the results of a task group is always implicitly inserted. Although if you used a discarding task group... and at the end of a scope the group is empty, then again that would not cause an actual suspension.

Hope this helps a bit, it's all about following the details when a suspension is actually triggered and in which task.

Hey, I just wanted to say thank you for addressing it. I've been working on the Nuke rewrite to Swift Concurrency on and off for the past year, and the lack of ability to start Tasks synchronously prevents me from eliminating the thread hops I wanted to eliminate with the rewrite (marked with TODO: remove thread hop).

Update: I'm not sure I should be using Task in the scenarios I linked. I'm looking for ways to rewrite it without Task.

not so sure about the naming of the methods

In this spirit of ideation:

  1. Update Task to start synchronously by default when it's isolated to the same actor as the scope. I can't think of a scenario where I would want Task to introduce a thread hop. The current behavior leads to inefficiencies in the best case and bugs in the worst case (from my experience writing apps – common, confusing).
  2. Task(options: [.startSyncronously]), Task(options: [.isolated]), Task(options: [.inheritAsyncronousContext])

Thanks for the ideas! So it can't be any "option" to an existing API because this method needs different type checking rules than the usual Task.init, so we'll need a new method or type name.

New type name is interesting, but somewhat too costly IMHO, it'd make interoperating with e.g. a collection of Task annoying. And if we are to bridge it to a Task anyway there's little reason to give it a new type name...

So we end up with a new method of some kind, and the trouble of naming it. We do have .detached{} without "start" but it'd be weird to say "Task.synchronous" because it's not really, just the first bit is... so that's how we arrived at the current naming, for better or worse :thinking:

Did you consider the earlier mentioned Task.immediate { ... } (and immediateDetached) though?

I have to say, none of the suggested versions seem particularly clear. You'll have to read documentation to understand how it works and what it's for.

If it's a niche feature, it should probably not be part of the high-level Task API – it should be some lower-level system (progressive disclosure). I assume it's a niche feature as it's designed to cover the scenario where you can't extract the prefix from the async function.

If you start using Swift Concurrency and introduce an issue due to the default Task behavior, you will likely realize that you made a wrong assumption about how Task works and rewrite it by extracting the prefix. I've hit this more than once as it's not always clear that Task runs asynchronously. I liked the original async function as it made clear that the code executes asynchronously.


Btw, is it too late to allow using await from synchronous contexts?

// the caller doesn't need to `await` – `load()` exits immediate
// `await` behaves as syntax sugar for completion callbacks 
// where you don't communicate the completion to the caller
func load() {
    guard !isLoading else { return }
    isLoading = true
    await loadSomething()
    isLoading = false
}

// now the caller needs to `await` 
func load() async {
    // ...
}

It could solve the outlined issue and help eliminate some boilerplate in the scenarios like this (wrote this numerous times):

func load() {
    guard !isLoading else { return }
    isLoading = true
    // I use `Task` only because I have to. It doesn't make what happens more clear.
    Task { 
        await loadSomething()
        isLoading = false
    }
}

If you have no Task, there is no confusion about where the prefix executes. It addresses the root cause.

I think it should also solve the scenario from the pitch. If you don't explicitly create a new task, the prefix executes synchronously – no need for a new startSyncronously method. I could try writing a pitch as that's what I always wanted.

Hey folks, just a reminder that this pitch is now under active review. Please make sure to leave any feedback you have over in the review thread!

2 Likes

Thanks Tony. I didn't see it. I'll post this as feedback under the review. Please disregard the previous message.