Is this a good use of Task.immediate?

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