I am happy to say that I have enjoyed and survived the 11 weeks of content for the accelerated iOS development bootcamp I embarked on in August. This was the last week with full content and assignments and the next two weeks are dedicated to polishing the capstone project and helping participants improve soft and interview skills.
This week it was all about modern concurrency. Although we had covered the topic of concurrency in Week 7 with GCD and Operations, we started learning about modern concurrency with async/await
in the last few weeks. This time we tackled more advanced topics including sequences and streams, tasks and task groups and actors. Some of all the great things we can do with modern concurrency is covered in this WWDC21 talk Swift Concurrency: Behind the scenes.
Some History
GCD came out in 2009 and it helped Swift launch in 2014 with the support for concurrency and asynchonours networking. However, that support wasn’t native. It borrowed the abilities from Objective-C. During WWDC2021, with the Swift 5.5, Apple introduced the new modern concurrency model. That year iOS 15.0 was introduced and developers usually need to wait a few years to start implementing all the new good stuff in their code. Most apps support latest iOS version and two versions behind, so iOS 13.0 and above. But, Apple made the new APIs available on iOS 13.0+. It was a much welcomed announcement among developers.
Enter Swift Concurrency
The Swift concurrency model provides new tools to achieve the same goals as the GCD example:
async
indicates that a method or function is asynchronous. Using it lets you suspend execution until an asynchronous method returns a result.await
indicates that your code might pause execution while it waits for anasync
-annotated method or function to return.- A
Task
is a unit of asynchronous work. You can wait for a task to complete or cancel it before it finishes.
This Swift achieves the same as GCD with fewer lines of code and it provides a lot more information to the compiler and the runtime:
- The
async
keyword tells the compiler that a function is asynchronous and so it can suspend and resume execution one or more times in the future. - With the use of
try
we can let a function return data or throw an error. The compiler checks this, so you don’t have to worry about forgetting to handle an erroneous code path! Task
executes its code in an asynchronous context, so the compiler knows what code is safe (or unsafe) to write in this closure. It won’t let you write potentially unsafe code, like mutating shared state.- You call an asynchronous function with the
await
keyword. This gives the runtime a chance to suspend or cancel your code and lets the system constantly update the priorities in the current task queue. The runtime handles this for you, so you don’t have to worry about threads and cores.
The new Swift concurrency model is tightly integrated with the language syntax, the Swift runtime and Xcode. In addition to async/await, you also get:
- Context-aware code compilation: The compiler keeps track of whether a given piece of code could run asynchronously. This enables new features like actors, which differentiate between synchronous and asynchronous access to their state at compile time and protect against corrupting data by making it harder to write unsafe code.
- Structured concurrency: Each asynchronous task is now part of a hierarchy, with a parent task and a given priority of execution. This hierarchy allows the runtime to cancel all child tasks when a parent is canceled or wait for all children to complete before the parent completes. It’s all under control. Outcomes are more obvious, with high-priority tasks running before any low-priority tasks in the hierarchy.
- And a cooperative thread pool: The new model manages a pool of threads to make sure it doesn’t exceed the number of CPU cores available. This way, the runtime doesn’t need to create and destroy threads or constantly perform expensive thread switching. Instead, your code can suspend and, later on, resume very quickly on any of the available threads in the pool.
What About Combine?
- Combine is a higher level, more complex framework, based on the reactive streams protocol. Its operators help you combine events and data over time from various sources, enabling you to solve very complex problems.
- Swift concurrency offers lower-level APIs for executing asynchronous code. Use it to replace GCD and Operations with tasks and async sequences and save Combine for more complex apps.
- 1It’s easy to integrate Combine into Swift concurrency, looping asynchronously over
Publisher.values
. You’ll do this in Part 2 of this course. - And Swift concurrency APIs are available on Linux while Combine isn’t.
Finally, we can use async let
to run tasks concurrently, but what if we need to run a thousand tasks in parallel, or we don’t know until runtime how many tasks need to run in parallel? We need more than async let
! Let’s have a look
Task Groups
The answer to the question above is TaskGroup
. We can create concurrency on the fly and safely process the results, while reducing the possibility of data races.
Here’s how you might use a task group: Let’s say that we are interested in getting a large number of images. We set tasks to do exactly this.
We set each task’s return type as Data
with the of
argument. The group as a whole will return an array of UIImage
. Let’s say that elsewhere in our code, we have calculated the number of images we want to fetch, and loop through them. We mark the code with ThrowingTaskGroup
and Inside the for
loop, we use group.addTask { ... }
to add tasks to the group.
Task groups conform to AsyncSequence
so, as each task in the group completes, we collect the results into an array of images and return it. We can manage the group’s tasks with the following APIs:
addTask(priority:operation:)
adds a task to the group for concurrent execution with the given (optional) priority.addTaskUnlessCancelled(priority:operation:)
is identical toaddTask(...)
, except that it does nothing if the group is already canceled.cancelAll()
cancels the group. It cancels all currently running tasks, along with all tasks added in the future.isCancelled
returns true if the group is canceled.isEmpty
returns true if the group has completed all its tasks, or has no tasks to begin with.waitForAll()
waits until all tasks have completed. Use this when you need to execute some code after finishing the group’s work.
Actor
An actor in Swift can safely access and mutate its own state. A special type called a serial executor, which the runtime manages, synchronizes all calls to the actor’s members. The serial executor, like a serial dispatch queue in GCD, executes tasks one after another. By doing this, it protects the actor’s state from concurrent access.
Access to the actor from other types is automatically performed asynchronously and scheduled on the actor’s serial executor. This is called the state isolation layer, which ensures that all state mutation is thread-safe.
If Xcode detects that execution is non sate, it will let you know that you can’t update the actor state from “outside” its direct scope. All code that isn’t confined to the serial executor of the actor is “outside” access. That means it includes calls from other types and concurrent tasks — like your TaskGroup
, in this case. Sendable
is a protocol that indicates that a given value is safe to use in concurrent code.
It is possible to make quick changes that drive the UI, with MainActor.run(...)
to access MainActor
from anywhere. In many cases, because your app runs on a single main thread, you can’t create a second or a third MainActor
. So it does make sense that there’s a default, shared instance of that actor that you can safely use from anywhere.
There are other times you need an app-wide, single-instance shared state, for example:
- The app’s database layer is usually a singleton type that manages the state of a file on disk.
- Image or data caches are also often single-instance types.
- The authentication status of the user is valid app-wide, whether they have logged in or not.
Luckily, Swift allows you to create your own global actors, just like MainActor
, for situations where you need a single, shared actor that’s accessible from anywhere.
Annotating an actor with the @globalActor
attribute makes it automatically conform to the GlobalActor
protocol. Its only requirement is a static property called shared
to expose an actor instance that you make globally accessible. You don’t need to inject the actor from one type to another, or into the SwiftUI environment.
Jelly Belly Update
With the knowledge covered in this week, I was able to implement an area that was left undone in the previous weeks – image downloading and caching. Each of the dishes in Jelly Belly was able to show an image of the dish. This week I was able to fix the lack of images from the previous couple of weeks with the use of modern concurrency. The app works great!
I was also able to implement a network monitor that alerts the user in case there is no network connection. A simple message is displayed in the app if this situation arises.
Week 12 – Here I come!
Pingback: iOS Development Bootcamp – Quantum Tunnel
Comments are closed.