Asynchronous operations allow executing long-running tasks without having to block the calling thread until the execution completes. It’s a great way to create separation of concern, especially in combination with creating dependencies in-between operations.
If you’re new to operations, I encourage you first to read my blog post Getting started with Operations and OperationQueues in Swift. This post gets you started and explains the basics. Let’s dive into asynchronous operations by first looking at the differences between them and its synchronous opposite.
Asynchronous vs. Synchronous operations
It seems like a small difference; in fact, it’s only an A, but the actual differences are much more significant. Synchronous operations are a lot easier to set up and use but can’t run as long as asynchronous operations without blocking the calling thread.
Asynchronous operations make the difference to get the most out of operations in Swift. With the possibility to run asynchronous, long-running tasks, it’s possible to use them for any task. Create separation of concern or use operations as the core logic behind your app’s foundation.
To sum it all up, asynchronous operations allow you to:
- Run long-running tasks
- Dispatch to another queue from within the operation
- Start an operation manually without risks
I’ll explain a bit more on the last point in the next paragraph.
Starting an operation manually
Both synchronous and asynchronous operations can start manually. Starting manually basically comes down to calling the start()
method manually instead of using an OperationQueue
to manage execution.
Synchronous operations always block the calling thread until the operation finishes. Therefore, they’re less suitable for manually starting an operation. The risk of blocking the calling thread is less significant when using an asynchronous task, as it’s likely that it dispatches to another thread.
Starting manually is discouraged
Even though it might be tempting to start asynchronous tasks now manually, it’s not recommended to do so. By making use of an OperationQueue
you don’t have to think about the order of execution in case of multiple operations, and you benefit from more features like prioritizing tasks. Therefore, it’s recommended to always start operations by adding them to an OperationQueue
.
Creating an Asynchronous Operation
Creating an asynchronous operation all starts with creating a custom subclass and overwriting the isAsynchronous
property.
class AsyncOperation: Operation {
override var isAsynchronous: Bool {
return true
}
override func main() {
/// Use a dispatch after to mimic the scenario of a long-running task.
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(1), execute: {
print("Executing")
})
}
}
This is not yet enough to make the task asynchronous as the task still enters the finished state directly after the print statement executes. This is demonstrated by executing the following piece of code:
let operation = AsyncOperation()
queue.addOperations([operation], waitUntilFinished: true)
print("Operations finished")
// Prints:
// Operations finished
// Executing
In other words, the task already marks as finished while the asynchronous task is still performing, which can lead to unexpected behavior. We need to start managing the state ourselves for the operation to work asynchronously.
Managing the state of an Asynchronous Operation
To manage the state correctly, we need to override the isFinished
and isExecuting
properties with multi-threading and KVO support. This looks as follows for the isExecuting
property:
private var _isExecuting: Bool = false
override private(set) var isExecuting: Bool {
get {
return lockQueue.sync { () -> Bool in
return _isExecuting
}
}
set {
willChangeValue(forKey: "isExecuting")
lockQueue.sync(flags: [.barrier]) {
_isExecuting = newValue
}
didChangeValue(forKey: "isExecuting")
}
}
We keep track of the execution state in a private property which we only access synchronously. As you’ve learned in the blog post Concurrency in Swift you know that we need to make use of a lock queue for thread-safe write and read access. We make use of the willChangeValue(forKey:)
and didChangeValue(forKey:)
to add KVO support which will make sure that the OperationQueue
gets updated correctly.
We also need to override the start()
method in which we update the state. It’s important to note that you never call super.start()
in this method as we’re now handling the state ourselves.
Finally, we’re adding a finish()
method that allows us to set the state to finished once the async task completes.
Adding this all together we get a subclass that looks like this:
class AsyncOperation: Operation {
private let lockQueue = DispatchQueue(label: "com.swiftlee.asyncoperation", attributes: .concurrent)
override var isAsynchronous: Bool {
return true
}
private var _isExecuting: Bool = false
override private(set) var isExecuting: Bool {
get {
return lockQueue.sync { () -> Bool in
return _isExecuting
}
}
set {
willChangeValue(forKey: "isExecuting")
lockQueue.sync(flags: [.barrier]) {
_isExecuting = newValue
}
didChangeValue(forKey: "isExecuting")
}
}
private var _isFinished: Bool = false
override private(set) var isFinished: Bool {
get {
return lockQueue.sync { () -> Bool in
return _isFinished
}
}
set {
willChangeValue(forKey: "isFinished")
lockQueue.sync(flags: [.barrier]) {
_isFinished = newValue
}
didChangeValue(forKey: "isFinished")
}
}
override func start() {
print("Starting")
isFinished = false
isExecuting = true
main()
}
override func main() {
/// Use a dispatch after to mimic the scenario of a long-running task.
DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + DispatchTimeInterval.seconds(1), execute: {
print("Executing")
self.finish()
})
}
func finish() {
isExecuting = false
isFinished = true
}
}
To make sure our task is actually working we’re going to execute the same piece of code as before:
let operation = AsyncOperation()
queue.addOperations([operation], waitUntilFinished: true)
print("Operations finished")
// Prints:
// Starting
// Executing
// Operations finished
This is great and exactly what we wanted! The only thing missing is cancellation.
Adding support for cancelation
As an operation can cancel at any time, we need to take this into account when we start executing. It could be that an operation is already canceled before the task even started.
We can do this by simply adding a guard inside the start()
method:
override func start() {
print("Starting")
guard !isCancelled else { return }
isFinished = false
isExecuting = true
main()
}
Although the isFinished
and isExecuting
property contains the correct value at this point, we still need to update them according to the documentation:
Specifically, you must change the value returned by
finished
toYES
and the value returned byexecuting
toNO
. You must make these changes even if the operation was cancelled before it started executing.
Therefore, we call the finish()
method from our start()
method inside the guard making our final method look as follows:
override func start() {
print("Starting")
guard !isCancelled else {
finish()
return
}
isFinished = false
isExecuting = true
main()
}
Making use of Asynchronous Tasks
After creating a subclass for long-running tasks, it’s time to benefit from it. The final asynchronous operation class looks as follows:
class AsyncOperation: Operation {
private let lockQueue = DispatchQueue(label: "com.swiftlee.asyncoperation", attributes: .concurrent)
override var isAsynchronous: Bool {
return true
}
private var _isExecuting: Bool = false
override private(set) var isExecuting: Bool {
get {
return lockQueue.sync { () -> Bool in
return _isExecuting
}
}
set {
willChangeValue(forKey: "isExecuting")
lockQueue.sync(flags: [.barrier]) {
_isExecuting = newValue
}
didChangeValue(forKey: "isExecuting")
}
}
private var _isFinished: Bool = false
override private(set) var isFinished: Bool {
get {
return lockQueue.sync { () -> Bool in
return _isFinished
}
}
set {
willChangeValue(forKey: "isFinished")
lockQueue.sync(flags: [.barrier]) {
_isFinished = newValue
}
didChangeValue(forKey: "isFinished")
}
}
override func start() {
print("Starting")
guard !isCancelled else {
finish()
return
}
isFinished = false
isExecuting = true
main()
}
override func main() {
fatalError("Subclasses must implement `main` without overriding super.")
}
func finish() {
isExecuting = false
isFinished = true
}
}
We’re triggering a fatal error when the main()
method executes by a subclass.
An example could be that you’re going to upload a file with a FileUploadOperation
:
final class FileUploadOperation: AsyncOperation {
private let fileURL: URL
private let targetUploadURL: URL
private var uploadTask: URLSessionTask?
init(fileURL: URL, targetUploadURL: URL) {
self.fileURL = fileURL
self.targetUploadURL = targetUploadURL
}
override func main() {
uploadTask = URLSession.shared.uploadTask(with: URLRequest(url: targetUploadURL), fromFile: fileURL) { (data, response, error) in
// Handle the response
// ...
// Call finish
self.finish()
}
}
override func cancel() {
uploadTask?.cancel()
super.cancel()
}
}
Note that we’re saving the data task so we can cancel it if needed.
This is just a very basic example. In the Collect by WeTransfer app, we’re using operations a lot for things like:
- Content creation
- Content receiving
- Content uploading
- Content enriching
And a lot more. The great thing is that you can chain these operations together as learned in the previous post on getting started with operations.
Conclusion
That’s it! We’ve created Asynchronous Operations that you can directly use in your projects. Hopefully, it allows you to create better separation of concern and code with high performance.
This post is part of a series:
- Getting started with Operations and OperationQueues in Swift
- Asynchronous operations for writing concurrent solutions in Swift
- Advanced asynchronous operations by making use of generics
And can also be found in the form of a Swift Playground: https://github.com/AvdLee/AsyncOperations
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!