@MainActor Doesn't work or misunderstood?

Hello everyone :wave:,

I have a little problem with @MainActor, and I don't know if it's a swift bug or a misunderstanding on my part?

If we consider the following code:

actor Bar {
  enum BarError: Error {
    case unknownError
  }
  
  func longAsyncWork() async throws {
    print("longAsyncWork thread : \(Thread.current.description)")
    try await Task.sleep(nanoseconds: 1000000000)
    throw BarError.unknownError
  }
}
​
​
class ViewController: UIViewController {
  
  override func viewDidLoad() {
    super.viewDidLoad()
    self.simpleFunction()
    self.launchTask()
  }
  
  func simpleFunction() {
    print("simpleFunction thread : \(Thread.current.description)")
  }
  
  func launchTask() {
    print("launch Task thread : \(Thread.current.description)")
    Task {
      await self.task()
    }
  }
  
  func task() async {
    do {
      print("In Do Task thread : \(Thread.current.description)")
      try await Bar().longAsyncWork()
    } catch {
      print("In catch Task thread : \(Thread.current.description)")
      self.update()
    }
  }
  
  @MainActor
  func update() {
    print("update thread : \(Thread.current.description)")
  }
  
}

And this output :

simpleFunction thread : <_NSMainThread: 0x6000027146c0>{number = 1, name = main}
launch Task thread : <_NSMainThread: 0x6000027146c0>{number = 1, name = main}
In Do Task thread : <NSThread: 0x6000027249c0>{number = 3, name = (null)}
longAsyncWork thread : <NSThread: 0x6000027249c0>{number = 3, name = (null)}
In catch Task thread : <NSThread: 0x600002748300>{number = 6, name = (null)}
update thread : <NSThread: 0x600002748300>{number = 6, name = (null)}

For me the update function should always be executed on the MainThread ?

Misunderstanding on my part? @MainActor issue? Something else ?
And if I misunderstand something what is the good fix ?
(Knowing that the @MainActor annotation is superficial because ViewController is already in theory @MainActor )

1 Like

My guess is that for now Swift does not enforce awaiting @MainActor-annotated methods to support older code, that was not designed with Concurrency in mind. Imagine there is a synchronous API somewhere that expects your update method to be synchronous. How would Swift enforce awaiting in that example?

Try adding await to the call to self.update() or async to update's declaration or both. Also I don't think @MainActor annotation propagates to subclass methods, so it would be wise to try and add it explicitly to ViewController declaration.

That looks surprising to me too. As a subclass of UIViewController, your ViewController is implicitly @MainActor as well, making the annotation on update() redundant. That, I think, is why the call to self.update() inside task() doesn't require an await. Both functions are already isolated to that actor.

But since task() is running in a subtask launched from a @MainActor function, I would have expected it to run on the main thread.

After investigation if we add async to @MainActor func update it's work as expected

Does that make everything run on the main thread, or just update()?

Hopefully somebody with a deeper, implementation-level knowledge chimes in, but from my experience and the docs, it seems like, in general, you can't guarantee on which thread instructions are run on after a suspension. And you shouldn't really care either, since the most important guarantee, data integrity is always there with actors.
In other words, even if you'll see some code ran on some thread you don't expect it to run, tis fine, the main thread won't clash with it. If you really want to force it to run on the main thread, then you'll have to annotate with @MainActor directly, and not rely on the fact that some main actor is calling it upstream.

If I understood example above correctly despite being MainActor annotated, "update" was somehow being called on a background thread/queue, and should it contain something that's only callable from the main thread (e.g. "UIApplication.shared") it would immediately trap.

I've missed the fact that update is actually marked with @MainActor. That's a bit weird, but still plausible:

What I wanted to say is that after the suspension point, the code can theoretically continue on another thread, but act on behalf of the same actor.
This can happen at least with regular actors, in my experience. Also, I think it's a feature not a bug: the thread poll is managed internally and as long as the threads don't race on critical sections, it doesn't matter which thread does the job.

A good point re UIApplication.shared, but only if there's a main thread assertion or similar, otherwise this specific secondary thread won't ever race with any other thread that acts on behalf of the main actor.

I'd love if somebody in the team could confirm/deny this. I'll try to have a look over the implementation later on too.

By the way, I could not reproduce this on 5.6. Wanted to test what happens if we access UIApplication.shared

@PoissonBallon what version of Swift were you using?

I can reproduce the issue (I'm on Xcode 13.1 ATM):

  @MainActor
  func update() {
      print("update thread : \(Thread.current.description)")
      _ = UIApplication.shared.applicationState
      // UIApplication.applicationState must be used from main thread only
      /*
      Main Thread Checker: UI API called on a background thread: -[UIApplication applicationState]
      PID: 70065, TID: 6764147, Thread name: (none), Queue name: com.apple.root.user-initiated-qos.cooperative, QoS: 25
      */
      print("done")
  }

SE-0338 changed the behavior around this, and it is (partially) in Swift 5.6. But yes, there is also a great deal of confusion because of the fact that Swift doesn’t currently enforce some kinds of actor isolation in code that doesn’t seem to be locally using concurrency features, which makes it very easy to bypass MainActor isolation checking.

I am currently using Swift 5.5

No, despite adding an iboutlet, or modify my ViewController update stay on generic thread

Thank you for this, it was a very interesting read, I understand that we have a problem with the current design of concurrency and the understanding of it :/ and its predictability

FYI:

I found two ways to fix my issue :

1. Adding async to my @MainActor function :

@MainActor func update() async

output :

simpleFunction thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}
launch Task thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}
In Do Task thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}
longAsyncWork thread : <NSThread: 0x60000011fc00>{number = 6, name = (null)}
In catch Task thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}
simpleFunction thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}
update thread : <_NSMainThread: 0x6000001286c0>{number = 1, name = main}

But my Task {} it's now called on MainThread O_o

2. Specify ViewController as @MainActor

output:

simpleFunction thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}
launch Task thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}
In Do Task thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}
longAsyncWork thread : <NSThread: 0x600003e5c040>{number = 4, name = (null)}
In catch Task thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}
simpleFunction thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}
update thread : <_NSMainThread: 0x600003e0c740>{number = 1, name = main}

In same way inside my Task {} i am in MainThread and it's really weird for me, but I suspect it's an optimization to minimize jump between thread ? I am right ?

@John_McCall What is the right option for you?

Thanks to all for your help :pray:

I confess reading SE-0338 didn't help my understanding of what's going on here. Is the behavior described above really as intended, with or without 338?

For one thing, I don't understand why adding @MainActor to the ViewController class does anything. Shouldn't that have been inherited?

I would expect everything in that class to be implicitly @MainActor, yes. Something odd is going on.

1 Like

Is this related to this bug I faced in Swift 5.6?

@MainActor
class A {}

@MainActor
class VC: UIViewController {
  let foo: A

  init() {
    self.foo = A() // ❌ Error: Call to main actor-isolated initializer 'init()' in a synchronous nonisolated context
    super.init(nibName: nil, bundle: nil) // ❌ Error: Call to main actor-isolated initializer 'init(nibName:bundle:)' in a synchronous nonisolated context
  }

  @MainActor
  init() {
    self.foo = A() // âś… OK
    super.init(nibName: nil, bundle: nil) // âś… OK
  }

  init(_ void: Void = ()) { 
    self.foo = A() // âś… OK
    super.init(nibName: nil, bundle: nil) // âś… OK
  }
}

In this case, I expected the initializers to inherit @MainActor, but they don't. Strangely the void parameter makes it compile.

This bug is [SR-15694] @MainActor doesn't cascade to init in class · Issue #57973 · apple/swift · GitHub, which was fixed recently for Swift 5.7. A good workaround for it is to manually annotate the init() with @MainActor. It's a problem specific to init() with no arguments when the class inherits from NSObject, which defines an init() as well. That's why adding the Void parameter fixes it too.

3 Likes

@kavon Thanks for the quick response. Is there any chance that it's going to cherry-picked for 5.6??

My guess is that the problem @PoissonBallon described is the same as [SR-15789] MainActor isolation is not ensured · Issue #58066 · apple/swift · GitHub, which was fixed for Swift 5.6. So that would explain why it doesn't reproduce in that version.

2 Likes