[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

49 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.

5 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.

5 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.

4 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.

5 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.

1 Like

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.