Jack Morris
Managing Async Work with Result Builders
May 2, 2021
Swift

With the recent release of Swift 5.4 as part of Xcode 12.5, result builders are now ready for mainstream use. Whilst most examples and usage I've seen of these has been around building and combining model-like value types (as the name suggests!), I thought I'd experiment with using them to manage and combine a number of asynchronous Tasks.

Note: these aren't the same Tasks that are currently making their way through Swift Evolution as part of the ongoing concurrency work, but the name felt too convenient not to reuse.

Skipping straight to the result, here's how the usage looks. Each Task is executed in parallel, and the completion handler passed to task.start is executed once all of the sub-Tasks have completed execution.

let task = Task {
  // A simple `Task` that's always executed.
  Task { callback in
    DispatchQueue.global().asyncAfter(deadline: .now() + .seconds(2)) {
      print("Some long running work")
      callback(.success(()))
    }
  }

  // if/else statements are supported to conditionally execute `Task`s.
  if needsMoreWork {
    Task { callback in
      print("Only executes if `needsMoreWork` is `true`")
      callback(.success(()))
    }
  }

  // switch statements can be used to execute different `Task`s as needed.
  switch Date() {
  case ..<cutOffDate:
    Task { callback in
      print("Current `Date` is prior to `cutOffDate`")
      callback(.success(()))
    }
  default:
    Task { callback in
      print("Later than `cutOffDate`.")
      callback(.success(()))
    }
  }
}

task.start { result in
  print("Done: \(result)")
}

The implementation here is relatively simple: Task itself is a wrapper around some work to perform (taking a closure to call on completion of said work), and we use a DispatchGroup to combine the execution of a bunch of Tasks into one. If any of the encapsulated work returns an Error, the collection of Errors will be supplied to the top-level completion handler. You can check it out below.

/// Encapsulates a unit of asynchronous work: an `Action` that should execute a supplied `Callback`
/// on completion.
struct Task {
  typealias Callback = (Result<Void, Error>) -> Void
  typealias Action = (@escaping Callback) -> Void

  /// The `Action` encapsulated by this `Task`.
  private let action: Action

  init(action: @escaping Action) {
    self.action = action
  }

  /// Executes this `Task` on the current queue.
  func start(callback: @escaping Callback) {
    action(callback)
  }
}

/// A `resultBuilder` for building a `Task` from a number of sub-`Task`s.
@resultBuilder
struct TaskBuilder {

  /// Container for a number of `Error`s.
  struct CombinedError: Error {
    var errors: [Error]
  }

  // Most @resultBuilder requirements are simple - just accumulate the `Task`s into an array.

  static func buildExpression(_ task: Task) -> [Task] { return [task] }
  static func buildBlock(_ tasks: [Task]...) -> [Task] { return tasks.flatMap { $0 } }
  static func buildOptional(_ tasks: [Task]?) -> [Task] { return tasks ?? [] }
  static func buildEither(first tasks: [Task]) -> [Task] { return tasks }
  static func buildEither(second tasks: [Task]) -> [Task] { return tasks }
  static func buildArray(_ tasks: [[Task]]) -> [Task] { return tasks.flatMap { $0 } }

  // When building the final result (a single `Task`, accumulate all of the sub-`Task`s into a 
  // single `Task`.

  static func buildFinalResult(_ tasks: [Task]) -> Task {
    return Task { callback in
      // Use `group` to track the execution of each task in `tasks`.
      let group = DispatchGroup()
      var errors: [Error] = []
      for task in tasks {
        group.enter()
        task.start { result in
          // Move back to main to synchronize access to `errors`. Alternatively, could guard 
          // access to the `errors` array with a mutex.
          DispatchQueue.main.async {
            if case .failure(let error) = result {
              errors.append(error)
            }
            group.leave()
          }
        }
      }

      // When all tasks have been completed, execute `callback` with the aggregated result.
      group.notify(queue: .main) {
        if errors.isEmpty {
          callback(.success(()))
        } else {
          callback(.failure(CombinedError(errors: errors)))
        }
      }
    }
  }

}

/// Extension on `Task` that permits initialization using a `TaskBuilder`.
extension Task {
  init(@TaskBuilder builder: () -> Task) {
    self = builder()
  }
}

~

Thanks for reading! I'd love to hear your feedback; feel free to contact me directly.