Search code examples
swiftasync-await

How to start a background task with the new Swift Structured Concurrency?


I'm writing a test in Swift. The function that I'm testing blocks the current thread so I want to run it on the background. Previously I would wrap it in DispatchQueue.global.async {}.

With Swift's new structured concurrency, I found Task.detached. However, the notes on it say

Creating detached tasks should, generally, be avoided in favor of using async functions, async let declarations and await expressions

Is there another Apple recommended way to start something asynchronously when it doesn't have the async flag?


Solution

  • The documentation is just telling you that in many cases, structured concurrency should be preferred over unstructured concurrency (especially detached tasks). But if you have to use detached task, then do so.


    A few observations:

    1. Favor structured concurrency over unstructured concurrency.

      Where possible, structured concurrency (async functions, async let, and await expressions) should be preferred. Those structured concurrency patterns enjoy automatic cancelation propagation from parent to child tasks.

    2. Avoid detached tasks, if you can.

      Consider this example of a global dispatch queue:

      func legacyFetch(from url: URL, completion: @escaping (Result<Data, Error>) -> Void) {
          DispatchQueue.global().async {
              do {
                  let data = try Data(contentsOf: url)
                  DispatchQueue.main.async {
                      completion(.success(data))
                  }
              } catch {
                  DispatchQueue.main.async {
                      completion(.failure(error))
                  }
              }
          }
      }
      

      That was the legacy approach to avoid blocking the current thread.

      As one shifts to Swift concurrency, one theoretically could use detached task to call this synchronous API on a background thread, to avoid blocking the current thread:

      func swiftConcurrencyLegacyFetch(from url: URL) async throws -> Data {
          return try await Task.detached {
              try Data(contentsOf: url)
          }.value
      }
      

      The use of Swift concurrency greatly simplifies that code and it accomplishes something akin to the old DispatchQueue.global().async {…} pattern.

      As an aside, as a general rule, whenever you have unstructured concurrency, you probably want to add cancelation logic. So that might look like:

      func slightlyBetterLegacyFetch(from url: URL) async throws -> Data {
          let task = Task.detached {
              let data = try Data(contentsOf: url)
              try Task.checkCancellation()
              return data
          }
      
          return try await withTaskCancellationHandler {
              try await task.value
          } onCancel: {
              // Note, `Data(contents:)` doesn't really respond to cancelation, so while this is the
              // correct cancelation pattern for unstructured concurrency, it is of diminished value
              // here because the `Data(contentsOf:)` won't really be canceled the task, but rather
              // will only check for cancelation after the request is done. But at least the caller
              // will be able to tell if the task was canceled or not.
              task.cancel()
          }
      }
      

      This is a modest improvement over the prior snippet (for the reasons outlined above), but it illustrates the common pattern for handling cancelation with unstructured concurrency.

      But all of that having been said, that documentation you quote is suggesting that you avoid detached tasks if there is an async function you could await. In this case, there is an asynchronous API for fetching data from a URL:

      func modernFetch(from url: URL) async throws -> Data {
          try await URLSession.shared.data(from: url).0
      }
      

      This is asynchronous, supports cancelation, avoids blocking any threads, and is much better than the detached task and completion-handler examples.

      In short, one can avoid detached tasks if you can simply await an async function.

    3. Use unstructured concurrency where appropriate.

      Probably needless to say, where you need unstructured concurrency, feel free to do so. Some typical examples include:

      • You are in a synchronous context and you need to invoke async expressions; or
      • You have an algorithm where you need some more refined control over the tasks than structured concurrency offers, you might introduce unstructured concurrency. But writing routines with unstructured concurrency is more complicated than those with structured concurrency, because you have to handle the cancelation of child tasks manually.

      In general, if one is already in an asynchronous context, structured concurrency is often easier.

    4. Where you need unstructured concurrency, decide whether you really need a detached task or not.

      GCD programmers are quite accustomed to using the global dispatch queue pattern. And Task.detached {…} is its closest analog.

      But if you are not doing anything slow and synchronous, often Task {…} is fine.

      For example, consider:

      override func viewDidLoad() {
          super.viewDidLoad()
      
          Task.detached {
              await foo()
              await bar()
          }
      }
      

      Because there is nothing slow and synchronous here, Task {…} may be preferable:

      override func viewDidLoad() {
          super.viewDidLoad()
      
          Task {
              await foo()
              await bar()
          }
      }
      

      The functions foo and bar are asynchronous and their respective asynchronous contexts are defined elsewhere. The use of Task.detached {…} vs Task {…} here has no bearing on that.

    5. What if the work is slow and synchronous?

      Yes, in that case, one might would move that off the main actor, with a detached task (or an actor, a nonisolated async method, etc.). Apple is not saying that you cannot use detached tasks, only that you should favor structured concurrency when possible.

      But one should be cautious about performing slow, synchronous work within Swift concurrency at all: As WWDC 2021 video Swift Concurrency: Behind the scenes) warns us, “The [cooperative] thread pool will only spawn as many threads as there are CPU cores, thereby making sure not to overcommit the system.”

      Because of this, as that video says, “the operating system needs a runtime contract that threads will not block”. Your slow, synchronous tasks will violate this runtime contract (unless you periodically yield). In WWDC 2022 video Visualize and optimize Swift concurrency, they suggest a solution, namely, GCD:

      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 dispatch queue – and bridge it to the concurrency world using continuations. Whenever possible, use async APIs for blocking operations to keep the system operating smoothly.

      Personally, if it is reasonably short and limited, I will just stay within the standard Swift concurrency system and getting it off the current actor (either a separate actor, a nonisolated async function, a detached task), and call it a day. Or if this a computational task with a loop, I will yield periodically to remain well behaved within Swift concurrency. But, like that document says, if it cannot yield, and if will tie up the thread for a prolonged time, then keep that work out of Swift concurrency, but bridge back with a continuation. For example:

      func intensive() async throws -> Result {
          try await withCheckedThrowingContinuation { continuation in
              DispatchQueue.global().async {
                  do {
                      let result = try intensiveSynchronousWork()
                      continuation.resume(returning: result)
                  } catch {
                      continuation.resume(throwing: error)
                  }
              }
          }
      }
      

      So, even in this case, you might end up not using a detached task at all. We try to keep the blocking work out of Swift concurrency, and bridge it back with withCheckedThrowingContinuation or withCheckedContinuation, as appropriate.

    In short, use detached tasks only where (a) you need unstructured concurrency; (b) you are calling something synchronous, but isn’t too slow; and (c) you want to avoid blocking the current actor. In practice, it is far less common pattern than global dispatch queues in GCD codebases.