This post discusses a subtlety in Swift concurrency, and specifically how it relates to SwiftUI, that I regularly see confusing folks. I decided to write it up here so that I can link to it rather than explain it repeatedly.
If you have a question or a comment, start a new thread and I’ll respond there. Put it in the App & System Services > Processes & Concurrency topic area and tag it with both Swift and Concurrency.
Share and Enjoy
—
Quinn “The Eskimo!” @ Developer Technical Support @ Apple
let myEmail = "eskimo" + "1" + "@" + "apple.com"
Task Isolation Inheritance
By default, tasks inherit their actor isolation from the surrounding code. This is a common source of confusion. My goal here is to explain why it happens, why it can cause problems, and how to resolve those problems.
Imagine you have a main actor class like this:
@MainActor
class MyClass {
var counter: Int = 0
func start() {
Task {
print("will sleep")
doSomeCPUIntensiveWork()
print("did sleep")
}
}
}
In this example the class is a model object of some form, but it could be an @Observable
type, a SwiftUI view, a UIKit view controller, and so on. The key thing is that the type itself is isolated to the main actor.
Remember that Swift code inherits its isolation from the surrounding code (in compiler author speak this is called the lexical context). So the fact that MyClass
is annotated with @MainActor
means that both counter
and start()
are isolated to the main actor.
IMPORTANT This model is what allows the compiler to detect concurrency problems at compile time. I’ve found that, whenever I’m confused by Swift concurrency, it helps to ask myself “What does the compiler know?”
Folks look at this code and think “But I’ve added a Task
, and that means that doSomeCPUIntensiveWork()
will run on a secondary thread!” That is not true. There are a couple of easy ways to prove this to yourself:
-
Actually run the code. If you put this code into an app, you’ll find that your app’s UI is unresponsive for the duration of the
doSomeCPUIntensiveWork()
. Indeed, you can test this example for yourself, as explained below in Example Context. -
Access a value that’s isolated to the main actor. For example, insert this
doSomeCPUIntensiveWork()
:self.counter += 1 doSomeCPUIntensiveWork()
The compiler doesn’t complain about this access to
counter
— a main-actor-isolated value — from this context, which tell you that this code will run on the main thread.
So, what’s going on? The task is running on the main actor because of a form of isolation inheritance. The mechanics of that are complex, something I’ll explained in the The Gory Details section below. For the moment, however, the key thing to note is that starting a task in this way — using Task.init(…)
— causes the task to inherit actor isolation from the surrounding code. In this case the surrounding code is the start()
method, which is isolated to the main actor because it’s part of MyClass
, and thus the code ends up calling doSomeCPUIntensiveWork()
on the main thread.
So, how do you prevent this? There are many different ways, but the two most obvious are:
-
Replace
Task.init(…)
withTask.detached(…)
:func start() { Task.detached() { print("will sleep") doSomeCPUIntensiveWork() print("did sleep") } }
And how does that work? Again, see the The Gory Details section below.
-
Move the code to a non-isolated method:
func start() { Task { print("will sleep") await self.myDoSomeCPUIntensiveWork() print("did sleep") } } nonisolated func myDoSomeCPUIntensiveWork() async { doSomeCPUIntensiveWork() }
In both cases you can prove to yourself that this has done the right thing: Add code to access counter
from the non-isolated context and observe the complaints from the compiler.
SwiftUI
While my “What does the compiler know?” thought experiment is super helpful, sometimes it’s not easy understand that. Folks are often caught out by the way that the SwiftUI View
protocol works. We’ve fixed this problem in Xcode 16, but that change has brought more confusion.
In Xcode 15 and earlier the View
protocol was defined like this:
public protocol View {
…
@ViewBuilder @MainActor var body: Self.Body { get }
}
Only the body
property is annotated with @MainActor
. The view as a whole is not. Consider this view:
struct CounterViewOK: View {
@State var counter: Int = 0
var body: some View {
VStack {
Text("\(counter)")
Button("Increment") {
Task {
counter += 1
}
}
}
}
}
This compiles because the task inherits the main actor isolation from body
. But if you make a seemingly trivial change, the compiler complains:
struct CounterViewNG: View {
@State var counter: Int = 0
var body: some View {
VStack {
Text("\(counter)")
Button("Increment") {
increment()
}
}
}
func increment() {
Task {
counter += 1
// ^ Capture of 'self' with non-sendable type 'CounterViewNG' in a `@Sendable` closure
}
}
}
That’s because the increment()
method is not isolated to the main actor, and thus neither is the task. The compiler thinks you’re trying to pass an instance of the view between contexts, and rightly complains.
In contrast, in Xcode 16 (currently in beta) the View
protocol looks ilke this:
@MainActor @preconcurrency public protocol View {
…
@ViewBuilder @MainActor @preconcurrency var body: Self.Body { get }
}
The entire View
is now isolated to the main actor. This makes everything easier to understand. Both of the examples above work. Specifically, CounterViewNG
works because the task inherits main actor isolation via the increment()
> CounterViewNG
> View
chain.
Of course, everything is a trade-off. More of your views are now running on the main actor, which can trigger the CPU intensive work issue that I described above.
Other Options
When I crafted the doSomeCPUIntensiveWork()
example above, I avoided mentioning SwiftUI. There was a specific reason for that: When working with a UI framework, it’s best to avoid doing significant work in your UI types. This is true in SwiftUI, but it’s also true in UIKit and AppKit. Indeed, doing all your app’s work in your view controllers is called the massive view controller anti-pattern.
So, if you’re find yourself doing significant work in your UI types, consider some alternatives. You have lots of options:
-
The simplest option is to move the code into an async function.
-
But you might also want to add an abstraction layer. Swift has lots of good options for that (structs, enums, classes, actors).
-
You can also define a new global actor.
The best option depends on your specific situation. If you’re looking for further advice, there’s no shortage of it out there on the ’net (-:
The Gory Details
To understand the difference between Task.init(…)
and Task.detached(…)
, you have to look at their declarations. This is easy to do from Xcode — just command-click on the init
or the detached
— but that’s misleading. The difference is keyed off a underscore-prefixed attribute and, for better or worse, Xcode won’t show you those.
To see the actual difference you have have to open the Swift interface file. Within any given SDK the relevant file is usr/lib/swift/_Concurrency.swiftmodule/arm64e-apple-macos.swiftinterface
. Here’s what you’ll see in the macOS SDK within Xcode 16.0b4:
@discardableResult
@_alwaysEmitIntoClient
public init(
priority: TaskPriority? = nil,
@_inheritActorContext @_implicitSelfCapture operation: __owned @escaping @isolated(any) @Sendable () async -> Success
) {…}
@discardableResult
@_alwaysEmitIntoClient
public static func detached(
priority: TaskPriority? = nil,
operation: __owned @escaping @isolated(any) @Sendable () async -> Success
) -> Task<Success, Failure> {…}
Note I’ve edited this significantly to make things easier to read.
The critical difference is the use of @_inheritActorContext
in the Task.init(…)
case. This tells the compiler that the closure argument should inherit its isolation from the surrounding code. This attribute is underscored, and thus there’s no Swift Evolution proposal for it, but there is some limited documentation.
Example Context
To run the example in context, create a new command-line tool project, rename main.swift
to start.swift
, and insert MyClass
into this scaffolding:
import Foundation
@MainActor
class MyClass {
… code above …
}
func doSomeCPUIntensiveWork() {
sleep(5)
}
@main
struct Main {
static func main() {
Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
print("tick")
}
let m = MyClass()
m.start()
withExtendedLifetime(m) {
RunLoop.current.run()
}
}
}
In this context:
-
doSomeCPUIntensiveWork()
uses thesleep
system call to hog the current thread for 5 seconds. -
The timer tick helps illustrate the unresponsive main thread.
-
It’s also need to ensure that the run loop continues to run indefinitely.
More Reading
There is a lot of good information available about Swift concurrency. My favourite resources include:
-
The Avoid hangs by keeping the main thread free from non-UI work section of Improving app responsiveness
-
WWDC 2023 Session 10248 Analyze hangs with Instruments, especially the section starting at 31:42.
-
SE-0431
@isolated(any)
Function Types which covers another subtle issue with tasks -
Matt Massicotte blog at https://www.massicotte.org
Revision History
-
2024-08-05 Added the Other Options section. Added some more links to the More Reading section. Made other minor editorial changes.
-
2024-08-01 First posted.