SE-0472: Starting tasks synchronously from caller context

Hello, Swift community!

The review of SE-0472: Starting tasks synchronously from caller context begins now and runs through April 10th, 2025.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by DM. When contacting the review manager directly, please put "SE-0472" in the subject line.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

swift-evolution/process.md at main · swiftlang/swift-evolution · GitHub

Thank you,
Tony Allevato
Review Manager

18 Likes

Having more control over the scheduler is always a good thing, so in theory I welcome this proposal.

In practice though I'm really puzzled about the reasoning…

"This initial scheduling delay can be problematic in some situations where tight control over execution is required."

Really? What dimensions of scheduling delay are we talking about? Is this new API really going to make a difference?

1 Like

It does make a huge difference to have a hot path be “no async scheduling at all” or “yes, we have to schedule the task to run later (at an undefined point in time, since other tasks may also get scheduled”).

Specifically you can think about happy path handshakes in ipc systems, if in most cases you can acknowledge a handshake and thus start communicating without being subject to scheduling delay this makes a big difference in overall “time to first message” when the synchronous path was able to be taken.

7 Likes

Big +1

Yes. I have add use cases for this. I found work arounds, but it wasn't satisfying.

Yes.

That's the default when using callback-flavoured API. With this we would have the best of both worlds.

A thorough reading.

An observation: It would be nice to be able to do this with async let. Maybe for the Future direction section.

And a request for clarification: how does it compose with itself ?
Regarding the following exemple:

func someSyncFunc() {
    // (1)
    Task.startSynchronously {
        // (2)
        await someAsyncFunc()
        // (7+)
    }
    // (6)
}

func someAsyncFunc() async {
    // (3)
    Task.startSynchronously {
        // (4)
        await actuallySuspending()
        // (7+)
    }
    // (5)
    await otherActuallySuspending()
    // (7+)
}

Does 1, 2, 3, 4, 5 and 6 happen in that order ?
Does the several 7+ happen after that, in undefined order ?

Thinking of this as a scheduler tweak is perhaps misleading. The task starts synchronously on the current thread. Things running synchronously can be semantically useful for all sorts of reasons, like guaranteeing that they happen atomically with respect to the actor isolation of the calling context.

8 Likes
  • What is your evaluation of the proposal?

Big +1

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes, there exists SPI in the standard library that does a limited form of this and it is quite useful.

  • Does this proposal fit well with the feel and direction of Swift?

Yes, it especially dovetails nicely with the semantic changes to run non isolated functions on the caller's executor, in the sense that it enables more predictable control over actor isolation and enables a less surprising behavior at the call site.

  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

I have not used other languages with async/await style concurrency enough to have run into this specific limitation.

  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A thorough reading, and having used the SPI variant of this.


One specific place where this will be immediately useful is being able to finally interoperate NSItemProvider loading via UIDropInteraction in UIKit code.

UIKit assumes if a client does not begin loading from a UIDragItem synchronously by the end of dropInteraction(_:performDrop:) that the client does not need to load the items, and it tears down the interaction. This nondeterminism makes it impossible to implement UIDropInteraction in a way that plays nicely with Swift concurrency.

With this API, it becomes possible:

let dataLoadTask: Task<Data, Error> = Task.startSynchronously {
    try await withCheckedThrowingContinuation { continuation in
        _ = self.loadDataRepresentation(for: type) { data, error in
            if let error {
                continuation.resume(throwing: error)
            } else if let data {
                continuation.resume(returning: data)
            }
        }
    }
}
7 Likes

Interesting that the motivation section seems to focus around performance. My first thought when looking at the proposed API was about the impact it would have on handling indefinitely-running AsyncSequences, which are increasingly popular these days.

Suppose I wrote a function to indefinitely await on a stream of events, like this:

func waitForFoodOrder(
    onFoodOrderReceived: @escaping (FoodOrder) async -> Void,
    isolation: (any Actor)? = #isolation
) async {
    for await foodOrder in foodOrderStream {
        await onFoodOrderReceived(foodOrder)
    }
    // Some cleanup that runs after task cancellation.
    cleanKitchen()
}

The function above can prevent further execution for a long time, so these kind of functions are often called inside a Task { ... }:

let handle = Task {
    await waitForFoodOrder { foodOrder in
        print("Preparing \(foodOrder)!")
        prepare(foodOrder)
    }
}

However, we can't easily know when that unstructured Task has started execution, which is important to avoid adding elements to the async sequence before the for await loop is set up to receive them.

Could the following be used to ensure the for await loop is already able to receive new elements in the sequence by the time order() is called? If so, it would be super useful:

let handle = Task.startSynchronously {
    await waitForFoodOrder { foodOrder in
        print("Preparing \(foodOrder)!")
        prepare(foodOrder)
    }
}
order(FoodOrder.pizza) // <-- Is `.pizza` guaranteed to be awaited by the for-await loop?

If I got the behavior above right, then not being able to annotate the task with a global actor for this use case feels unnecessarily limiting. For example, an async version that allows hopping to a different actor while preserving the ordering guarantees:

let handle = await Task.startSynchronously { @MainActor in
    for await order in foodOrderStream { // <-- Let's say `foodOrderStream` requires @MainActor
        print("Preparing \(foodOrder)!")
        prepare(foodOrder)
    }
}
order(FoodOrder.pizza)

Could be useful too. The task starts (although in a different actor), its first synchronous section runs, and then (after the first suspension point in the callee) the caller function can continue executing (in the original executor).

1 Like

It is strictly required to provide the behavior this API is promising, here's why:

If we are to synchronously and immediately to start a task it means we are going to be on the caller's context. Therefore, isolation must be the same as the enclosing context.

In order to have it be isolated to some other actor, we'd have to switch to that actor, and that may imply an actual suspension, thus would not be able to guarantee the "start synchronously, without suspending" which is the goal of this API.

I don't fully understand your question here to be honest. The only thing this API does is start a task on the calling context, if you'd hit any actual suspension it'd still suspend. So it's not going to help in any way to block the calling context on asynchronous code - which seems it seems is what you're asking about?

The answer depends on which async function execution mode you're running with: [Focused re-review] SE-0461: Run nonisolated async functions on the caller's actor by default

If in the present day mode, then an async function must hop off to the global pool, so there's a suspension and enqueue to the global pool there at which point that's a switch and the calling context would continue to point 6.

If we're running in the "new mode" where async functions run on their caller context the call to someAsyncFunc does not cross a boundary and it'd be closer to the single-context execution you perhaps would want here.

I'd suggest checking the details of [Focused re-review] SE-0461: Run nonisolated async functions on the caller's actor by default to check in how that relates to async function execution.

Perhaps I'm missing something, but doesn't this violate the principle of forward progress? I saw the relevant paragraph in the proposal:

The proposed Task.startSynchronously API forms an async context on the calling thread/task/executor, and therefore allows us to call into async code, at the risk of overhanging [emphasis mine] on the calling executor. So while this should be used sparingly, it allows entering an asynchronous context synchronously.

So my question is, would it make sense to mark this new method @unsafe?

Otherwise, this seems like a good addition!

Not really any more than any nonisolated async function under the mode Swift is adopting now due to [Focused re-review] SE-0461: Run nonisolated async functions on the caller's actor by default

It's a tradeoff between overhangs and excessive switching, which for async functions we realized was the wrong tradeoff. Here we're not changing any default behavior of Task, but we are offering a way to opt into the slightly more overhanging version of it with similar implications and tradeoffs.

2 Likes

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

For the case of the main actor specifically, wouldn’t that be equivalent to await MainActor.run { Task.startSynchronously { … } }?

Thanks for the writeup, Raul.

Yeah, I certainly agree that the continued quest to regain tight ordering control is something that's quite important to many developers and we continue to provide more tools to get back that control, including through this proposal.

I do agree that what in this proposal is the special case of running on the caller and therefore gaining these ordering properties "for free". I do not think we can lean into an API that makes the caller await though, at least not in this iteration of the API, because one of the important users we need to serve with this is synchronous code entering asynchronous code... So the await Task.<something> {} is interesting, but I don't think we can replace the current proposal with that -- it might be worth considering as a future direction though!

I am worried that this would lean too much into unstructured concurrency, while what we actually need here is "enqueue immediately on that target actor" with that small twist of waiting till the first suspension... I am a bit wary of baking this whole thing into a specific API like that, and I wonder if a pattern like passing a await Worker.run { <sync first bit> }, andThen: { await ... } would not be suitable enough... (though implementing that is a bit boilerplate heavy, but it feels like we should explore more options here)

So I'm not opposed to exploring the idea, but it feels like it serves a different need than this immediate and if we're honest a bit special case -- since we get the ordering just thanks to the synchronous start, rather than hooking into the continuations of partial async tasks, which would be much harder to pull off I think. Maybe worth it, but I'd probably subset it out into potential future directions.

6 Likes

The thing that gives me pause about this proposal is its ability to invalidate guarantees that, thus far, have been solid. Suppose I've got a library that exposes this function:

@SomeActor public func doSomething() {
  // do something
}

As things stand today, having written the function above, I can be assured that the code in it will always run on SomeActor. With this change, however, there's a possibility that calling code might use startSynchronously as a crowbar to get around this, which, if the actor synchronization was important to prevent thread-related issues, could lead to any kind of undesired behavior.

I wonder if we should put functions that can be called this way behind some kind of access control modifier:

@SomeActor @syncable public func doSomething() {
  // code that doesn't assume that we're 100% certain to be on the actor
  // (feel free to come up with a better name than @syncable)
}

@SomeActor public func doSomethingElse() {
  // this code will _definitely_ run on SomeActor
}

Failing that, I wonder if we could at least restrict startSynchronously to calling code that's in the same module.

startSynchronously absolutely does not allow you to call something without being isolated to the right actor. If there's something in the proposal document which led you to believe this, please point it out so that we can fix the wording.

4 Likes

Read the proposal. Overall, I’m a +1.

I was recently working on some code that tried to replace a serial queue with an actor. The code looked something like this:

actor EventDispatcher<State> {
    var mutableState: State
    
    func dispatch<Event: Sendable>(
        _ event: Event, 
        to handlers: [Handler<Event>]
    ) {
        for handler in handlers {
            handler.handle(event, state: &mutableState)
        }
    }
}

final class Observer<State> {
    let eventDispatcher: EventDispatcher<State> = .init(…)
    …

    func observe<Event: Sendable>(event: Event) {
        let handlers = getRegisteredHandlersSomehow()

        // Concern was raised here that Task scheduling isn’t necessarily 
        // FIFO, so this doesn’t match behavior with DispatchQueue.async(_:)
        Task { 
            await eventDispatcher.dispatch(event, to: handlers)
        }
    }
}

When my team reviewed the code above, they raised concerns that that the spawned Task doesn’t have any scheduling guarantees, so we weren’t sure that events would be handled in the order they were received. If we replaced the Task { } call with Task.startSynchronously { }, then we would effectively get FIFO guarantees, right? Because whichever thread hit the await would be first in line in the actor’s queue? Or would we additionally need to use a custom serial executor for the actor?

Regardless, if some combination of those would ensure that we could eliminate even more dispatch queues in our code, that would be great.

3 Likes

Kind of...

This proposal does not give way to "real" FIFO guarantees with default actors (actors NOT on custom executors), because reordering of tasks due to priority escalation may still cause reordering. But if you had no priority escalations it does bring you closer to FIFO-ness, but does not guarantee it -- I want to stress that point in case someone then accidentally discovers that they in fact DID get reordered.

Today since custom actor executors cannot participate in reordering though, if you'd combine this API with an actor using a custom executor, you'd get a non-reorderable actor execution that's true.

IMHO a nicer way to express this "enqueue [1] and then [2] on actor A" will be provided by the SE-0472: Starting tasks synchronously from caller context proposal when we get around to implementing it. Because then it is about Task { [isolated a] <1> } Task { [isolated a] <2> } , and not relying on a weird artifact of how the task runs synchronously... but we truly enqueue the tasks on a immediately.

2 Likes

What is your evaluation of the proposal?

Sounds good, considering the future direction is to automatically start the tasks synchronously when possible.

Is the problem being addressed significant enough to warrant a change to Swift?

Yes.

Does this proposal fit well with the feel and direction of Swift?

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 but 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 have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

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.

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

A bit.

1 Like