23

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?

matt
  • 515,959
  • 87
  • 875
  • 1,141

2 Answers2

14

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.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • The ability to call `@MainActor` method `test2` despite being in a detached task is really strange. Did you ever figure out why this happens? – Bill Nov 24 '21 at 00:54
  • 2
    @Bill It's apparently a known trade-off... https://developer.apple.com/forums/thread/693660 – Ivica M. Dec 06 '21 at 20:54
  • May I know, is using `Task.detached { [weak self] in` a proper way, if I want ViewController member function, able to execute Task in non-main thread? Thanks. – Cheok Yan Cheng Apr 25 '22 at 14:33
  • 1
    @CheokYanCheng `[weak self]` is irrelevant to what thread this will be. In theory, `Task.detached` should run on a non-main thread. But the way to be _certain_ that specific code runs in a non-main thread is to put that code in an Actor. That's what an actor is for. – matt Apr 25 '22 at 14:42
  • I still not entirely clear, on how Task works with async/ await. Do you mind to provide some input on the following question? Thanks - https://stackoverflow.com/questions/72001707/is-using-task-detached-a-best-practice-and-correct-approach-to-make-sure-the-ta – Cheok Yan Cheng Apr 25 '22 at 15:10
  • @CheokYanCheng So I answered that question in my previous comment. I'm not going to come over to your new question and say it _again._ – matt Apr 25 '22 at 15:26
  • It's worth noting that in swift 5.9, this is no longer the behavior and this answer's code won't even compile. once you add an await to `self.test2()` the print out is `test1 true test1 task false test2 true test2 task true ` – flopshot Jul 30 '23 at 09:04
5

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
    }
}
Soumya Mahunt
  • 2,148
  • 12
  • 30