Task Isolation Inheritance and SwiftUI

This thread has been locked by a moderator; it no longer accepts new replies.

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(…) with Task.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 the sleep 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:

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.

Boost
Task Isolation Inheritance and SwiftUI
 
 
Q