Search code examples
swiftswiftuiasync-awaitconcurrencytask

Investigating context changes triggered by function calls in tasks


I'm curious about why Task C and Task D are not executed on the main thread, unlike Task A and Task B. I comprehend that tasks inherit context from their parent, and in this case, they all share the same parent. Could it be assumed that the calling function results in the creation of detached tasks?

struct ThreadTestView: View {
    var body: some View {
        Text("Test Thread")
            .onAppear {
                // Task A
                print("Task A - \(threadInfo())")
                
                // Task B
                Task {
                    print("Task B - \(threadInfo())")
                }
                
                // Task C
                printInfo("Task C")
                
                // Task D
                Task {
                    printInfo("Task D")
                }
            }
    }
    
    func printInfo(_ source: String) {
        Task {
            print("\(source) - \(threadInfo())")
        }
        // detached
        Task.detached {
            print("\(source) - detached \(threadInfo())")
        }
    }
    
    func threadInfo() -> String {
        "isMain:\(Thread.isMainThread) - \(Thread.current)"
    }
}

#Preview {
    ThreadTestView()
}

console output:

Task A - isMain:true - <_NSMainThread: 0x280694040>{number = 1, name = main}
Task C - isMain:false - <NSThread: 0x2806c6b00>{number = 8, name = (null)}
Task C - detached isMain:false - <NSThread: 0x2806c4580>{number = 5, name = (null)}
Task B - isMain:true - <_NSMainThread: 0x280694040>{number = 1, name = main}
Task D - isMain:false - <NSThread: 0x2806c6b00>{number = 8, name = (null)}
Task D - detached isMain:false - <NSThread: 0x2806c6b00>{number = 8, name = (null)}

Solution

  • Here's my understanding. Will be happy to correct/improve it if anyone has a deeper understanding of what's going on.

    Your View (struct ThreadTestView: View) is not annotated as @MainActor. Protocol View itself is not annotated with @MainActor either. Only body inside protocol View is annotated with @MainActor:

    @ViewBuilder @MainActor var body: Self.Body { get }

    So I think the behavior derives from those 3 facts.

    First of all we should monitor not only tasks spawned in printInfo, but also where the function itself is called:

    func printInfo(_ source: String) {
            
            // THIS: where the function itself is called
            print("\(source) - function itself \(threadInfo())")
            
            Task {
                print("\(source) - \(threadInfo())")
            }
            // detached
            Task.detached {
                print("\(source) - detached \(threadInfo())")
            }
        }
    

    So what do I expect here:

    • function printInfo itself (the new line I added above) will be called on main thread in all 4 cases. This is because all the items that have body as a parent (which is annotated with @MainActor) will keep that context.
    • In case C the Task is spawned from printInfo, which is not, as we established, a @MainActor. Hence that Task is not a main actor either.
    • In case D first Task is spawned from inside the body, and it will inherit the main actor context (so function printBody will run on main thread) - much like case B. But the second Task is spawned from the function printInfo will not be on main thread, like case C.

    I agree that cases C and D are confusing. Some good discussion about these limitations here and here