Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

What determines whether a Swift 5.5 Task initializer runs on the main thread?

This is sort of a follow-up to my earlier asyncDetached falling back into main thread after MainActor call.

Here's the complete code of an iOS view controller:

import UIKit

func test1() {
    print("test1", Thread.isMainThread) // true
    Task {
        print("test1 task", Thread.isMainThread) // false
    }
}
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        test1()
        test2()
    }

    func test2() {
        print("test2", Thread.isMainThread) // true
        Task {
            print("test2 task", Thread.isMainThread) // true
        }
    }
}

The two functions test1 and test2 are identical, and are being called from the very same place. Yet one of them runs its Task initializer operation: function on a background thread, and the other runs on the main thread.

What determines this? I can only think it has to do with where the method is declared. But what does it have to do with where the method is declared?

like image 587
matt Avatar asked Jun 13 '21 02:06

matt


2 Answers

I think the rule must be that a Task initializer in a MainActor method runs on the main thread.

And all methods of a view controller are MainActor methods by default; plus, I observe that if I declare test2 to be nonisolated, its Task operation runs on a background thread instead of the main thread.

My guess, then, is that this is an example of the rule that a Task initializer's operation "inherits" from its context:

  • test2 is a MainActor method; it runs on the main thread, so the Task operation "inherits" that.

  • But test1 is not marked for any special thread. test1 itself runs on the main thread, because it is called on the main thread; but it is not marked to run on the main thread. Therefore its Task operation falls back to running on a background thread.

That's my theory, anyway, But I find it curious that this rule is nowhere clearly enunciated in the relevant WWDC videos.

Moreover, even test2 is only a MainActor method in a sort of "weak" way. If it were really a MainActor method, you could not be able to call it from a background thread without await. But you can, as this version of the code shows:

func test1() {
    print("test1", Thread.isMainThread) // true
    Task {
        print("test1 task", Thread.isMainThread) // false
    }
}
class ViewController: UIViewController {
    override func viewDidLoad() {
        super.viewDidLoad()
        test1()
        Task.detached {
            self.test2()
        }
    }

    func test2() {
        print("test2", Thread.isMainThread) // false
        Task {
            print("test2 task", Thread.isMainThread) // true
        }
    }
}

I find that truly weird, and I have some difficulty enunciating what rule would govern this relentless context-switching behavior, so I don't regard the matter as settled.

like image 155
matt Avatar answered Oct 07 '22 01:10

matt


This is because of how actor isolation and task creation work in swift. Actors have serial executor that processes one task at a time to synchronize mutable state. So any method that is isolated to the actor will run on the actor's executor. And when creating Task with Task.init the newly created task inherits the actor's context (isolated to the parent actor) it was created in (unless you specify a different global actor explicitly when creating task) and then processed by the actor's executor.

What's happening here is your ViewController class and all its methods and properties are MainActor isolated since UIViewController is MainActor isolated and you are inheriting from it. So your test2 method is isolated to MainActor and when you are creating a task inside test2 the new task inherits the MainActor context and gets executed by MainActor on the main thread.

But this behaves differently from your test1 method because your test1 method isn't isolated to MainActor. When you are calling test1 from viewDidLoad the synchronous part of test1 is executed on MainActor as part of the current task but when you are creating a new task is test1, since test1 isn't isolated to MainActor, your new task isn't executed on it.

To have the same behavior in test1 as test2, you can mark your method to be isolated to MainActor by applying the @MainActor attribute to function definition:

@MainActor
func test1() {
    print("test1", Thread.isMainThread) // true
    Task {
        print("test1 task", Thread.isMainThread) // true
    }
}
like image 31
Soumya Mahunt Avatar answered Oct 06 '22 23:10

Soumya Mahunt