Contemplating Default Isolation Control

I've just recently re-watched a wonderful talk about progressive disclosure in Swift. The whole thing is worth your time, but there's one part in particular (about 30m-35m) that I have been thinking about a lot. That 5 minute section discusses the differences between progressive disclosure and false simplifications.

The crux of the concept is an effort to simplify a system can also suppress essential learning. This can result in an illusion of simplicity. That doesn’t mean progress isn’t happening. But, when you inevitably need to dig in deeper, only then will you encounter the true nature of the system. This is undesirable, first because real learning process hasn't actually started yet. But, also because there's the possibility that existing lessons and work might be invalided.

SE-0466 introduced the ability to control the default isolation inference. We've had just over a year to observe its use out in the real world. And I think there has been enough time and experience to justify a discussion about this feature.

I have come to believe that default isolation of MainActor is an example of false simplification.

I don't want to re-hash too much of the motivations from the proposal or the corresponding vision document. But I do think a small amount is warranted.

With a default of nonisolated, Swift has this built-in assumption of concurrency. This can present a real problem when writing single-threaded Swift code. And that's very common! It comes up when first learning Swift - a time when there is likely no intention (and possibly even no understanding) of concurrency. But it is certainly not limited to getting started. A substantial portion of real Swift code, across many domains, is single-threaded.

However, here is where I have observed the first major problem. While lots of code is single-threaded, an enormous number of real 1st- and 3rd-party APIs necessarily expose their clients to concurrency. This is not limited to lower-level systems, either. Even projects of modest complexity are unable to avoid encountering non-main thread execution when using UI frameworks exclusively. Single-threaded code is common but single-threaded libraries are a rarity.

Encountering concurrency in a form that is not compatible with a MainActor type:

a) happens for virtually all code
b) results in confusion and frustration

Now, neither of these points are unique to this mode. They can happen with a default of nonisolated too. But there is a critical difference. API design, documentation, and community-knowledge has been instilling a sense of what must be main thread-only for decades. Developers, even with limited experience, have a surprisingly good sense of what should be main-thread only.

Understanding the reverse, why something cannot be MainActor, has proven to be much harder. Interpreting the compiler diagnostics that can occur here is challenging. This is most-pronounced for users that do not understand the concurrency system. Reasoning about when a constraint is needed is more intuitive than understanding when an implicit constraint is no longer appropriate. Not to mention understanding how to remove the constraint and why it fixes the issue.

This problem, however, is not limited to understanding. Global actor isolation generally affects usage sites as well. This can permeate across a code base, potentially expanding the scope of a constraint that might not even be necessary in the first place.

This brings me to a fundamental assumption of SE-0466:

The easiest and best way to model single-threaded code is with a global actor.

Global actor isolated types do have some distinct advantages, but they also have disadvantages that demand an inverted and fairly-advanced form of thinking. I have observed, over and over, developers struggling because of their use of a MainActor default. I have also observed MainActor-isolated types that gain nothing from the isolation annotation while also paying a complexity price for having it. I think non-Sendable types, especially when paired with NonisolatedNonsendingByDefault can be, at least sometimes, a viable alternative. They are inherently single-threaded by their nature.

I do not believe this is purely academic. Non-Sendable, nonisolated types also enjoy a level simplicity and compatibility that actor-isolated types just cannot provide, even with isolated conformances. And when they are an inappropriate solution, which happens all the time, the compiler is there to provide feedback.

There's one more problem I have seen.

The vision document outlines and addresses the risks of a language dialect quite well. And I think that for the most part, they have unfolded as expected. The exception, which is critical, is readability. It is now harder to discuss Swift code. This affects documentation, code review, and snippets on this forum. It is true that there are cases where isolation isn't important or is not ambiguous. But, it regularly causes confusion. And does so particularly for situations where someone is already having difficultly. This is a second area where those with less-experience using concurrency are now subject to another, unique form of complexity.

I have also found that medium-to-large sized organizations dismiss the mode almost immediately. They tend to have highly-modular code bases, where many people review code across modules regularly. Here, the risk to readability does not outweigh any reduction in annotations.

The language dialect is, in my opinion, a very high cost to pay.

This is not a proposal. At this stage, I'm not sure it is appropriate for me to make concrete suggestions on how to proceed. I've struggled with how or even if this is something that I should bring up. I realize the implications of change here are non-trivial, possibly even impractical.

My goal here is just to get a discussion going. I'm interested to hear from others, particularly those that are using this mode with success.

Edit: I also feel it is worth clarifying that I was originally supportive of this feature. But I failed to anticipate how common types incompatible with global actors would be, combined with how much less-necessary MainActor types have become with the other features of 6.2.

47 Likes

A few thoughts:

I think the number of concurrency abstractions that exist today has generally made it more difficult for people, especially newcomers, to understand the basic execution model of a given system. I'm still surprised by the number of people who struggle to grasp the concept of a thread, for example.

Something else that doesn't help the situation much is that MainActor does not represent the "main thread" on all platforms. AFAIK, on all non-Apple platforms, the MainActor's thread is not the thread where the program first starts executing, but some other thread. I attempted to investigate this in the past but didn't make much progress. As far as I understand it, the problem is that libdispatch_init isn't called on the thread where the program initially starts, I think it would be a good idea to fix this first.

On a personal note, I'm still undecided on whether SE-0466 was a good idea. But I agree with your sentiment that it might be an example of false simplification. I personally never use MainActor default isolation, and I also hope that we can keep the nonisolated(nonsending) keyword after NonisolatedNonsendingByDefault becomes the default, since I very much prefer being explicit in my code.

P.S. The SE-0466 link doesn't seem to be working.

6 Likes

My codebase is currently not using default isolation or nonisolated(nonsending)-as-default. I'm considering turning on nonisolated-nonsending-as-default, but I don't have a lot of interest in default isolation control.

In my experience so far, it's easy to solve the problems default isolation control was made for with a little planning. SwiftUI already marked View as isolated to MainActor, and by applying the same annotation to the equivalent protocol (ViewModel/Interactor/whatever you want to call it) that all my business logic conforms to, I get ~95% of the benefits. Engineers still experience issues when writing code that's outside of the View-ViewModel dyad, but that's solved by nonisolated(nonsending)-as-default.

I appreciate the intention behind default isolation control (and was excited about it at the time it was introduced), but I think that it may not be necessary.

2 Likes

nonisolated(nonsending) as default really needs a migrator. I'd like to adopt it but we have hundreds of async calls that need to remain where they are and I'm not about to manually add @concurrent to all of them. Hopefully we get something before the setting defaults on.

3 Likes

There is, apparently, a migrate version of the setting -enable-upcoming-feature NonisolatedNonsendingByDefault:migrate(from here), though I'm not sure that works in Xcode. However, it's not really a migration, as you then have to manually apply the fixits it generates. I was hoping for a tool that would simply apply the proper markup for equivalent semantics in all my files.

1 Like

I wish I had something more concrete to say besides: making types implicitly MainActor in some code, but not others, in the same project, based on a compiler flag, is incredibly confusing.

Swift has always made it a bit hard to reason about code in isolation. You have to check type and function definitions to know what’s going on (for example, whether a type is a reference or value type, or if an await is for a context hop or an async method). But at least those are in the code, and they shouldn’t change without the code changing. The impact of feature flags and language modes makes this worse, but those are supposed to be temporary (though, after years of ongoing work, we are still struggling to get large parts of our code to Swift 6 concurrency). Default MainActor takes that problem and makes it permanent and intentional.

Had it been the default since the start, it would have been fine. Good even. But it layers ambiguity on top of a system that is already extremely confusing and subtle. I agree that it’s the illusion of simplicity and makes an unfortunate situation worse rather than better.

13 Likes

For what it's worth, my current Xcode projects have:

SWIFT_APPROACHABLE_CONCURRENCY = YES
SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor

and my SwiftPM packages (where the vast majority of my code lives), have:

 .defaultIsolation(MainActor.self),
 .enableUpcomingFeature("NonisolatedNonsendingByDefault"),
 .enableUpcomingFeature("InferIsolatedConformances"),
 .enableExperimentalFeature("SendableProhibitsMainActorInference"),

I am finding that combination works fine for me, and feels fairly intuitive. There are occasional moments of confusion, but they are usually easily resolved, and I don't particularly feel that this setup is worse than before.

It might be better, but it's a little hard to make a comparison without switching around all the code and trying the old approach again (some automated migrations would certainly be helpful there).

I'm not sure that this reply adds much to the debate! I do think there's an element of "complex concepts are complex" in all of this :slight_smile:.

Intuitively I think I'd prefer not to default everything to the main actor - which feels somewhat defeatist - but then I'd also prefer not to have a system which required lots of things to happen on the main thread.

2 Likes

Just to clarify some of my thinking here:

I do not believe that nonisolated(nonsending) is a full solution to the kinds of problems that default MainActor addresses. There are a number of commonly-encountered situations in which a non-Sendable type cannot easily be used. However, I think that if you are able to remain entirely with structured concurrency, it can work well.

(Improving the ergonomics and usability of non-Sendable types is an area of interest of mine, personally. However, how much can actually be done is unclear.)

I also do not believe that the inference you get from SwiftUI's View, App/UIKit's ViewController, or user-defined base protocols is sufficient.

However, I do think that these two things, together, cover a substantial portion of the potential problems you can encounter. The problem, and why this mode exists, is that they cannot cover all. Global, mutable state, in particular, is one of the more vexing examples. And I believe that global variables, especially as part of a single-file program with top-level code, are a very important use-case to support well.

2 Likes

My biggest practical concern with MainActor default isolation, and more importantly, the fact it was made the default in Xcode, is that it breaks numerous libraries which were designed as nonisolated by default.

It's evidently a source of much confusion - there's a new Reddit thread every other day asking about why some code doesn't compile, with a hard-to-parse error, where the solution is to turn off MainActor default isolation.

People's concept of MainActor default isolation is usually incorrect too, often thinking that this is "how it used to be" before Swift Concurrency. I've had numerous discussions with people working with Swift codebases suggesting they should adopt it, because they think this, because they think it'll simplify things, etc. when in reality it'd break their integrations with existing libraries, and they'd end up trading keywords – instead of marking things as @MainActor, they'd be marking them as nonisolated and/or @concurrent.

8 Likes

Perhaps a step back and some overview would help?

Swift concurrency features interact with each other and with libraries and their runtime expectations, and they have themselves evolved different approaches over time, resulting in a partial solutions that work in subsets of use-cases and build/deployment scenarios.

This makes them difficult to understand and teach, and thus almost impossible to model a self-teaching "progressive disclosure" path.

Open-source communities and development are good at scratching itches, but risk the hysteresis of veering between self-inflicted pain points. Language designers and developer are very conservative, preserving both past expectations and future possibilities. The two can produce a lot of discourse to little effect.

The pain is real, but at this point it's hard to imagine engaging in wide-ranging discussion without some map or model, and a concrete checklist of things that have to be addressed in concurrency discussions. Early vision documents project an expected landscape, but later for people (who are not principals) to navigate, checklists map the actual land mines and roads. Then I think people wouldn't struggle so much with how to proceed; they'd find a solution for their pain and run it through an all-factors analysis that at least ensures they haven't missed anything. I'd really like to see "someone" develop that checklist (and show it covers the issues raised in relevant discussions).

For this issue specifically (and commonly with concurrency), the question is how to hold it right; a starting and possibly sufficient partial solution is just to enumerate what works when, e.g.,

1 Like

Are you saying that guidance is needed on how to use/combine specific upcoming features in relation to default isolation? Or maybe something even more broad?

I suppose that adding a "MainActor by default" checkbox (unchecked by default) to the Xcode library template could be a starting point for solving the problem.

4 Likes

Agreed. I don't know how I feel about it being turned on by default as well for (new) app targets.

From a Swift perspective I think nonisolated should be the default but from an Xcode/new developer perspective developer it should be MainActor.

I also think it achieves progressive disclosure because the new developer won’t be writing extra annotations until they need a nonisolated function.

I would argue writing many ‘@MainactorMainactor’ is more arcane than a few ‘nonisolated’’s for a newcomer. Given most newcomers to Swift will likely be learning from an iOS app tutorial.

For the more experienced, likely all of us, we can just switch it off and be expl@MainActorcit with@MainActoradding ‘@MainActor’.

However I still don’t like having to check the settings on a new project to be sure. Maybe a toggle during project creation should be added.

Maybe if the concern around concurrency was level of difficulty for new@MainActorlaygroundomers a sort of “@MainActorMainActorlayground mode” that had ‘@MainActor’ as the default should have been added rather than introducing a configurable setting.

Edit: not sure why it’s duplicating main actor text :laughing:

Something else to keep in mind, although this is not unique to MainActor default isolation, are frequent context switches. A classic example is a hot loop running on the MainActor where each iteration involves another actor for one reason or another. While this is easy to resolve and relatively niche, nonisolated(nonsending) and MainActor default isolation can make this issue more subtle, especially when performance remains acceptable.

I wonder if a feature similar to namespaces for the purposes of establishing isolation domain would help:

isolation(MainActor) {

    func foo() {} // otherwise these are global functions

    func bar() {}

    @main class MainApplication {
        static func main() async {
            print("Hello, World !")
        }
    }
}

This highlights one of the things that has always bothered me about the ability to change the default isolation with a…compiler setting? It makes it impossible to reason about the code in a file just by looking at the contents of the file. You have to look elsewhere—not even in another source code file, but outside all the source code entirely—to know how to interpret the code.

I'm sure this is not a new insight, so just consider this my vote for either a single, unchangeable default for the whole language in Swift language mode N, or else changing this from a compiler flag to something in the code like tera's proposal above (but spelled differently, obviously… :person_biking::derelict_house:)

It seems like SE-0478 is trying to fix this, but the un-fun guessing game won't really be avoidable entirely until the compiler flag is no longer supported for a particular language mode.

21 Likes

:+1:
A language should be... well, a language.
And not a bunch of obscure settings.
Where "I hate you" means "I love you" just because i-love-you-by-default is set to "on" don't know where.

12 Likes

The whole practice of modifying language behavior in IDE or package manager is gravely wrong. The language should be independent of IDE or package manager used.

3 Likes

Unless someone makes a swift{,-}format rule for it.

Also while UI apps/libraries might want this setting, non-UI apps/libraries definitely don't.