Is this a good use of Task.immediate?

I have just discovered Task.immediate.

This is a big discovery for me, because my UIKit apps use a "presenter / processor" architecture where all user inputs received by a view controller ("presenter") are immediately passed along to a business logic object ("processor") — and where, because of possible multithreading in some of the business logic, the function that does this passing-along is async, and so require an await. Thus my apps are full of code that with a structure like this:

    /// The user tapped the Stats button.
    @IBAction func doStats(_ sender: UIButton) {
        Task {
            await processor?.receive(.stats(source: sender))
        }
    }

The pattern here is that an @IBAction or other @objc func is called on the main thread by the runtime, and we immediately turn around and signal the processor that this has happened. But because we must say await to talk to the processor, there has to be a Task initializer (because every async calling chain must be "rooted" somewhere, and since there is no way to tell the runtime to call me async, an explicit Task initializer has to be the "root" of the chain).

In every case, the Task initializer is the last (and usually, the only) thing in the func.

Now then. It has always bothered me, on multiple grounds, that a Task initializer is merely a scheduler. For one thing, a note of indeterminacy is introduced; there is in fact a risk that the user could do thing in very quick succession and the corresponding Tasks could be launched in the wrong order. For another, there's a delay. I can sense the existence of this delay in the app's response to the user's actions, and I can (and do) prove its existence in my unit tests; if I say

@Test func stats() {
    let button = UIButton()
    subject.doStats(button)
    #expect(processor.thingsReceived == .stats(source: button))
}

...the test fails, because I need to introduce a delay between the doStats call and the #expect that checks the outcome of that call — a delay that is due to the Task initializer.

Well, when I learned of Task.immediate, my little heart went pit-a-pat. Wouldn't this eliminate the delay? Sure enough, if I change Task to Task.immediate in my IBAction func, the very same test passes.

We come at last to my questions.

  1. Before I go changing all my code in all my apps, is this a good, legitimate, and valid use of Task.immediate?
  2. More controversially, perhaps: In situations like this (where "like this" means that the Task initializer is the last thing in the call chain (and perhaps also that the call chain is initiated by the runtime)), couldn't / shouldn't the compiler treat the Task initializer as meaning Task.immediate, thus eliminating the delays automatically?
1 Like

Task.immediate is super cool and I'm always happy to find another enthusiast. I think this is perfectly reasonable use!

However, the second question is a lot more complex. I'm pretty sure that such a transformation would be syntactically-compatible. But I don't think it is identical semantically, because I believe the immediate variant uses a different version of @_inheritActorContext.

And that's on top of cutting off the ability to allow non-immediate behavior. Sometimes that can matter, like allowing the runloop to finish one more turn. There'd need to be a new way to express that.

It is an interesting idea for sure. And it could be that such a default would make sense in many cases. But I do not think it would be trivial. Especially given how much attention @_inheritActorContext has gotten in the past (and hopefully gets in the future too).

1 Like

The main question is whether processor is a standalone actor (meaning its methods must run off the main thread) or it's a @MainActor class that happens to expose an async method.

If it's the latter (since your tests are passing), then yes, Task.immediate will solve the problem because it immediately enters the async function and only suspends when the execution legitimately has to change actors/threads, somewhere later in the call stack. It will also eliminate any re-ordering.

If it's the former, then it won't fix this issue since the task will enter the body, but immediately suspend because it has to hop off the main thread (essentially perform an equivalent of myProcessorsDispatchQueue.async { } kind of call). In that case, and to solve the interleaving of tasks, it's better to implement processor.receive as a synchronous function that pushes events into a mutex-protected FIFO queue, for instance.

6 Likes

That's a great distinction and a very helpful explanation. I'm sorry that I didn't realize I needed to specify that. It's the latter; it's a @MainActor class that exposes an async method (by way of a protocol).

The task creation should probably be inside processor, which should also be @MainActor. And only the heavy work should go inside a @concurrent function.

No problem! The idea behind Task.immediate is exactly that: given an async function, start running it immediatelly and as long/deep as possible, specifically to eliminate any discontinuity and re-ordering when caller both originates on e.g. the main actor and calls into the same actor. In that sense, it's a legitimate use because it's what it's designed to do.

More generally, however, having processor.receive be an async function might be a bit of a design mistake that leaks the internal implementation of processor into the caller — the caller needs to perform and be aware of the scheduling ceremony. It being the last call is especially telling, since you're not actually awaiting on the results of that task, i.e. you don't have a use case where you need to do something after processor?.receive has run.

Thus here I do agree with @Cyberbeni: the async part should be an impl detail of the processor, and receive should be simply synchronous.

This generally agrees with how such message/event sending patterns are implemented elsewhere: e.g. AsyncStream.Continuation.yield is synchronous.

2 Likes

That's useful; I'll look into moving the async knowledge.

Sidenote: since Swift 6.0 (compiler version), you can enforce guaranteed task enqueue ordering by making sure that the closure passed to the Task initializer has an explicit isolation, either by adding an explicit global actor isolation ({ Task @MainActor in … }) or by explicitly capturing an isolated variable in the closure body.

This was introduced in SE-0431: @isolated(any):

Swift will always start a task function on the appropriate executor for its formal dynamic isolation unless:

  • it is non-isolated or
  • it comes from a closure expression that is only implicitly isolated to an actor (that is, it has neither an explicit isolated capture nor a global actor attribute). This can currently only happen with Task {}.

As a result, in the following code, these two tasks are guaranteed to start executing on the main actor in the order in which they were created …

The way this works (I think) is that the Swift 6.0+ compiler generates code that synchronously enqueues each task's job immediately on the correct serial executor for its isolation. Before Swift 6.0, all such jobs were first scheduled on the global concurrent executor (which has no ordering guarantees), only to then hop to their correct serial executor once a job started running.

2 Likes

:exploding_head: @ole Way cool, I had no idea.

Note that this is not a substitute / synonym for Task.immediate (not that you said it was, I just wanted to make this explicit); adding @MainActor in may guarantee the order of enqueuing onto the actor, and that can certainly eliminate a source of worry, but it doesn't of itself eliminate the delay before the task operation execution starts, even if we are on the main actor, we say @MainActor, and this is the last thing we say.

1 Like

Just to clarify this, what is truly important for this behavior is a non-nil static isolation. The explicitness is not actually necessary. So, if the enclosing scope is MainActor, and it sounds like it is, then you are guaranteed the synchronous enqueue from Task.init.

This is particularly important when dealing with isolated parameters, because while the capture is (unfortunately, today) necessary, it is not sufficient. The value also needs to be non-nil. There must actually be an actor at runtime to accept the job. I’m not actually sure if it also has to have a non-optional type or not…

Going further, that’s rarely a sufficiently-useful constraint unless the actor also happens to be the MainActor. But everything here sounds like it is, so I think you can safely make use of this guarantee, if it is helpful.

1 Like

Yes. Good point to make this explicit.

Are you certain? I re-read SE-0431 before writing my answer and what you say doesn't match how I read the proposal's Adoption in task-creation routines section. Quoting from there (bold emphasis mine):

This proposal modifies all of these APIs [i.e. the task creation APIs] so that the task function has
@isolated(any) function type. These APIs now all synchronously enqueue
the new task directly on the appropriate executor for the task function's
dynamic isolation.

Swift reserves the right to optimize the execution of tasks to avoid "unnecessary" isolation changes, such as when an isolated async function starts by calling a function with different isolation.[1] In general, this
includes optimizing where the task initially starts executing:

@MainActor class MyViewController: UIViewController {
  @IBAction func buttonTapped(_ sender : UIButton) {
    Task {
      // This closure is implicitly isolated to the main actor, but Swift
      // is free to recognize that it doesn't actually need to start there.
      let image = await downloadImage()
      display.showImage(image)
    }
  }
}

As an exception, in order to provide a primitive scheduling operation with
stronger guarantees, Swift will always start a task function on the
appropriate executor for its formal dynamic isolation unless:

  • it is non-isolated or
  • it comes from a closure expression that is only implicitly isolated
    to an actor (that is, it has neither an explicit isolated capture
    nor a global actor attribute). This can currently only happen with
    Task {}.

As a result, in the following code, these two tasks are guaranteed
to start executing on the main actor in the order in which they were
created, even if they immediately switch away from the main actor without
having done anything that requires isolation:[2]

func process() async {
  Task { @MainActor in
    ...
  }

  // do some work

  Task { @MainActor in
    ...
  }
}

The exception here to allow more optimization for implicitly-isolated closures is an effort to avoid turning Task {} into a surprising performance bottleneck. Programmers often reach for Task {} just to
do something concurrently with the current context, such as downloading
a file from the internet and then storing it somewhere. However, if
Task {} is used from an isolated context (such as from a @MainActor
event handler), the closure passed to Task will implicitly formally
inherit that isolation. A strict interpretation of the scheduling
guarantee in this proposal would require the closure to run briefly
on the current actor before it could do anything else. That would mean
that the task could never begin the download immediately; it would have
to wait, not just for the current operation on the actor to finish, but
for the actor to finish processing everything else currently in its
queue. If this is needed, it is not unreasonable to ask programmers
to state it explicitly, just as they would have to from a non-isolated
context.

I read this as: Swift wants to reserve the right to make more aggressive optimizations to implicitly @MainActor-isolated contexts, and that's why the explicit Task { @MainActor in … } annotation is required to get the strict ordering guarantee. (Or a capture of a non-nil isolated variable, as you rightly say @mattie)

I also found this from @John_McCall from 2024-03 (the whole thread is interesting): `@isolated(any)` function types - #32 by John_McCall

[…] The upshot is that, if you want the ordering guarantee, you should be explicit about the isolation of your tasks.


(@mattneub Sorry for derailing the thread a bit. As you noted, the task ordering guarantees provided by @isolated(any) aren't directly relevant to your original question.)


  1. This optimization doesn't change the formal isolation of the functions
    involved and so has no effect on the value of either #isolation or
    .isolation. ↩︎

  2. This sort of guarantee is important when working with a FIFO
    "pipeline", which is a common pattern when working with explicit queues.
    In a pipeline, code responds to an event by performing work on a series
    of queues, like so:

    func handleEvent(event: Event) {}
      queue1.async {
        let x = makeX(event)
        queue2.async {
          let y = makeY(event)
          queue3.async {
            handle(x, y)
          }
        }
      }
    }
    

    As long as execution always goes through the exact same sequence of FIFO
    queues, each queue will execute its stage of the overall pipeline in
    the same order as the events were originally received. This can be a
    difficult property to maintain --- concurrency at any stage will destroy
    it, as will skipping any stages of the pipeline --- but it's not uncommon
    for systems to be architected around it. ↩︎

1 Like

That’s right. Note that this would only matter if the task needed to start by doing something off of its initial function’s formal isolation, like if it was formally @MainActor but stated by calling something on a different actor. The optimization allows Swift to start the task on that second actor rather than on the main actor. This could break ordering if you were relying on the strict sequence of enqueuing for correctness. But as I mentioned in the cited thread, I generally believe it’s far too difficult to think about ordering correctness when code is written that way, and I strongly recommend doing something more explicit if you need that ordering.

2 Likes

I think that everything that has been said so far is fully compatible. I strongly agree that you should not rely on the internal optimization of how Task’s initialization behaves, enabled by @isolated(any). And the additional optimizations around the first asynchronous call the task makes (or at least reserves the right to make) I did not know about.

I don’t think I have encountered a situation where that would matter. But I have found this technique to be a useful, particularly when migrating callback-based code to async functions within existing, complex synchronous paths. I think the motivating example for this thread is kind of an example of that.

But I believe it is true that adding an explicit isolation which does not actually change the static isolation is unnecessary. It might be good practice. But it can also be confusing. Because it sends the signal that the author was unsure about the static isolation without the annotation. And since that is such a common problem, the explicit, redundant annotation alone, without a comment explaining the subtleties, isn’t my favorite.

I really appreciate the clarification, especially because no, I am never certain. And I learned something I didn’t know! But I believe we are all actually in agreement that if ordering guarantees are important, this stuff is not the best option. And also yes, sorry about hijacking the thread!

1 Like

I don't regard any of this as "hijacking the thread". On the contrary, a very useful discussion.

4 Likes