Parallel programming with Swift: Operations
In the Parallel programming with Swift: Basics I’ve introduced a lot of low-level options to control concurrency within Swift. The original idea was to collect all different types of approaches we can use on iOS at one place. But while writing that guide I’ve realized there are way too many, to list them in one post. So I decided to cut down the higher level methods.
I did mention Operations in the last post, but let’s look into them more closely.
Let’s recap: Operations are Cocoa’s high-level abstraction on GCD. To be precise it is an abstraction of dispatch_queue_t. It uses the same principle of having queues in which you can add tasks. In case of an OperationQueue, these tasks are Operations. When executing an operation, we need to be aware of the thread it’s running on. As always, if we want to update the UI, we need the MainOperationQueue.
Otherwise, we can use a private queue.
A difference to dispatch_queue_t is the option to set the maximum number of Operations to run at the same time.
While OperationQueue is a high-level abstraction of dispatch_queue_t you can think of operations as high-level abstractions of dispatch blocks. But there are some differences. For example, while a dispatch block runs for a few milliseconds, an operation can run for multiple minutes or even longer. Since Operations are classes, we can use them to encapsulate our business logic. This way we will only have to replace a few small operations to change major components (e.g. our database layer).
During its lifetime an Operation runs through different stages. When being added to a queue it is in Pending state. In this state, it waits for its conditions. As soon as all of them are fulfilled it enters the Ready state and in case there is an open slot it will start executing. Having done all its work, it will enter the Finished state and will be removed from the OperationQueue. In each state (except Finished) an Operation can be cancelled.
Cancelling an Operation is quite simple. Depending on the operation, cancellation can have an entirely different meaning. For example, running a network request, cancellation can result in stopping this request. While importing data it could mean to discard your transaction. You will be responsible to give the cancellation meaning.
So how do I cancel an Operation? You just call .cancel(). This will toggle the isCancelled property. That’s all what iOS will do for you. It’s up to you to check for cancellation and behave accordingly.
Be aware cancelling an Operation results in it discarding all conditions and start executing immediately to enter Finished state as soon as possible. Entering Finished state is the only way for the Operation to get removed from the Queue.
If you forget to check for the cancel flag, you might see your Operations being executed, even though you cancelled them. Also be aware that this is susceptible to race conditions. Pressing a button and setting the flag takes a few microseconds. During this time the operation might finish and the cancel flag has no more effect.
Readiness is once again only described by a single Boolean flag. This means the operation is ready to execute and it is waiting for the queue to start it. In a serial queue the Operation, which enters Ready state first, will be executed first, even though it might be at position 9 within the queue. If there are multiple operations entering Ready state at the same time, the priority will decide. An Operation will only enter Ready state when all its dependencies are completed.
This is one of the really huge features of Operations. We can create tasks, which specifically state that other tasks need to execute first before they themselves can be executed. At the same time, there are tasks, which can be executed in parallel to other tasks but are a dependency for the follow-up. This can be done by calling .addDependency()
Any operation which has dependencies will by default only enter the Ready state after all its dependencies are completed. However, it’s up to you to decide how to proceed after a dependency was canceled.
This enables us to strictly order our Operations.
I don’t think this is very easy to read, so let’s create our own operator (==>) to create dependencies. This way we can say, execute the operations in this order from left to right.
The nice thing about dependencies, they can be even across different OperationQueues. At the same time, this can create unexpected locking behavior. E.g. your UI might stutter, as an update depends on an operation in the background and blocks other operations. Be aware of circular dependencies. This is the case if Operation A depends on Operation B and B depends on A. This way they are both waiting for the other Operation to execute, so you will be creating a deadlock.
After execution, the Operation will enter the Finished state and execute its completion block exactly once. The completion block can be set like this:
With all these basics down, let’s create a simple framework for Operations. Operations have quite a lot of complex concepts. Instead of creating an example which is overly complex, let’s just print “Hello world” and try to incorporate most of them. This will contain asynchronous execution, dependencies and multiple Operations considered as one. Let’s dive in!
First, we will create an Operation to create asynchronous tasks. This way we can subclass and create any kind of asynchronous tasks.
This looks quite ugly. As you can see we have to override isFinished and isExecuting. Furthermore changes to these need to be KVO compliant, otherwise, the OperationQueue won’t be able to observe the state of our operations. Within our start() method, we manage the state of our operation from starting execution to entering the Finished state. We’ve created a method execute(). This will be the method our subclasses need to implement.
In this case, we just have to pass the text we want to be printed in the init() and override execute().
A GroupOperation will be our implementation for merging multiple Operations into one.
As you can see, we create an array in which our subclasses will add their operations. Then during execution, we just add the Operations to our private Queue. This way we ensure they will be executed in order. Calling addOperations([Operation], waitUntilFinished: true) results in the queue blocking until the added Operations are done. Afterwards the GroupOperation will change its state to Finish.
Hey, we’ve finally got here. This is a piece of cake! Just create your Operations, set the dependencies and add them to the array. That’s it.
So how do we find out that an Operation is finished? One way is to add a competionBlock. The other is to register an OperationObserver. This is a class, which registered on keyPaths via KVO and can observe everything as long as you keep it KVO compliant.
In our little framework let’s make it print “done”, as soon as the HelloWorldOperation finishes:
“Hello World!” has no reason to pass data, but let’s have a quick look into this. The easiest way is to use BlockOperations. Using these, we can set properties for the next Operation, which requires the data. Don’t forget to set the dependency, otherwise, the operation might not be executed in time ;)
Another thing we haven’t look at yet is error handling. Truth be told I haven’t found a nice way to do this yet. One option would be to add a method finished(withErrors:) and let each AsyncOperation call this instead of the AsyncOperation handling it in start(). This way, we could check for errors and add it to an array.Let’s say we have an Operation A which depends on Operation B. Suddenly an Operation B finishes with some errors. And in this case Operation A could check this array and abort. Depending on your requirement, you could add further errors.
It could look like this:
Be aware, that the suboperations need to handle their state accordingly and some changes need to be done on the AsyncOperation for this to work.
But as always there are a lot of ways and this is only one. You could also use an observer to observe the error value.
It doesn’t really matter how you do it. Just make sure, that your operation will clean up after itself. For example: If you write into a CoreData context and something goes wrong, you want to clean this context up. Otherwise, you might have an inconsistent state.
Operations are not limited to elements you can’t see. Everything you do within the app can be Operations (even though I’d advise you against it). But there are some things, which are easier to see as Operations. Everything that is modal should be considered accordingly. Let’s have a look at an Operation to display a dialog:
As you can see, it will pause its execution until a button is pressed. Afterwards, it will enter its finished state and then all other Operations depending on this one can continue.
Considering we can use Operations for UI, it opens a different problem. Imagine displaying a dialog on error messages. Maybe you queue multiple operations which will display an error when the network is not available. This can easily result in all these operations creating an Alert displaying connectivity problems. As a result, we would have multiple dialogs popping up at the same time, and wouldn’t know which is the first, or second. So we will have to make these dialogs mutually exclusive.
Even though the idea is complex, it’s quite easy to implement with dependencies. Just create a dependency between these dialogs and you are done. One problem is to keep track of the operation. But it can be solved by naming operations and then accessing the OperationQueue and searching for the name. This way you don’t have to hold a reference.
Operations are a nice tool for concurrency. But don’t be fooled, they are harder than you think. Currently, I’m maintaining a project, which is based on Operations and some parts are pure nightmares. Especially the error handling sticks out to be error-prone. Every time you execute a group operation and it fails, there is the possibility of more than one error. You will have to filter it for relevant errors, so sometimes an error gets obfuscated by your error mapping routines.
Another problem is about you stopping to think about possible concurrent problems. I haven’t talked about those yet in detail, but be aware of the GroupOperations with error handling code above. It contains a bug, which will be fixed in a future post. I had to fix something similar and I want to show you how easy it is, to mess things up, when you stop thinking about concurrency, due to the tools you use.
In case you found it, contact me on Twitter!
Even though I’m saying this, Operations are a good tool to get concurrency under control. GCD is still not out of the picture. For small stuff like switching threads, or tasks, which need to be executed as fast as possible, you might not want to use Operations. For these, GCD is the perfect solution.