Mastering Swift's Development Blog

Follow Us On Twitter
  • Jon Hoffman

Parallelism and Concurrency in Swift


The async and await keywords were added with SE-0296 in Swift 5.5. The latest version the Mastering Swift series was written for Swift 5.3, therefore it does not contain information about async and await. This post is meant to be a partial update for Chapter 16, Concurrency and Parallelism in Swift of that book. The reason I say partial is we are only covering Async and Await in this post and not looking at Task or Actors.


Concurrency and parallelism

Concurrency is the concept of multiple tasks starting, running, and completing within the same time period. This does not necessarily mean that the tasks are executing simultaneously. In fact, in order for tasks to be run simultaneously, our application needs to be running on a multicore or multiprocessor system. Concurrency allows us to share the processor or cores with multiple tasks; however, a single core can only execute one task at a given time.


Parallelism is the concept of two or more tasks running simultaneously. Since each core of our processor can only execute one task at a time, the number of tasks executing simultaneously is limited to the number of cores within our processors and the number of processors that we have. As an example, if we have a four-core processor, then we are limited to running four tasks simultaneously. Today's processors can execute tasks so quickly that it may appear that larger tasks are executing simultaneously. However, within the system, the larger tasks are actually taking turns executing subtasks on the cores.


In order to understand the difference between concurrency and parallelism, let's look at how a juggler juggles balls. If you watch a juggler, it seems they are catching and throwing multiple balls at any given time; however, a closer look reveals that they are, in fact, only catching and throwing one ball at a time. The other balls are in the air waiting to be caught and thrown. If we want to be able to catch and throw multiple balls simultaneously, we need to have multiple jugglers.


This example is good because we can think of jugglers as the cores of a processor. A system with a single-core processor (one juggler), regardless of how it seems, can only execute one task (catch or throw one ball) at a time, just like a single-core processor can only execute one task at a time. If we want to execute more than one task at a time (parallel processing), we need to use a multicore processor just like if we want to catch more than one ball at the same exact time we need multiple jugglers.


Back in the days when all of the processors were single-core, the only way to have a system that executed tasks simultaneously was to have multiple processors in the system. This also required specialized software to take advantage of the multiple processors. In today's world, just about every device has a processor that has multiple cores, and both iOS and macOS are designed to take advantage of these multiple cores to run tasks simultaneously.


Traditionally, the way applications added concurrency was to create multiple threads; however, this model does not scale well to an arbitrary number of cores. The biggest problem with using threads was that our applications ran on a variety of systems (and processors), and in order to optimize our code, we needed to know how many cores/processors could be efficiently used at a given time, which is usually not known at the time of development.


To solve this problem, many operating systems, including iOS and macOS, started relying on asynchronous functions. These functions are often used to initiate tasks that could possibly take a long time to complete, such as making an HTTP request or writing data to disk. An asynchronous function typically starts a long-running task and then returns prior to the task's completion. Usually, this task runs in the background and uses a callback function (such as a closure in Swift) when the task completes.


These asynchronous functions work great for the tasks that the operating system provides them for, but what if we need to create our own asynchronous functions? Starting with Swift 5.5, we can use the async and await keywords.


Asynchronous Functions

An asynchronous function is a special function that can be paused part way through, while waiting for a task to complete, in order for other short-term tasks, like updating the UI, to run. To mark a function as an asynchronous function, we add the async keyword to the functions definition right after the parameter list and before the return values. Here are a couple examples on how we would define an async function.

func MyAsyncFunction(name: String) async {}
func MyAsyncFunction(name: String) async -> String {}
func MyAsyncFunction(name: String) async throws -> String {}

To call an asynchronous function we will use the await keyword. We will see how this is used for both parallel and concurrent code later on in this post, but the basic way is to use await as shown here:

await MyAsyncFunction(name: “Jon”)

For the examples in this post, I created a simple command line tool to run the code. Prior to Swift 5.7 we needed to jump through some minor hopes to get asynchronous code to work properly from a command line tool but starting with Swift 5.7and SE-0343 we can now run concurrency in top level code. We will assume that you are using Swift 5.7 or above for this post.


Most examples that I see using async and await show sample calls to webservices to download lists, images, or other similar task. That is primarily what async and await are used for but not sure if it gives us the best understanding on what is going on especially demonstrating how parallel processing works. For our examples here we are going to create a couple functions that will help us simulate long running tasks. These functions are:

func doCalc() {
    let x = 100
    let y = x*x
    _ = y/x
}

func performCalculation(_ iterations: Int, tag: String) async {
    let start = CFAbsoluteTimeGetCurrent()
    for _ in 0 ..< iterations {
        doCalc()
    }
    let end = CFAbsoluteTimeGetCurrent()
    print("time for \(tag):\(end-start)")
}

The doCalc() function simply performs two basic calculations to “waste time” but still perform work that requires the cores of the processor to be working. It is a pretty good assumption that you will be running this code on a system with multi-core architecture, you should be able to run multiple instances of the doCalc()function in parallel. The performCalculation() function is an asynchronous function that will call the doCalc()function a specified number of times and calculate the time it took to execute all interactions.


Now we will use these functions to demonstrate how an asynchronous function would work using async and await. Let’s create another function with the following code in it.

func concurrency() async {
    let start = CFAbsoluteTimeGetCurrent()
    await performCalculation(100_000, tag: "first_Concurrent")
    await performCalculation(1_000_000, tag: "second_Concurrent")
    await performCalculation(10_000, tag: "third_Concurrent")
    let end = CFAbsoluteTimeGetCurrent()
    print("total time:\(end-start)")
}

In this code we make three calls to the performCalculation() function running 100000, 1000000 and 10000 iterations of our doCalc() function. We also calculate the time it takes to perform all three functions and print the time to the console. One thing to note before we see how this functions runs, is we are using the awaitkeyword each time we are calling the performCalculation() function. We could call this function like this:

await concurrency()

When we run this, we should see results that look like this (probably different runtimes).

time for first_Concurrent:0.025866031646728516
time for second_Concurrent:0.2556999921798706
time for third_Concurrent:0.002593994140625
total time:0.2842390537261963

Because we are using the await keyword each time we are calling the performCalculation() function, the code is waiting for each call to the performCalculation() function to finish before making the next call. The advantage we get with concurrent or asynchronous calls such as this, is if we were making a call to a webservice or something similar, we would not be blocking the main thread from doing other work, like updating the UI, while we were waiting for the call. We also control the order that the functions are called and ensure that one function is finished prior to calling the next.


Note:  If we look up the definition of concurrency, we will see something like this:  Concurrency is about multiple tasks which start, run, and complete in overlapping time periods, in no specific order.  How we are using await here, we are specifying the order.

If we had code that we wanted to run in parallel, maybe large calculations or route mapping for multiple routes, we can do that as well however the code would be a little different than our previous concurrent code. Let’s create a new function with the following code in it.

func parallelism() async {
    let start = CFAbsoluteTimeGetCurrent()
    async let one = performCalculation(100_000, tag: "first_Parallel")
    async let two = performCalculation(1_000_000, tag: "second_Parallel")
    async let three = performCalculation(10_000, tag: "third_Parallel")
    let _ = await [one, two, three]
    let end = CFAbsoluteTimeGetCurrent()
    print("total time:\(end-start)")
}

With this code, we are not using the await keyword for each call to the performCalculation() function instead we are using async let to call the functions and then using the await keyword with the array of constants. Each of the functions will start as soon as async let calls them however we will not wait for the execution to finish before moving to the next line. We use the await keyword with the return values of the performCalculation() function to wait for the execution for all the functions to finish. We could run this function like this:

await parallelism()

If we run this code the results should look something like this:

time for third_Parallel:0.003170013427734375
time for first_Parallel:0.027894020080566406
time for second_Parallel:0.25748205184936523
total time:0.257580041885376

Notice that this time, the third call finishes first, followed by the first function call and finally the second one finishes. The reason for this is all three tasks are run in parallel, if your system has multiple cores.


Let’s look at one more example of parallelism. We mentioned earlier that the number of items that we can actually run in parallel is limited by the number of cores in our processor. So what happens if we run more parallel functions then we have cores. The system that I have has a 10 core processor, so lets see what happens if I run this function:

func parallelism3() async {
    let start = CFAbsoluteTimeGetCurrent()
    async let one = performCalculation(100_000, tag: "1")
    async let two = performCalculation(1_000_000, tag: "2")
    async let three = performCalculation(10_000, tag: "3")
    async let four = performCalculation(100_000, tag: "4")
    async let five = performCalculation(1_000_000, tag: "5")
    async let six = performCalculation(10_000, tag: "6")
    async let seven = performCalculation(100_000, tag: "7")
    async let eight = performCalculation(1_000_000, tag: "8")
    async let nine = performCalculation(100_000, tag: "9")
    async let ten = performCalculation(100_000, tag: "10")
    async let eleven = performCalculation(1_000, tag: "11")
    async let twelve = performCalculation(1_000, tag: "12")
    let _ = await [one, two, three, four, five, six, seven, eight, nine, ten, eleven, twelve]
    let end = CFAbsoluteTimeGetCurrent()
    print("total time:\(end-start)")
}

Notice that we are calling twelve functions in parallel. Let’s jump right into what the results of this code may look like when it is run:

time for 3:0.00282895565032959
time for 11:0.0002720355987548828
time for 12:0.0002740621566772461
time for 6:0.007151961326599121
time for 4:0.029875993728637695
time for 1:0.03155195713043213
time for 10:0.03337705135345459
time for 7:0.044306039810180664
time for 9:0.05090892314910889
time for 5:0.2798880338668823
time for 2:0.2806659936904907
time for 8:0.2858400344848633
total time:0.28616297245025635   

Even though I only have ten cores, which means the system can actually only execute ten tasks simultaneously, all of the parallel function calls are ran. Remember our juggler example at the beginning of this post? Even though we only have ten cores, all twelve task seen to be running in parallel because the cores are juggling the tasks.


There is so much more to show and understand especially with Tasks and Actors but we will save that for another post. We hope you enjoyed this one.


masteringSwift.jpeg

Mastering Swift 5.3

The sixth edition of this bestselling book, updated to cover through version 5.3 of the Swift programming language

Amazon

Packt

pop.jpeg

Protocol Oriented Programming

Embrace the Protocol-Oriented design paradigm, for better code maintainability and increased performance, with Swift.

Amazon

Packt