skip to Main Content

Let’s say I have the following function which performs a long-running, expensive, synchronous operation:

func doSomethingExpensive() -> Foo {
    let result = // ... some long-running, expensive, synchronous operation ...
    let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
    return manipulatedResult
}

Knowing that this is a long-running, expensive operation, I would like to do it asynchronously on a background thread to avoid blocking the UI. Prior to async/await, I would have achieved this as follows:

func doSomethingExpensive(completion: ((Foo) -> Void)) {
    DispatchQueue.global().async {
        let result = // ... some long-running, expensive, synchronous operation ...
        let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
        DispatchQueue.main.async {
            completion(manipulatedResult)
        }
    }
}

With async/await, I could write my function as follows to make it asynchronous, but this doesn’t give me any control over which thread it ends up being executed on — that’ll be determined by the caller.

func doSomethingExpensive() async -> Foo {
    let result = // ... some long-running, expensive, synchronous operation ...
    let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
    return manipulatedResult
}

So if the caller is MainActor-isolated, my async func will get executed on the main thread and block the main thread until its long-running, expensive operation completes:

func reloadUI() {
    Task { @MainActor in
        showSpinner = true
        foo = await doSomethingExpensive()
        showSpinner = false
    }
}

So that’s obviously no good. I’ve done a lot of research and it seems to all boil down to two solutions:

  1. Task.detached

    func doSomethingExpensive() async -> Foo {
        await Task.detached {
            let result = // ... some long-running, expensive, synchronous operation ...
            let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
            return manipulatedResult
        }.value
    }
    
  2. GCD + Continuations

    func doSomethingExpensive() async -> Foo {
        await withCheckedContinuation { continuation in
            DispatchQueue.global().async {
                let result = // ... some long-running, expensive, synchronous operation ...
                let manipulatedResult = manipulateResult(result) // another long-running, expensive, synchronous operation
                continuation.resume(returning: manipulatedResult)
            }
        }
    }
    

And this is where my confusion comes in as I’ve seen conflicting information. For example, this comment states:

[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.

In addition, there’s this statement excerpted from the WWDC21 video, “Swift concurrency: Behind the scenes”:

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.

If I’m understanding this correctly, this would mean “Solution 1: Task.detached” is no good as we shouldn’t be executing any long-running, expensive, blocking operations within an async function (including Task.detached { ... }) as we’d be blocking forward progress and breaking that runtime contract.

So if we’re not supposed to do that, I would have thought that GCD would have been the correct alternative, but I’ve also seen comments stating that GCD should be avoided:

One should avoid using DispatchQueue at all.

https://stackoverflow.com/a/70645342/8408162

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.

https://stackoverflow.com/a/74580345/8408162

Lastly, this comment:

While it is interesting to look at Thread.current, it should be noted that Apple is trying to wean us off of this practice. E.g., in Swift 5.7, if we look at Thread.current from an asynchronous context, we get a warning:

Class property ‘current’ is unavailable from asynchronous contexts; Thread.current cannot be used from async contexts.; this is an error in Swift 6

The whole idea of Swift concurrency is that we stop thinking in terms of threads and we instead let Swift concurrency choose the appropriate thread on our behalf (which cleverly avoids costly context switches where it can; sometimes resulting code that runs on threads other than what we might otherwise expect).

If this is true — and I hope it is because I love the idea of not having to think about threads — I would imagine that I should be able to retire GCD completely and rely entirely on Swift concurrency, but based on the earlier comments above I’m not convinced that that’s the case.

All of that being said, I’m not sure what to believe nor which solution is correct. Hoping someone can give me a definitive answer (and explanation) one way or the other. Thanks!

2

Answers


  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.

    Login or Signup to reply.
  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.

    One might infer that they intend (if you cannot yield to allow interleaving) to put this on a thread outside of the cooperative thread pool, e.g., via a GCD queue. But, Swift concurrency cannot 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).

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search