Must you opt-in to Swift 6.2’s Major Actor isolation? – Donny Wals

0
1
Must you opt-in to Swift 6.2’s Major Actor isolation? – Donny Wals


Revealed on: September 11, 2025

Swift 6.2 comes with a some attention-grabbing Concurrency enhancements. One of the crucial notable modifications is that there is now a compiler flag that can, by default, isolate all of your (implicitly nonisolated) code to the primary actor. This can be a big change, and on this put up we’ll discover whether or not or not it is a good change. We’ll do that by looking at a number of the complexities that concurrency introduces naturally, and we’ll assess whether or not transferring code to the primary actor is the (right) resolution to those issues.

By the tip of this put up, it is best to hopefully have the ability to determine for your self whether or not or not major actor isolation is smart. I encourage you to learn by way of your entire put up and to fastidiously take into consideration your code and its wants earlier than you leap to conclusions. In programming, the precise reply to most issues will depend on the precise issues at hand. That is no exception.

We’ll begin off by trying on the defaults for major actor isolation in Xcode 26 and Swift 6. Then we’ll transfer on to figuring out whether or not we must always preserve these defaults or not.

Understanding how Major Actor isolation is utilized by default in Xcode 26

While you create a brand new undertaking in Xcode 26, that undertaking may have two new options enabled:

  • World actor isolation is ready to MainActor.self
  • Approachable concurrency is enabled

If you wish to study extra about approachable concurrency in Xcode 26, I like to recommend you examine it in my put up on Approachable Concurrency.

The worldwide actor isolation setting will robotically isolate all of your code to both the Major Actor or no actor in any respect (nil and MainActor.self are the one two legitimate values).

Because of this all code that you simply write in a undertaking created with Xcode 26 might be remoted to the primary actor (except it is remoted to a different actor otherwise you mark the code as nonisolated):

// this class is @MainActor remoted by default
class MyClass {
  // this property is @MainActor remoted by default
  var counter = 0

  func performWork() async {
    // this perform is @MainActor remoted by default
  }

  nonisolated func performOtherWork() async {
    // this perform is nonisolated so it isn't @MainActor remoted
  }
}

// this actor and its members will not be @MainActor remoted
actor Counter {
  var depend = 0
}

The results of your code bein major actor remoted by default is that your app will successfully be single threaded except you explicitly introduce concurrency. All the things you do will begin off on the primary thread and keep there except you determine it’s essential to depart the Major Actor.

Understanding how Major Actor isolation is utilized for brand spanking new SPM Packages

For SPM packages, it is a barely totally different story. A newly created SPM Package deal won’t have its defaultIsolation flag set in any respect. Because of this a brand new SPM Package deal will not isolate your code to the MainActor by default.

You’ll be able to change this by passing defaultIsolation to your goal’s swiftSettings:

swiftSettings: [
    .defaultIsolation(MainActor.self)
]

Notice {that a} newly created SPM Package deal additionally will not have Approachable Concurrency turned on. Extra importantly, it will not have NonIsolatedNonSendingByDefault turned on by default. Because of this there’s an attention-grabbing distinction between code in your SPM Packages and your app goal.

In your app goal, every part will run on the Major Actor by default. Any capabilities that you have outlined in your app goal and are marked as nonisolated and async will run on the caller’s actor by default. So in the event you’re calling your nonisolated async capabilities from the primary actor in your app goal they’ll run on the Major Actor. Name them from elsewhere they usually’ll run there.

In your SPM Packages, the default is to your code to not run on the Major Actor by default, and for nonisolated async capabilities to run on a background thread it doesn’t matter what.

Complicated is not it? I do know…

The rationale for operating code on the Major Actor by default

In a codebase that depends closely on concurrency, you may must cope with a variety of concurrency-related complexity. Extra particularly, a codebase with a variety of concurrency may have a variety of information race potential. Because of this Swift will flag a variety of potential points (whenever you’re utilizing the Swift 6 language mode) even whenever you by no means actually supposed to introduce a ton of concurrency. Swift 6.2 is significantly better at recognizing code that is secure though it is concurrent however as a common rule you wish to handle the concurrency in your code fastidiously and keep away from introducing concurrency by default.

Let’s take a look at a code pattern the place we have now a view that leverages a job view modifier to retrieve information:

struct MoviesList: View {
  @State var movieRepository = MovieRepository()
  @State var motion pictures = [Movie]()

  var physique: some View {
    Group {
      if motion pictures.isEmpty == false {
        Checklist(motion pictures) { film in
          Textual content(film.id.uuidString)
        }
      } else {
        ProgressView()
      }
    }.job {
      do {
        // Sending 'self.movieRepository' dangers inflicting information races
        motion pictures = strive await movieRepository.loadMovies()
      } catch {
        motion pictures = []
      }
    }
  }
}

This code has a difficulty: sending self.movieRepository dangers inflicting information races.

The rationale we’re seeing this error is because of us calling a nonisolated and async methodology on an occasion of MovieRepository that’s remoted to the primary actor. That is an issue as a result of inside loadMovies we have now entry to self from a background thread as a result of that is the place loadMovies would run. We even have entry to our occasion from inside our view at the very same time so we’re certainly making a attainable information race.

There are two methods to repair this:

  1. Be sure that loadMovies runs on the identical actor as its callsite (that is what nonisolated(nonsending) would obtain)
  2. Be sure that loadMovies runs on the Major Actor

Choice 2 makes a variety of sense as a result of, so far as this instance is anxious, we all the time name loadMovies from the Major Actor anyway.

Relying on the contents of loadMovies and the capabilities that it calls, we would merely be transferring our compiler error from the view over to our repository as a result of the newly @MainActor remoted loadMovies is looking a non-Major Actor remoted perform internally on an object that is not Sendable nor remoted to the Major Actor.

Finally, we would find yourself with one thing that appears as follows:

class MovieRepository {
  @MainActor
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let motion pictures: [Movie] = strive await carry out(req)

    return motion pictures
  }

  func makeRequest() -> URLRequest {
    let url = URL(string: "https://instance.com")!
    return URLRequest(url: url)
  }

  @MainActor
  func carry out(_ request: URLRequest) async throws -> T {
    let (information, _) = strive await URLSession.shared.information(for: request)
    // Sending 'self' dangers inflicting information races
    return strive await decode(information)
  }

  nonisolated func decode(_ information: Information) async throws -> T {
    return strive JSONDecoder().decode(T.self, from: information)
  }
}

We have @MainActor remoted all async capabilities apart from decode. At this level we won’t name decode as a result of we won’t safely ship self into the nonisolated async perform decode.

On this particular case, the issue may very well be fastened by marking MovieRepository as Sendable. However let’s assume that we have now causes that forestall us from doing so. Perhaps the true object holds on to mutable state.

We may repair our downside by really making all of MovieRepository remoted to the Major Actor. That method, we are able to safely move self round even when it has mutable state. And we are able to nonetheless preserve our decode perform as nonisolated and async to forestall it from operating on the Major Actor.

The issue with the above…

Discovering the answer to the problems I describe above is fairly tedious, and it forces us to explicitly opt-out of concurrency for particular strategies and ultimately a complete class. This feels fallacious. It looks like we’re having to lower the standard of our code simply to make the compiler joyful.

In actuality, the default in Swift 6.1 and earlier was to introduce concurrency by default. Run as a lot as attainable in parallel and issues might be nice.

That is nearly by no means true. Concurrency just isn’t the most effective default to have.

In code that you simply wrote pre-Swift Concurrency, most of your capabilities would simply run wherever they had been known as from. In follow, this meant that a variety of your code would run on the primary thread with out you worrying about it. It merely was how issues labored by default and in the event you wanted concurrency you’d introduce it explicitly.

The brand new default in Xcode 26 returns this habits each by operating your code on the primary actor by default and by having nonisolated async capabilities inherit the caller’s actor by default.

Because of this the instance we had above turns into a lot easier with the brand new defaults…

Understanding how default isolation simplifies our code

If we flip set our default isolation to the Major Actor together with Approachable Concurrency, we are able to rewrite the code from earlier as follows:

class MovieRepository {
  func loadMovies() async throws -> [Movie] {
    let req = makeRequest()
    let motion pictures: [Movie] = strive await carry out(req)

    return motion pictures
  }

  func makeRequest() -> URLRequest {
    let url = URL(string: "https://instance.com")!
    return URLRequest(url: url)
  }

  func carry out(_ request: URLRequest) async throws -> T {
    let (information, _) = strive await URLSession.shared.information(for: request)
    return strive await decode(information)
  }

  @concurrent func decode(_ information: Information) async throws -> T {
    return strive JSONDecoder().decode(T.self, from: information)
  }
}

Our code is way easier and safer, and we have inverted one key a part of the code. As a substitute of introducing concurrency by default, I needed to explicitly mark my decode perform as @concurrent. By doing this, I be certain that decode just isn’t major actor remoted and I be certain that it all the time runs on a background thread. In the meantime, each my async and my plain capabilities in MoviesRepository run on the Major Actor. That is completely nice as a result of as soon as I hit an await like I do in carry out, the async perform I am in suspends so the Major Actor can do different work till the perform I am awaiting returns.

Efficiency affect of Major Actor by default

Whereas operating code concurrently can enhance efficiency, concurrency does not all the time enhance efficiency. Moreover, whereas blocking the primary thread is dangerous we should not be afraid to run code on the primary thread.

Each time a program runs code on one thread, then hops to a different, after which again once more, there is a efficiency price to be paid. It is a small price normally, but it surely’s a value both method.

It is usually cheaper for a fast operation that began on the Major Actor to remain there than it’s for that operation to be carried out on a background thread and handing the outcome again to the Major Actor. Being on the Major Actor by default signifies that it is rather more express whenever you’re leaving the Major Actor which makes it simpler so that you can decide whether or not you are able to pay the fee for thread hopping or not. I am unable to determine for you what the cutoff is for it to be price paying a value, I can solely inform you that there’s a price. And for many apps the fee might be sufficiently small for it to by no means matter. By defaulting to the Major Actor you may keep away from paying the fee unintentionally and I believe that is a superb factor.

So, do you have to set your default isolation to the Major Actor?

On your app targets it makes a ton of sense to run on the Major Actor by default. It lets you write easier code, and to introduce concurrency solely whenever you want it. You’ll be able to nonetheless mark objects as nonisolated whenever you discover that they must be used from a number of actors with out awaiting every interplay with these objects (fashions are a superb instance of objects that you’re going to most likely mark nonisolated). You should use @concurrent to make sure sure async capabilities do not run on the Major Actor, and you should use nonisolated on capabilities that ought to inherit the caller’s actor. Discovering the proper key phrase can typically be a little bit of a trial and error however I usually use both @concurrent or nothing (@MainActor by default). Needing nonisolated is extra uncommon in my expertise.

On your SPM Packages the choice is much less apparent. In case you have a Networking package deal, you most likely don’t desire it to make use of the primary actor by default. As a substitute, you may wish to make every part within the Package deal Sendable for instance. Or possibly you wish to design your Networking object as an actor. Its’ solely as much as you.

If you happen to’re constructing UI Packages, you most likely do wish to isolate these to the Major Actor by default since just about every part that you simply do in a UI Package deal must be used from the Major Actor anyway.

The reply is not a easy “sure, it is best to”, however I do suppose that whenever you’re doubtful isolating to the Major Actor is an efficient default selection. While you discover that a few of your code must run on a background thread you should use @concurrent.

Observe makes excellent, and I hope that by understanding the “Major Actor by default” rationale you may make an informed determination on whether or not you want the flag for a selected app or Package deal.

LEAVE A REPLY

Please enter your comment!
Please enter your name here