How @MainActor preservation rule works?

Hello,
I created examples to demonstrate the different results of @MainActor behaviour while passing async closures

First in SwiftUI views

struct RunActionView: View {
    let title: String
    let action: @MainActor () async -> Void

    init(title: String, action: @MainActor @escaping () async -> Void) {
        self.title = title
        self.action = action
    }

    var body: some View {
        Button(action: executeAction) {
            Text(title)
        }
    }

    private func executeAction() {
        print(title)
        Task { @MainActor in
            try await run(operation: action)
        }
    }
}

struct RunActionWithThrowsView: View {
    let title: String
    let action: @MainActor () async throws -> Void

    init(title: String, action: @MainActor @escaping () async throws -> Void) {
        self.title = title
        self.action = action
    }

    var body: some View {
        Button(action: executeAction) {
            Text(title)
        }
    }

    private func executeAction() {
        print(title)
        Task { @MainActor in
            try await run(operation: action)
        }
    }
}

func run(operation: () async throws -> Void) async throws {
    try await operation()
}

struct RunActionDirectView: View {
    let title: String
    let action: @MainActor () async throws -> Void

    init(title: String, action: @MainActor @escaping () async -> Void) {
        self.title = title
        self.action = action
    }

    var body: some View {
        Button(action: executeAction) {
            Text(title)
        }
    }

    private func executeAction() {
        print(title)
        Task { @MainActor in
            try await action()
        }
    }
}

struct RunActionWithTaskView: View {
    let title: String
    let action: @MainActor () async throws -> Void

    init(title: String, action: @MainActor @escaping () async -> Void) {
        self.title = title
        self.action = action
    }

    var body: some View {
        Button(action: executeAction) {
            Text(title)
        }
    }

    private func executeAction() {
        print(title)
        Task { @MainActor in
            try await runWithTask(operation: action)
        }
    }
}

func runWithTask(operation: () async throws -> Void) async throws {
    Task {}
    try await operation()
}

struct ContainerView: View {
    // When: 
    // * action: () async -> Void | All actions will be run on non MainActor
    // * action: @Main () async -> Void | All actions will be run on MainActor
    let action: () -> Void

    var body: some View {
        VStack {
            RunActionView(title: "RunActionView", action: action) // TRUE
            RunActionDirectView(title: "RunActionDirectView", action: action) // FALSE
            RunActionWithTaskView(title: "RunActionWithTaskView", action: action) // FALSE
            RunActionWithThrowsView(title: "RunActionWithThrowsView", action: action) // FALSE
        }
    }
}

Only in the first case the action will be run on the @MainActor (when neither marked with async nor @MainActor.

But if I change as in the comments to async in all cases the action will be run on non @MainActor and in the most clear case when ContainerView.action closure is marked as @MainActor, then in all cases the action will be run on the @MainActor.

Moreover in case of non SwiftUI's View swift, we can observe again different behaviours:

struct Not_Preserved {
    let action: () -> Void

    func run() {
        Async(action: action).run()
    }
}

struct Preserved_When_Async {
    let action: () async -> Void

    func run() {
        Async(action: action).run()
    }
}

struct Preserved_When_Annotated {
    let action: @MainActor () -> Void

    func run() {
        Async(action: action).run()
    }
}

struct Async {
    let action: () async -> Void

    func run() {
        Task { @MainActor in
            await action()
        }
    }
}

final class PlaygroundTests: XCTestCase {
    @MainActor
    func test_Not_Preserved() async {
        let exp = expectation(description: #function)
        let action = { @MainActor in
            XCTAssertTrue(Thread.isMainThread)
            exp.fulfill()
        }

        let sut = Not_Preserved(action: action)

        sut.run()

        await fulfillment(of: [exp])
    }

    @MainActor
    func test_Preserved_When_Async() async {
        let exp = expectation(description: #function)
        let action = { @MainActor in
            XCTAssertTrue(Thread.isMainThread)
            exp.fulfill()
        }

        let sut = Preserved_When_Async(action: action)
        sut.run()

        await fulfillment(of: [exp])
    }

    @MainActor
    func test_Preserved_When_Annotated() async {
        let exp = expectation(description: #function)
        let action = { @MainActor in
            XCTAssertTrue(Thread.isMainThread)
            exp.fulfill()
        }

        let sut = Preserved_When_Annotated(action: action)
        sut.run()

        await fulfillment(of: [exp])
    }
}

The link with the playground with the code above in zip.

Could someone explain the behaviour and what actually the @MainActor annotation does in case of a closure?

Hi, I can confirm your SwiftUI example result. Also, enbaling strict concurrency check doesn't help in this case (no error or warning message on the silent ignorance of @MainActor). Based on the explanation here, I modified the code by removing async from all closure signatures. With that change, my experiment showed they all ran in main thread.

However, what I really want to say is that IMO your code are unnecessarily complex - @MainActor and async keywords show up in too many places, which is not only inefficient but also hard to reason about. Take the first part of the code an example, below is how I'd implement it (note that only run() is async and has @MainActor applied). Hope it helps.

struct RunActionView: View {
    let title: String
    let action: () -> Void

    init(title: String, action: @escaping () -> Void) {
        self.title = title
        self.action = action
    }

    var body: some View {
        Button(action: executeAction) {
            Text(title)
        }
    }

    private func executeAction() {
        print(title)
        Task {
            try await run(operation: action)
        }
    }
}

@MainActor
func run(operation: () throws -> Void) async throws {
    try operation()
}
1 Like

I haven't checked SwiftUI version within the compiler, but more likely you are experiencing that due to the Button's action not being annotated to run on main actor. With strict concurrency checks there should be a warning that passing closure looses main actor isolation. It is widely adopted due to that (and other reasons) in SwiftUI cases to always annotate views with main actor as whole, that eliminates most of the issues you might face with concurrency right now.

First issue in that example of non-SwiftUI cases that all (except main-actor annotated, but right now it better to add there as well) closures has to specify that closure is @Sendable - in each case you have closure crossing isolation boundary. And with strict concurrency checks Swift will point you that (as warnings for now).

Once you annotate sendability of closures, you will get new warning in test cases, specifically for the test_Not_Preserved:

@MainActor
func test_Not_Preserved() async {
    let exp = expectation(description: #function)
    let action = { @MainActor @Sendable in
        XCTAssertTrue(Thread.isMainThread)
        exp.fulfill()
    }
    
    let sut = Not_Preserved(action: { action() })  // error: call to main actor-isolated let `action` in a synchronous nonisolated context

    sut.run()

    await fulfillment(of: [exp])
}

Which has a lot of sense now - Not_Preserved struct is nonisolated, so all the invocations as well.

Once such sendability warnings will become an error, it would be impossible to write such code. Yet currently that is a bit unpleasant behaviour.

All of that was summarized by this answer - Even if you assign the `@MainActor` attribute to a closure, the processing inside the closure is not executed on the main thread - #10 by John_McCall

1 Like

Oh, I've missed this - you have to keep in mind that these run and runWithTask functions are being executed on a generic executor, and task @MainActor isolation on create has no effect on that.

1 Like

Yes, I made it on purpose overcomplicated, to show that reasoning about the @MainActor transfer with annotations on closures is very hard.
Even if you mark a closure with @MainActor it does not necessarily mean it will be run on the MainActor.

1 Like