© Andrés Ibañez Kautsch 2023
A. I. KautschModern Concurrency on Apple Platformshttps://doi.org/10.1007/978-1-4842-8695-1_1

1. Introduction

Andrés Ibañez Kautsch1  
(1)
La Paz, Bolivia
 
Programmers are used to writing programs that are executed in a linear fashion. As you write, test, and execute your program, you expect your instructions to run in the order that you wrote them. In Listing 1-1, you have a program that will first assign a variable a to the number 2. It will then assign a variable b to the number 3, followed by assigning a variable sum, the sum of a + b, and it will finally print a result to the console. There is no way this program will work if you try to print(sum) before you even managed to calculate the value for sum.
let a = 2
let b = 3
let sum = a + b // This variable depends on the values for a and b, but the variables themselves can be assigned in any order.
print(sum) // We can only print a variable that we have the value of.
Listing 1-1

A simple program that runs from top to bottom

This is called procedural programming , because you write simple statements, and they are executed from top to bottom. Even if you add statements that can alternate the execution flow, it’s still easy to follow. If you a call function when working with procedural programming , your program will “jump” to a different place in memory and execute its contents, but the execution of these lines will also be done procedurally in the same order they were written until control flow is returned to the caller.

Even people who are not programmers can follow any instruction set if they are written in a specific order and if they are doing one thing at a time. Someone following a cooking recipe, or someone building a doghouse from an online tutorial may not be a programmer, but people are naturally good at doing something if they have the steps clearly laid down.

But computer programs grow and become more complex. While it is true that a lot of complex software can be written that follows such a linear execution flow , often programs will need to start doing more than one thing at once; rather than having a clear code execution path that you can follow with your bare eyes, your program may need to execute in such a way that it’s not obvious to tell what’s going on at a simple glance of the source code. Such programs are multithreaded, and they can run multiple (and often – but not always – unrelated) code paths at the same time.

In this book, we will learn how to use Apple’s async/await implementation for asynchronous and multithreaded programming. In this chapter, we will also talk about older technologies Apple provides for this purpose, and how the new async/await system is better and helps you to not concern yourself with traditional concurrency pitfalls.

Important Concepts to Know

Concurrency and asynchronous programming are very wide topics. While I’d love to cover everything, it would go out of the scope of this book. Instead, we will define four important concepts that will be relevant while we explore Apple’s async/await system , introduced in 2021. We will define them with as few words as possible, because it’s important that you keep them in mind while you work through the chapters of this book. The concepts of the new system itself will be covered in the upcoming chapters.

Note

Apple is not the original creator of the async/await system . The technology has been used in other platforms in the past. Microsoft announced C# would get async/await support in 2011, and C# with these features was officially released to the public in 2012.

Threads

The concept of Thread can vary even when talked about in the same context (in this case, concurrency, and asynchronous programming ). In this book, we will treat a thread as a unit of work that can run independently. In iOS, the Main Thread runs the UI of your app, so every UI-related task (updating the view hierarchy, removing and adding views) must take place in the main thread. Attempting to update the UI outside of the main thread can result in unwanted behavior or, even worse, crashes.

In low-level multithreading frameworks , multithreading developers will manually create threads and they need to manually synchronize them, stop them, and do other thread management operations on their own. Manually handling threads is one of the hardest parts of dealing with multithreading in software.

Concurrency and Asynchronous Programming

Concurrency is the ability of a thread (or your program) to deal with multiple things at once. It may be responding to different events, such as network handlers, UI event handlers, OS interruptions , and more. There may be multiple threads and all of them can be concurrent.

There are different APIs throughout Apple’s SDKs that make use of concurrency. Listing 1-2 shows how to request permission to use Touch ID or Face ID, depending on the device.
func requestBiometricUnlock() {
    let context = LAContext()
    var error: NSError? = nil
    let canEvaluate = context.canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: &error)
    if canEvaluate {
        if context.biometryType != .none {
            // (1)
            context.evaluatePolicy(
                .deviceOwnerAuthenticationWithBiometrics,
                localizedReason: "To access your data") { (success, error) in
                // (2)
                if success {
                    // ...
                }
            }
        }
    }
}
Listing 1-2

Biometric unlock is an asynchronous task

(1) calls context.evaluatePolicy, which is a concurrent call . This will ask the system to suspend your app so it can take over. The system will request permission to use biometrics while your app is suspended. The thread your app was running on may be doing something entirely different and not even related to your app while the system is running context. evaluatePolicy . When the user responds to the prompt, either accepting or rejecting the biometric request, it will deliver the result to your app. The system will wait for an appropriate time to notify your app with the user’s selection. The selection will be delivered to your app in the completion handler (also called a callback ) on (2), at which point your app will be in control of the thread again. The selection may be delivered in a different thread than the one which launched the context.evaluatePolicy call – this is important to know, because if the response updates the UI, you need to do that work on the main thread . This is also called a blocking mechanism or interruption , as evaluatePolicy is a blocking call for the thread. If you have done iOS for at least a few months now, you are familiar with this way of dealing with various events. URLSession, image pickers, and more APIs make use of this mechanism.

People often think that asynchronous programming is the act of running multiple tasks at once. This is a different concept called Multithreading , and we will talk about it in the next point.

Note

If you are thinking on implementing biometric unlock to resources within your app, please don’t use the code above. It has been simplified to explain how concurrency works, and it doesn't have the right safety measures to protect your user's data.

Multithreading is the act of running multiple tasks at once. Multiple threads (hence its name – multithreading) are usually involved. Many tasks can be running at the same time in the context of your app. Downloading multiple images from the internet at the same time or downloading a file from your web browser while you open some tabs are some examples of multithreading. This allow us to run tasks in parallel and is sometimes called parallelism .

Multithreading Pitfalls

Concurrency and multithreading are traditionally hard problems to solve. Ever since their introduction in the computer world, developers have had to develop paradigms and tools to make dealing with concurrency easier. Because programmers are used to thinking procedurally, writing code that executes at unexpected times is hard to get right.

In this section we will talk about some of the problems developers who write low-level multithreading code often face, and the models they have created to work with them. It is important you understand this section because these traditional problems are real, but their solutions are already implemented in the async/await system . It will also help you decide which technology you should use next time you need to implement concurrency or multithreading in your programs.

Deadlocks

In the context of multithreading, a deadlock occurs when two different processes are waiting on the other to finish, effectively making it impossible for any of them to finish to begin with.

This can happen when both processes are sharing a resource. If Thread B wants a resource that Thread A is holding, and Thread A wants a resource that Thread B has, both processes will be waiting for each other to finish, sitting in a perpetual deadlock state. Figure 1-1 illustrates how this might occur.

A schematic diagram depicts the execution flow of Threads A and B, which consists of resource C with the correct symbol at Thread A and resource D with the incorrect symbol and vice versa of symbols at Thread B.

Figure 1-1

Deadlocking in action

Figure 1-1 illustrates how Thread A may try to access Resource C and Resource D, one after the other, and how Thread B may try to do the same but in different order. In this example, the deadlock will happen rather soon, because Thread A will get hold of Resource C while Thread B gets hold of Resource D. Whoever needs each other’s resource first will cause the deadlock .

Despite how simple this problem looks, it is the culprit of a lot of bugs in a lot of multithreaded software. The simple solution would be to prevent each process from attempting to access a busy resource. But how can this be achieved?

Solving the Deadlocking Problem

The deadlock problem has many established solutions. Mutex and Semaphores being the most used ones. There is also Inter-process communication through pipes, but we will not talk about it because it goes beyond a single program.

Mutex
Mutex is short for Mutually exclusive lock (or flag). A mutex will signal other processes that a process is currently using some resource by adding a lock to it and preventing other processes from grabbing until that lock is freed. Ideally, a process will acquire a lock to all the resources it will need at once, even before using them. This way, if Thread A needs Resource C and Resource D, it can lock them before Thread B tries to access them. Thread B will wait until all the locks are freed before attempting to access the resources itself. Figure 1-2 illustrates how this is done.

A schematic diagram depicts the execution flow of Thread A and B, which consists of Resources C and D with the lock symbol, continue execution with locked resources, and wait for resources to get unlocked with the refresh symbol.

Figure 1-2

Using a mutex

Keep in mind that in this specific situation, it means that Thread A and Thread B, while multithreaded, will not run strictly at the same time, because Thread B needs the same resources as Thread A and it will wait for them to be free. For this reason, it is important to identify tasks that can be run in parallel before designing a multithreaded system.

Semaphores

A Semaphore is a type of lock, very similar to a mutex . With this solution, a task will acquire a lock to a resource. Any other tasks that arrive and need that resource will see that the resource is busy. When the original thread frees the resources, it will signal interested parties that the resource is free, and they will follow the same process of locking and signaling as they interact with the resource.

Mutex and Semaphores sound similar, but the key difference lies in what kind of resource they are locking, and if you ever find yourself in a situation in which you need to decide what to protect (you won’t find such a case when using async/await), it is important to think about what makes sense in your use case. In general, a mutex can be used to protect anything that doesn’t have any sort of execution on its own, such as files, sockets, and other filesystem elements. Semaphores can be used to protect execution in your program itself such us shared code execution paths. These can be mutating functions or classes with shared state. There may be cases in which executing a function by more than one thread will have unintended consequences, and semaphores are a good tool for that. For example, a function that writes a log to disk may be protected with a semaphore because if multiple processes write to the log at the same time, the log will become corrupted for the end user. Whoever has a semaphore to the logging function needs to signal the other interested parties that it is free so they can continue their execution.

A rule of thumb is to always add a timeout whenever a semaphore is acquired and let processes timeout if they take too long. If this is acceptable in the context of your program (no data corruption can occur, or there can be no other unintentional consequences to canceling tasks), consider adding a timeout so the semaphore is free again after a certain period.

The use of semaphores will allow threads to synchronize, as they will be able to coordinate the resources amongst each other so they both have their fair usage of them.

Starvation

The Starvation problem happens when a program is stuck in a state of perpetually waiting for resources to do some work but never receiving them. Figure 1-3 illustrates this problem.

A circular model represents the five people symbol outside the circle, which resembles a dining table with five forks and plates.

Figure 1-3

The dining philosophers problem

Figure 1-3 describes what is known as the dining philosophers problem, and it is a classical representation of the starvation problem .

The problem illustrates five philosophers who want to eat at a shared table. All of them have their own dishes, and there are five forks. However, each philosopher needs two forks to eat. This means that only two philosophers get to eat, and only the ones who are not sitting directly next to each other. When a philosopher is not eating, he puts both forks down. But there can be a situation in which a philosopher decides to overeat, leaving another to starve.

Luckily for low-level multithreading developers, it’s possible to use the semaphores we talked about above to solve this problem. The idea is to have a semaphore that keeps track of the used forks, so it can signal another philosopher when a fork is free to be used.

Race Conditions

Likely the multithreading problem most developers are familiar with, Race Conditions are very similar to deadlocks , with the exception that they can cause data or memory corruption that will lead to other unpleasant consequences. When we are dealing with multithreading, it isn’t inherently a bad thing that two processes are reading data at the same time. If the resource is read-only, there is no harm. However, if the processes can modify or update the resource in any way, the processes will continually overwrite the data another process just wrote, and this will eventually lead to reading and writing corrupted data. If the resource in question is user data, we will have unhappy users. If the resource is provided by the OS, we can cause other unwanted consequences and eventually reach one of the multithreading issues such as deadlocks . Figure 1-4 illustrates this pitfall.

A schematic diagram depicts the history log in the center, with Thread A and Thread B pointing towards it.

Figure 1-4

Multiple threads writing to the same file at the same time

If a history log is being written to by any threads with no control, events may be registered in a random order, and one thread may override the line another thread just wrote. For this reason, the History Log should be locked at the writing operation. A thread writing to it will get a mutex to the log, and it will free it after it’s done using it. Anyone else needing to write to the log will be waiting until the lock is freed.

Deadlocks and race conditions are very similar. The main difference, in deadlocks, is we have multiple processes waiting for the other to finish, whereas in race conditions we have both processes writing data and corrupting it because no processes know that the resource in question is being used by someone else. This means that the solutions for deadlocking also apply to race conditions , so just block your resource with a mutex or semaphore to guarantee exclusive access to the resource by one process only. This is called an Atomic Operation.

Livelocks

There is a saying in life that goes “you can have too much of a nice thing.” It is important to understand that throwing locks willy-nilly is not a solution to all multithreading problems. It may be tempting to identify a multithreading issue, throw a mutex at it, and call it a day. Unfortunately, it’s not so easy. You need to understand your problem and identify where the lock should go.

Similarly, this problem can occur when processes are too lenient with the conditions under which they can release and attain resources. If Thread A can ask Thread B for a resource and Thread B complies without any thought, and Thread B can do the same to Thread A, it may happen that eventually they will not be able to ask each other for the resource back because they can’t even get to that point of the execution.

This is a classic example in real life. Suppose you are walking towards a closed door and someone else arrives to it at almost the same time as you. You may want to let the other person go in first, but they may tell you that you should go first instead. If you are familiar with this awkward feeling, you know what a livelock feels like for our multithreaded programs.

Existing Multithreading and Concurrency Tools

Apple offers some lower-level tools to write multithreaded and concurrent code. We will show examples for some of them. They are listed from lower level to higher level. The lower the level, the harder it is to use that system correctly, and the less likely it is that you will encounter it in the real world anyway. You should be aware of these tools so you can be prepared to work with them if you ever need to.

pthreads

pthreads (POSIX Threads) are the implementation of a standard defined by the IEEE.1 The POSIX part of their name (Portable Operating System Interface) tells us that they are available in many platforms, and not only on Apple’s operating systems. Traditionally, hardware vendors used to sell their products offering their own, proprietary multithreading APIs. Thanks to this standard you can expect to have a familiar interface in multiple POSIX systems. The great advantage of pthreads is that they are available in a wide array of systems if they are POSIX compliant, including some Linux distributions.

The disadvantage is that pthreads are purely written in C. C is a very low-level programming language, and it is a language that is slowly fading from the knowledge base of many developers. The younger they are, the less likely they are to know C. While I do not expect C to disappear any time soon, the truth is that it’s very hard to find iOS developers who know the C programming language, let alone use it correctly. pthreads are the lowest-level multithreading APIs we have available, and as such they have a very steep learning curve. If you opt to use pthreads , it’s because you have highly specific needs to control the entire multithreading and concurrency flow, though most developers will not need to drop down to these levels often, if at all. You will be launching threads and managing resources such as mutex more manually. If you are familiar with pthreads because you worked with them in another platform, you can use your knowledge here too, but be aware future programmers who will maintain your code may not be familiar with either pthreads or the C language itself.

NSThreads

NSThread is a Foundation object provided by Apple. They are low level, but not as low level as pthreads. Since they are foundation objects, they offer an Objective-C interface . Offering this interface will expose the tool to many more developers, but as time goes on, less iOS developers are likely to know Objective-C. In fact, it’s entirely possible to find iOS developers with some years of experience who have never had to use this language before, although it can be used in Swift as well.

If you want to work with multithreading, you will end up creating multiple NSThread objects, and you are responsible for managing them all. Each NSThread object has properties you can use to check the status of the thread, such as executing, cancelled, and finished. You can set the priority of each thread which is a very real use case in multithreading. You can even get the main thread back and ask if the current thread is the main thread to avoid doing expensive work on it.

Just like pthreads , most developers will not have to drop down to this level often, if at all.

The Grand Central Dispatch (GCD)

We will now talk about the first high-level tool for high-level concurrency and multithreading on Apple platforms. The Grand Central Dispatch (simply called GCD from here on) is so ubiquitous throughout Apple’s SDKs that you have likely used it even without knowing. I’d be surprised if I met a developer who has never written a line like Listing 1-3.
DispatchQueue.main.async {
    nameLabel.text = userResponse.username
}
Listing 1-3

Calling code on the main thread

Looks familiar? This is one of the most famous calls on the GCD because it allows you to defer some work quickly and painlessly to be done on the main thread . This call is likely to be found after a subclass of URLSessionTask finishes some work and you want to update your UI, for example.

The GCD , being high level, saves you from doing a lot of work than pthread s and NSThread s . With the GCD, you will never concern yourself with managing threads manually. It truly is a high-level framework in that sense, but it carries its own baggage as well. Listing 1-4 shows a classic issue some developers have when working with the GCD .
func fetchUser() {
    userApi.fetchUserData { userData in
        DispatchQueue.main.async {
            self.usernameLabel.text = userData.username
            self.userApi.fetchFavoriteMovies(for: userData.id) { movies in
                DispatchQueue.main.async {
                    self.userMovies = movies
                }
            }
        }
    }
}
Listing 1-4

The pyramid of doom

Listing 1-4 shows hypothetical code that would retrieve a user’s data and their favorite movies from somewhere. This usage is very typical with the GCD . When your work requires you to do more than one call that depends on the task of another, your code starts looking like a pyramid of doom . There are ways to solve this (like creating different functions for fetching the user data and their movies), but it’s not entirely elegant.

Despite its drawbacks, the GCD is a very popular way to create concurrent and multithreaded code. It has a lot of protections in place already for you, so you don’t have to be an expert in the theory of multithreading to avoid making mistakes. The samples we saw here only show a very tiny bit of functionality it offers. While the tool is high level enough to save you from many headaches, it exposes a lot of lower level functionality, and it gives you the ability to directly interact with some primitives such as semaphores .

Finally, this technology is open source, so it’s possible to find in platform outside of anything Apple develops. The GCD is big, and talking about its features would go outside the scope of this book, but be aware that it has been in use for a very long time and it’s possible you are going to see some advanced techniques with it in your career.

The NSOperation APIs

Sitting at a higher level than the GCD , the NSOperation APIs are a high-level tool to do multithreading. Despite the fact they are not as widely known as the GCD, they found their place in some parts of the SDK. For example, the CloudKit APIs make use of this technology.

This one truly abstracts a lot of the pains of multithreading for you, but it loses a lot of flexibility. You will never have to manage locks or semaphores with it, and in turn the API is simple. This tool abstracts so many details to the point that you don’t even know if tasks you run with it are running on different threads. If the system thinks it makes sense to run your tasks in the same thread, it will do that. Otherwise, it may honor your intention of running in multiple threads. It may even choose to run your task in the main thread if it sees it’s not worth it to launch another thread for its work. Listing 1-5 creates two different tasks that count from 1 to 10 and from 10 to 20 in different queues.
func startCounting() {
    /// We will need a queue for this.
    let operationQueue = OperationQueue()
    /// You can give your queue an optional name, if you need to identify it later.
    operationQueue.name = "Counting queue"
    /// This will just count from 1 to 10...
    let from1To10 = BlockOperation {
        for i in (1 ... 10) {
            print(i)
        }
    }
    /// ... and this from 11 to 20
    let from11To20 = BlockOperation {
        for i in (11 ... 20) {
            print(i)
        }
    }
    /// Add the operations to the queue
    operationQueue.addOperation(from1To10)
    operationQueue.addOperation(from11To20)
    /// To ensure the program doesn't exit early while the operations are running.
    operationQueue.waitUntilAllOperationsAreFinished()
}
Listing 1-5

Multithreaded usage of the NSOperation APIs

Using these APIs is very simple. You begin by creating an OperationQueue. This queue’s responsibility is to execute any task you add to it. You can also give it a name to refer to it later, or if you need to search for it.

In Listing 1-5 , we create two tasks (in this case, instances of BlockOperation – the original API is in Objective-C, so in Swift, it’s a closure), from1To10 and from11To20. They begin executing as soon as they are submitted to the queue via the OperationQueue.addOperation call. In this example, you will see that the numbers get printed almost in an interleaved manner. The results you get are going to be different each time you run the program.

It’s very easy to run multiple tasks at once, but what if you want to run one task after the other because one of them depends on the data of another, or simply because it makes sense to do so?

In that case, BlockOperation has a method that allows you to define an operation as a dependency of another. If you make from1to10 a dependency of from11to20, then the number sequence will be printed in the order you expect. Listing 1-6 modifies a portion of Listing 1-5 to print the numbers in order by creating a dependency.
// Ensure the numbers print in order. We do this before adding the operations to the queue.
from11To20.addDependency(from1To10)
/// Add the operations to the queue
operationQueue.addOperation(from1To10)
operationQueue.addOperation(from11To20)
Listing 1-6

Adding operations as dependencies of other operations

It’s worth to note that while you don’t have thread management abilities with this framework, you can check the status of each operation (isCancelled, isFinished, etc.), and you can cancel your operations at any time (by calling cancel on it). If an operation gets cancelled, other operations that depended on it will also be cancelled.

Using the NSOperation APIs is simple, as you have evidenced. It can still be a great tool when you have simple multithreaded needs.

Introducing async/await

async/await is a high-level system to write concurrent and multithreaded code. By using it, you don’t have to think about manual thread management or deadlocks . The system will take care of all those details and more for you under the hood. By using very simple constructs, you will be able to write safe and powerful concurrent and multithreaded code. It is worth noting that this system is very high level. It’s hard to use it incorrectly. While the system won’t give you fine-grain control over the concurrency and multithreading primitives, it will give you a whole set of abstractions to think of multithreaded code very differently. Semantically, it’s hard to compare async/await with any of the other systems we have explored before, because async/await is deeply integrated into Swift, the language, itself. It’s not an add-on in the form of a framework or a library. The primitives this system exposes are easier to understand thanks to Swift’s focus on readability.

To give you a quick look of what we will be studying for the rest of this book, we will rewrite Listing 1-4 in Listing 1-7 using async/await .
@MainActor
func fetchUser() async {
    let userData = await userApi.fetchUserData()
    usernameLabel.text = userData.username
    let movies = userApi.fetchMovies(for: userData.id)
    userMovies = movies
}
Listing 1-7

async/await in action

Listing 1-7 has gotten rid of a lot of code in Listing 1-4. You can see that the code that defers the results to the main thread is gone. You can also see that the code can be read from top to bottom – just like a normal procedural programming style would do! This version of the user data fetching API is simpler to read and simpler to write. @MainActor is what’s known as a global actor. We will explore actors and global actors later in the book. For now, know that the @MainActor will ensure that member updates within a function or class marked with it will run in the main thread .

By understanding when to use the system’s keywords directly (Listing 1-7 shows that async and await are keywords themselves) and the rest of the system’s primitives, you will be able to write concurrent and multithreaded code that any programmer will be able to understand. And to make things even better, if a developer is new to Apple platform development but they have experience with another technology that makes use of async/await , they will be able to quickly become familiar with the system. Your codebase will be cleaner and welcoming for new developers in your team.

Using async/await will prevent you from having to write low-level concurrent code. You will never manage threads directly. Locks are also not your responsibility at all. As you saw, this new system allows us to write expressive code, and pyramids of doom are a thing of the past.

It is important to remember that as much as a high-level system this is, you may find the extremely peculiar case in which you need a lower-level tool. That said, most developers will be able to go through their careers without finding a single use case to put async/await aside.

Requirements

To follow along, you will need to download at least Xcode 13 from the App Store. If you want to use async/await in iOS 14 and iOS 13, you will need Xcode 13.3. You should be comfortable reading and writing Swift code. Some examples will be written in SwiftUI so as to prevent the UI code distracting you from the actual contents of each chapter.

Summary

Concurrency and multithreading are traditional computing problems. Doing concurrency at the lower level and managing resources yourself can be a challenge because they are easy to misuse. Misusing these resources and not getting your multithreaded code right will result in bugs in the best case and user data corruption in the worst case. Because of this, many developers, including Apple, have designed multiple tools to abstract away the details and provide easier interfaces. In the case of Apple, they have given developers the following tools (sorted from lower level to higher level):
  • pthread s

  • NSThread s

  • The Grand Central Dispatch (GCD)

  • NSOperation and related APIs

  • async/await

Most developers will never need to go down to the pthread or NSThread level . The GCD , and NSOperation APIs provide higher-level abstractions, whereas “async/await” not only will provide abstractions for the multithreading primitives, but it will also provide a whole system that most developers can use without even knowing what the components underneath are. This new system allows us to write shorter code that is easier to read and easier to write. It’s worth studying this new system in detail, and that’s what this book is about.

Exercises

Answer the Questions
  1. 1.

    When using traditional concurrency tools, what are some pitfalls you can fall into if you don’t use them correctly?

     
  2. 2.

    Prior to async/await, what were some concurrency tools available on Apple’s platforms?

     
  3. 3.

    What advantages does async/await have over other, lower-level concurrency tools?

     
..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset
18.225.5.176