4

I understand that an asynchronous Swift Task is not supposed to block (async worker threads must always make forward progress). If I have an otherwise 100% async Swift application but need to introduce some blocking tasks, what is the correct way to do this that will not block any of the swift async thread pool workers?

I'm assuming a new dedicated thread outside of the async thread pool is required, if that assumption is correct what is the thread safe way for an async function to await for that thread to complete? Can I use the body of withCheckedContinuation to launch a thread, copy the continuation handle into that thread and call continuation.resume from that thread when it completes?

Rob
  • 415,655
  • 72
  • 787
  • 1,044
zaphoyd
  • 2,642
  • 1
  • 16
  • 22
  • Since `await` already pauses the code and waits for something else to complete (the whole point), why do you need some other kind of "blocking task" and what would it even look like (or mean)? – matt Jul 04 '22 at 23:10
  • I want to call code in an existing blocking third party libary from my otherwise async program. – zaphoyd Jul 04 '22 at 23:11
  • How does it work architecturally? Does it have a callback? Can't you give _any_ details? Why the vagueness? – matt Jul 04 '22 at 23:12
  • But sure, wrap the call in a with checked continuation block. Why not? – matt Jul 04 '22 at 23:13
  • its just a sycronous function that takes a very long time to run. Think of it like sleep or a computation loop that takes a long time. – zaphoyd Jul 04 '22 at 23:14
  • My question specifically with the checked continuation block is whether it is safe to copy the continuation handle into an outside thread and/or is it safe to call the resume function from a thread outside of the async thread pool. – zaphoyd Jul 04 '22 at 23:15
  • 2
    A computation loop that takes a long time does not block. It just takes a long time. How is this special at all? This is what async await is for! Just run your time consuming code in an actor. You are way over thinking this. – matt Jul 04 '22 at 23:15
  • Let us [continue this discussion in chat](https://chat.stackoverflow.com/rooms/246158/discussion-between-zaphoyd-and-matt). – zaphoyd Jul 04 '22 at 23:18

3 Answers3

6

I understand that an asyncronous Swift Task is not supposed to block (async worker threads must always make forward progress).

This is correct. The cornerstone of the Swift concurrency system is that tasks must always be making forward progress.

Can I use the body of withCheckedContinuation to launch a thread, copy the continuation handle into that thread and call continuation.resume from that thread when it completes?

Yes, this is also correct, and is exactly the purpose of continuations:

CheckedContinuation
A mechanism to interface between synchronous and asynchronous code, logging correctness violations.

The purpose of a continuation is to allow you to fit blocking synchronous operations into the async world. When you call withCheckedContinuation, it

[s]uspends the current task, then calls the given closure with a checked continuation for the current task.

The task is suspended indefinitely until you resume it, which allows other tasks to make progress in the meantime. The continuation value you get is a thread-safe interface to indicate that your blocking operation is done, and that the original task should resume at the next available opportunity. The continuation is also Sendable, which indicates that you can safely pass it between threads. Any thread is allowed to resume the task, so you don't even necessarily need to call back to the continuation on the same thread.

An example usage from SE-0300: Continuations for interfacing async tasks with synchronous code:

func operation() async -> OperationResult {
  // Suspend the current task, and pass its continuation into a closure
  // that executes immediately
  return await withUnsafeContinuation { continuation in
    // Invoke the synchronous callback-based API...
    beginOperation(completion: { result in
      // ...and resume the continuation when the callback is invoked
      continuation.resume(returning: result)
    }) 
  }
}

Note that this is only necessary for tasks which are truly blocking, and cannot make further progress until something they depend on is done. This is different from tasks which perform active computation which just happen to take a long time, as those are tasks which are at least making active progress. (But in chat, you clarify that your use-case is the former.)

Itai Ferber
  • 28,308
  • 5
  • 77
  • 83
  • Thanks! The notes confirming the appropriateness of the approach and the thread safety of the continuation objects was what I needed. My use case is a complicated mess of many long running computations (enough to starve shorter async stuff) and truly blocking (condition variables, socket reads). Both types were causing deadlock and starvation problems and needed to get off the async Task threads. I was trying to keep the question less focused on my specific details and more on how the generic swift concurrency primitives worked. – zaphoyd Jul 05 '22 at 00:35
  • @zaphoyd Happy to have helped! – Itai Ferber Jul 05 '22 at 00:40
2

You said:

I'm assuming a new dedicated thread outside of the async thread pool is required …

I would not jump to that conclusion. It depends entirely upon the nature of this “blocking task”.

  1. If it simply is some slow, synchronous task (e.g. a CPU-intensive task), then you can stay within the Swift concurrency system and perform this synchronous task within a detached task or an actor. If you do this, though, you must periodically Task.yield() within this task in order to ensure that you fulfill the Swift concurrency contract to “ensure forward progress”.

  2. If it is some blocking API that will wait/sleep on that thread, then, as Itai suggested, we would wrap it in a continuation, ideally, replacing that blocking API with a non-blocking one. If it is not practical to replace the blocking API with an asynchronous rendition, then, yes, you could spin up your own thread for that, effectively making it an asynchronous task, and then wrapping that within a continuation.


But, in the context of long-running, blocking, computations, if one cannot periodically yield, SE-0296 - Async/await says that one should run it in a “separate context” (emphasis added):

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.

The term “separate context” is never explicitly defined, but we can presume they mean resorting to a dispatch queue or other threading pattern. Just be aware that Swift concurrency cannot reason about other threads outside of its scope and will continue to fully avail itself of the cooperative thread pool leading to a potential over-commit of the CPU (one of the problems the cooperative thread pool was designed to solve). This is suboptimal, so some care is needed, but it may be a necessary evil if you are using Swift concurrency in conjunction with blocking or computationally intensive library designed without the cooperative thread pool in mind.

Rob
  • 415,655
  • 72
  • 787
  • 1,044
  • 1
    Thanks for the additional context here. While my primary question in this case involves how to manage truly blocking work, I also have long running non-blocking work to manage and appreciate the clear distinction that all the answers so far have made between the two. As you suggest in chat, I'll open another question with some of my lingering questions about long running (non-blocking) tasks on Swift async and perhaps a second about my specific blocking case and whether there is an alternative to blocking. – zaphoyd Jul 06 '22 at 12:44
1

I'm using the function below to do a blocking call on the global background threadpool and integrate it with async:

import Foundation

func dispatchBackground<T>(_ qos: DispatchQoS.QoSClass, blockingCall: @escaping () -> T ) async -> T {

    func dispatch(completion: @escaping (T) -> Void) {
        DispatchQueue.global(qos: qos).async {
            let result: T = blockingCall()
            completion(result)
        }
    }

    // Suspend the current task until dispatch calls the completion callback
    return await withCheckedContinuation { continuation in
        dispatch { result in
            continuation.resume(returning: result)
        }
    }
}

Example usage:

func sleep(forSeconds: Int) async -> String {
    return await dispatchBackground(.userInitiated) {
        Thread.sleep(forTimeInterval: TimeInterval(forSeconds))
        return "Slept for \(forSeconds) seconds in async"
    }
}
Agost Biro
  • 2,709
  • 1
  • 20
  • 33