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

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