[Prospective Vision] Improving the approachability of data-race safety

If we're discussing approachability, would it be possible to have an option to let the compiler expose the inferred "Sendable" conformances in public or maybe just package types?

When I converted our project to the Swift 6 Language mode, this was the biggest stumbling block, and we had to apply the Sendable conformance to hundreds of types. Our application was built in a way that made the conversion very easy otherwise, but we still have to manually apply Sendable to almost every type we create, even though the compiler could infer it if we kept everything in the same module.

The decision to modularize our project made it much more cumbersome to adopt the swift 6 language mode. In an ideal world, we could make both decisions, modularization and (ergonomic) data-race safety in isolation, without one impacting the other.

4 Likes

I'm working on a mature project which is firmly in the stage 3 of the progressive disclosure. And still, I think @MainActor isolation would be a suitable default choice for majority of the modules.

Risks of introducing language dialect could be mitigated to some degree by the tooling. Sometimes I find myself in the need to see the types inferred by the constraint solver. I think it would be handy to have a feature in Xcode to explicitly show inferred types and isolation. But this would not help with Git clients and Web-based code review tools, where impact of the different dialects is actually higher because of the limited context. I think in the end in our project we would just stick to the @MainActor-by-default dialect to avoid confusion.

Now, an isolated conformance is less flexible than a nonisolated conformance. For example, generic types and functions from nonisolated modules (including all current declarations) will still be interpreted as requiring nonisolated conformances. This will mean that they can't be called with a type that only has an isolated conformance, but it will also allow them to freely use the conformance from any concurrency domain, the same way they can today. Generic types and functions in "single-threaded" modules will default to allowing conformances isolated to the module's default global actor. Since those functions will themselves be isolated to that global actor, they won't have any problem using those conformances.

Is module setting the only thing that controls how conformance is interpreted? Or will there be an explicit syntax to specify isolated conformance in nonisolated modules and vice versa?

I'm excited to see that isolated conformances and subclassing are identified as a pain point. I was actually trying to draft something about isolated conformances myself, and when trying to do so, I've encountered unsoundness with Sendable conformance and downcasting:

protocol P {
    func foo()
}

@MainActor
class C: @MainActor P {
    var k: Int = 0
    func foo() { k += 1 }
}

func useIt<T>(_ x: T) where T: isolated P, T: Sendable {
    let s: any Sendable = x
    Task {
        let y = s as! isolated P
        y.foo() // data race
    }
}

This can be solved either by making isolated conformances and Sendable conformances mutually exclusive, or by checking isolation dynamically, as part of the downcast. Is there already any information about the preferred direction at the vision stage or will this be worked out later?

Also the second option (checking isolation dynamically) implies that isolation value is part of the type we are casting to, leading to dependent types. We already have a precedent of types dependent on integer values, and types dependent on isolation would generalise isolated conformances and subclassing, lifting many restrictions, but introducing their own challenges (e.g. expressing merging of regions, retain cycles with actors). But if these challenges are solved, we can have a landscape where everything is sendable, but not necessarily usable from other isolations. Not having to worry about sendability at all sounds attractive. Were dependent types considered by the LSG as a solution for the mentioned problems? Would be nice to have vision document to mention them at least in alternatives considered.

3 Likes

It looks like you're suggesting syntax sugar for declaring a new global actor. You can already do this with a freestanding macro, e.g. you can write a #globalActor("NewIsolation") macro that expands to a global actor declaration that would let you write @NewIsolation on types, functions, and closures. But a global actor is still fundamentally a singleton actor, because actors provide isolation domains.

It's important to note that this vision document is not revisiting the fundamentals of the concurrency system. The ideas in this vision document are changing the defaults for actor isolation inference and the execution semantics of async functions, plus a few additive features that make those defaults possible and help with incremental migration. I don't believe the fundamentals are the problem, and there's a real danger in changing the fundamental behavior of a system that programmers have been internalizing for the last 3-4 years.

I think this effectively falls out of the ideas in the vision document for @MainActor-isolated subclasses including overrides, and the existing impact of SE-0423 on @objc methods that are isolated. You'll be able to add @MainActor to a subclass of a nonisolated superclass, and the compiler will ensure that the subclass is only ever used from the main actor. Of course, there's no enforcement on the Clang side, so methods that are exposed to @objc have a dynamic actor isolation check automatically inserted by the compiler. I still think the dynamic actor isolation checking can be difficult to work with for the reasons outlined in the section on mitigating runtime assertions due to isolation mismatches, but some of those solutions could also apply to isolated @objc methods.

I'm pretty hesitant to go down the direction of allowing modules to specify an arbitrary global actor as the default, because any global actor other than the main actor does not help with progressive disclosure. It has the opposite effect - it forces asynchrony on any main-actor-isolated caller. So, I'm hesitant to give library authors an easy way to default everything to some other global actor, because I am not at all convinced that approach would be a good design decision.

However, there's nothing in the design here that would prohibit us from adding that in the future, so we can still pursue it if we discover a need for this in the future.

6 Likes

You didn't miss anything! I didn't include any concrete design sketches for solutions only because this area is not as far along as the others; most of the other features already have a concrete proposal either already written or actively in progress, and this is still in the early stages of experimentation to determine what's feasible.

I'm currently investigating two possible APIs:

  1. An API to block on synchronous work that's isolated to an actor. This is effectively the equivalent of DispatchQueue.asyncAndWait, but for an actor. It would allow you to synchronously access actor-isolated state and synchronous methods from outside the actor.
  2. A general API to block on arbitrary async work.

Now for the obligatory caution tape for idea 2: a general API to block on arbitrary async work comes with serious, fundamental tradeoffs, including unsolvable priority inversions and deadlocks.

As noted in the document, we may be able to mitigate deadlocks in some situations, but any solution to this is a tradeoff between deadlocks and possibly unexpected actor re-entrancy, which is an issue that people are already struggling with when using actors. The most common deadlock scenario is when you block on an async function from the main actor, and that async function needs to do work on the main actor in order to compete. Today if you attempt to block on such an async call using a condition variable, you'll experience a deadlock. But always deadlocking in this scenario would render this blocking API impossible to use together with the "main actor by default" mode. To mitigate the deadlock, we'd need to allow actor re-entrancy during the async call. We don't want to allow all work enqueued on the main actor to run, because that would risk unexpected re-entrancy bugs where state changes out from under you while you made the blocking call. We could instead employ a heuristic that only allows actor-isolated work that's part of the task tree for the async call. This heuristic wouldn't be perfect - there would still be cases where the async call could kick off main actor work that isn't covered by the heuristic but is necessary for the async work to complete, which would deadlock.

Hopefully this gives you an idea of the complexity of this problem. It might not be solvable in a way that's usable in practice. I hope to have a better idea of the concrete solution that we could realistically offer in the next few weeks.

7 Likes

I don't believe this is changing the fundamental behavior. As you're saying yourself, this is what global actors already are. More than making changes in the language, I believe this is more about adjusting the Swift Concurrency story, documentation and communication, to emphasize global actors usage over actors usage. I'm mentioning this here because it seems to me this approach would help a lot of developers into adopting Swift Concurrency (but I suspected this might be out of topic, sorry then).

2 Likes

Would it be useful to have a dedicated discussion alongside or after this one about the order of concepts that programmers are introduced to when learning about concurrency? I agree that you can get very far with only @MainActor, async/await, and tasks, and creating your own actors and global actors can come later along the learning curve. I'd love to put together a "Getting started with concurrency" article for Swift.org that introduces these concepts gradually from most common to more advanced use cases.

13 Likes

Since this document introduces concrete "phases" of adoption for progressive disclosure, it might be helpful to dive into this more deeply now rather than later as it could (should?) influence our approach to approachability.

2 Likes

Are you talking about the default for -strict-concurrency, e.g. making complete the default in all language modes < 6 and opting into minimal? I think that has never been on the table because of how pervasive the warnings are throughout code bases. It's possible that we'd be able to revisit this if we eliminate enough false-positives. Perhaps the false-positive rate would be low enough under the changes described in this document that we could at least proactively warn developers to turn on complete checking when the new options are enabled in language modes < 6. I think we'd need more data about the impact of these changes on strict concurrency warnings in real code bases before starting that discussion.

I'm definitely in favor of inferring Sendable conformances for types with package access.

The focus of the document is improving progressive disclosure of data-race safety, which is related but not quite the same as improving the learning curve for concurrent programming in general. The focus here is on the initial stages of single-threaded, non-concurrent programming to single-threaded asynchronous programming, because as long as you're in the single-threaded world, there's no risk of data races. As such, the document very strongly suggests that async/await should be the first concurrency concept along the learning curve. But it puts pretty much all other concurrency concepts into the last phase of "embracing concurrency" without suggesting how those concepts should be introduced and in what order. My instinct says these are separate discussions.

3 Likes

Sure, I agree that we don't need to enumerate every concurrency concept in this document in a fixed order.

However, since we're outlining a vision whereby @MainActor, Sendable, and isolation are inferred to make the language more approachable, I'm hesitant about splitting out entirely the question about when we expect folks to transition from using these features implicitly to using them explicitly.

It may well be that this exercise (defining what features have to be implicitly inferred in a single-threaded mode) will reveal the natural contours of a core set of concurrency features—or it may not, but my sense is that it'd be wise to keep the consideration in mind.

3 Likes

It'd be great to do a holistic review of features that have been made internal-only with the rationale of preventing accidental burdens on public APIs, as my sense is that more than just this one can be reasonably transitioned from internal-or-less to less-than-public.

But as a standalone measure, I think it makes perfect sense to place it in the context of this vision as it goes to the very topical question of what can be inferred for approachability versus when users should be required to adopt explicitly. My sense is that it shouldn't be a compiler flag but just an across-the-board change for everyone.

4 Likes

Yes, here or elsewhere (whatever is decided, fine with me). Besides documentation, I also think it would help if global actors had a nicer syntax (to declare them), I feel like the current one is preventing wider use from developers (and I'm not convinced the macro is helping here, still feels awkward and not first-class citizen).

1 Like

I’m a -1 on doing this. I know lots of people want it but in my experience with GCD it was always a foot gun that lots of developers always wanted to reach for and very often caused deadlocks. It was just too easy to do and they didn’t have any idea it could cause deadlocks.

It is a critical primitive for implementing fork-join techniques when you can’t completely rearchitect your codebase to use structured concurrency.

1 Like

I think only a tiny slice of app developers have seriously adopted strict concurrency beyond async/await over the past few years, which is why Swift 6 has been so difficult and why fundamental changes should be on the table.

I'd love to have a Swift 7 breaking change on the table to address concurrency, starting with making MainActor the default for un-annotated code. At the end of the day, app developers are the vast majority of Swift users, and the default should make sense for us. Making this per-module is going to be the first major language dialect and inevitable wart as you can't read source and know the behavior.

I also think naming has been a big issue with Swift concurrency. For example, nonisolated is a term without meaning; everytime I see or read it I have to pause and remember that it means something is usable on any actor. Sendable and actor are terms that likewise don't convey meaning on their own, they have to be learned. nonisolated though to me is the biggest naming issue and could be fixed in Swift 7.

5 Likes

absolutely, 100 percent, yes.

Swift’s concurrency concepts by themselves, one at a time, are not that hard to grasp. but it is really hard to learn how to put them together, and when to choose one instead of the other. i spent years (!) flailing around in the dark trying to discover the right patterns, and from speaking to others i have found this to be a common experience among Swift Concurrency adopters.

we need documentation that provides “recipes” for common patterns, that use more than one concurrency tool together at a time. we need to be more forthcoming about what can be accomplished using tools in the standard library and what still requires external packages such as swift-async-algorithms. we should clearly state, up front, that things like AsyncChannel require libraries, so that people do not waste time reinventing or searching for something in the wrong place.

documentation should acknowledge that there were gaps in the original Swift 5.5 era concurrency toolkit (like atomics and Sendable streams) that have since been addressed in modern toolchains, so that people with bad memories of Swift 5.5 are willing to give Swift Concurrency another shot.

a preface that communicates something along the lines of “Hey, we know you gave us a try four years ago and it ended badly, but we’ve learned from our mistakes and we really think we got it right this time” could do a lot of good on this front. our documentation as a whole should talk to readers like human beings instead of sticking strictly to technical description and assuming potential adopters will always pick up on subtle details and immediately reconsider their previous conclusions.

documentation should loudly and deliberately contradict outdated recommendations such as “preemptively mark everything Sendable”, that were in retrospect, workarounds for limitations of the compiler, so that teams are not hamstrung by internal debates over “best practices” that are no longer applicable today. ideally, there should be a table somewhere that summarizes the differences between the 5.10 and 6.0 compilers, which includes discussion about unresolvable warnings in the 5.10 compiler.

and finally, we should ditch terms like structured concurrency that are vague, mean different things to different people, have been proven to cause a lot of developer confusion, and fuel a lot of unproductive philosophical pondering about what the “purest” form an implementation should take (“should we budget two months to rewrite everything using actors?” “should Task be banned in our code base?” “does async let count as structured?”). instead, documentation should focus on specific pitfalls inherent in using certain tools (such as Task) and provide localized, actionable alternatives to them.

6 Likes

All words are meaningless until you learn their meaning. I don't think there's an obvious term that was missed when this was designed.

And I think the literal meaning is actually more useful than the one you came up with: "not isolated", which links directly to Swift concurrency's notion of isolation domains. Understanding those is somewhat more accurate than tying everything to actors, which are only one way of conveying isolation.

2 Likes

I can't speak to the confusion levels of "different people", but I found "structured concurrency" to actually be quite firmly defined in the context of Swift.

Also, I for one think that the "baked-into-the-language-structuredness" bit is what Swift really gets right (as opposed to loosey-goosey promise-like types being passed around in other languages).

This is probably not the thread to discuss the details about this, but I'd rather see us doubling down on "structured concurrency" instead of getting rid of it as a term.

I am totally on board with you on this one, and I think Swift should really come with more high-quality primitives for setting up data streams between tasks. For the stdlib, I find the lonely AsyncStream a rather underwhelming offering...

3 Likes

Swift and other programming languages are based on English. Keywords are not meaningless until learned, unless they were badly chosen keywords.

1 Like

*for me

Please, let's not generalize based on personal experience, that's not very constructive, or at least add imho and etc. Concurrency is well known to be hard, Swift is doing great solving lots of pain issues*.

*— thx @LeoNatan for comment, I guess message is a bit not clear—there are number of issues in concurrency field, which are not like mostly didn't exist , Swift is solving those issues. great means overall there were lot's of effort to do it properly, not like I'm liking it or not. But let it be imho Swift is doing great solving lots of pain issues.

3 Likes