Search code examples
swiftasynchronousasync-awaitswift-concurrency

Async function for blocking (CPU-bound) task?


I have a slow, blocking function in Swift that I want to call in a non-blocking (async/await) way.

Here is the original blocking code:

// Original
func totalSizeBytesBlocking() -> UInt64 {
    var total: UInt64 = 0
    for resource in slowBlockingFunctionToGetResources() {
        total += resource.fileSize
    }
    return total
}

And here is my attempt at making it non-blocking:

// Attempt at async/await
func totalSizeBytesAsync() async -> UInt64 {
    // I believe I need Task.detached instead of just Task.init to avoid blocking the main thread?
    let handle = Task.detached {
        return self.slowBlockingFunctionToGetResources()
    }
    // is this the right way to handle cancellation propagation with a detached Task?
    // or should I be using Task.withTaskCancellationHandler?
    if Task.isCancelled {
        handle.cancel()
    }
    let resources = await handle.value
    var total: UInt64 = 0
    for resource in resources {
        total += resource.fileSize
    }
    return total
}

My goal is to be able to await the async version from the main actor/thread and have it do the slow, blocking work on a background thread while I continue to update the UI on the main thread.

How should this be done?


Solution

  • There are a few questions here.

    1. How to handle cancelation?

      You are correct in your suspicion that this Task.isCancelled pattern is insufficient. The problem is that you are testing immediately after the task is created, but it will quickly pass that cancelation check, then suspend at the await of the task, at which point no further cancelations will be detected. As you guessed, when dealing with unstructured concurrency, you might use withTaskCancellationHandler, instead:

      func totalSizeBytesAsync() async throws -> UInt64 {
          let task = Task.detached {
              try self.slowBlockingFunctionToGetResources()
          }
          let resources = try await withTaskCancellationHandler {
              try await task.value
          } onCancel: {
              task.cancel()
          }
      
          return resources.reduce(0) { $0 + $1.fileSize }
      }
      
      func slowBlockingFunctionToGetResources() throws -> [Resource] {
          var resources: [Resource] = []
          while !isDone {
              try Task.checkCancellation()
              resources.append(…)
          }
          return resources
      }
      

      Probably needless to say, as shown above, cancelation will only work correctly if that slow blocking function supports it (e.g., periodically tries checkCancellation or, less ideal, tests isCancelled).

      If this slow synchronous function doesn’t handle cancelation, then there is little point in checking for cancelation, and you are stuck with a task that will not finish until the synchronous task is done. But at least totalSizeBytesAsync will not block. E.g.:

      func totalSizeBytesAsync() async -> UInt64 {
          let resources = await Task.detached {
              self.slowBlockingFunctionToGetResources()
          }.value
      
          return resources.reduce(0) { $0 + $1.fileSize }
      }
      
      func slowBlockingFunctionToGetResources() -> [Resource] {…}
      
    2. Should you use unstructured concurrency at all?

      As a general rule, we should avoid cluttering our code with unstructured concurrency (where we bear the burden for manually checking for cancelation). So, it begs the question of how you get the task off the current actor, while remaining within structured concurrency. You can put the synchronous function in its own, separate actor. Or, because of SE-0338, you can alternatively just make your slow function both nonisolated and async. That gets it off the current actor:

      func totalSizeBytesAsync() async throws -> UInt64 {
          try await slowBlockingFunctionToGetResources()
              .reduce(0) { $0 + $1.fileSize }
      }
      
      nonisolated func slowBlockingFunctionToGetResources() async throws -> [Resource] {
          var resources: [Resource] = []
          while !isDone {
              try Task.checkCancellation()            
              resources.append(…)
          }
          return resources
      }
      

      But by remaining within structured concurrency, our code is greatly simplified.

      Obviously, if you want to use an actor, feel free:

      let resourceManager = ResourceManager()
      
      func totalSizeBytesAsync() async throws -> UInt64 {
          try await resourceManager.slowBlockingFunctionToGetResources()
              .reduce(0) { $0 + $1.fileSize }
      }
      

      Where:

      actor ResourceManager {
          …
      
          func slowBlockingFunctionToGetResources() throws -> [Resource] {
              var resources: [Resource] = []
              while !isDone {
                  try Task.checkCancellation()
                  resources.append(…)
              }
              return resources
          }
      }
      
    3. How slow is the synchronous function?

      Swift concurrency relies upon a “contract” to avoid blocking any thread in the cooperative thread pool. See https://stackoverflow.com/a/74580345/1271826.

      So if this really is a slow function, we really should have our slow process periodically Task.yield() to the Swift concurrency system to avoid potential deadlocks. E.g.,

      func totalSizeBytesAsync() async throws -> UInt64 {
          try await slowNonBlockingFunctionToGetResources()
              .reduce(0) { $0 + $1.fileSize }
      }
      
      nonisolated func slowNonBlockingFunctionToGetResources() async throws -> [Resource] {
          var resources: [Resource] = []
          while !isDone {
              await Task.yield()
              try Task.checkCancellation()
              resources.append(…)
          }
          return resources
      }
      

      Now, if (a) you do not have to opportunity to refactor this function to periodically yield; and (b) it really is very, very slow, then Apple advises that you get this function out of the Swift concurrency system. E.g., in WWDC 2022 video Visualize and optimize Swift concurrency, they suggest GCD:

      If you have code that needs to do these things [slow, synchronous functions that cannot periodically yield to the Swift concurrency system], 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.

      Bottom line, be wary of ever blocking a cooperative thread pool thread for any prolonged period of time.