If you’ve been building apps for a while, you probably know that we must perform UI changes on the main thread. Most of the time, code runs on the main thread by default. This is the case when working in UIKit or SwiftUI. However, stepping out of the main thread often happens, potentially even without you noticing. A delegate callback could be one of those cases, in which you’re required to dispatch back to the main thread.

Before Swift Concurrency, we would use the traditional DispatchQueue.main methods to ensure our code runs on the main thread. In Swift Concurrency, we can make use of the @MainActor.

What is the @MainActor?

The @MainActor is a global actor who performs his tasks on the main thread. You can use it for properties, methods, instances, and closures to perform tasks on the main thread. Proposal SE-0316 Global Actors introduced the main actor as an example of a global actor, inheriting the GlobalActor protocol.

Because @MainActor is a global actor, the principles covered in the previous lesson still apply.

How to use MainActor in Swift?

As we’ve learned, you can use a global actor with properties, methods, closures, and entire types. For example, we could add the @MainActor attribute to a view model to perform all tasks on the main thread:

@MainActor
final class HomeViewModel {
    // ..
}

A class can only be annotated with a global actor if it has no superclass, the superclass is annotated with the same global actor, or the superclass is NSObject. A subclass of a global-actor-annotated class must be isolated to the same global actor.

You can also decide to only mark individual properties with @MainActor:

final class HomeViewModel {

    @MainActor var images: [UIImage] = []

}

Marking the images property with the @MainActor property ensures that it can only be updated from the main thread:

The MainActor attribute requirements are enforced by the compiler.

The compiler enforces the MainActor attribute requirements.

This is great when working with MVVM in SwiftUI as you only want to trigger view redraws on the main thread.

You can mark individual methods with the attribute as well:

@MainActor func updateViews() {
    /// Will always dispatch to the main thread as long as it's called
    /// within a concurrency context.
}

Note

This method is only guaranteed to be dispatched to the main thread if you call it from an asynchronous context (e.g. a method marked with async). Xcode will adequately let you know about this, but it’s essential to be aware of this functionality to understand how a main actor attribute applies. In other words — if you would call this method from DispatchQueue.global(), the method will not run on the main thread due to the @MainActor.

And finally, you can mark closures to perform on the main thread:

func updateData(completion: @MainActor @escaping () -> ()) {
    Task {
        await someHeavyBackgroundOperation()
        await completion()
    }
}

Although in this case, you should rewrite the updateData method to an async variant without needing a completion closure.

Using the MainActor directly

The MainActor in Swift comes with an extension to use the actor directly:

extension MainActor {

    /// Execute the given body closure on the main actor.
    public static func run<T>(resultType: T.Type = T.self, body: @MainActor @Sendable () throws -> T) async rethrows -> T
}

This lets us call into the MainActor in methods without using its attribute in the body.

Task {
    await someHeavyBackgroundOperation()
    await MainActor.run {
        // Perform UI updates
    }
}

In other words, there’s no need to use DispatchQueue.main.async anymore. However, I do recommend using the global attribute to restrict any access to the main thread. Without the global actor attribute, anyone could forget to use MainActor.run, potentially leading to UI updates taking place on a background thread.

When should I use the @MainActor attribute?

You might have defined many dispatch statements to ensure tasks are running on the main thread. An example could look as follows:

func fetchImage(for url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) {
    URLSession.shared.dataTask(with: url) { data, response, error in
        guard let data, let image = UIImage(data: data) else {
            DispatchQueue.main.async {
                completion(.failure(ImageFetchingError.imageDecodingFailed))
            }
            return
        }

        DispatchQueue.main.async {
            completion(.success(image))
        }
    }.resume()
}

In the above example, you’re sure a dispatch is needed to return the image to the main thread. We have to perform dispatches in several places, resulting in code clutter with several closures.

Sometimes, we might even dispatch to the main queue while already on the main thread. Such a case would result in an extra dispatch that you could’ve skipped. By rewriting your code to use async/await and the main actor, you allow optimizations to be dispatched only if needed.

In those cases, isolating properties, methods, instances, or closures to the main actor ensures tasks perform on the main thread. Ideally, we would rewrite the above example as follows:

@MainActor
func fetchImage(for url: URL) async throws -> UIImage {
    let (data, _) = try await URLSession.shared.data(from: url)
    guard let image = UIImage(data: data) else {
        throw ImageFetchingError.imageDecodingFailed
    }
    return image
}

The @MainActor attribute ensures the logic executes on the main thread while the network request is still performed on the background queue. Dispatching to the main actor only takes place if needed to ensure the best performance possible.

Assuming @MainActor isolation applies

Sometimes, you’re in a synchronous context and want to access @MainActor-isolated state directly. To do this safely, you can use the assumeIsolated method that assumes and verifies you’re currently running on the actor’s serial executor:

MainActor.assumeIsolated {
    /// Assume that the current method is executing on the main actor’s serial executor, or stop program execution.
}

If you’re indeed on the right executor—such as the main thread when working with @MainActor—the method will give you isolated access to the actor, letting you access its internal state without needing to use await or cross an asynchronous boundary.

However, be careful: if you’re not on the correct executor, or if you’re dealing with a another actor, the app will crash with a fatal error. It expects isolation and crashes if that assumption is false.

It’s important to note that this method checks the executor, not the actor itself. That means if two actors share the same serial executor—for example, by setting their unownedExecutor to the same one (more on this later in this course)—this method will still succeed. That’s fine from a concurrency perspective, since the serial executor still guarantees mutual exclusion.

Finally, remember this only works in synchronous functions. If you’re in an async context, you should simply call the actor’s method normally—Swift will automatically execute on the correct actor if needed. Luckily enough, the compiler will tell us if we do try to use this method in an asynchronous method:

The assumeIsolated method can only be called from synchronous contexts.

When to assume isolation

It’s best practice to never assume isolation, but to let the compiler check for isolation. In other words, if possible, try to use @MainActor directly instead of assuming your isolated to the main actor already. The latter would risk a fatal error and that’s something you always want to avoid.

If you’re sure code runs on the main thread already, but it’s not yet @MainActor isolated, you’re still required to create a concurrency context:

Calling a @MainActor isolated method requires an asynchronous context.

The compiler is actually smart enough to detect and assume main thread access, as this code would compile even though there’s no concurrency task:

@MainActor
func someMainActorIsolatedMethod() {
    /// Some code that needs to execute on the main thread...
}

func someNonisolatedMethod() {
    DispatchQueue.main.async {
        someMainActorIsolatedMethod()
    }
}

Yet, it could be that your method is always called from the main thread, but via another method:

func methodA() {
    DispatchQueue.main.async {
        methodB()
    }
}

func methodB() {
    MainActor.assumeIsolated {
        someMainActorIsolatedMethod()
    }
}

@MainActor
func someMainActorIsolatedMethod() {
    /// Some code that needs to execute on the main thread...
}

The compiler can’t check this path completely and would throw a compilation error as before inside methodB if we wouldn’t use assumeIsolated.

In other words, we can write:

func methodB() {
    MainActor.assumeIsolated {
        someMainActorIsolatedMethod()
    }
}

Instead of writing:

func methodB() {
    Task { @MainActor in
        someMainActorIsolatedMethod()
    }
}

And we remove the need of creating an extra suspension point. Though, remember, it’s your responsibility to ensure methodB actually runs on the main thread. It might be that this method returns from a 3rd party library that you’re sure about. Yet, I recommend adding the traditional main thread assertion to catch changes early in the process:

func methodB() {
    /// Assert that we're running on the main thread.
    assert(Thread.isMainThread)

    MainActor.assumeIsolated {
        someMainActorIsolatedMethod()
    }
}

The assertion will warn you during development to indicate that code assumed to be running on the main thread is no longer doing so. However, the above statement doesn’t make much sense since our application would crash during debugging either way from assuming the isolation.

Summary

In this lesson, we’ve learned how to use @MainActor to ensure our code executes on the main thread. This is crucial for User Interface-related code. We can assume isolation in cases we’re certain code already runs on the main thread, but we need to be cautious since it can result in crashes.

In the next lesson, we’re going to look into the nonisolated and isolated keywords.