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

Hello Swift Community!

The Language Steering Group would like to gather feedback on a prospective vision for improving the approachability of data-race safety that @John_McCall and I have been working on.

Vision documents help describe an overall direction for Swift. The concrete Swift changes for executing on the vision will come as a series of separate Swift evolution proposals, so specific details (e.g., specific syntax, API names, etc.) are less important than the overall direction. There is more information about the role of vision documents in the evolution process here.

Please leave design feedback in this pitch thread, and leave editorial feedback on the swift-evolution PR.

I look forward to your questions, thoughts, and other constructive feedback!

-Holly

68 Likes

Thank you for putting this together; it seems quite thoughtful and covers a number of points that have been in need of attention (at least at a high level brushstrokes of what needs to be focused upon).

I'm sure that this has subtleties that I will need time to digest more, but I have one particular area of interest that could use a bit more clarity.

Instead of directly calling the function in the wrong isolation domain, enqueue a job that calls the function on the actor. This only works if the function does not return a result.

This is a very specific call-out that I feel I run across often. I would be interested in what you or John think are reasonable approaches to this?

For example, one potential solution near or directly in this space is being able to pass a closure to an actor to run it on that actor's isolation without awaiting the result of that function. I feel that the cost of Task is both over and under-estimated in code. When used pervasively it can be rather impactful both memory and resource wise, but likewise to avoid Task wholesale is rather difficult to use or interface specific APIs. Often times from a framework perspective we might be able to have an actor on hand from our callers and a desire to be able to run a given block of code on said actor.

I could envision the solution of being able to do that much less costly than spawning a full Task and providing a way for framework code to be able to do the right thing while letting their callers be both safer and easier to use.

9 Likes

Haven't read everything thoroughly yet but I deeply believe "single-threaded by default and only reach for concurrency/parallelism explicitly when needed" is the way things should be. So huge +1 for this from me.

While we're rethinking things a bit here, may I take the opportunity to talk about something which I think makes Swift Concurrency a lot more complicated to use than it should be? Which is the actor type itself. I feel there might be a better way to approach things. What's wrong with it? First it's a new type that developers need to wrap their heads around. It used to be you needed to figure out whether you want to make something an enum, struct or class. Now developers also need to figure out when to make something an actor. Then it's really not easy to understand how to use this new thing and execute things (and in particular existing code) inside of that actor. I've seen developers pass async closures into an actor and call those from there, I'm not sure it does what they think it does. I would think it might be a good idea to pivot things towards isolation domains, e.g.:

This is now:

class ExistingClass { ... }

// What do I do with this now?
actor NewActor { ... }

What if instead we made isolations the easy thing to reach for:

// Define new isolation, just like MainActor.
isolation NewIsolation

// Just tag existing things in the program to NewIsolation, easy!
@NewIsolation
class ExistingClass { ... }

// You can isolate functions too!
@NewIsolation
func foo()

// And even closures and tasks!
let closure = { @NewIsolation in ... }

Sorry if I'm derailing the subject a bit, just wanted to throw this out somewhere.

8 Likes

Having been working with the Swift 6 mode for the past couple of months, this is a very welcome direction. While I personally believe to have accustomed to Swift 6 thinking by now, I see coworkers struggling with the adoption. This would make it easier for all of us.

That said, for such a broad vision, I’m missing coverage of the present issues with isolation behavior mismatches to Objective-C code and Objective-C system libraries. (Well maybe it is covered, but then I didn't get it. Sorry in that case.)

To this date, much of Apple’s new platform development is still done in ObjC, and many – if not most – Apple platform developers can’t evade UIKit or AppKit in their day-to-day work. Yet this language does not know anything about strict concurrency and allows comfortably programming in very non-compatible ways. What I dearly miss are tools to bridge this gap, to make using those APIs from Swift 6 as comfortable as it is from Swift 5 or ObjC.

As much as we all want to live in a pure Swift world, many of us will have to work with ObjC frameworks for years to come, at least, if not decades. I believe it should be a main objective for the Swift language to make that nice even with strict concurrency.

Most importantly, to me, we need more robust and flexible ways to declare dynamic MainActor isolation. Basically MainActor.assumeIsolated but for entire classes and without all the sendability-dance for passing things in and out of that closure. (#isolation being non-nil when called from the ObjC main thread would also be nice.)

tl;dr: an example follows, hope this is okay.


I struggle to write this more precisely in abstract terms, so I’d like to give an example from my recent work with TextKit2 – a fairly new system iOS/macOS API, that’s thought and written entirely in ObjC. If you're not familiar: While it's a view model for text controls, by its design, most of TextKit2 could also be used headless without any views. For example, an NSTextLayoutManager could be used to layout and render some document in the background. And so all classes in TextKit2 are nonisolated.

The most common way of using TextKit2 though is inside an UI/NSTextView — which is a view and thus isolated to the @MainActor. While all the classes are non-isolated, in reality everything runs on the main thread. 99.9% of all API clients could safely assume this. But that's not formalized anywhere, because how?

Most problematic, there is things like NSTextAttachmentViewProvider, a class that manages an NSView for the text system. If you know NSViewController, it's similar. Yet unlike NSViewController, that provider is not @MainActor – it's nonisolated. I don't want to discuss whether this API's design is good or bad, because as platform developers we need to live with and work with those APIs as they are, right here and right now.

And this means working with this nonisolated class, subclassing it (like we do for view controllers), and then overriding functions and properties that are nonisolated. Yet in those functions, we need to deal with main-actor isolated objects like NSView. And the only way I know to do this is through MainActor.assumeIsolated (which is rightfully condemned in the proposal).

Here's snippet to illustrate the situation and how it must be implemented today:

class MyProvider: NSTextAttachmentViewProvider {
    …
    override var view: NSView {…} // nonisolated var of a @MainActor instance
    …
    override func loadView() { // nonisolated
         self.view = MainActor.assumeIsolated {
             return NSView() // @MainActor
         }
    }
}

Note: While we could declare the class @MainActor, that wouldn't change the nonisolated nature of loadView(), because that's how it comes from the superclass. We also cannot declare loadView itself @MainActor because that conflicts with superclasses' declaration. We cannot create the view directly, because it's @MainActor. We even cannot do something like this:

     override func loadView() {
         MainActor.assumeIsolated { // Error: passing non-sendable `self`
             self.view = NSView()
         }
    }

Because assumeIsolated isn't just an assumption or an assertion, like one would maybe expect (I did), but requires a regular @Sendable @MainActor closure as body. So we cannot even access properties or other non-sendable parameter there, because this construct is so limited.

In practice, right now, I end up with functions that have multiple MainActor.assumeIsolated somewhere in the middle, surrounded by nonisolated code. It's a mess.

I don't have a specific suggestion on how to solve this, other than making that closure of assumeIsolated somehow not sending, so we can pass in an out what we want. But what's needed is a way for developers to say "this class is @MainActor and I'd like the compiler to assume this for the compilation and check that dynamically". Like an @MainActor! force, that silences the compiler but checks at runtime like assumeIsolated does.


Hope this makes sense and is on-topic. Thanks for reading and sorry I couldn't put it any shorter.

8 Likes

Good news! I believe the TextKit 2 example you bring up here is directly addressed by the concept of "isolated conformances" in this document.

Oh, I also want to add that I've found using preconcurrency conformances to be a great way to work around this kind of isolation mismatching today. It feels weird to do it, but removes almost all the boilerplate and avoids needing to expose a nonisolated API.

2 Likes

It sounds like you’re asking for something approximately like:

extension Actor {
  func async(operation: @Sendable @escaping () -> ())
}

…with whatever annotations would make it clear that that always runs on the actor you call it on. But, yeah, “please run this small synchronous piece of work on this other actor.”

Alternatively:

extension Task {
  static func enqueue(operation: @escaping @isolated(any) () -> ())
}

Regardless of how it’s written, it’s trivially implementable. If that would be useful as a tool, we could certainly provide it.

13 Likes

That’d be awesome. Though I read this section again and it seems to be mostly talking about protocol conformance, yet in TextKit we must be subclassing.

Last time I checked an @preconcurrency import AppKit did not change anything about that situation. And @preconcurrency can only be added to protocol conformances, not superclasses.

Notably, parts of TextKit2 are annotated with sendability, like NSTextLineFragment. So it's not that it wasn't thought about at all, or should be thought of as pre-concurrency. It's just that those nonisolated requirements are really sticky...

1 Like

Ack I'm dumb. Of course, you are right, a preconcurrency conformance is only appropriate for protocols. I'm sorry about that!

But, there's still hope, because I think the "Isolated subclasses and overrides" section might still be helpful here.

1 Like

+1. Excited to see the isolated conformances in particular.

Just wanted throw in a suggestion for a way to do the default actor that might address some concerns around explicitness/fracturing into dialects, through it would be less ergonomic: following the precedent set by the literal types and allow this to be set with a type alias.

This has the additional benefit that users can pick any actor for the default, not just the main actor, which I could see being useful in some kinds of applications. I imagine it’ll be harder to implement from a type checking standpoint though.

2 Likes

I love the idea of a module assuming it’s single threaded. I think this has been a real pain point for UI applications and this is a great idea to solve that.

I also echo the objc comments above. We’ll continue to have to deal with objc for a long time and it would really help to have tools that make those parts of migration easier.

Other concurrency libraries like Dispatch provide a limited tool set to wait on asynchronous work, such as DispatchQueue.asyncAndWait

Does this mean there will be a synchronous “block until this asynchronous call finishes” api?

2 Likes

This is incredible! I truly appreciate the amount of thinking behind it. My first impression of the default-to-MainActor and the single threaded modules is that I love it! I can already see many of these ideas drastically simplifying the system, or at least shield whoever not needing/willing to fight with it from doing so. I already feel like I need to re-read the proposal 3 times at least to digest everything though.

I still disagree with changing the default behavior of nonisolated async functions, but I am pretty sure you have put more energy and thoughts into this than I did. We keep making the argument that sync is different than async, and I wish we solve that by changing the sync functions behavior instead. But, again. It’s only my opinion.

Can you clarify the section “ Bridging between synchronous and asynchronous code” more? I was so excited when I read the title, but the description did seem to go over existing pain points without proposing any solutions, unless I missed something. :S

1 Like

That’s what I thought, too, and honestly I was soo excited because this is a real pain. But I think the proposal is just referring to this API?

It is referring to that but I think the paragraph is saying it’s missing from swift concurrency?

First of all, I'm happy to see the effort that is directed at this vision, with so many improvements regarding situations that I've been through in my own code and that I've seen being brought up by the community.

Regarding the “single-threaded” by default approach, I have something I'd like to shine a bit of light on:

My understanding is that the document says that one of the reasons that analyzing the program as a whole is bad is because "it would make the first adoption of concurrency extremely painful". Then, it goes on to say that a better approach is to make the single-threaded assumption in smaller parts of the program. Finally, the document proposes that these smaller parts are the modules.

Choosing the modules as the smaller parts have caught my attention because over the last 3 years I've interacted with a couple dozens of beginner Swift programmers and the vast majority of the apps I've seen them develop do not have the code they've written broken down in smaller modules. The apps are mostly composed by 1 module + dependencies.

Is breaking out of the single-threaded default on these projects with one big module possibly going to be quite painful?

If I understood it correctly, the per-module would be an improvement over the whole-program approach even on these cases because at least the package dependencies are separated.

Since specially in this vision we care so much about beginners, I believe projects structured this way should be taken into consideration. Does the LSG believe that the proposed vision brings is good enough for a good break-out experience on them?

3 Likes

When you start introducing concurrency into one of these modules, you’ll probably find yourself calling functions and using types that are still defaulting to @MainActor, and you may need to make some of it nonisolated, make things Sendable, and so on. But you won’t have to do that to your entire module at once, which is what a rule based on a truly global no-concurrency assumption would require.

2 Likes

What I would wish for is the simplicity of api that I find when you look at Kotlin’s Corutines.

Especially very clear with simple usage API, that clearly solves daily problems:

  • runBlocking
  • CoroutineScope
    • launch - dispatch scope
  • Channels
  • Build in withTimeout
  • Lazily started async
  • Clear usage of coroutine dispatchers
  • Asynchronous Flow
    • This seems to be for me the biggest gap. Beautiful, clear super simple usage with true cooperative cancellation built in.
    • Imperative/declarative API
    • StateFlow - multiple consumers build in
    • SharedFlow
    • MutableStateFlow

I know that in many ways Kotlin is different from Swift but on the other hand, for me, its overall API seems to be less verbose and at the same time clearer and has more features useful on a daily basis. Personally, it would be very happy if Swift would learn from it.

Also, take a look at how nice the documentation is, with examples that you can run directly on the website.

2 Likes

Thanks so much for putting this together. I'm really excited about all this!

One thing that has occurred to me is the way warnings are currently handled. Changing the behavior of non-isolated async functions is going to go a very long way here to improve the situation. But, has any thought been given to making concurrency warning suppression opt-in, instead of the default?

2 Likes

I agree that at least the messaging about actors is really unfortunate; they have been enormously over-emphasized in my opinion.

Swift Concurrency is simple and still very powerful if you solely use tasks, async functions, and continuations. These building blocks, combined with traditional locks and atomics, let you easily build useful tools and abstractions.

An actor is just a lock that doesn't block a thread on contention, at the cost of not being usable from synchronous code. Actors should be introduced to developers late into their concurrency education, as a sometimes-useful tool that dangerously encourages shared mutable state program designs.

4 Likes

I think tools like this would be useful to have. I've found with myself and other developers there's a lot of uncertainty around what is and isn't safe to do. Having some more tools that solve common use-cases would be would be helpful as known-safe patterns/techniques. Either baked into the language or documented.

4 Likes