A deep dive into @MainActor in Swift

Apr 24, 2024#swift#concurrency

Swift actors are fantastic for isolating instance data, providing a form of reference type that can be used in concurrent programs without introducing data races. Global actors are globally-unique actors.

The primary motivation for global actors is the main actor, and the semantics of this feature are tuned to the needs of main-thread execution. @MainActor is a global actor that describes the main thread, pretty handy given how often you need to make quick changes that update the user interface:

@globalActor
public actor MainActor {
  public static let shared = MainActor(...)
}

For systems that use the Dispatch library as the underlying concurrency implementation, the main actor uses a custom executor that wraps the main dispatch queue.

Manually back to the main thread

The main thread is considered the special thread where all view-related access should be performed. If something will consume any significant time, e.g. calling a web service, compressing a file, etc., you will want to run code in a separate thread, and when the task completes, return to the main thread where you update the user interface.

The common pattern used with GCD of executing main-thread code via DispatchQueue.main.async:

DispatchQueue.main.async {
  // Safely update UI
}

If using Swift concurrency, you can synchronously use other @MainActor-annotated closures or MainActor.run() method.

// (1)
Task.detached { @MainActor in
  // Safely update UI
}

// (2)
Task { @MainActor [weak self] in
	// Safely update UI
}

// (3)
Task {
	await MainActor.run { [weak self] in
		// Safely update UI
	}
}

Automatically run on main thread

The magic of @MainActor is that it automatically forces methods or whole types to run on the main actor, a lot of the time without any further work from us. Instead of remembering switching back to main thread manually, now now the compiler does it for us implicitly.

@MainActor is a custom attribute, similar to property wrapper types or result builder types. Any declaration can state that it is main-actor-isolated by annotate with @MainActor, at which point all of the normal actor-isolation restrictions come into play: the declaration can only be synchronously accessed from another declaration on the main actor, but can be asynchronously accessed from elsewhere.

@MainActor var globalCounter = 0

@MainActor func incrementGlobalCounter() {
  globalCounter += 1   // okay, we are on the main actor
}

func readCounter() async {
  print(globalCounter)         // error: cross-actor read requires 'await'
  print(await globalCounter)   // okay
}

By marking a declaration with @MainActor, you tell the compiler to enforce that other concurrency-aware code must call it on the main thread. There is a huge amount of existing of non-concurrency-aware code, though, and @MainActor does nothing for those cases yet, because doing so would cause far too many warnings even in cases that are actually safe.

If you’re calling @MainActor code inside a old-style completion handler, you still need to explicitly hop back to the main thread for now. Where possible, convert completion handlers to async functions or add @Sendable or @MainActor annotations to your completion handler callbacks.

Implicitly @MainActor

Declarations that are not explicitly annotated with either @MainActor or nonisolated can infer @MainActor isolation from several different places:

  1. Subclasses infer actor isolation from their superclass
class FooViewController: UIViewController { // implicitly @MainActor
  func baz() {
    // implicitly @MainActor
  } 
}
  1. An overriding declaration infers actor isolation from the declaration it overrides:
class Foo {
  @MainActor func baz() { ... }
}

class Bar: Foo {
  override func baz() { ... } // implicitly @MainActor
}
  1. A witness that is not inside an actor type infers actor isolation from a protocol requirement that is satisfies, so long as the protocol conformance is stated within the same type definition or extension as the witness:
protocol P {
  @MainActor func f()
}

struct X { }

extension X: P {
  func f() { } // implicitly @MainActor
}

struct Y: P { }

extension Y {
  func f() { } // okay, not implicitly @MainActor because it's in a separate extension
               // from the conformance to P
}
  1. 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:
@MainActor protocol P {
  func updateUI() { } // implicitly @MainActor
}

class C: P { } // C is implicitly @MainActor

// source file D.swift
class D { }

// different source file D-extensions.swift
extension D: P { // D is not implicitly @MainActor
  func updateUI() { } // okay, implicitly @MainActor
}
  1. A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:
@propertyWrapper
struct UIUpdating<Wrapped> {
  @MainActor var wrappedValue: Wrapped
}

struct CounterView { // infers @MainActor from use of @UIUpdating
  @UIUpdating var intValue: Int = 0
}