SE-0472: Starting tasks synchronously from caller context

I think you’ve talked yourself into something here because of your awareness of implementation details. Custom actor executors do not have to be FIFO, full stop. Dynamic priority changes are not relevant to the question.

You can make a custom executor be FIFO, of course. In the future, if we allow executors to respond dynamically to priority changes, executors will be able to do do while both staying FIFO and avoiding priority inversion; it just means you have to escalate the priority that all the intervening jobs run at instead of letting the job jump the queue. But this is always necessary to a degree because a job might already be running.

2 Likes

I just wanted to add that I find it useful to think how you could potentially represent it with GCD. Currently, you have to use Task to start any async work from a synchronous context, and Task starts asynchronously. So the equivalent GCD code would look something like this:

// Assuming it runs no main queue
func load() { 
    guard !isLoading else { return }
    isLoading = true
    DispatchQueue.main.async { // <-- this line is not something you typically do
        loadSomething {
            DispatchQueue.main.async { // and maybe not this, depending on conventions
                isLoading = false
            }
        }
    }
}

I don't think that's the way how anyone would code it, and it illustrates to me that the current way of invoking async methods with Task is insufficient. But it's also hard to imagine someone using this proposed API in practice:

func load() {
    guard !isLoading else { return }
    Task.startSynchronously {
        isLoading = true // Is it fair game to put it here now?
        await loadSomething()
        isLoading = false
    }
}

Do you expect a thread hop to be there in the first place? Would the reader understand why startSynchronously was used? Was it necessary or was it just an optimization? If you remove it, do you risk introducing a regression? Does it affect how the prefix of loadSomething is invoked or only the prefix of the task's closure? It doesn't seem clear.

You have to complete the picture. In the native world of GCD, async functions are sync functions with completion handlers. Absent any more specific discipline, every call to such a function is essentially a use of Task.startSynchronously: you can start it synchronously from any context, and some portion of the work will happen immediately, while some unknown amount of the work will run asynchronously and (likely) concurrently, including the final call to the completion handler.

2 Likes

Yeah, It is similar to Task.startSynchronously. I think these are useful qualities for app development, especially for eliminating thread hops with the potential inefficiencies, logic bugs, and debugging challenges they can introduce. It seems to be a good default with no major limitations or drawbacks.

and some portion of the work will happen immediately, while some unknown amount of the work will run asynchronously and (likely) concurrently

I find the current Task behavior to be similar as there is some unknown amount of work that happens in the prefix immediately after a thread hop with some unknown amount of work (or none) that runs concurrently. So, essentially, the only difference is the additional thread hop, which is what this proposal is trying to eliminate. Is that a fair assessment? I can't say I understand what the thread hop is for (except, of course, when you need to switch to a different actor). I would look into removing it to avoid introducing two different behaviors.

What is your evaluation of the proposal?

Overall +1, though I want to echo my suggestion from the pitch thread to rename the API to startImmediately.

Rationale: "synchronous" carries heavy baggage and the name could lead to the assumption that the entire task will be run in a blocking manner. I find that "immediate" is more accurate (that word is used 6 times in the proposal document!) and doesn't carry the same baggage.

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

Yes, the ability to start a Task without a hop is a primitive that isn't possible without explicit language support. It's often necessary to do this, for example when interacting with APIs bridged from Objective-C (cf Harlan's NSItemProvider example) or C. Or more generally, any API that doesn't assume Swift's concurrency model, and has both an "immediate" and "later" part. Expecting client Swift code to vend separate synchronous and asynchronous parts to solve for this is awkward in the Swift world, and being able to start tasks immediately is a very nice solution.

This can also be useful in pure Swift. Consider the following example:

struct MyView: View {
  @State private var name: String?
  var body: some View {
    if let name {
      Text(name)
    } else {
      ProgressView()
    }
    Color.clear.onAppear {
      Task.startImmediately {
        // API can return synchronously if cached
        self.name = await API.fetchName()
      }
    }
  }
}

If one uses .task or .onAppear { Task { ... } }, the view will, on occasion, briefly flicker with a visible ProgressView even if API.fetchName has a cached result that it can return synchronously. Meanwhile, startImmediately will deterministically eliminate this flicker if the result is cached. (Aside: it would be great if View.task also got a companion View.immediateTask API that started immediately, though I know this is outside the purview of Swift Evolution.)

The above API can be spelled in a way that makes eliminating the flicker possible today, but it's harder to read, increases the API surface, and adds more logic to the view layer.

struct MyView: View {
  @State private var name: String?
  var body: some View {
    if let name {
      Text(name)
    } else {
      ProgressView()
    }
    Color.clear.onAppear {
      // View needs to explicitly check for cache
      if let name = API.cachedName {
        self.name = name
      } else {
        Task {
          self.name = await API.fetchName()
        }
      }
    }
  }
}

However, if API.fetchName were a third-party API, there's no guarantee they would provide direct access to the cached property. Frequently, a bridged Objective-C API could say something like "If the result is cached, the completion handler will be invoked synchronously." in which case startImmediately would be the only way to avoid that flicker.

I also see this being very useful in tests, where one might want to assert that a certain portion of an asynchronous method is invoked immediately.

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

I think so, especially as we also move to reduce executor hops with SE-0461. I wish we could have gone a step further and made this (Task.startImmediately) the default behavior, in line with the observation from SE-0461 that most code probably doesn't need to executor-switch or hop at all. I assume it's a bit late for that now though.

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

I really like the mental model that Combine (RIP) had, where execution order was highly deterministic and any hops required an explicit receive(on:) or similar. It's fair to say that such determinism isn't always necessary, and to that end I don't think it's the worst thing that Tasks start asynchronously by default, but it would be very nice to be able to regain that control.

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

I aimed to read the proposal in depth. I've also experimented with the SPI version of this primitive (Task.startOnMainActor) and found it very useful.

3 Likes

For reference, here's a concrete example of startSynchronously (née startOnMainActor) making something possible that would otherwise not be

Isn't that more working around a bug in SwiftUI? And how does startOnMainActor even help there? Looking at the code it doesn't seem like there's any synchronous work in the Task at all. So could you explain how this changes things?

No, when you use .onAppear { Task { ... } } (or .task { ... }), it schedules the task which means it will be executed at some point in the future. If that point in the future is far enough away then we don't end up setting name in time for the frame commit and so SwiftUI has no choice but to show the progress view.

The await API.shared.loadName() doesn't necessitate suspension. In the example gist, note that we return the cache value immediately if it's non-nil, so all of that happens in-line during the Task.startSynchronously invocation. That's why the API is so powerful, it allows you to call into async-colored code and only suspends at the first "real" suspension point that's encountered at runtime.

4 Likes

So, essentially, the only difference is the additional thread hop, which is what this proposal is trying to eliminate. Is that a fair assessment?

Here is an example adapted from the proposal:

func sync() {}
func async() async {}
func f() {
  Task {
    sync()        // (3)
    await async() // (4) assume we don't suspend here
    await async() // (5) assume we DO suspend here, resume at (6)
  }
  sync()          // (1)
  // (2) continue execution
}

func g() {
  Task.startSynchronously {
    sync()        // (1)
    await async() // (2) assume no suspension
    await async() // (3) assume we suspend here, resume at (6)
  }
  sync()          // (4)
  // (5) continue execution
}

Note: This is only about the order of execution within a single call to f() or g() . In f() , the point at which the Task begins executing is not known relative to the surrounding code — it may be scheduled immediately, or sometime later. In contrast, g() uses startSynchronously , so the task starts at a known and deterministic point , both within the function and in relation to the rest of the program.

Edit: Added a note about the example.

Seems I was writing under the incorrect assumption that we did not expose priority on ExecutorJob yet.

I did have to double check now and it seems that we actually do offer priority already. I thought we'll only expose this during Alastair's ongoing proposal somehow. So okey, yeah we do have job.priority so we could have implemented non-fifo but priority order executors.

I was talking about the majority of executors though, and if @kean wanted to, he can write such executor to just queue.async{} as most do in enqueue(job:) and this way get the FIFO-ness he was asking about.

// edited since I noticed we do expose .priority already.

Hmm. I think you're reasoning forwards from what information is available and therefore what you imagine an implementation might reasonably do rather than backwards from what you actually need to demonstrate in order to prove your claim. Actors are not constrained to run jobs in any particular order; if they wanted to pick their next job by rolling a die, they could.

The Isolation rules section of the proposal states that it's not safe for Task.startSynchronously to be passed a closure whose isolation is different from the caller's isolation:

@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 (!)
  }
}

However, there is a formulation of this that is safe: It is safe for Task.startSynchronously to be given a differently-isolated closure as long as the switch to the isolated actor is considered the first dynamic suspension point:

@MainActor var counter: Int = 0

func sayHello() {  
  Task.startSynchronously { @MainActor in
    // The first dynamic suspension point is here to switch
    // to the main actor

    counter += 1
    // This runs on the main actor, but it does not happen 
    // synchronously with respect to the caller of 
    // Task.startSynchronously
  }
}

These rules are perfectly safe, they still cover the motivating use cases of the proposal, and they give people an API for accomplishing "run synchronously if in the right context, otherwise enqueue as usual" as mentioned in the future directions. In fact, this proposal already allows programmers to express these semantics even if we go with the isolation rules written the proposal if the first async call in the body of the closure crosses an isolation boundary statically:

@MainActor var counter: Int = 0

func sayHello() {  
  Task.startSynchronously {
    // If 'sayHello' is on the main actor at runtime, this call should not
    // dynamically suspend per the proposal's rules.
    await { @MainActor in
      counter += 1
    }()
  }
}

The more likely version of this is:

@MainActor var counter: Int = 0

@MainActor func incrementCounter() {
  counter += 1
}

func sayHello() {  
  Task.startSynchronously {
    // If 'sayHello' is on the main actor at runtime, this call should not
    // dynamically suspend per the proposal's rules.
    await incrementCounter()
  }
}

I personally think we should just embrace this as the behavior of Task.startSynchronously and change the name of the API to make its semantics more clear. The future direction section alludes to this alternative with an objection:

The proposed startSynchronously API is a tool to be used in performance and correctness work, and any "may sometimes run synchronously, it depends" is not a good solution to the task at hand. Because of that, we aim for this API to provide the predictable behavior of running synchronously on the caller, without impacting the isolation, that can compose naturally with assumeIsolated that can recover dynamic into static isolation information.

However, I think it's impossible for the programmer to predict when the first dynamic suspension point will occur in the body of a startSynchronuosly closure because that is a dynamic property. We cannot guarantee that any of the closure body will run synchronously because the closure can immediately call some isolated function. I think that's okay as long as the API name and its documentation encourage the right mental model for this behavior.

9 Likes

Heh, sure they could, that's really nitpicking though isn't it. The specific question the developer asked was if they'd get a FIFO execution using a reasonable custom executor (and this API). And the answer is yes, it would work like that. A reasonable executor, including a dispatch queues today would give this behavior - although queues could change their implementation in the future; so a wrapper custom executor around a simple dispatch async could be a way to achieve what they asked for.

We can split hairs that in theory there's a possibility to implement an executor which would do something else, but the specific case they've asked about is served well enough by this combination of this API and an executor which just enqueues in order.

I'd be happy with accepting the reality of this API. It is a behavior that can be explained and understood, if something has an isolation we'll hop to it, and that's well understood. So only specifying the same (or not specifying explicitly) would be the way to get the "synchronously" behavior bit.

The Task.startSynchronously { @MainActor in } highlights the bad naming of the method then, but it's the name we have to fix rather than the behavior then.

The difference between Task.startSynchronously { @MainActor in } and Task { @MainActor in } would be that the prior attempts to run immediately if already on main actor, but the latter always just enqueues on the main actor. The same would apply if and when we get [isolated target] captures.

Just to try out some other naming ideas

  • Task.continue "continue from caller...", isn't too bad, but I wonder if it's descriptive enough.
  • Task.inline sadly this may suggest that the WHOLE task runs inline, so that's not great... startInline is kindof the same as startSynchronously so not much win here, and it's less clear what inline means to folks not used to this terminology. And it is not an inline task.
  • Task.start Task.startOnCaller technically not wrong... and we'd keep the start without explicitly saying the synchronously...
  • :light_bulb: Task.immediate or Task.startImmediately - actually sound interesting... I wonder if "immediate task" isn't a bad way to describe this, it starts immediately after all, and if it has to it'll hop somewhere :eyes:

Or just keep the startSynchronously and realize that this may actually immediately hop if there's an isolation difference :thinking:

6 Likes

This makes a lot of sense to me, but I wonder how we'd make it clear (assuming my understanding is correct) this will always run at least the synchronous functions immediately:

@MainActor
func f() {
  Task.startSynchronously { @MainActor in
    // assume these are also @MainActor isolated
    sync() // guaranteed to run immediately
    await work() // may also run immediately
  }
}

While this will never? (or maybe this isn't allowed):

@OtherActor
func f() {
  Task.startSynchronously { @MainActor in
    sync() // guaranteed to not run immediately?
    await work()
  }
}

The way I have been thinking about it is (not that I think these are good names) Task.startSynchronously(.always) and .ifIsolated.

Task already has a lot of implied behavior, so being explicit would be appreciated. Task.enqueueOnlyIfNecessary is more descriptive and contrasts nicely with the actual behavior of Task, which always enqueues (this has slightly changed recently, but I believe it starts directly on the destination executor now, rather than hopping to the default first?). It also hints more directly that it might fix issues like those pointed out by @kabiroberai above, where startSynchronously wouldn't seem to do anything when the body immediately awaits something. (Though I really don't think anyone without intimate knowledge of SwiftUI and the implications of starting without enqueuing would write that solution. It would be more like dispatching to main is now. "I don't know why this is broken, but this fixes it.")

Stepping back, the necessity of this proposal makes me think there's something wrong with Swift's (lack of) guarantees around await. Really what this change does is require not only that the Task's closure is called immediately in the current context (if allowed) but any awaits within will not suspend unless absolutely necessary. This would let developers and users better reason about code as it is written. It would be very useful for such a guarantee to be available outside Task. Would an alternate keyword be useful here? (awaitIfNecessary)

To the end, while we can't make the change generally, is there a reason this shouldn't just be the default behavior?

1 Like

I've often thought we should get rid of Task's implicit behavior and just allow passing parameters. Task(.immediate, .inherit(.actor)) {}, Task(.suspend, .inherit(.none)) {}

8 Likes

That's not really right. Task{} does not "suspend" as "suspends" here would imply that the caller was suspended, but Task{} does nothing to the caller at all, since there is no potential suspension point there -- if there was, you'd have to write await Task{}.

Suspensions may only ever happen at potential suspension points which are specifically places where we write await. Code can ever suspend without being in a potential suspension point (marked by await). In source one notable caveat here are implicit awaits at ends of scopes, for e.g. async let but this still follows this rule -- the actual suspension point is on the caller of such scope where the async let was declared. E.g. func test() async { async let x = ... }; func outer() async { await test() } the suspension may be implicit at the end of test() to await on the x but we still only ever suspended "at" a potential suspension point marked by await in xx, the caller of this async function.

Swift already works like that.

This API does not change anything about how suspension points work; it just avoids the initial enqueue of the Task{} to an executor and runs it in place. Any subsequent await inside the task work as they always have -- if they don't have to suspend or hop executors, they don't.

While I'd agree that passing "where to enqueue" would be nice, we can't just change these things using parameters. Instead, along with other proposals, and partially thanks to @isolated(any) we're heading towards where spelling this is done as follows:

Task { @MainActor in ... }

Task { [isolated who] in ... } //  pending "closure isolation control" proposal
// there's a pitch but no implementation of this yet ^

So the passing "where to execute" into the initializer, and then making the closure notice this isn't really something we're immediately pursuing.

Having that said, this has come up from time to time, but would require the very difficult @isolated(to:) capability which is far outside the scope of today's proposals. It has come up a few times, but it would be then as follows:

init(on isolation: any Actor, op: @isolated(to: isolation) () async -> T) 

This is generally useful, but not something we're able to implement short term, but it might be something we'd get to in the long term. Can't promise anything here, but since it's been coming up here and there, maybe someday we'd consider it. Today though, that is outside the realm of short term possibility.

1 Like

I'll go ahead and edit my post to use "enqueue" instead of suspend, since that's the part I was talking about, but I did want to respond to one thing here.

As someone who's not familiar with the implementation details of Swift concurrency, there seems to be a lot between the lines here, combining implicit behaviors of Task and await.

First, isn't it more complete to say that it avoids the initial enqueue of the Task to an executor if possible. That is, if I'm in another context and say Task.startSynchronously { @MainActor in }, it can't avoid the enqueue, it has to in order to meet the requirement of the captured actor, right? Or is that form just disallowed so it can guarantee no immediate enqueue?

Second, awaits not suspending or hopping if they don't have to is an implementation detail and optimization, isn't it? Doesn't the language say await may suspend? So while what you say may be true at any given point, it's never guaranteed. Or is it?

At present it would be prohibited but there's an argument to be made that we could allow it. This is what @hborla was posting about:

That we could allow this, but yes it'd enqueue and thus not be "synchronous" and thus we're looking for a new name, see there:


The language rule is specifically that: these are potential suspension points. The details when a switch can happen are not strictly documented however there are concrete verifiable only at runtime conditions which determine that.

In general it is not possible to answer if a potential suspension point will suspend or not statically -- e.g. it depends on if the target is a default actor and is not running any tasks right now, and the origin is also a default actor etc... There are some situations where you can know "for sure", which are basically "calling on the same actor" most of the time, but in general this is a runtime decision. There's various conditions which come into play here and we've not set them in stone in any proposal so far.

1 Like