Give your simulator superpowers

RocketSim: An Essential Developer Tool
as recommended by Apple

MainActor usage in Swift explained to dispatch to the main thread

MainActor is a new attribute introduced in Swift 5.5 as a global actor providing an executor that performs its tasks on the main thread. When building apps, it’s essential to perform UI updating tasks on the main thread, which can sometimes be challenging when using several background threads. Using the @MainActor attribute will help ensure your UI is always updated on the main thread.

If you’re new to Actors in Swift, I recommend reading my article Actors in Swift: how to use and prevent data races. Global actors act similar to actors, and I won’t go into much detail on how actors work in this post.

What is a MainActor?

A MainActor is a globally unique 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.

Understanding Global Actors

Before we dive into how to use the MainActor in your code, it’s important to understand the concept of global actors. You can see Global Actors as singletons: only one instance exists. We can define a global actor as follows:

@globalActor
actor SwiftLeeActor {
    static let shared = SwiftLeeActor()
}

The shared property is a requirement of the GlobalActor protocol and ensures having a globally unique actor instance. Once defined, you can use the global actor throughout your project, just like you would with other actors:

@SwiftLeeActor
final class SwiftLeeFetcher {
    // ..
}

Anywhere you use the global actor attribute, you’ll ensure synchronization through the shared actor instance to ensure mutually exclusive access to declarations. The outcome is similar to actors in general, as explained in Actors in Swift: how to use and prevent data races.

The underlying @MainActor implementation is similar to our custom-defined @SwiftLeeActor:

@globalActor
final actor MainActor: GlobalActor {
    static let shared: MainActor
}

It’s available by default and defined inside the concurrency framework. In other words, you can start using this global actor immediately and mark your code to be executed on the main thread by synchronizing via this global actor.

How do you stay current as a Swift developer?

Let me do the hard work and join 19,144 developers that stay up to date using my weekly newsletter:

How to use MainActor in Swift?

You can use a global actor with properties, methods, closures, and instances. For example, we could add the @MainActor attribute to a view model to perform all tasks on the main thread:

@MainActor
final class HomeViewModel {
    // ..
}

Using nonisolated, we ensure methods without the main thread requirement perform as fast as possible by not waiting for the main thread to become available. You can only annotate a class 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.

In other cases, we might want to define individual properties with a global actor:

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.
}

Be warned: This method is only guaranteed to be dispatched to the main thread if you call it from an asynchronous context. Xcode 16 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.

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 main actor 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 @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?

Before Swift 5.5, 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.

Understanding Why @MainActor Doesn’t Always Ensure Main Thread Execution

If you’re new to concurrency, you might think marking a method with @MainActor always ensures it runs on the main thread. Unfortunately, this is not guaranteed for synchronous methods in non-isolated contexts. Several teams found out the hard way, just like Cyril Cermak, who reached out to me during one of my conference visits.

Before sharing the details, it’s good to know that Swift 6 language mode catches most of these occasions. However, since many teams are still migrating to Swift 6, I wanted to highlight this misunderstanding.

Synchronous methods in non-isolated contexts run on the same thread as the caller, regardless of any actor annotations. Depending on which Swift language mode you’re using, you might or might not be warned about potential issues.

For example, this code compiles fine in Swift 5 language mode:

In Swift 5 language mode, it's possible to run into unexpected main actor behavior.
In Swift 5 language mode, it’s possible to run into unexpected main actor behavior.

The dispatch via the global queue creates a non-isolated context and the @MainActor attribute isn’t respected.

However, if we turn on Swift 6 language mode, the compiler will tell us about the potential flaw right away:

The compiler with Swift 6 language mode will prevent you from mistakes when using the @MainActor attribute.
The compiler with Swift 6 language mode will prevent you from mistakes when using the @MainActor attribute.

In larger projects, it’s likely that a chain of methods is called on a background thread. Eventually, such a chain can end up calling a method attributed to the main actor while synchronously dispatched to the same thread as the caller: a background thread. Therefore, see if you can get your code on Swift 6; otherwise, be sharp when using the main actor attribute.

You might not directly encounter issues if you always call the synchronous method from a source running on the main thread. However, awareness of this subtle difference is essential since your expectations might not align, resulting in unexpected UI-related crashes before migrating to Swift 6.

Continuing your journey into Swift Concurrency

The concurrency changes are more than just actors and include other features that you can benefit from in your code. So, while at it, why not dive into other concurrency features?

Conclusion

Global actors are a great addition to actors in Swift. It allows us to reuse common actors like @MainActor and perform UI tasks in a performant manner, as the compiler can optimize our code internally. You can use Global actors on properties, methods, instances, and closures, to ensure synchronized access.

If you like to learn more tips on Swift, check out the Swift category page. Feel free to contact me or tweet to me on Twitter if you have any additional tips or feedback.

Thanks!

 
Antoine van der Lee

Written by

Antoine van der Lee

iOS Developer since 2010, former Staff iOS Engineer at WeTransfer and currently full-time Indie Developer & Founder at SwiftLee. Writing a new blog post every week related to Swift, iOS and Xcode. Regular speaker and workshop host.

Are you ready to

Turn your side projects into independence?

Learn my proven steps to transform your passion into profit.