0

Let’s say I have an async function within a @MainActor class that performs some slow, synchronous, CPU-intensive operation:

@MainActor
class MyClass {
    // ...

    func getFoo() async -> Foo {
        let foo = // ... some slow, synchronous, CPU-intensive operation ...
        return foo
    }

    // ...
}

How can I make my function run on a background thread (so that I don’t block the main thread)? I’ve seen two different approaches but am not sure which is correct (if either) as I’ve seen arguments against both of them.

  1. Task.detached

    func getFoo() async -> Foo {
        await Task.detached {
            let foo = // ... some slow, synchronous, CPU-intensive operation ...
            return foo
        }.value
    }
    
  2. Continuation + GCD

    func getFoo() async -> Foo {
        await withCheckedContinuation { continuation in
            DispatchQueue.global().async {
                let foo = // ... some slow, synchronous, CPU-intensive operation ...
                continuation.resume(returning: foo)
            }
        }
    }
    

Arguments Against Task.detached

[You] should not run long-running expensive, blocking operations in a concurrency context, regardless of if it's the main actor or not. Swift concurrency is a cooperative model, i.e. the functions that run in a concurrency context are expected to regularly suspend to give up control of their thread and give the runtime a chance to schedule other tasks on that thread.

Source

Recall that with Swift, the language allows us to uphold a runtime contract that threads will always be able to make forward progress. It is based on this contract that we have built a cooperative thread pool to be the default executor for Swift. As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally.

Swift concurrency: Behind the scenes (WWDC21)

If I’m understanding these two quotes correctly, slow, synchronous operations shouldn’t be done within Swift concurrency as they would block forward progress and that would violate the runtime contract. That being said, I would have assumed that the alternative would have been continuations + GCD, but I’ve also seen arguments against that.

Arguments Against Continuations + GCD

One should avoid using DispatchQueue at all.

Source

I would avoid introducing GCD, as that does not participate in the cooperative thread pool and Swift concurrency will not be able to reason about the system resources, avoid thread explosion, etc.

Source

So, all of that being said, what is the correct way of running an async func on a background thread?

NSExceptional
  • 1,368
  • 15
  • 12
  • Surely the "long-running" operation can be broken up into smaller tasks? That's where you can "give up control" of the current thread. – Sweeper Jul 10 '23 at 04:51

2 Answers2

2

tl;dr

Ideally, the long computation should periodically yield, then you can remain entirely within Swift concurrency.


You quote this comment:

[You] should not run long-running expensive, blocking operations in a concurrency context, regardless of if it’s the main actor or not. Swift concurrency is a cooperative model, i.e. the functions that run in a concurrency context are expected to regularly suspend to give up control of their thread and give the runtime a chance to schedule other tasks on that thread.

We should note that two sentences later, they answer your question:

You can do this by calling await Task.yield() periodically…

So, bottom line, if you have your own long-running routine, you should periodically yield and you can safely perform computationally intense calculations within Swift concurrency while upholding the contract to not impede forward progress.


This begs the question: What if the slow routine cannot be altered to periodically yield (or otherwise suspend in a cooperative manner)?

In answer to that, the proposal, SE-0296 - Async/await, says:

Because potential suspension points can only appear at points explicitly marked within an asynchronous function, long computations can still block threads. This might happen when calling a synchronous function that just does a lot of work, or when encountering a particularly intense computational loop written directly in an asynchronous function. In either case, the thread cannot interleave code while these computations are running, which is usually the right choice for correctness, but can also become a scalability problem. Asynchronous programs that need to do intense computation should generally run it in a separate context. When that’s not feasible, there will be library facilities to artificially suspend and allow other operations to be interleaved.

So, the proposal is reiterating what was said above, that you should integrate the ability to “interleave code” (e.g., periodically yield within the long computations). If you do that, you can safely stay with Swift concurrency.

Re long computations that do not have that capability, the proposal suggests that one “run it in a separate context” (which it never defined). On the forum discussion for this proposal, someone asked for clarification on this point, but this was never directly answered.

However, in WWDC 2022’s Visualize and optimize Swift concurrency, Apple explicitly advises moving the blocking code out of the Swift concurrency system:

Be sure to avoid blocking calls in tasks. … If you have code that needs to do these things, move that code outside of the concurrency thread pool – for example, by running it on a DispatchQueue – and bridge it to the concurrency world using continuations.

So, if you cannot periodic yield, to allow interleaving, move this code outside of the cooperative thread pool, e.g., a dispatch queue. Note that Swift concurrency will not be able to reason about other threads that might be tying up CPU cores, which theoretically can lead to an over-commit of CPU resources (one of the problems that the cooperative thread pool was designed to address), but at least it eliminates the deadlock risks and other concerns that are discussed in that video.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • Thanks Rob, this is helpful! I actually came across another answer of yours after I posted my question and it ended up answering nearly all of my questions. I realize now that I misunderstood what “blocking” meant and consequently misunderstood the contract. I was considering my slow, synchronous (CPU-intensive) operation to be “blocking” and preventing “forward progress” but your answer taught me that it’s not. Furthermore you said to use a detached task or actor for slow, synchronous operations or a continuation for truly blocking APIs — this was exactly the answer I was looking for. Cheers! – NSExceptional Jul 11 '23 at 21:56
  • Didn’t have enough characters to include the link to your other answer, so here it is: https://stackoverflow.com/a/72872967 – NSExceptional Jul 11 '23 at 22:35
  • One more question: in the linked answer you say to use a detached task or actor (which is exactly what I would have expected in order to avoid blocking the main thread). In the answer above, however, there’s no mention of this, just yield. Does this mean that just by yielding periodically I could run my slow, synchronous operation on the main thread without blocking it? I actually gave this a try and while the UI remained responsive, the operation took _significantly_ longer than when run in a detached task (30sec vs 1sec). Seems explicitly running off the main thread is still necessary, yeah? – NSExceptional Jul 11 '23 at 22:48
  • A few observations: 1. I would never do anything remotely computationally intensive on the main actor, even with periodic `yield` calls. 2. In my experience, with computationally intensive calculations in a detached task (even without `yield`), the work on the main actor/thread has always been silky smooth. But per SE-0296, we really should `yield` periodically within the computationally intensive calculations. In practice, I have never found it to be necessary to maintain main thread performance, but, it’s prudent (so that higher priority tasks can slip into the cooperative thread pool). – Rob Jul 11 '23 at 22:59
  • Perfect, that’s exactly what I thought, thanks for confirming! One last question for you. Does an `async` func only run on the main thread if it (or the class it belongs to) is marked as `@MainActor`? Basically I’m wondering if a detached task or an actor is strictly required to run my task off the main thread or if that will happen by default so long as the MainActor context isn’t inherited by my `async` func. – NSExceptional Jul 13 '23 at 05:41
  • An `async` method runs on the actor on which it is isolated. An `async` function that is not actor isolated runs on a generic executor. See [SE-0338](https://github.com/apple/swift-evolution/blob/main/proposals/0338-clarify-execution-non-actor-async.md). So, yes, if it is not isolated to the main actor, it won’t run on the main actor. – Rob Jul 24 '23 at 20:03
  • Sorry @Rob, I hadn’t accepted your answer as it didn’t completely answer the question I was trying to ask (there were some great learnings in there, though), but I realize now it’s my fault for wording my question ambiguously. My question was really just how to run an `async` func on a background thread. I’ve rewritten my question to make that clearer. If you’d like to edit your answer to include the info about when to use detached tasks vs continuations from your [other answer](https://stackoverflow.com/a/72872967) (which answered my question perfectly), I’ll gladly accept. – NSExceptional Jul 27 '23 at 00:40
1

As your second quote says,

As you adopt Swift concurrency, it is important to ensure that you continue to maintain this contract in your code as well so that the cooperative thread pool can function optimally.

The "contract" refers to allowing other threads to make progress, and you do that by "give up control of their thread and give the runtime a chance to schedule other tasks on that thread", as your first quote says.

So that's what you should do.

Break up the task you want to do into non-"long running" smaller pieces. For example, you can make manipulateResult and the function that gets you the result both async.

func doSomethingExpensive() async -> Foo {
    let result = await getResult()
    let manipulatedResult = await manipulateResult(result)
    return manipulatedResult
}

You can make the functions that getResult and manipulateResult uses async too, and await their return values. Illustrative code:

func getResult() async -> Bar {
    let x = await subTask1()
    let y = await subTask2(x)
    return await subTask3(x, y)
}

func manipulateResult(_ result: Bar) async -> Foo {
    let x = await subTask4(result)
    let y = await subTask5(x)
    return y
}

Every time you write await, you have a suspension point where the Task can "give up control of their thread" and allow other tasks on that thread to make progress.

You can also give other tasks a chance to run by awaiting Task.yield(), but that wouldn't work if the current task has the highest priority.

And yes, Task.detached is what you should use if you don't want to inherit the actor context.

Sweeper
  • 213,210
  • 22
  • 193
  • 313
  • For argument’s sake, let’s assume that I can’t break up the long-running task into smaller pieces; let’s assume I have one big, expensive, synchronous operation that I want to ensure doesn’t run on (or more importantly, block) the main thread and needs to be awaitable. The only two options I see are `Task.detached { … }` and GCD + Continuations. – NSExceptional Jul 10 '23 at 06:36
  • 1
    @NSExceptional Then regularly call `await Task.yield()` and give the task a low priority. If you can't even do that, then I'm sorry, but you should not use Swift concurrency. As your second quote states, this is a contract that you are *expected* to follow. – Sweeper Jul 10 '23 at 06:41