Tidbit: Multithreading in iOS

Introduction

If you don't know what threads are, you probably don't need to look at this guide. You should go learn what threads are, as well as the associated perils, and then come back. If you're reading this guide, you're probably making a HTTP request or running an O(2^n!) algorithm and don't particularly like having your UI hang in the meantime. You're in the right place.

A Short Overview of Threads on iOS

Most lines of code that you've written in Xcode hitherto have happened on the UI thread. This is the specific thread where all UI responsivity exists--UIView updates, button touches, and gesture recognition, among other things. You are designated one and only one UI thread (and if you know more about the innards of threading, it's basically the scheduler's favorite thread to schedule--i.e. it has super high priority).

There is one very golden rule to UI design (according to me), and it's to NEVER EVER EVER block the UI thread. If you do, your app becomes unresponsive--button presses, swipes, and all animations cease to exist--until you unblock the thread. This is considered A Bad Thing, and might actually cause your app to be rejected from The App Store. So, if you have to make an HTTP request (takes a second or two to return), or Bogosort 100,000 items (could literally take forever), do not do it on the thread which responds to the user.

There's a second golden rule: don't update the UI on a thread which isn't the UI thread. Think about why this may be the case--we'll show you more of an example later.

So, we'll show you what it looks like when you make an HTTP request on the UI thread, and then we'll show you how to mitigate this problem.

IsItCarnivalYet, revisited

Blocking the UI Thread

You might remember the Is It Carnival Yet app we built in a previous lecture. Well we're going to dive into some of the base code to examine what happens when we poll the IsItCarnivalYet.com website on the UI thread. First, we see how the app should work:

Pretty smooth, right? We see have an indicator pop up to tell the user that stuff is actually happening behind the scenes. Works pretty nicely. Let's try to naively recreate this experience. Consider the following implementation:

@IBAction func checkForCarnival(sender: AnyObject) {
    self.answerLabel.text = "CHECKING..."
    self.indicator.hidden = false
    self.indicator.startAnimating()
    
    // THIS IS THE CALL THAT MAKES THE HTTP REQUEST
    // I added a sleep(5) call to this function to simulate 'latency'.
    // This will block whatever thread it's on for >5 seconds.
    let text = CarnivalFinder.carnivalYet()
    
    self.answerLabel.text = text
    self.indicator.stopAnimating()
    self.indicator.hidden = true
    let lastUpdated = CarnivalFinder.lastUpdated() ?? ""
    self.lastUpdated.text = "Last updated: \(lastUpdated)"
}

When we consider the sequential execution of this code, it should theoretically recreate the above experience. Alas...

Moving Everything to Another Thread

This app sucks. We didn't even get to see the indicator start spinning. We need to make the .carnivalYet() happen on a different thread so that we can achieve a bit more responsiveness in this app. This is where the beloved dispatch_* family of functions come into play. Consider the following code:

@IBAction func checkForCarnival(sender: AnyObject) {
    self.answerLabel.text = "CHECKING..."
    self.indicator.hidden = false
    self.indicator.startAnimating()
    
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0),
        { () -> Void in
            // THIS IS THE CALL THAT MAKES THE HTTP REQUEST
            // I added a sleep(5) call to this function to simulate 'latency'.
            // This will block whatever thread it's on for >5 seconds.
            let text = CarnivalFinder.carnivalYet()
            self.answerLabel.text = text
            self.indicator.stopAnimating()
            self.indicator.hidden = true
            let lastUpdated = CarnivalFinder.lastUpdated() ?? ""
            self.lastUpdated.text = "Last updated: \(lastUpdated)"
        }
    )
}

That's some weird stuff happening there. But let's break it down. On the UI thread, we set the answerLabel text to "CHECKING...", unhide the indicator, and start its animation. We punted the rest of the code to a completely different thread. More specifically, we punted the rest of the code to QOS_CLASS_USER_INITIATED-type thread, which is something we'll explain later. So, everything that happens in that Swift block is all code that is run on another thread. In theory, we should see the wheel spinning.

Okay. It's literally taking 15 seconds to return our HTTP call. While it's great that our wheel is spinning and our button is responsive, this ends up STILL being really bad for UI responsivity if our multithreading is actually making our app take longer than if we didn't have multithreading. So what's the problem?

Moving Some Stuff Back to the UI Thread

Remember the second golden rule of designing UIs--changes to the UI should happen on the UI thread. This has to do with a lot of the thread internals in the iOS kernel--basically, it takes a lot of work to make UI changes on a thread that isn't the UI thread. Due to some nondeterminism in the kernel code, your UI-code-running-on-the-non-UI-thread could take anywhere between a few extra seconds to a few extra minutes to actually run. Or it may never run at all. Or your app may crash!

So, we punted all of the heavy work to another thread. But we have to punt any UI changes we make back to the main thread. This is actually really easy to do, for a job that sounds somewhat complicated. We just make another dispatch_async call, as follows:

@IBAction func checkForCarnival(sender: AnyObject) {
    self.answerLabel.text = "CHECKING..."
    self.indicator.hidden = false
    self.indicator.startAnimating()
    
    dispatch_async(dispatch_get_global_queue(QOS_CLASS_USER_INITIATED, 0),
        { () -> Void in
            // THIS IS THE CALL THAT MAKES THE HTTP REQUEST
            // I added a sleep(5) call to this function to simulate 'latency'.
            // This will block whatever thread it's on for >5 seconds.
            let text = CarnivalFinder.carnivalYet()
            
            // Punt everything back to the main thread.
            dispatch_async(dispatch_get_main_queue(),
                { () -> Void in
                    // Closures. :)
                    self.answerLabel.text = text
                    self.indicator.stopAnimating()
                    self.indicator.hidden = true
                    let lastUpdated = CarnivalFinder.lastUpdated() ?? ""
                    self.lastUpdated.text = "Last updated: \(lastUpdated)"
                }
            )
        }
    )
}

And now it works perfectly (taking into account the 5 seconds of added latency).

Quality of Service Classes

Remember above when we made the dispatch_get_global_queue call and we passed in this weird QOS_CLASS_USER_INITIATED? Well that is a parameter which specifies how important the work being done on that thread is. The above class specifies that work being done is user initiated, which essentially translates to the scheduler as really damn important. So, it will schedule work on that thread as much as it can without compromising the UI (or other potentially more important threads).

You can specify different qualities of service to basically signify how important some work is. If you have 100 different jobs running on the USER_INITIATED queue, and some of them are not really super important, your app responsivity suffers for no good reason. If you're doing some behind-the-scenes work, for example, that's less important and can most certainly go on a less important queue to allow room for the really important stuff.

In order of importance, you may use QOS_CLASS_USER_INTERACTIVE (as important as the UI thread), QOS_CLASS_USER_INITIATED (really important but not moreso than the UI), QOS_CLASS_DEFAULT (eh, it's not terribly necessary--choose this if you're not sure what QOS class to choose), QOS_CLASS_UTILITY (doing some work that the user isn't really waiting for anytime soon), and QOS_CLASS_BACKGROUND (the user doesn't need to know this stuff is happening). For more information, check out the official documentation at qos.h:

/*!
 * @constant QOS_CLASS_USER_INTERACTIVE
 * @abstract A QOS class which indicates work performed by this thread
 * is interactive with the user.
 * @discussion Such work is requested to run at high priority relative to other
 * work on the system. Specifying this QOS class is a request to run with
 * nearly all available system CPU and I/O bandwidth even under contention.
 * This is not an energy-efficient QOS class to use for large tasks. The use of
 * this QOS class should be limited to critical interaction with the user such
 * as handling events on the main event loop, view drawing, animation, etc.
 *
 * @constant QOS_CLASS_USER_INITIATED
 * @abstract A QOS class which indicates work performed by this thread
 * was initiated by the user and that the user is likely waiting for the
 * results.
 * @discussion Such work is requested to run at a priority below critical user-
 * interactive work, but relatively higher than other work on the system. This
 * is not an energy-efficient QOS class to use for large tasks and the use of
 * this QOS class should be limited to operations where the user is immediately
 * waiting for the results.
 *
 * @constant QOS_CLASS_DEFAULT
 * @abstract A default QOS class used by the system in cases where more specific
 * QOS class information is not available.
 * @discussion Such work is requested to run at a priority below critical user-
 * interactive and user-initiated work, but relatively higher than utility and
 * background tasks. Threads created by pthread_create() without an attribute
 * specifying a QOS class will default to QOS_CLASS_DEFAULT. This QOS class
 * value is not intended to be used as a work classification, it should only be
 * set when propagating or restoring QOS class values provided by the system.
 *
 * @constant QOS_CLASS_UTILITY
 * @abstract A QOS class which indicates work performed by this thread
 * may or may not be initiated by the user and that the user is unlikely to be
 * immediately waiting for the results.
 * @discussion Such work is requested to run at a priority below critical user-
 * interactive and user-initiated work, but relatively higher than low-level
 * system maintenance tasks. The use of this QOS class indicates the work should
 * be run in an energy and thermally-efficient manner.
 *
 * @constant QOS_CLASS_BACKGROUND
 * @abstract A QOS class which indicates work performed by this thread was not
 * initiated by the user and that the user may be unaware of the results.
 * @discussion Such work is requested to run at a priority below other work.
 * The use of this QOS class indicates the work should be run in the most energy
 * and thermally-efficient manner.
 *
 * @constant QOS_CLASS_UNSPECIFIED
 * @abstract A QOS class value which indicates the absence or removal of QOS
 * class information.
 * @discussion As an API return value, may indicate that threads or pthread
 * attributes were configured with legacy API incompatible or in conflict with
 * the QOS class system.
 */

A More...Modern Approach

Believe it or not, you are tapping into some really old C functions. While this works, it really doesn't fit with Apple's object oriented style. As of iOS 8, there are some new ways to properly do multithreading, and it's something everyone should resolve to learn by iOS 9. For more information, check out this blog entry. Everything is super well explained (by a CMU alum, too!).