Has Swift's concurrency model gone too far?

This probably means your types violate the OOP "single responsibility" rule.

It might be better in the long run to split up or rework the concrete conforming types, instead.

1 Like

I’ll give an example from an app I’ve been converting to be more conurrency-safe recently.

The app has a global config object. The object is mutable, but it’s typically mutated a single-digit number of times ever, during app startup. The obvious solution would be an async-friendly read-write lock, but there’s none in the standard library and I don’t feel like rolling my own (plus it would probably be buggy if I did). Instead, I notice that all of the writes and ~80% of the reads are on the main thread, so I throw @MainActor on it, subject the other ~20% of reads to await, and call it a day.

4 Likes

There's your problem. I'm fairly certain that the intended way to handle this with concurrency would be to make the configuration data members of an actor, and then have all mutations of that state go through that actor.

I agree that this shift in how you work with your system in a non-blocking way is not an easy one.

The need to escape back to synchronous world is usually caused by years of practicing this in GCD. This is simply (ok, bad wording) what you want to avoid in new model in Swift. Once you get on the train ā€œconstant progressā€, a lot of things actually start to improve, as you are no longer fighting the way things supposed to be from the compiler perspective. And this fighting actually is the source of many struggles as well.

I’d say you almost never want this. Equatable on classes doing logic usually has no meaning, because what you are more likely want is to compare its state, which can be a struct and get sendability for free. If you have such conflicting requirements, you probably need to separate types.

There is the need to confirm to some protocol sometimes and if it is not designed to be confirmed by the actor/actor-isolated type, it can be a problem and worth addressing.

This is perfect case for the mutex, while it’s limited to the latest OSes, if you are on Apple platforms there is OSAllocatedUnfairLock which is perfect for the job.

What if the mutable global variable is the user's logged-in status and you want to access it from a million places in the app, but it gets modified only occasionally? This is what I call "async contamination": just because of this one global variable, many parts of your code become async where otherwise there'd be no need for asynchronicity. I mentioned this earlier in this thread.

Today, this is only resolved with sync primitives like a semaphore or mutex.

2 Likes

I just want to clarify that I did not invent this idea and am doing very little. It was already being worked on by the Swift team. That thread is just the only place I personally have encountered it being discussed on the forum.

It currently doesn't exist even in pitch form, so I think realistically it will not be any time soon.

I feel the opposite! The current behavior combined with the default of no diagnostics allows so much accidentally-incorrect code into projects. And, especially when combined with protocols, can be difficult to fix without fundamental design changes. This is one of the major reasons why, as you noted earlier, concurrency usage can make code less-safe.

Never say never :sweat_smile: I think an excellent rule of thumb is you should feel good about making a reference type @unchecked Sendable because it already had internal synchronization. But, if you think you need to add synchronization to a type so that it can become Sendable, you are probably going down the wrong path.

Most of the time, what you want to focus on instead is eliminating the isolation boundaries that are pushing you to make something Sendable in the first place. But this is easier said than done.

Swift concurrency has this very strong tendency to force you to uncomfortable decision points.

"Do I force all callers to be async now, or add a lock?"
"Is this type really MainActor? I don't want to deal with the knock-on effects of that."
"Is this protocol requirement actually synchronous?"

Personally, I greatly appreciate these when starting a new design. I think it has levelled up my ability to think about the concurrent behaviors of my systems tremendously. But for an existing system, it can be painful. Modelling the behaviors of an existing system can require you basically understand every possible trick in language. And there are a lot! You can pull off many things, but learning how they all work and what the trade-offs are is a huge effort.

According to my understanding, such a thing would be impossible, because it would change the nature of protocols. This is another decision point. Is your type isolated or is the protocol synchronous? These are both major constraints, and they impact each other.

(I also could be wrong here, and would love to be surprised...)


Threads like this often devolve into criticism of user design choices. As I said before, Swift concurrency does force you to deal with the implications of a design. And while I think that is, at a very high level, a valuable and underappreciated thing, it can also be profoundly unhelpful for real projects. That's not the purpose of the language, it's a side-effect, and one that can be extremely unwelcome. Especially when you are knee-deep in impenetrable errors from a subsystem you didn't even build or understand.

(Oh yeah, just remembered that there have been a few great improvements to diagnostics in Swift 6.1!)

I think unsafe-opt outs are under-utilized. So many tough situations can be solved with a well-placed nonisolated(unsafe). But usually people feel uneasy about using this kind of thing, because they do not have a sufficient mental model of the system to feel confident they aren't just introducing a footgun.

Ok, so now to return to the original side-discussion. Would the ability to block while waiting for async work in certain circumstances be useful. Yes! And that's why it was specifically called out in the vision document.

3 Likes

Even OSAllocatedUnfairLock is too new for my use cases, unfortunately.

I find that every single await to a protocol method is counted as a boundary for some reason, unless the protocol and the caller are marked with the same global actor. I thought region-based isolation was supposed to fix this, but right now if I turn RBI on I get dozens of warnings from files that otherwise give none at all, so evidently not.

1 Like

Unfortunately, what you are experiencing is the effect of non-isolated async functions always running on the global executor (ie background). This is what all this "inheriting isolation" discussion is about, and yeah, this is a really common problem. This is not what region-based isolation addresses.

I'd have to see more code to fully understand what a good potential solution could be. But today, non-isolated protocols with async methods can be very challenging to use when combined with non-Sendable types.

I now see from many examples in this topic that the pitched isolation inheritance, even though its kinda unfortunate that the behaviour for nonisolated code might change so drastically after major release, is what probably solve tons of complications in Swift's concurrency model.

1 Like

There is @swhitty/swift-mutex which backports Mutex to macOS 10.15+ and iOS 13+.

2 Likes

I want to come back to what I think is the biggest issue, which is interoperating with existing systems that you either cannot change (first or third party libraries, etc), or that changing would be politically difficult or very time consuming.

This is the area where things like propagating async through the codebase becomes an issue, because you hit a hard boundary where you can't (or practically can't) do that. And I think the language needs a better answer than "these dependencies that you don't control should have been designed better" — it would be great if they were designed better. But that's not under my control, so I need to move forward from where I am now!

For example, the issue where valid uses of Combine were crashing in Swift 6 because Combine had not been properly updated. Should Apple update Combine? Yes, of course. Should the fact that Apple haven't updated Combine mean my app should crash? No! Using Combine in this way is not unsafe -- the only unsafe thing is the deliberate crash put in by Swift Concurrency because it doesn't understand how the code is ensuring safety. As an app developer, I should be able to opt out of that.

10 Likes

Thank you so much @mattie for such a well reasoned and nuanced response. I think you've summed up and got to the main and important points very clearly.

This is exactly it.

And this too 100%. Eg when Slava says: —

He is not doubt completely right. But these are not "my" classes, they are the approximately 6000 classes in the codebase I work in. How many of those is it reasonable to refactor until it works with the new model?

Conversely in a new project things are much better and just as you said being forced to do things with this stuff in mind from the off is helpful. Although that said it can quickly become painful again if you have to interact with a third party framework that hasn't been updated (and updating will be painful for them, as aforementioned) then you can get stuck or forced into tricky corners again. And some of these frameworks are Apple frameworks — eg Combine. I think you have written on this subject before @mattie? (You can get crashes if you use Combine and don't manually label closures with @Sendable — with no warnings even in Swift 6. I'm not the only one with this problem: Mike Apurin: "In Swift 6, the sink closure inherits MainActor i…" - Hachyderm.io. True you can turn off that executor check... for now at least.)

Ah right, I got excited prematurely there! I wish they could redesign this so that isolation was a property of the conformance, not the protocol, but it would be a major change even if it were possible, and you might want those constraints too and that is getting very complicated!

1 Like

Ah you beat me to it mentioning that. I think the optimistic crash from the executor is less an issue than you don't get warned that the preconcurrency framework is breaking your invariants. So you might end up running code you have labelled @MainActor directly yourself, not on the main thread at all. And so you lose all your actor protection. If you turn off the executor crash, you might not have any issues, or you might have subtle synchronization bugs, or straight up data race crashes.

I think there is a bit of the apple documentation on migration to concurrency that mentions this, and the only thing you can do is manually add @Sendable to escaping closures in the third party framework calls. In a big codebase that is just not practical.

1 Like

I dread to think how many there are in mine -- 30k plus probably.

I wonder what a plot of "size of codebase" vs "developer sentiment about Swift concurrency" would look like.

6 Likes

It is not necessarily unsafe! But it could be and this is why SE-0423 added this behavior. You can, though, disable this in a Swift 6 mode target. However I have had trouble making this work with the published method, and had to use another approach.

(Now, I am of the opinion that closures for non-6-mode code should have been imported with an assumption of @Sendable, kinda like the assumption of @escaping, but that didn't/couldn't happen so we are where we are.)

2 Likes

I could not agree more!

1 Like

I haven't written anything on Combine specifically, though it comes up a huge amount because it is in such wide use. I honestly just don't know Combine well enough to feel like I could have anything particularly useful to say about it aside from "be extremely careful with it and 6 mode".

I will say that my experience has been that it usually isn't a big deal for Swift 5 mode frameworks to add minimal support for Swift concurrency. Make sure your @Sendable closures are marked, throw some @preconcurrency in there to preserve support existing clients and you should be ok. The big problem, I think, is that work is pretty much mandatory.

If anyone has run into more issues around adding a base level of Swift 6 compatibility to an otherwise Swift 5 library I would definitely be interested!

There's been an immense response to this simple (and valid, imo) question, and I have strong feelings about the topic, but I don't feel qualified for constructive criticism, since I have long lost track of the intricacies of the language. Nowadays, I maybe understand 30% of the language.

6 Likes

If there is one thing I think we can be certain of, it is that the original question was not simple :laughing:

6 Likes

Controversial stuff is good, I like a good discussion.

1 Like