Integrate a blocking function into Swift async

Viewed 109

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?

2 Answers

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

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 would stay within the Swift concurrency system and perform this synchronous task within a detached task or an actor.

  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.


To be clear, if we are talking about some computationally intensive task, then you definitely do not want to use the continuation pattern. You want to remain within Swift concurrency’s cooperative thread pool.

The idea of the cooperative thread pool is that it constrains the number of threads to the number of CPU cores (except on the iOS simulator, which is artificially constrained even further). If you start spinning up other threads outside of the Swift concurrency system, it will no longer be able to reason correctly about the correct number of active threads.

When they talk about the Swift contract in which threads must be able to make “forward progress”, they are warning against ever sleeping a thread (or otherwise wait on a thread) because, again, the cooperative thread pool would be unable to reason about how many threads are actively running and/or whether a CPU core can switch to some other task.

For example, consider WWDC 2021 video, Swift concurrency: Behind the scenes, which discusses Preserving the runtime contract. They only single out primitives that require caution (e.g., locks) and unsafe primitives (e.g., semaphores). At no point do they say that synchronous tasks must be avoided. (Obviously, though, you would never run a slow, synchronous task on the main actor.)

So, if the question is merely how to integrate slow, synchronous code with your asynchronous code, I would advise against spinning up threads outside Swift concurrency. In this case, if the synchronous task is reasonably fast and off the main actor, I would simply intersperse it within the asynchronous routine. If it is slow enough to justify running it on a separate thread, I would just wrap it within a detached task or actor, and await that.

If, however, you are dealing with slow, synchronous API that effectively incorporates/requires one of those “unsafe primitives”, then, yes, you would first see if you could just replace that with an asynchronous rendition and wrap that within a continuation as Itai described. But that would be a worst-case scenario.

Related