iOS Concurrency with GCD & Operations

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

Part 3: Operations & OperationQueues

15. 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: 14. Challenge: Make Number Class Thread-Safe Next episode: 16. OperationQueues

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: 15. Operations

Operations are built on top of GCD, adding features like dependencies on other operations, the ability to cancel the running operation, and an object-oriented model to support more complex requirements.

The simplest operation looks the same as a closure you’d run on a dispatch queue. But you can only use this form with the OperationQueue addOperation method.

A BlockOperation manages the concurrent execution of one or more closures on the default global queue. It acts like a dispatch group and is useful in apps that already use operation queues and don’t want to create dispatch queues.

You’ll usually want a reusable operation, so you’ll subclass Operation. An Operation is an actual Swift object, so you can define inputs and outputs, implement helper methods, etc. You wrap up a task to execute sometime in the future, as many times as necessary, with different inputs. To define what your operation does, you override the main method.

An Operation has a state machine that represents its lifecycle. When you instantiate an Operation, it transitions to the state “isReady”. When something invokes its start method, the operation moves to the state “isExecuting”. Once an operation isExecuting, it can finish in one of two ways: If your app calls its cancel method, then it transitions to the state isCancelled, before moving on to the state isFinished. If your app doesn’t cancel the operation, it eventually transitions directly to the state isFinished.

The Operation API shows this lifecycle. The Boolean state properties isReady, isExecuting, isCancelled and isFinished are KVO-compliant, so you can register to receive notifications for state transitions.

You can run an operation by calling its start method, but normally, you’ll add operations to an OperationQueue, which by default runs on a dispatch queue. By default, an operation runs synchronously so, in an app, don’t call start on the main queue. You can also cancel an operation, provide a completionBlock to run when the operation completes, and specify a quality of service.

BlockOperation

In the starter playground, here’s a variable to hold the result of adding two numbers:

var result: Int?

Creating a BlockOperation is like adding a task to a dispatch queue, because that’s what you’re doing:

let sumOperation = BlockOperation(   // pause to see initializers

BlockOperation has two possible initializers. The first, default one takes no arguments. You would use it to create an empty BlockOperation, then add blocks to it later. This other initializer takes a block, or closure, that you want to run, so use this one and make it a trailing closure:

let sumOperation = BlockOperation {

}

And define the operation inside the closure:

result = 2 + 3

Now, run this operation by calling its start method:

sumOperation.start()

Doing this is OK in a playground but, in an app, don’t call start on the main queue, because operations run synchronously. On the next line, just write result to see its value in the side pane:

result

Now, run these lines of code:

<NSBlockOperation 0x7fc623406b30 isFinished=NO isReady=YES isCancelled=NO isExecuting=NO>
5

In the sidebar, it creates a BlockOperation and shows the result is 5.

To see that the current thread waits while the operation is running, add some sleep time to the operation:

sleep(2)  // below result = 2 + 3

And wrap the operation start in the duration utility:

duration {
  sumOperation.start()  // existing
}

Run these lines again:

<NSBlockOperation 0x7fc623406b30 isFinished=NO isReady=YES isCancelled=NO isExecuting=NO>
5
0
2.005437970161438
()
5

You had to wait for the sleep time. And the duration is a bit more than 2 seconds because the operation ran synchronously. Now, here are 3 interesting things about BlockOperation:

  • First: A BlockOperation can have multiple blocks. You can start with an empty BlockOperation, then addExecutionBlocks:
let multiPrinter = BlockOperation()
multiPrinter.addExecutionBlock {  print("Hello"); sleep(2) }
multiPrinter.addExecutionBlock {  print("my"); sleep(2) }
multiPrinter.addExecutionBlock {  print("name"); sleep(2) }
multiPrinter.addExecutionBlock {  print("is"); sleep(2) }
multiPrinter.addExecutionBlock {  print("Audrey"); sleep(2) }

The strings combine to say “hello my name is Audrey”

  • Second: Although each closure takes 2 seconds or so, the BlockOperation does not take 10 seconds.

To see this, I wrapped its start call in duration:

duration {
  multiPrinter.start()
}

Run the playground up to this line:

2.00261402130127

It only took 2 seconds! And, down in the debug console, the strings printed in a different order.

is
name
Hello
my
Audrey

All the signs that concurrency happened! All 5 blocks ran at the same time.

A BlockOperation is just a wrapper around the default global dispatch queue. But remember that a BlockOperation‘s blocks must be independent. You can’t use the result from one block in another block because you have no control over the order they run in. You’ll soon learn how to add dependencies between Operations.

  • Third: BlockOperation also behaves like a dispatch group: You can define a completion handler that runs after all its blocks have finished. Uncomment this completion block:
// right after let multiPrinter = BlockOperation()
multiPrinter.completionBlock = {
  print("Finished multiPrinting!")
}

You have to define it before you call start. Now run, and check the debug console:

name
is
my
Hello
Audrey
Finished multiPrinting!

And there’s the completion message, after the other 5 have printed.

BlockOperation is useful as an alternative to GCD if your app is already using Operations, but you’d mostly use it for pretty simple tasks. It’s more common to subclass Operation to define a more complex, reusable operation, with input and output properties.

So in this next section, you’ll create a TiltShiftOperation that takes an input image, then outputs the tilt-shifted version. But first, comment out the two start lines in the BlockOperation section. You don’t need to run them anymore.

Subclassing Operation

In Subclassing Operation, click the run button on the inputImage line, then tap the Show result button to see the input image.

let inputImage = UIImage(named: "dark_road_small.jpg")

It’s a road at night.

w 500 h 574 (in side pane)

The tilt-shift filter code is here in the Sources folder: It’s set up according to the tilt-shift filter recipe in Apple’s Core Image Programming Guide. This code takes an image, creates a mask, then applies a blur. But all you need to do is create the operation itself.

Create TiltShiftOperation

Double-click on the playground name to go back there. Here’s a TiltShiftOperation class — a subclass of Operation — with a CIContext property — you’ll need this to convert the filter’s output to a CGImage:

class TiltShiftOperation: Operation {
  private static let context = CIContext()
...
}

Add input and output properties:

private let inputImage: UIImage?
var outputImage: UIImage?

And provide an initializer:

init(image: UIImage?) {
  inputImage = image
  super.init()
}

Now, you need to override the operation’s main function. Start by filtering the input image:

guard let inputImage,
      let filter = TiltShiftFilter(image: inputImage),
      let output = filter.outputImage else {
  print("Failed to generate tilt shift image")
  return
}

Option-click output: The filter’s output image is of type CIImage. To display it, you need to convert it to a CGImage then create a UIImage from that. To create a CGImage, you need that CIContext property:

private static let context = CIContext()  // scroll up to top of class

It’s static because Apple’s documentation says CIContext is thread-safe, so you can safely use the same CIContext for all instances of TiltShiftOperation. Scroll back down to uncomment this CGRect that you’ll pass to the createCGImage method, so it uses the whole CIImage:

let fromRect = CGRect(origin: .zero, size: inputImage.size)

Now, add this code to create the CGImage:

guard let cgImage =
        TiltShiftOperation.context.createCGImage(
          output,
          from: fromRect)
else {
  print("No image generated")
  return
}

Then create the UIImage:

outputImage = UIImage(cgImage: cgImage)

Run TiltShiftOperation

So now, you’ve encapsulated all the functionality the operation should do. To use your new operation subclass, instantiate one with your inputImage:

let tsOp = TiltShiftOperation(image: inputImage)

You’re setting its inputImage property to the dark road inputImage. Then wrap its start call in duration:

duration {
  tsOp.start()
}

And ask the playground to show the resulting outputImage property:

tsOp.outputImage

Now, run the playground:

0.3255159854888916
...
w 500 h 574

When the size of the output image appears, tap the quick look eye to see the filtered image. It’s blurred at the top and bottom, like looking down into a miniature scene. The duration on my M2 MacBook is 0.325 seconds, which is too slow if you’re trying to scroll a view. In the next video, you’ll learn about OperationQueues, and then you’ll move this slow operation off the main queue!