iOS Concurrency with GCD & Operations

Sep 12 2023 · Swift 5.8, macOS 13, iOS 16, Xcode 14.3

Part 3: Operations & OperationQueues

18. Asynchronous Operations

Episode complete

Play next episode

Next
About this episode

Leave a rating/review

See forum comments
Cinema mode Mark complete Download course materials
Previous episode: 17. Challenge: TiltShiftOperations in OperationQueue Next episode: 19. Challenge: Download Images in OperationQueue

Get immediate access to this and 4,000+ other videos and books.

Take your career further with a Kodeco Personal Plan. With unlimited access to over 40+ books and 4,000+ professional videos in a single subscription, it's simply the best investment you can make in your development career.

Learn more Already a subscriber? Sign in.

Transcript: 18. Asynchronous Operations

Often, you’ll want to execute a chain of asynchronous tasks, like download an image, then resize it, then apply a filter to the resized image. If you just call the next task in the completion handler of the preceding task, you’ll get something like this pyramid of doom, with several levels of nesting.

In Part 1, you saw an example of a simple chain of asynchronous tasks, implemented with the notify method of DispatchWorkItem. It was a little convoluted, and not easy to scale up to longer chains. You’ll soon see how operation queues and dependencies provide a more straightforward way to manage this. Both approaches need a way to know when an asynchronous function really finishes.

In Part 1 of this course, you learned how to wrap an asynchronous function so you could use it in a dispatch group, because a dispatch group needs to know when a task really finishes. Now, it’s time to learn how to create an asynchronous operation that you can use in an operation queue. The reason is the same as for dispatch groups: An operation queue needs to know when tasks really finish.

Operations have a more complex life-cycle than dispatch groups. This gives you greater control, but you must do more work to wrap an asynchronous function in an operation: You must manually manage the operation’s state to make it work like this: When an operation starts, its state changes from isReady to isExecuting. If it’s an asynchronous task, like downloading an image, it sends a call to the network then returns immediately. It looks like it’s finished: It’s no longer doing any work on the current thread. But, the asynchronous task is running on a background thread. You need a way to manually set the operation’s state to isExecuting until it really finishes and runs its completion handler. Then, you’ll set its state to isFinished when it really finishes.

The Operation state properties are read-only: You can’t set them directly, so how do you “manage their values” for an asynchronous operation? You must “do something” to cause the Operation state property to return the correct value. The Operation class relies on KVO — Key Value Observation — to send notifications for state, so here’s what you’ll do: Create a state property that the asynchronous operation can set to ready, executing or finished.

The values of your asynchronous operation state don’t have the “is” prefix. You use this computed property to construct the matching keyPath isReady and so on. And you connect your asynchronous operation state to the parent operation state with these property observers. You’ll dive into the details of this in the next exercise.

In this exercise, you’ll create a subclass of Operation called AsyncOperation to handle all the state changes. Then you’ll subclass AsyncOperation to wrap an asynchronous function, so it works in an operation queue.

The Operation state properties are read-only: You can’t set them directly. Instead, you’ll create a state property for your AsyncOperation, then use this to manage the base class Operation state properties.

The starter playground has an enumeration to represent the AsyncOperation state:

extension AsyncOperation {
  enum State: String {
    case ready, executing, finished
  }
}

This is of type String with 3 cases: ready, executing and finished, the same as the base class Operation‘s state properties, but without the “is” prefix. You’ll set up the “is” versions later in this video.

Your AsyncOperation needs a variable property to manage its state:

var state = State.ready

An AsyncOperation‘s default initial state is ready. This is the simplest version of state — you’ll soon replace it with a thread-safe version — for now, this is enough to let you override the base class Operation’s state properties:

override var isReady: Bool {
  super.isReady && state == .ready
}

override var isExecuting: Bool {
  state == .executing
}

override var isFinished: Bool {
  state == .finished
}

override var isAsynchronous: Bool {
  true
}

Apple’s documentation actually says not to override the Operation state property isReady. The Operation itself decides when it’s ready, by examining dependencies. If the readiness of your operations is determined by factors other than dependent operations, however — such as by some external condition in your program — you can provide your own implementation of the ready property and track your operation’s readiness yourself.

Generally, you only need to set the Operation state properties isExecuting and isFinished to use your new AsyncOperation state property:

override var isExecuting: Bool {
  state == .executing
}

override var isFinished: Bool {
  state == .finished
}

isExecuting and isFinished just return “is my state property equal to executing or finished?”. Notice that setting state to .finished also causes isExecuting to return false.

override var isAsynchronous: Bool {
  true
}

You set isAsynchronous to true in case you run the operation manually, outside an operation queue. Then this ensures the operation runs off the main thread.

And, you can use your AsyncOperation state values to override the Operation methods start and cancel:

// TODO: Override start method
override func start() {

}

// TODO: Override cancel method
override func cancel() {

}

The start method checks if the operation has been cancelled, and sets our AsyncOperation state to finished or executing:

override func start() {
  if isCancelled {
    state = .finished
    return
  }
  main()
  state = .executing
}

If the operation is cancelled, change state to finished and return. If the operation isn’t cancelled, call the main function. Remember this is an asynchronous function that returns immediately, so you need to manually set its state to executing. Very soon you’ll see that, when the asynchronous function finishes, its completion handler must set state to finished.

For the cancel operation:

override func cancel() {
  super.cancel()
  state = .finished
}

You call the superclass’s method, then set your own state to finished.

Now for the exciting part: You need to connect your AsyncOperation state to the parent Operation state. Operation uses KVO - key value observation - to keep track of its state, so your AsyncOperation must send KVO notifications whenever its state value changes. And, it must do this in a thread-safe way, with a dispatch barrier.

It’s time to create thread-safe state management, up here at the top of AsyncOperation. First, create a private concurrent queue:

private let stateQueue = DispatchQueue(label: "AsyncOperationState",
                                       attributes: .concurrent)

And create a private State property that you’ll only access synchronously in this queue:

private var stateValue: State = .ready

Now, replace the temporary state property with a thread-safe one. Reading its value is pretty easy:

var state: State {
  get {
    stateQueue.sync { return stateValue }
  }
  set {

  }
}

stateQueue is concurrent, so more than one get can access stateValue at the same time.

To set the value, you need to use the same keyPath values as the base class Operation properties so, in the enumeration, uncomment the keypath computed property:

fileprivate var keyPath: String {
  "is\(rawValue.capitalized)"
}

This capitalizes the first letter of each case name and prefixes it with “is”, to get isExecuting and so on. Once you’ve completed writing AsyncOperation, you’ll most likely reuse it by putting it in its own file. keyPath is fileprivate, not private, so it’s visible to code in the file.

Now, you can write the set code:

set {
  let oldValue = state
  willChangeValue(forKey: newValue.keyPath)
  willChangeValue(forKey: state.keyPath)
  stateQueue.sync(flags: .barrier) {
    stateValue = newValue
  }
  didChangeValue(forKey: oldValue.keyPath)
  didChangeValue(forKey: state.keyPath)
}

You need to notify the base class Operation about both old and new states. If the current state is isExecuting, and it’s changing to isFinished, you must send notifications about both Operation state properties. The dispatch barrier holds up write access to stateValue until the current read tasks have finished.

So that’s your thread-safe AsyncOperation state property done, and you now have a kind of abstract AsyncOperation class that you can subclass to create specific asynchronous operations. Scroll down to AsyncSumOperation, which implements an asynchronous version of the slowAdd function by dispatching it to the default queue and sleeping a short time. To add the final touch, set state to .finished in the dispatched task:

class AsyncSumOperation: AsyncOperation {
  let lhs: Int
  let rhs: Int
  var result: Int?
  
  init(lhs: Int, rhs: Int) {
    self.lhs = lhs
    self.rhs = rhs
    super.init()
  }
  
  override func main() {
    DispatchQueue.global().async {
      sleep(2)
      self.result = self.lhs + self.rhs
      self.state = .finished   // Add only this line
    }
  }
}

This tells the operation queue this operation is now finished and no longer needs any time to operate. What would happen if you forgot to set state to .finished? Think about this question while you try out AsyncSumOperation.

Here’s an operation queue and an array of number pairs.

let queue = OperationQueue()
let pairs = [(2, 3), (5, 3), (1, 7), (12, 34), (99, 99)]

Fill in this loop over the pairs array, creating an instance of AsyncSumOperation for each:

pairs.forEach { pair in
  let op = AsyncSumOperation(lhs: pair.0, rhs: pair.1)
  op.completionBlock = {
    guard let result = op.result else { return }
    print("\(pair.0) + \(pair.1) = \(result)")
  }
}

The completion block checks there’s a result, then prints it. Now, add each operation to the operation queue in the usual way:

queue.addOperation(op)

And, wait for the queue, to keep the playground running until all the operations finish:

queue.waitUntilAllOperationsAreFinished()

It’s OK to do this in a playground, but never do it on the main thread in an app. Run the playground and open the debug console:

99 + 99 = 198
5 + 3 = 8
1 + 7 = 8
2 + 3 = 5
12 + 34 = 46

The sums appear, although not in the order you added them to the operation queue. Now scroll up and comment out the state = .finished line. Then run the playground again. What do you think will happen?

No print statements! The print statement is in the completion block, which never runs, because the operation never transitions to finished. Go back and uncomment that important line.

How about that!? These are asynchronous tasks that you’ve modelled as Operations. You added them to an operation queue, which has managed their execution, using the state property. And this AsyncOperation class that you created up here, you can reuse that as often as you like. It will be the foundation for all your asynchronous operations. In fact, you’ll use it in the next video, to download a group of images.