⬆️ ⬇️

Parallel Programming in Swift: Operations

In parallel programming in Swift: The Basics I have presented many low-level ways to manage concurrency in Swift. The initial idea was to collect all the various approaches that we can use in iOS in one place. But when writing this article, I realized that there are too many of them to list in one article. Therefore, I decided to reduce the methods of a higher level.



image



I mentioned Operations in one of my articles, but let's take a closer look at them.



OperationQueue



image

')

Recall: Operation is a high-level abstraction of Cocoa over GCD . To be more precise, this is an abstraction over dispatch_queue_t . It uses the same principle as the queues to which you can add tasks. In the case of OperationQueue, these tasks are operations. When performing the operation, we need to know about the thread in which it runs. If you need to update the user interface, the developer should use MainOperationQueue .



OperationQueue.main 


Otherwise, we can use a private queue.



 let operationQueue: OperationQueue = OperationQueue() 


The difference from dispatch_queue_t is the ability to simultaneously set the maximum number of operations to run.



 let operationQueue: OperationQueue = OperationQueue() operationQueue.maxConcurrentOperationCount = 1 


Operations



OperationQueue is a high-level abstraction of dispatch_queue_t , and the operations themselves are considered abstractions of the upper level of dispatch blocks. But there are some differences. For example, an operation can be performed within a few minutes, or longer, and a block is operating within a few milliseconds. Since Operations are classes, we can use them to encapsulate our business logic. Thus, we will need to replace a few small operations to change the main components (for example, our database level).



Life cycle



image



Over the period of its existence, Operations goes through different stages. When added to a queue, it is pending. In this state, she expects her conditions. As soon as all of them are completed, Operations transitions to the ready state, and in the case of an open slot, it will start. When you have completed all your work, Operations will enter the Finished state, and will be removed from the OperationQueue. In each state (except Completed), Operation may be canceled.



Cancel



Canceling Operation is pretty simple. Depending on the operation, the cancellation may have completely different meanings. For example, when starting a network request, canceling can result in stopping this request. When importing data, this can mean abandoning a transaction. The responsibility for assigning this value lies with you.



So, how to cancel the Operation? You simply call the .cancel () method. This will change the isCancelled property. That's all iOS will do for you. It depends on you how to respond to this cancellation of the operation and how to proceed.



 let op = DemoOperation() OperationQueue.addOperations([op], waitUntilFinished: false) op.cancel() 


Keep in mind that the cancellation of an operation leads to the cancellation of all its conditions and the immediate start, in order to enter the Finished state as soon as possible. Moving to the Finished state is the only way to remove an operation from the queue.



If you forget to check for cancellation of an operation, you can see that they are executed, even if you canceled them. Also keep in mind that it is susceptible to the race condition . Pressing the button and setting the mark takes a few microseconds. During this time, the operation may complete and the cancellation mark will have no effect.



Readiness



Readiness is described by only one Boolean value. This means that the operation is ready for execution, and it is waiting for its launch queue. In a sequential queue, an operation is first performed that enters the “Ready” state, although it may be in position 9 in the queue. If several operations entered the ready state at the same time, they will be prioritized. Operation will enter the ready state only after all its dependencies are completed.



Dependencies



This is one of the really huge features of operations. We can create tasks in which it is indicated that other tasks must be performed first before they can be completed. At the same time, there are tasks that can be performed in parallel with other tasks, but are dependent on subsequent actions. This can be done by calling .addDependency ()



 operation2.addDependency(operation1) //execute operation1 before operation2 


Any operation that has dependencies will by default have a ready state after all its dependencies are completed. However, it’s up to you to decide how to act after you cancel the dependency



This allows us to strictly streamline our operations.



I don't think it's very easy to read, so let's create our own operator (==>) to create dependencies. Thus, we can specify the order of operations from left to right.



 precedencegroup OperationChaining { associativity: left } infix operator ==> : OperationChaining @discardableResult func ==><T: Operation>(lhs: T, rhs: T) -> T { rhs.addDependency(lhs) return rhs } operation1 ==> operation2 ==> operation3 // Execute in order 1 to 3 


Dependencies can be in different OperationQueues . At the same time, they can create unexpected blocking behavior. For example, the user interface can work with slowdown, because the update depends on the operation in the background and blocks other operations. Remember about cyclic dependencies. This happens if operation A depends on how B works and B depends on A. So they both expect each other to complete, so you get a deadlock .



Done



After completing the Operation, it enters the “Ready” state and completes its completion block exactly once. Completion block can be set as follows:



 let op1 = Operation() op1.completionBlock = { print("done") } 


Practical example



Let's create a simple structure for operations with all these principles. Operations have quite a few complex concepts. Instead of creating an example that is too complicated, let's just type in “ Hello world ” and try to include most of them. The example will contain asynchronous execution, dependencies, and several operations, treated as one. Let's dive into creating an example!



AsyncOperation



First we will create an Operation to create asynchronous tasks. Thus, we can create subclasses and any asynchronous tasks.



 import Foundation class AsyncOperation: Operation { override var isAsynchronous: Bool { return true } var _isFinished: Bool = false override var isFinished: Bool { set { willChangeValue(forKey: "isFinished") _isFinished = newValue didChangeValue(forKey: "isFinished") } get { return _isFinished } } var _isExecuting: Bool = false override var isExecuting: Bool { set { willChangeValue(forKey: "isExecuting") _isExecuting = newValue didChangeValue(forKey: "isExecuting") } get { return _isExecuting } } func execute() { } override func start() { isExecuting = true execute() isExecuting = false isFinished = true } } 


It looks pretty ugly. As you can see, we need to override isFinished and isExecuting . In addition, changes to them must meet the requirements of the KVO , otherwise OperationQueue will not be able to monitor the status of our operations. In our start () method, we manage the state of our operation from the start of execution to the entry into the state of Finished . We have created an execute () method. This will be the method that our subclasses need to implement.



TextOperation



 import Foundation class TextOperation: AsyncOperation { let text: String init(text: String) { self.text = text } override func execute() { print(text) } } 


In this case, we just need to pass the text that we want to print to init () and override execute () .



GroupOperation



GroupOperation will be our implementation to combine several operations into one.



 import Foundation class GroupOperation: AsyncOperation { let queue = OperationQueue() var operations: [AsyncOperation] = [] override func execute() { print("group started") queue.addOperations(operations, waitUntilFinished: true) print("group done") } } 


As you can see, we create an array in which our subclasses will add their operations. Then, at run time, we simply add transactions to our private queue. Thus, we guarantee that they will be executed in a specific order. Calling the addOperations ([Operation], waitUntilFinished: true) method locks the queue until additional operations are performed. After that, GroupOperation will change its state to Finish .



HelloWorld Operation



Just create your own operations, set the dependencies and add them to the array. That's all.



 import Foundation class HelloWorldOperation: GroupOperation { override init() { super.init() let op = TextOperation(text: "Hello") let op2 = TextOperation(text: "World") op2.addDependency(op) operations = [op2, op] } } 


Operation observer



So, how do we know that the operation is completed? As one of the ways, you can add competionBlock. Another way is to register the OperationObserver. This is the class that subscribes to keyPath via KVO. He oversees everything as long as it is compatible with KVO.



Let's print “done” in our little framework as soon as HelloWorldOperation ends:



 import Foundation class OperationObserver: NSObject { init(operation: AsyncOperation) { super.init() operation.addObserver(self, forKeyPath: "finished", options: .new, context: nil) } override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) { guard let key = keyPath else { return } switch key { case "finished": print("done") default: print("doing") } } } 


Data transfer



For “Hello World!” It makes no sense to transfer data, but let's quickly consider this case. The easiest way is to use BlockOperations . Using them, we can set properties for the next operation that needs data. Do not forget to establish a dependency, otherwise the operation may not be completed on time;)



 let op1 = Operation1() let op2 = Operation2() let adapter = BlockOperation() { [unowned op1, unowned op2] in op2.data = op1.data } adapter.addDependency(op1) op2.addDependency(adapter) queue.addOperations([op1, op2, adapter], waitUntilFinished: true) 


Error processing



One more thing that we do not consider now is error handling. Truth be told, I have not yet found a good way to do this. One option is to add a call to the method finished (withErrors :) and to enable each asynchronous operation to call it instead of AsyncOperation , processing it in start () . Thus, we can check for errors and add them to the array. Suppose we have operation A, which depends on operation B. Suddenly, operation B ends with an error. And in this case Operation A can check this array and abort its execution. Depending on the requirements, you may add additional errors.



It might look like this:



 class GroupOperation: AsyncOperation { let queue = OperationQueue() var operations: [AsyncOperation] = [] var errors: [Error] = [] override func execute() { print("group started") queue.addOperations(operations, waitUntilFinished: true) print("group done") } func finish(withError errors: [Error]) { self.errors += errors } } 


Keep in mind that sub-operations must handle their state accordingly, and for this you need to make some changes in AsyncOperation .



But, as always, there are many ways, and this is only one of them. You can also use observer to monitor the error value.



It doesn't matter how you do it. Just make sure your operation will be deleted upon completion. For example: If you write in the context of CoreData , and something goes wrong, you need to clear this context. Otherwise, you may have an unspecified state.



UI Operations



Operations are not limited to items that you do not see. Everything that you do in an application can be an operation (although I would advise you not to do this). But there are some things that are easier to see as Operations. Everything that is modal should be considered accordingly. Let's look at the operation to display the dialog:



 import Foundation class UIOperation: AsyncOperation { let viewController: UIViewcontroller! override func execute() { let alert = UIAlertController(title: "My Alert", message: @"This is an alert.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .`default`, handler: { _ in self.handleInput() })) viewController.present(alert, animated: true, completion: nil) } func handleInput() { //do something and continue operation } } 


As you can see, it suspends its execution until the button is pressed. After that, it will enter its finished state, and then all other operations that depend on this can continue.



UI Operations



Operations are not limited to items that you do not see. Everything that you do in an application can be an operation (although I would advise you not to do this). But there are some things that are easier to see as Operations. Everything that is modal should be considered accordingly. Let's look at the operation to display the dialog:



 import Foundation class UIOperation: AsyncOperation { let viewController: UIViewcontroller! override func execute() { let alert = UIAlertController(title: "My Alert", message: @"This is an alert.", preferredStyle: .alert) alert.addAction(UIAlertAction(title: "OK", style: .`default`, handler: { _ in self.handleInput() })) viewController.present(alert, animated: true, completion: nil) } func handleInput() { //do something and continue operation } } 


As you can see, it suspends its execution until the button is pressed. After that, it will enter its finished state, and then all other operations that depend on this can continue.



Mutual exclusion



Given that we can use Operations for the User Interface, a different challenge appears. Imagine that you see an error dialog. You may be adding several operations to a queue that will display an error when the network is unavailable. This can easily lead to the fact that when warnings appear, all the above-mentioned Operations can break the network connection. As a result, we would have several dialogues that would be displayed simultaneously, and we do not know which one is the first and which is the second. Therefore, we will have to make these dialogues mutually exclusive.



Despite the fact that the idea itself is complex, it is fairly easy to implement with dependencies. Just create a dependency between these dialogs, and everything is ready. One of the problems is tracking the operation. But this can be resolved by using naming operations, and then by accessing the OperationQueue and searching for the name. Thus, you do not need to keep the link.



 let op1 = Operation() op1.name = "Operation1" OperationQueue.main.addOperations([op1], waitUntilFinished:false) let operations = OperationQueue.main.operations operations.map { op in if op.name == "Operation1" { op.cancel() } } 


Conclusion





Operations is a good tool for concurrency. But do not be fooled, they are more complicated than you think. Currently, I support the project based on Operations, and some of its parts are very complex and inconvenient in work. In particular, a lot of faults appear in error handling. Every time you perform a group operation, and it is performed incorrectly, there is a possibility of more than one error. You will have to filter them to get the necessary errors of a certain kind, so sometimes the error is confusing because of the display routines.



Another problem is that you stop thinking about possible parallel identical problems. I haven’t talked about these details yet, but I remember GroupOperations with the error handling code given above. They contain a bug that will be fixed in a future post.



Operations is a good tool for managing concurrency. GCDs are still not ordered. For small tasks, such as switching threads or tasks that need to be completed as quickly as possible, you may not want to use operations. The ideal solution for this is GCD .

Source: https://habr.com/ru/post/350096/



All Articles