SE-0472: Starting tasks synchronously from caller context

The API as proposed does two useful things:

  • Start a Task synchronously, without suspending.
  • Execute the first section of said task —up to the first suspension point— before continuing execution at the caller / task creation place. This implies some ordering guarantees about when code in that first section will be executed relative to other code in the function that spawned the task.

For example, in the first example in the proposal:

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

It's guaranteed that (1) will happen before (4), since there's no suspension point until (2). That is useful in itself.

In some cases this ordering guarantee could be the only reason why someone adds Task.startSynchronously to a piece of code, while not caring much about the whole "starts without suspending" part of the API for that particular piece of code. The usefulness of those ordering guarantees is even acknowledged in the proposal:

[Alternatives considered]
Banning from use in async contexts (@available(*, noasync))
During earlier experiments with such API it was considered if this API should be restricted to only non-async contexts, by marking it @available(*, noasync) however it quickly became clear that this API also has specific benefits which can be used to ensure certain ordering of operations, which may be useful regardless if done from an asynchronous or synchronous context.

One example where I see this ordering guarantees becoming useful is to ensure a "subscription" task is "observing" new values through an async sequence before emitting any values, which is an extremely common need.

The minimal example of how startSynchronously would be useful in that scenario is:

func asyncContext() async {
    Task.startSynchronously {
        for await element in sequence {
            // ...
        }
    }
    sequence.add(SomeElement())
} 

Or, the desugared version:

func asyncContext() async {
    Task.startSynchronously {
        let iterator = sequence.makeAsyncIterator() // (1)
        while let element = await iterator.next() { // (2)
            // ... (4)
        }
    }
    // (3)
    sequence.add(SomeElement())
} 

Here it's guaranteed that (1) is executed first, then reaches the suspension point in (2), and execution continues in (3), while the just-created Task is already awaiting new elements in the async sequence (which would trigger (4) but no longer tied to the calling context).

Due to that guaranteed ordering, adding an element to an async sequence after (3) is —I believe— not racy: it's always going to trigger (4), because the task is already awaiting new values in the async sequence. While a version with a regular Task initializer:

func asyncContext() async {
    Task { // ⚠️
        let iterator = sequence.makeAsyncIterator() // (2)
        while let element = await iterator.next() { // (3)
            // ... (4)
        }
    }
    // (1)
    sequence.add(SomeElement())
} 

Is racy, because sequence.add(SomeElement()) may run before the first await iterator.next, so that first value may never be observed.

As far as I can tell, this is a legit use of the proposed Task.startSynchronously API, even though I understand it's not the primary use case that led to the proposal of the API.

But then, Task.startSynchronously imposes some isolation requirements that limit the use case I mention to only work within the same isolation domain:

For example, the following example would not be safe, as unlike Task.init the task does not actually immediately become isolated to the isolation of its closure:

@MainActor var counter: Int = 0

func sayHello() {

  Task.startSynchronously { @MainActor in // ❌ unsafe, must be compile time error
    counter += 1 // Not actually running on the main actor at this point (!)
  }
}

Obviously Task.startSynchronously can't start without suspending the caller and also switch executors. It's either one or the other. I understand that, for the main goal stated in the motivation section, the fact that Task.startSynchronously is always... well, synchronous, is a cornerstone property.

But I was looking at it from the point of view of users that want to use Task.startSynchronously because of the ordering of operations (which, again, the proposal acknowledges as a valid use for the API), and not because of the "starts task immediately with no suspension" property. It's not unreasonable to think such users would eventually come up with the need for function that behaves just like Task.startSynchronously (in terms of ordering) but allows a suspension point before starting the task to switch executors.

To give a concrete example, let's imagine the sequence in my previous example was main actor isolated. Trying to use startSynchronously in the main actor would necessarily emit a compiler error:

func asyncContext() async {
    Task.startSynchronously { @MainActor in // ❌ error: Not in the Main Actor at this point!
        let iterator = sequence.makeAsyncIterator()
        while let element = await iterator.next() {
            // ...
        }
    }
    //
    sequence.add(SomeElement())
} 

Because switching to the main actor must introduce a suspension point. But users aren't allowed to introduce that suspension point, because there's no async alternative to Task.startSynchronously, requiring instead to completely rewrite the code above.

It seems to me like an async alternative to Task.startSynchronously would suit this use case just fine:

func asyncContext() async {
    await Task.startSynchronously { @MainActor in // (1)
        let iterator = sequence.makeAsyncIterator() // (2)
        while let element = await iterator.next() { // (3)
            // ... (5)
        }
    }
    // (4)
    sequence.add(SomeElement())
} 

Where the above would suspend at (1) to switch to a different executor, then execute the first section up to the first suspension point inside the task (2-3), and then suspend (3) and resume execution at the caller (4).

So, the same ordering as the example where sequence is in the same isolation domain. Just with an extra suspension point to allow switching executors.

I know this sounds sort of antithetical to the startSynchronously name, but other than the name I don't see anything immediately wrong with such an async alternative.

No, I don't think the calling context should block. Just suspend → execute the first synchronous block of the spawned task → resume at the calling context. No blocking.

7 Likes