iosswiftconcurrencymainactor

Why is the following code not running on the main thread?


When I mark a protocol with async functions with @MainActor and it's conformance does not specify it's functions are async, it is not executing on main thread. I don't know if this is a bug or not.

For example, given the following protocol and implementation:

@MainActor
protocol MyMainActorProtocol {
    func doStuff() async
}

struct MyMainActorStruct: MyMainActorProtocol {
    nonisolated init() {}
    func doStuff()  {
        print(">>> \(Thread.isMainThread)")
    }
}

When I execute this code:

Task {
    let dependency: MyMainActorProtocol = MyMainActorStruct()
    print(">>> \(Thread.isMainThread)")
    await dependency.doStuff()
}

both print statements run in a background thread.

But with the following adjustments of either

@MainActor
protocol MyMainActorProtocol {
    func doStuff()
}

struct MyMainActorStruct: MyMainActorProtocol {
    nonisolated init() {}
    func doStuff()  {
        print(">>> \(Thread.isMainThread)")
    }
}

or

@MainActor
protocol MyMainActorProtocol {
    func doStuff() async
}

struct MyMainActorStruct: MyMainActorProtocol {
    nonisolated init() {}
    func doStuff() async  {
        print(">>> \(Thread.isMainThread)")
    }
}

I get the correct result, where the body of the function inside the struct runs in the main thread.

Is this expected behavior? What is the explanation behind it.


Solution

  • According to the global actor inference rules,

    A non-actor type that conforms to a global-actor-qualified protocol within the same source file as its primary definition infers actor isolation from that protocol

    So provided you put them in the same source file, MyMainActorStruct should be MainActor-isolated too, and so is doStuff.

    As a result, you don't get errors when you try to e.g. initialise a UIView in doStuff. However, when you do try to do that at runtime, you get logs in the console saying "UI API called on a background thread". This should not happen. This behaviour is clearly a bug.

    Note that even if you add @MainActor to MyMainActorStruct, or even to doStuff, this behaviour still remains.

    To find out how this happened, I compared the SIL generated by the following codes (See the full SIL on godbolt.org):

    @MainActor
    protocol MyMainActorProtocol {
        func doStuff() async
    }
    
    @MainActor
    struct MyMainActorStruct: MyMainActorProtocol {
        nonisolated init() {}
        func doStuff() async { }
    }
    
    @MainActor
    protocol MyMainActorProtocol {
        func doStuff() async
    }
    
    @MainActor
    struct MyMainActorStruct: MyMainActorProtocol {
        nonisolated init() {}
        func doStuff() { }
    }
    

    The former has a hop_to_executor %4 : $MainActor line in doStuff, while the latter lacks it.

    This is very similar to a previous (fixed) bug that happens in a very similar situation. That bug was about actors implementing async protocol requirements with non-async code, e.g.

    protocol Foo {
        func foo() async
    }
    
    actor FooActor : Foo {
        func foo() { }
    }
    

    and the same actor-hop was missing as well. Although this bug was fixed, it could very likely be that they also forgot to emit an actor hop in the case of global actors.