Multithreading in iOS - codepath/ios_guides GitHub Wiki

Overview

Production applications will often need to perform heavier operations such as downloading high-resolution images or a executing non-cached database queries. To prevent stalling the main thread (and a hit in frame rate), Apple has provided a few tools to help you out! We'll take a look at Grand Central Dispatch, Operations, and the performSelectorInBackground method on NSObject.

Available Options

  1. Grand Central Dispatch
    • Grand Central Dispatch is a technology that abstracts away the low-level details of multithreading. When using GCD, you only have to think about the tasks you want to perform. These tasks can then be added to serial or concurrent queues. Moreover, you can add tasks to groups and run code after all tasks within the group complete!
  2. Operation and OperationQueue
    • Operations and OperationQueues provide you with a higher-level API, when compared to GCD. They were first introduced in iOS 4 and are actually implemented with GCD under the hood. Typically, you'll want to use this API over GCD, unless you're performing a simple unit of work on a specific queue. Operations provide you with powerful functionality such as cancellation and dependencies.
  3. performSelectorInBackground
    • If you need to perform a simple unit of work on a new thread, NSObject provides you with performSelectorInBackground(_:withObject:). Using this, you can run a function (with an argument) on a background thread.

Grand Central Dispatch

Let's walk through an example where we download an image from a remote URL and then use it to populate a UIImageView.

// Assume we have an `imageView` property on self
private func loadWallpaper() {
    DispatchQueue.global(qos: .background).async { [weak self] in
        guard
            let wallpaperURL = URL(string: "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-157301.jpg"),
            let imageData = try? Data(contentsOf: wallpaperURL)
        else {
            return
        }

        DispatchQueue.main.async {
            self?.imageView.image = UIImage(data: imageData)
        }
    }
}
  • Most uses of GCD start with a call to DispatchQueue.async, which takes a closure to execute on that queue
  • In our example, we'd like to execute the wallpaper download on a background queue, so we make use of the system-defined global queue with a background quality of service (QoS), .background.
  • Now we have the block of work to execute
    • We construct a URL via its failable String initializer and then fetch the data associated with that resource via Data(contentsOf:).
  • If the above step completes successfully (else we just return from the block), we now have our data at hand!
  • To update imageView's image property, we need to make sure we return to the main thread via DispatchQueue.main.async { /* ... */ }. Remember in iOS, all UI updates should be performed on the main thread. Inside the main thread block, we set the image using the Data initializer on UIImage.

Now that we've seen a one-off block example, let's dive into how you can accomplish groups of dependent tasks. Imagine you wanted to download multiple wallpapers and present an alert to the user when all of the images finish loading. Dispatch groups will be your best friends in these scenarios!

First, let's refactor the loadWallpaper function from the previous example to accept a DispatchGroup and a target URL.

private func loadWallpaper(group: DispatchGroup, url: String) {
    DispatchQueue.global(qos: .background).async(group: group) {
        guard
            let wallpaperURL = URL(string: url),
            let imageData = try? Data(contentsOf: wallpaperURL)
        else {
            // In production scenarios, we would want error handling here
            return
        }

        // Use imageData in some manner, e.g. persisting to a cache, present in view hierarchy, etc.
        print("Image downloaded \(url)")
    }
}
  • The function has been modified slightly to accept a parameter group of type DispatchGroup (we'll go into how to create these groups in the next snippet) and a target URL. Additionally, we use DispatchQueue.global(qos: .background).async(group:), which automatically signals the group when the block completes — no manual enter/leave calls are needed.

To use loadWallpaper(_:url:) a call site could look like so:

private func fetchAllWallpapers() {
        let urls = [
            "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-329991.jpg",
            "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-329805.jpg",
            "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-330201.jpg"
        ]

        let wallpaperGroup = DispatchGroup()

        urls.forEach {
            loadWallpaper(group: wallpaperGroup, url: $0)
        }

        wallpaperGroup.notify(queue: .main) { [weak self] in
            let alertController = UIAlertController(title: "Done!", message: "All images have downloaded", preferredStyle: .alert)
            alertController.addAction(UIAlertAction(title: "OK", style: .default, handler: nil))

            self?.present(alertController, animated: true)
        }
    }
  • We start by creating a dispatch group, wallpaperGroup, using DispatchGroup()
  • With the group in hand, we loop over all of the wallpaper URLs and call loadWallpaper(group:url:). Because loadWallpaper uses async(group:), each dispatch is automatically tracked by the group — no manual enter/leave calls are needed at the call site.
  • To run code after completion of the group, we call notify(queue:execute:) on the group. In our case, we'll simply present a UIAlertController letting the user know that all of the downloads have finished.

While GCD can be extremely powerful, the modern DispatchQueue API is already clean Swift and doesn't require a wrapper. For reference, the equivalent of our wallpaper example using DispatchQueue directly is:

DispatchQueue.global(qos: .background).async { [weak self] in
    guard
        let url = URL(string: "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-157301.jpg"),
        let data = try? Data(contentsOf: url)
    else {
        return
    }

    DispatchQueue.main.async { [weak self] in
        self?.imageView.image = UIImage(data: data)
    }
}

NSOperation

To start, we'll port the wallpaper downloading example to use a BlockOperation. BlockOperation is a simple wrapper on a block of work that can be added to a queue.

private func loadWallpaper(queue: OperationQueue, url: String) {
    guard let wallpaperURL = URL(string: url) else { return }

    let downloadOperation = BlockOperation {
        guard let imageData = try? Data(contentsOf: wallpaperURL) else { return }

        OperationQueue.main.addOperation { [weak self] in
            self?.imageView.image = UIImage(data: imageData)
        }
    }

    queue.addOperation(downloadOperation)
}
  • The initializer for BlockOperation simply takes a block to run. In our case, we'll download the data from wallpaperURL and return to the main queue to set the image property on imageView
  • After initializing downloadOperation, we add it to queue

When creating an OperationQueue, you have a few points of customization

let queue = OperationQueue()
queue.maxConcurrentOperationCount = 1

// If you want to hold the queue, use the `isSuspended` property
queue.isSuspended = true
  • The maxConcurrentOperationCount property allows you to set a limit on how many operations may run concurrently in a given queue. Setting this to 1, implies your queue will be serial (queuing order may not be preserved, as operations only run when their isReady flag is set to true). If this property isn't set, it defaults to OperationQueue.defaultMaxConcurrentOperationCount, which is dictated by system conditions.
  • By default, all operations that are ready (isReady property is true) are run when added to a queue. You can halt all execution on a queue by setting the isSuspended property to true.

Operations become really powerful when you separate them out into operation subclasses. To demonstrate this, let's make a wallpaper resizing operation. Subclass Operation directly and override main() — the framework handles KVO notifications for you (see 'Subclassing Notes' in the docs).

Note: The example below uses UIGraphicsBeginImageContextWithOptions, which was deprecated in iOS 17. For new code, prefer UIGraphicsImageRenderer (available since iOS 10).

class ResizeImageOperation: Operation {

    enum ResizeError {
        case fileReadError
        case resizeError
        case writeError
    }

    let targetSize: CGSize
    let path: URL
    var resizeError: ResizeError?

    init(size: CGSize, path: URL) {
        self.targetSize = size
        self.path = path
    }

    override func main() {
        guard !isCancelled else { return }

        guard let sourceImage = UIImage(contentsOfFile: path.path) else {
            resizeError = .fileReadError
            return
        }

        let finalWidth: CGFloat, finalHeight: CGFloat
        let ratio = sourceImage.size.width / sourceImage.size.height

        // Scale aspect fit the image
        if sourceImage.size.width >= sourceImage.size.height {
            finalWidth = targetSize.width
            finalHeight = finalWidth / ratio
        } else {
            finalHeight = targetSize.height
            finalWidth = finalHeight * ratio
        }

        let imageSize = CGSize(width: finalWidth, height: finalHeight)
        UIGraphicsBeginImageContextWithOptions(imageSize, true, 0.0)
        defer { UIGraphicsEndImageContext() }

        let rect = CGRect(origin: .zero, size: imageSize)
        sourceImage.draw(in: rect)

        guard
            let resizedImage = UIGraphicsGetImageFromCurrentImageContext(),
            let imageData = resizedImage.jpegData(compressionQuality: 1.0)
        else {
            resizeError = .resizeError
            return
        }

        do {
            try imageData.write(to: path)
        } catch {
            resizeError = .writeError
        }
    }
}
  • To help with error handling, we add a nested ResizeError enum with a few cases.
  • ResizeImageOperation can be initialized with a target size and path to write.
  • The meat of the operation is placed in the main() method (overridden from Operation). We check isCancelled at the start so the operation can be cancelled cleanly.
  • We then proceed with resizing the image (scale aspect fit) and saving it to disk.

Now that we have a resizing operation in hand, let's refactor our download operation a bit to work with it:

private func downloadWallpaper(url: URL, path: URL) -> Operation {
    return BlockOperation {
        guard
            let imageData = try? Data(contentsOf: url),
            let image = UIImage(data: imageData),
            let jpegData = image.jpegData(compressionQuality: 1.0)
        else { return }

        try? jpegData.write(to: path)
    }
}
  • We now return an Operation and have the operation write the image data to disk.

Lastly, to make the download and resize operations dependent, we can use them like so:

// Assume self has `imageView` and `wallpaperQueue` properties

if let cacheDirectoryURL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first {
    let targetURL = cacheDirectoryURL.appendingPathComponent("wallpaper.jpg")
    let downloadOperation = downloadWallpaper(url: URL(string: "http://wallpapers.wallhaven.cc/wallpapers/full/wallhaven-329991.jpg")!, path: targetURL)

    let resizeOperation = ResizeImageOperation(size: CGSize(width: imageView.bounds.size.width * 2, height: imageView.bounds.size.height * 2), path: targetURL)
    resizeOperation.addDependency(downloadOperation)

    resizeOperation.completionBlock = { [weak self, weak resizeOperation] in
        if let error = resizeOperation?.resizeError {
            print(error)
            return
        }

        guard
            let path = resizeOperation?.path,
            let imageData = try? Data(contentsOf: path)
        else {
            return
        }

        OperationQueue.main.addOperation {
            self?.imageView.image = UIImage(data: imageData)
        }
    }

    wallpaperQueue.isSuspended = true
    wallpaperQueue.addOperation(downloadOperation)
    wallpaperQueue.addOperation(resizeOperation)
    wallpaperQueue.isSuspended = false
}
  • The key line to notice is resizeOperation.addDependency(downloadOperation). That's how we express the resizing operation's dependency on downloadOperation.
  • Moreover, in the completion block of resizeOperation, we check for errors and proceed with displaying the resized image.
  • Note: we make sure to suspend the queue first, then add the operations. This prevents the operations from beginning immediately upon addition.

PerformSelectorInBackground

To wrap up, let's show a simple example of performSelectorInBackground. Assuming self has a method sleepAndPrint(_:), we can make the following call:

performSelectorInBackground("sleepAndPrint:", withObject: "supsup")

If our target selector had no argument, the selector would simply be "sleepAndPrint").

func sleepAndPrint(message: String) {
    Thread.sleep(forTimeInterval: 1)
    print(message)
}

Key Takeaways

We've used GCD, Operations, and NSObject's performSelectorInBackground method as means of performing work in a multithreaded fashion. If you have small units of work to perform, you'll want to reach for GCD or performSelectorInBackground. On the other hand, if you have larger operations that may have dependencies, Operation should be your tool of choice. For modern async code targeting iOS 13+, also consider Swift's structured concurrency (async/await and TaskGroup). For more info on these topics check out Apple's Concurrency Programming Guide and the WWDC 2015 session on Advanced Operations!