Swift 5.5 introduces Swift concurrency. It allows us to write asynchronous code in synchronous mechanism. Greatly reduce the complexity of asynchronous code. This article will introduce the basics of Swift concurrency.
Table of Contents
async & await
Swift’s async/await allows us to write asynchronous code as if it were synchronous code, as follows. In the fourth line, when getImageData() is called, getImage() will suspend, and then wait for getImageData() to complete. At this point, getImage() will give up the current thread so that the current thread will not be blocked. When getImageData() finishes executing, getImage() will resume executing the rest code.
func getImageData() async throws -> Data func getImage() async throws -> UIImage? { let data = try await getImageData() return UIImage(data: data) }
Suspension Points
A suspension point is a point when an asynchronous function gives up its thread. In the above code, the fourth line calling getImageData() is a suspension point. When an asynchronous function runs on a thread and reaches a suspension point, other codes can run on the same thread. This is very important when the thread is the UI main thread. Because this makes the UI thread not blocked.
Generally speaking, where the await
keyword is placed, it is a suspension point.
Structured Tasks
Tasks
In the system, a task is a basic unit of concurrency. Every asynchronous function is executed in a task. A task contains its scheduling information, such as the priority of the task.
Child Tasks
An asynchronous function can create a child task. Child tasks inherit some information, such as priority, from their parent tasks. Child tasks and parent tasks can be executed concurrently. However, this concurrency is bounded. That is, an asynchronous function must wait for all its child tasks to finish before it ends.
Because tasks can have hierarchical relationships like parents and children, they are called structured tasks.
There are two ways to create a child task, one is TaskGroup and the other is async let
.
TaskGroup
The following code shows how to use TaskGroup to create multiple child tasks.
/// Concurrently chop the vegetables. func chopVegetables() async throws -> [Vegetable] { // Create a task group where each child task produces a Vegetable. try await withThrowingTaskGroup(of: Vegetable.self) { group in var rawVeggies: [Vegetable] = gatherRawVeggies() var choppedVeggies: [Vegetable] = [] // Create a new child task for each vegetable that needs to be chopped. for v in rawVeggies { group.addTask { try await v.chopped() } } // Wait for all of the chopping to complete, collecting the veggies into // the result array in whatever order they're ready. while let choppedVeggie = try await group.next() { choppedVeggies.append(choppedVeggie) } return choppedVeggies } }
async let
Another way to create child tasks is async let
, as follows. The second line creates a child task to execute chopVegetables(). makeDinner() does not wait for chopVegetables() to end, but continues execution. After that, use await
to wait for the completion of the child task and get the return value.
func makeDinner() async throws -> Meal { async let veggies = chopVegetables() async let meat = marinateMeat() async let oven = preheatOven(temperature: 350) let dish = Dish(ingredients: await [try veggies, meat]) return try await oven.cook(dish, duration: .hours(3)) }
Jobs
In the system, a Job is the basic unit of a schedulable work. In an asynchronous function, a piece of synchronous code to a suspension point, or to the end of the function, is a job. In the following code, the second to third lines are a job; the fourth to fifth lines are a job; the sixth line is a job.
func getUserProfile() async throws -> UserProfile { let url = "https://www.waynestalk.com/user" let data = try await getUserProfile(url) let userProfile = try JSONDecoder().decode(UserProfile.self, from: data) try await saveToDatabase() return userProfile }
Executors
An executor is a service that accepts job submissions and schedules threads to execute them.
Swift provides a default executor implementation. In general, when calling an asynchronous function, we don’t need to specify which executor to use for execution.
Unstructured Tasks
All the tasks we have discussed so far must have parent tasks. In other words, these tasks are child tasks. However, in practice, this is not possible in the current iOS development environment. Because most of the current iOS code (legacy code) is not concurrency code. So, when we want to call an asynchronous functions, we have to create a new task, and this task has no parent task. So, this is called unstructured tasks.
Task()
We can use Task() to create a new task as follows. viewDidLoad() will execute immediately after calling Task() to create a new task, without waiting for the new task finishing. So, viewDidLoad() and the new task will execute concurrently.
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let handle = Task { // This task will run in the UI thread. let userProfile = try await getUserProfile() } // You can cancel this task. handle.cancel() } private func getUserProfile() async throws -> UserProfile { let url = "https://www.waynestalk.com/user" let data = try await getUserProfile(url) let userProfile = try JSONDecoder().decode(UserProfile.self, from: data) try await saveToDatabase() return userProfile } ... }
Context Inheritance
If Task() is called within a task,
- It inherits the priority of the current task.
- It inherits all task-local values. Copy them all to a new task.
- If it is called in an actor’s function,
- It inherits the actor’s context. It will execute the new task in this actor’s executor.
- The closure inside Task {} will become actor-isolated.
If Task() is not called within a task,
- It will determine the best priority based on the current thread.
- It will be executed in the global concurrent executor.
Task.detached()
Task.detached() creates a new task like Task(), but it does not inherit the current context. That is, it does not inherit priority, task-local values, and actor context.
One situation where Task.detached() will be used is that in the UI thread, we want to create a task that is executed on a non-UI thread.
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() let handle = Task.detached { // This task will run in a non-UI thread. let userProfile = try await getUserProfile() } // You can cancel this task. handle.cancel() } private func getUserProfile() async throws -> UserProfile { let url = "https://www.waynestalk.com/user" let data = try await getUserProfile(url) let userProfile = try JSONDecoder().decode(UserProfile.self, from: data) try await saveToDatabase() return userProfile } ... }
withCheckedContinuation() & withCheckedThrowingContinuation()
withCheckedContinuation() and withCheckedThrowingContinuation() allow us to wrap legacy asynchronous code that uses callback into async/await code, as follows.
class ViewController: UIViewController { let webView = WKWebView() override func viewDidLoad() { super.viewDidLoad() Task { let result = try await execJavaScript("some js code") print(result) } } private func execJavaScript(_ js: String) async throws -> String { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<String, Error>) in webView.evaluateJavaScript(js) { result, error in if let error = error { continuation.resume(throwing: error) } else { continuation.resume(returning: "\(result ?? "")") } } } } }
Actors
Actors is a way provided by Swift to statically detect data races. In other words, Swift wants to be able to detect the code of data races at compile time, and this solution is actors.
Actors are a new Swift type. It is very similar to classes in that they are reference types, but actors do not support inheritance. In the code below, we declare an actor called BankAccount. It uses the same way as classes and structs to create instances.
actor BankAccount { var accountNumber: String var balance: Double init(accountNumber: String, balance: Double) { self.accountNumber = accountNumber self.balance = balance } func deposit(amount: Double) { balance = balance + amount } } let bankAccount = BankAccount(accountNumber: "123456789", balance: 300)
Actor Isolation
When we access actor variables or methods from outside the actor, it is called cross-actor reference. We can directly access the actor’s immutable variables outside of the actor BankAccount, because immutable variables will not change since they are declared. But if we directly access the mutable variables of the actor outside the actor BankAccount, or call the methods of the actor directly, Xcode will display the Actor-isolated property/method can not be referenced error, as follows.
class ViewController: UIViewController { let webView = WKWebView() override func viewDidLoad() { super.viewDidLoad() let bankAccount = BankAccount(accountNumber: "123456789", balance: 300) let balance = bankAccount.balance // Error: Actor-isolated property 'balance' can not be referenced from the main actor bankAccount.deposit(amount: 100) // Error: Actor-isolated instance method 'deposit(amount:)' can not be referenced from the main actor } }
This is because mutable variables and methods inside actors are actor-isolated. If you want to access actor-isolated variables or methods, you need access them like calling an asynchronous function, as follows.
This is because each actor contains a serial executor. An actor’s code must be executed on its own executor. In the example above, when we called bankAccount.deposit(amount:) we were calling it the same way we would call an asynchronous function. That is to say, we send a task to the actor’s executor, and wait for the executor to process its current task in order until the executor processes our task.
class ViewController: UIViewController { let webView = WKWebView() override func viewDidLoad() { super.viewDidLoad() Task { let bankAccount = BankAccount(accountNumber: "123456789", balance: 300) let balance = await bankAccount.balance await bankAccount.deposit(amount: 100) } } }
When an actor-isolated method in an actor accesses actor-isolated variables or methods in the same actor, it can access them synchronously. For example, deposit(amount:) in actor BankAccount can directly access the balance variable.
actor BankAccount { var balance: Double ... func deposit(amount: Double) { balance = balance + amount } }
So far, you may be confused about actors. Perhaps you can imagine an actor as an object containing the following.
- A data structure
- A queue
- A serial executor
When all the tasks that access the actor-isolated variables or methods of the actor will be queued into its internal queue. There is a serial executor inside the actor, which executes the tasks in the queue one by one. Therefore, at any point in time, only one task will be executed, and only one single thread will access the actor-isolated variables or methods of the actor. Therefore, actors will not have data races.
Actor Reentrancy
In the code below, both Person.thinkOfGoodIdea() and Person.thinkOfBadIdea() are actor-isolated methods. We call Person.thinkOfGoodIdea() and Person.thinkOfBadIdea() at the same time. Assume that Person.thinkOfGoodIdea() is executed first, when the execution reaches the suspension point on line 9, the actor-isolated method suspend, and the actor will execute other work first, that is, Person.thinkOfBadIdea(). When execution reaches line 15, the actor-isolated method suspend. The actor may execute the return value of line 9. This is called actor reentrancy.
Actor reentrancy eliminates deadlocks and improves performance by not unnecessarily blocking.
actor Person { let friend: Friend // actor-isolated opinion var opinion: Judgment = .noIdea func thinkOfGoodIdea() async -> Decision { opinion = .goodIdea await friend.tell(opinion, heldBy: self) return opinion } func thinkOfBadIdea() async -> Decision { opinion = .badIdea await friend.tell(opinion, heldBy: self) return opinion } } let goodThink = Task.detach { await person.thinkOfGoodIdea() } // runs async let badThink = Task.detach { await person.thinkOfBadIdea() } // runs async
nonisolated
When you are sure that a method in an actor will not access actor-isolated variables or methods, you can declare the method as nonisolated
. At this time, you can call it outside the actor like calling a synchronous function, as follows.
class ViewController: UIViewController { let webView = WKWebView() override func viewDidLoad() { super.viewDidLoad() Task { let bankAccount = BankAccount(accountNumber: "123456789", balance: 300) await bankAccount.deposit(amount: 100) let accountNumber = bankAccount.getAccountNumber() } } } actor BankAccount { private let accountNumber: String private var balance: Double init(accountNumber: String, balance: Double) { self.accountNumber = accountNumber self.balance = balance } func deposit(amount: Double) { balance = balance + amount } nonisolated func getAccountNumber() -> String { accountNumber } }
@globalActor & @MainActor
We can find that the biggest purpose of using actors is that we want its actor isolation, because actor isolation can eliminate data races. But it has a disadvantage that it can only be used inside actors. Imagine if we could use actor isolation cross all our code. In other words, in any line of code, we can use actor isolation to eliminate data races. Swift proposes global actors to achieve this goal.
Global actors are different from general actors. So don’t think of a global actor as a general actor just because of the similarity of the names. In this way, you will be confused and unable to understand the global actor.
A global actor can be a struct, enum, actor, or final class. It must have a static shared
variable. The following code shows how to declare a global actor and how to use it.
@globalActor struct SomeGlobalActor { actor MyActor { } static let shared = MyActor() } @SomeGlobalActor var someVariable = "something" class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() Task { let sv = await someVariable } } }
In general, we may not declare global actors ourselves. However, we often use a pre-defined global actor called the main actor.
@available(macOS 10.15, iOS 13.0, watchOS 6.0, tvOS 13.0, *) @globalActor final public actor MainActor : GlobalActor { public static let shared: MainActor @inlinable nonisolated final public var unownedExecutor: UnownedSerialExecutor { get } @inlinable public static var sharedUnownedExecutor: UnownedSerialExecutor { get } @inlinable nonisolated final public func enqueue(_ job: UnownedJob) public typealias ActorType = MainActor }
In iOS, any variables or methods marked @MainActor
will be actor-isolated to the UI main thread. In the code below, we can see that BankAccount.deposit(amount:) is executed on a background thread. However, since BankAccount.getAccountNumber() is marked as @MainActor
, it executes on the main thread.
In addition, if we mark a class as @MainActor
, all its variables and methods will be executed on the main thread.
In practical, if a piece of code needs to update the UI, we can mark it as @MainActor
, then it will be executed on the UI thread. This can replace DispatchQueue.main.async to execute a certain piece of code on the UI thread.
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() Task.detached { print("Task.detached, \(Thread.current)") let bankAccount = BankAccount(accountNumber: "123456789", balance: 300) await bankAccount.deposit(amount: 100) _ = await bankAccount.getAccountNumber() } } } actor BankAccount { private let accountNumber: String private var balance: Double init(accountNumber: String, balance: Double) { self.accountNumber = accountNumber self.balance = balance } func deposit(amount: Double) { balance = balance + amount print("deposit, \(Thread.current)") } @MainActor func getAccountNumber() -> String { print("getAccountNumber, \(Thread.current)") return accountNumber } } // Output: // Task.detached, <NSThread: 0x6000034c1000>{number = 7, name = (null)} // deposit, <NSThread: 0x6000034c1000>{number = 7, name = (null)} // getAccountNumber, <_NSMainThread: 0x600003494200>{number = 1, name = main}
It is worth noting that in UIKit, many UI-related codes are marked with @MainActor
. That is to say, when you call the methods in UIViewController or UIView in the background thread, it will switch to the UI main thread for execution.
@MainActor open class UIViewController : UIResponder, NSCoding, UIAppearanceContainer, UITraitEnvironment, UIContentContainer, UIFocusEnvironment @MainActor open class UIView : UIResponder, NSCoding, UIAppearance, UIAppearanceContainer, UIDynamicItem, UITraitEnvironment, UICoordinateSpace, UIFocusItem, UIFocusItemContainer, CALayerDelegate
Sendable & @Sendable
Mutable variables and methods within an actor are actor-isolated. Then when this variable is a reference type, how do we ensure that the variables in this reference type will not have data races. That is to say, if there is a class type variable in the actor, how can we ensure that the members in the class will not have data races? In other words, when calling actor’s methods, if its parameter or return value is class type, how to ensure that the members in this parameter or return value will not have data races? Swift proposes Sendable to solve this problem.
Sendable
is a marker protocol. That is, it is an empty protocol.
public protocol Sendable {}
All cross-actor variables must conform to Sendable
. That is to say, the parameters and return values of all actor-isolated methods must conform to Sendable
. Otherwise Xcode will display errors at compile time. However, any class that conforms to Sendable must also eliminate data races. Otherwise, Xcode will display errors at compile time. In the following code, class Money implements Sendable. Xcode will help us check whether Money may have data races during compilation.
class Money : Sendable { // Error: Non-final class 'Money' cannot conform to 'Sendable'; use '@unchecked Sendable' var amount: Double // Error: Stored property 'amount' of 'Sendable'-conforming class 'Money' is mutable var rate: Double // Error: Stored property 'rate' of 'Sendable'-conforming class 'Money' is mutable init(amount: Double, rate: Double) { self.amount = amount self.rate = rate } func calculateAmount() -> Double { amount * rate } }
Therefore, with Sendable, the Swift compiler can eliminate data races in cross-actor variables.
In addition to classes, we also need to consider another type, which is function type. When the parameter passed to the actor’s methods is a closure, if the closure modifies external variables, there will be data races. We can add @Sendable
on the parameter when the method is declared. In this way, Xcode will help us confirm whether there are date races in the closure at compile time, as follows.
class ViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() Task.detached { var amount = 1.0 let bankAccount = BankAccount(accountNumber: "123456789", balance: 300) await bankAccount.deposit { amount = amount * 100 // Error: Mutation of captured var 'amount' in concurrently-executing code return amount // Error: Mutation of captured var 'amount' in concurrently-executing code } } } } actor BankAccount { private let accountNumber: String private var balance: Double init(accountNumber: String, balance: Double) { self.accountNumber = accountNumber self.balance = balance } func deposit(computeAmount: @Sendable () -> Double) { balance = balance + computeAmount() } }
However, in fact, on the current Xcode 14.x, the parameters and return values of actor-isolated methods do not have to conform Sendable. In other words, Xcode will not display an error because they do not implement Sendable. This is because there are still many legacy codes in the current iOS development environment. For compatibility, Sendable has become optional. However, for the code we write ourselves, it is best to conform to Sendable.
actor BankAccount { private let accountNumber: String private var balance: Double init(accountNumber: String, balance: Double) { self.accountNumber = accountNumber self.balance = balance } // Xcode does not display any error. func deposit(money: Money) { balance = balance + money.calculateAmount() print("deposit, \(Thread.current)") } } class Money { var amount: Double var rate: Double init(amount: Double, rate: Double) { self.amount = amount self.rate = rate } func calculateAmount() -> Double { amount * rate } }
Conclusion
Swift Concurrency simplifies asynchronous code by synchronous mechanism. However, it also adds quite a few new concepts. We must understand these things before we can write correct code. This article only briefly explains these new concepts, and you must also read the articles listed in the reference.
Reference
- Swift Concurrency, The Swift Programming Language.
- Concurrency, Apple Developer.
- Async/await, Swift Evolution.
- Structured concurrency, Swift Evolution.
- async let binding, Swift Evolution.
- Sendable and @Sendable closures, Swift Evolution.
- Global actors, Swift Evolution.
- Actors, Swift Evolution.
- Custom Actor Executors, Swift Evolution.