Asynchronous operations for writing concurrent solutions in Swift

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 to YES and the value returned by executing to NO. 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 `execute` 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 a better separation of concern and code with high performance.

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!