Getting started with Operations and OperationQueues in Swift

Operations in Swift are a powerful way to separate responsibilities over several classes while keeping track of progress and dependencies. They’re formally known as NSOperations and used in combination with the OperationQueue.

Make sure first to read my article on concurrency in Swift, so you know the basics of queues and dispatching. Operations have a lot in common with dispatch blocks but come with a few more benefits. Let’s dive into it!

What is an operation in Swift?

An Operation is typically responsible for a single synchronous task. It’s an abstract class and never used directly. You can make use of the system-defined BlockOperation subclass or by creating your own subclass. You can start an operation by adding it to an OperationQueue or by manually calling the start method. However, it’s highly recommended to give full responsibility to the OperationQueue to manage the state.

Making use of the system-defined BlockOperation looks as follows:

let blockOperation = BlockOperation {
    print("Executing!")
}

let queue = OperationQueue()
queue.addOperation(blockOperation)

And can also be done by adding the block directly on the queue:

queue.addOperation {
  print("Executing!")
}

The given task gets added to the OperationQueue that will start the execution as soon as possible.

Creating a custom operation

You create separation of concern with custom operations. You could, for example, create a custom implementation for importing content and another one for uploading content.

The following code example shows a custom subclass for importing content:

final class ContentImportOperation: Operation {

    let itemProvider: NSItemProvider

    init(itemProvider: NSItemProvider) {
        self.itemProvider = itemProvider
        super.init()
    }

    override func main() {
        guard !isCancelled else { return }
        print("Importing content..")
        
        // .. import the content using the item provider

    }
}

The class takes an item provider and imports the content within the main method. The main() function is the only method you need to overwrite for synchronous operations. Add the operation to the queue and set a completion block to track completion:

let fileURL = URL(fileURLWithPath: "..")
let contentImportOperation = ContentImportOperation(itemProvider: NSItemProvider(contentsOf: fileURL)!)

contentImportOperation.completionBlock = {
    print("Importing completed!")
}

queue.addOperation(contentImportOperation)

// Prints:
// Importing content..
// Importing completed!

This moves all your logic for importing content into a single class on which you can track progress, completion, and you can easily write tests for it!

Different states of an operation

An operation can be in several states, depending on its current execution status.

  • Ready: It’s prepared to start
  • Executing: The task is currently running
  • Finished: Once the process is completed
  • Canceled: The task canceled

It’s important to know that an operation can only execute once. Whenever it’s in the finished or canceled state, you can no longer restart the same instance.

Within custom implementations, you need to manually check the canceled state before execution to make sure a task cancels. Do know that a data race can occur when an operation is both started and canceled at the same time. You can read more about data races in my blog post Thread Sanitizer explained: Data Races in Swift.

The OperationQueue will remove the task automatically from its queue once it becomes finished, which happens both after execution or cancellation.

Making use of dependencies

A benefit of using operations is the use of dependencies. You can easily add a dependency between two instances. For example, to start uploading after the content is imported:

let fileURL = URL(fileURLWithPath: "..")
let contentImportOperation = ContentImportOperation(itemProvider: NSItemProvider(contentsOf: fileURL)!)
contentImportOperation.completionBlock = {
    print("Importing completed!")
}

let contentUploadOperation = UploadContentOperation()
contentUploadOperation.addDependency(contentImportOperation)
contentUploadOperation.completionBlock = {
    print("Uploading completed!")
}

queue.addOperations([contentImportOperation, contentUploadOperation], waitUntilFinished: true)

// Prints:
// Importing content..
// Uploading content..
// Importing completed!
// Uploading completed!

The upload will only start after the content importation is finished. It does not take into account cancelation which means that if the import operation cancels, the upload would still start. You have to implement a check to see whether the dependencies were canceled or not:

final class UploadContentOperation: Operation {
    override func main() {
        guard !dependencies.contains(where: { $0.isCancelled }), !isCancelled else {
            return
        }

        print("Uploading content..")
    }
}

Conclusion

I hope that you’ve got yourself excited to start implementing operations in Swift. It’s a hidden gem that allows you to separate concern, add dependencies between tasks, and track completion.

This post is part of a series:

If you like to improve your Swift knowledge, even more, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!