Making Apple silicon faster: 3 Multitasking
Prior to version 5.5, Swift had limited support for running multiple tasks as completion handlers in closures, and no support for multithreading. This changed in Swift 5.5, which first introduced support for what it terms concurrency, and is consolidated in Swift 6.0.
The language documentation (for 6.0 beta) explains concurrency as the combination of asynchronous and parallel code:
“Swift has built-in support for writing asynchronous and parallel code in a structured way. Asynchronous code can be suspended and resumed later, although only one piece of the program executes at a time. Suspending and resuming code in your program lets it continue to make progress on short-term operations like updating its UI while continuing to work on long-running operations like fetching data over the network or parsing files. Parallel code means multiple pieces of code run simultaneously — for example, a computer with a four-core processor can run four pieces of code at the same time, with each core carrying out one of the tasks. A program that uses parallel and asynchronous code carries out multiple operations at a time, and it suspends operations that are waiting for an external system.”
This therefore includes both multithreading on multiple cores, and cooperative multitasking within the same thread running on a single core.
async/await
Central to Swift’s support for cooperative multitasking are asynchronous tasks, declared as async:
func runTaskAsync() async -> Bool
and typically called using
async let theResult = runTaskAsync()
That runs the function runTaskAsync() asynchronously, returning its result into a let value. There can be successive calls to run async code. The calling code then proceeds without waiting for each function to return, until it meets an await for a return value, such as
let theOutcome = await theResult
and theOutcome is then used, for example displayed as the result. That calling code is then suspended until runTaskAsync() returns.
Where appropriate, the async call and await can be merged into the more concise
let theResult = await runTaskAsync()
which runs runTaskAsync() and suspends until it returns theResult, but that doesn’t allow any further calls until that async function returns.
This has great advantages over completion handlers in that asynchronous code is clearly marked, as are potential suspension points. Replacing deeply nested closures with async makes code far easier to write, read and debug. Swift also incorporates aids to avoid problems that can occur when using asynchronous code in races, deadlocks and their relatives.
Going async
If you try inserting async functions and calls into synchronous code, you’ll immediately discover that asynchronicity propagates upwards, and the compiler won’t let you do that. SwiftUI provides features that can be used to call async functions, integrating them well, but in other places the solution is normally to resort to unstructured concurrency using Tasks.
Formally, there are three places that asynchronous code can be called:
from other asynchronous code
in a static main() method of a structure, class or enumeration marked with @main
in a Task, either from a parent task using a structured task group, or using unstructured concurrency.
Task and Task.detached
For this example of the use of Tasks and detached Tasks, I use an asynchronous function that runs computationally intensive code. This is atypical as it isn’t a suitable candidate for multitasking, as I explain below, but is ideal for examining how Swift handles tasks. This function performs a floating-point calculation theReps times, and returns a Float result.
func runAccTestAsync(theReps: Int) async -> Float {
var tempA: Float = 1.23456
for _ in 1…theReps {
// run code
}
return tempA
}
This is then called four times asynchronously at a set TaskPriority before pooling the Float results in an array, and displaying that result. Using a Task runs this ‘on the current actor’, such that only one task at a time can access the mutable state of that actor.
func doSwift6(_ sender: Any) {
let theQoSval = UInt8(33)
let theQoS = TaskPriority(rawValue: theQoSval)
let myCount = 1000000
Task(priority: theQoS) {
async let theResult = runAccTestAsync(theReps: myCount)
async let theResult1 = runAccTestAsync(theReps: myCount)
async let theResult2 = runAccTestAsync(theReps: myCount)
async let theResult3 = runAccTestAsync(theReps: myCount)
let theReses = await [theResult, theResult1, theResult2, theResult3]
self.appendOutputText(string: “results (theReses)n”)
}
}
Alternatively, these can be run in a detached Task, where each task is part of a separate actor.
func doSwift6(_ sender: Any) {
let theQoSval = UInt8(33)
let theQoS = TaskPriority(rawValue: theQoSval)
let myCount = 1000000
Task.detached(priority: theQoS) {
async let theResult = self.runAccTestAsync(theReps: myCount)
async let theResult1 = self.runAccTestAsync(theReps: myCount)
async let theResult2 = self.runAccTestAsync(theReps: myCount)
async let theResult3 = self.runAccTestAsync(theReps: myCount)
let theReses = await [theResult, theResult1, theResult2, theResult3]
await self.appendOutputText(string: “results (theReses)n”)
}
}
There are subtle differences here that I haven’t seen documented elsewhere:
in Task.detached, both the subsequent synchronous tasks require await, whereas in the plain Task, only the first does;
in Task.detached, the calls to asynchronous functions require self, whereas those aren’t necessary in the plain Task.
Xcode should help you get those right.
The use of Task for this purpose is explained by Friese (pp 381-3), and Eidhof and Kugler (pp 407-409), as well as in the language documentation.
Specifically, the latter gives the expectation that all four calls to runAccTestAsync(theReps:) start without waiting for the previous one to complete. Then, “if there are enough system resources available, they can run at the same time. None of these function calls are marked with await because the code doesn’t suspend to wait for the function’s result.” Instead, execution continues until the line where theReses is defined – “at that point, the program needs the results from these asynchronous calls, so you write await to pause execution until all” four calls to runAccTestAsync(theReps:) complete. “Call asynchronous functions with async-let when you don’t need the result until later in your code. This creates work that can be carried out in parallel.” (Quotations from the Swift 6 language documentation.)
Quality of Service (QoS)
Both Task and Task.detached accept TaskPriority values that appear common to QoS used in multithreading using Dispatch (GCD). These are:
TaskPriority/QoS 9 (binary 001001), named background and intended for threads performing maintenance, which don’t need to be run with any higher priority.
TaskPriority/QoS 17 (binary 010001), utility or low, for tasks the user doesn’t track actively.
TaskPriority 21 (binary 010101), medium, unused by Dispatch.
TaskPriority/QoS 25 (binary 011001), userInitiated or high, for tasks that the user needs to complete to be able to use the app.
QoS 33 (binary 100001), userInteractive, for user-interactive tasks, only available in Dispatch.
Apple’s documentation makes no cross-reference between TaskPriority and QoS, though, so these common values could be pure coincidence.
Multitasking or multithreading?
Extensive testing of the code given above demonstrates that it’s run using multitasking, in the main thread of the app, and on a single core at any moment in time. Its four asynchronous tasks are neither run in separate threads on multiple cores, nor are they ever run at a different priority, although tested using TaskPriority values of 9 (background), 17 (low), 25 (high) and 33 (QoS userInteractive). Test loads consisted of a dot product using simd_dot() and matrix multiplication using vDSP_mmul(), two tests I have used extensively in the past to assess CPU core performance in Apple silicon chips.
This chart taken from Xcode Instruments shows results from a test run in Debug configuration. The four async tasks are each run within the app’s main thread, where they fully load a single core. That thread is relocated from CPU 7 to CPU 9, back to CPU 7, briefly to CPU 8, back to CPU 7, and finally on CPU 8 again. Frequent relocation between cores like this is only seen when running apps in Debug mode and assessing them using Instruments; when built for release and assessed using powermetrics, such relocation is much less frequent. I will look at that issue in a separate article.
Cooperative multitasking using asynchronous tasks is ideally suited to code that takes time to complete, but doesn’t impose a sustained high CPU core load, classically handling input/output operations such as fetching data from a remote server. Delays in satisfying such requests can amount to several seconds, during which suspension of the requesting task can allow other tasks in the same thread to proceed. When performed using async tasks with await with Swift’s new concurrency support, this can be a great advantage.
Multithreading, as explored in the previous article, is best-suited to threads that incur substantial CPU core load, as used in these tests. There is little or no advantage to running those using multitasking within the same thread, although controlling their core allocation through TaskPriority/QoS can be valuable.
It’s also worth noting that several of the examples given for the use of async/await employ timers, a good example of code that should perform identically whether run using cooperative multitasking or multithreading.
There are alternative code structures in Swift 6 that could behave differently using async/await with Tasks, such as setting each asynchronous task within its own Task, or within a Task Group. However, those significantly complicate the code and lose the elegant simplicity of async/await.
Conclusions
async/await as implemented in Swift 5.5 and later provides clean and simple support for cooperative multitasking within the same thread, that is ideally suited to input/output and other operations that take time to complete, and could suspend to allow other tasks in the same thread to proceed.
Attempts to observe async/await within Task and Task.detached constructs failed to show any multithreading or effects of different TaskPriority settings.
As currently implemented in Swift 6 beta, concurrency doesn’t include support for multithreading in parallel on multiple cores in the way that Dispatch does.
Deciding whether to implement multitasking or multithreading is a design choice dependent on expected CPU core load, and whether task suspension would be beneficial or even detrimental.
References
Previous articles in this series
1 Threads and tasks
2 Multithreading
Apple
Dispatch
Threading
Concurrency
Swift 6
Concurrency
Books
Chris Eidhof and Florian Kugler (2023) Thinking in SwiftUI, objc. ISBN 979 8 3972 4668 2.
Peter Friese (2023) Asynchronous Programming with SwiftUI and Combine, Apress. ISBN 978 1 4842 8571 8.