SE-0414: Region Based Isolation

Hello, Swift community!

The review of SE-0414: Region Based Isolation begins now and runs through December 18th, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by email or DM. When contacting the review manager directly, please put "SE-0414" in the subject line.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it for Linux, for Windows, or for macOS. You will need to add -strict-concurrency=complete -enable-experimental-feature RegionBasedIsolation to your build flags.

There's an example package available with the code examples from the proposal and the experimental flag set at GitHub - gottesmm/swift-region-based-isolation-examples: Examples for the Swift Region Based Isolation Proposal.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

Holly Borla
Review Manager

17 Likes

I haven’t read the proposal and don’t have feedback on it, except… could we possibly change the title of the proposal?

When I read “region based isolation” it makes me think of, well, geographic locking where things only work in certain countries.

That is clearly not what is being proposed here.

1 Like

Please at least read the introduction before jumping to conclusions about the proposal title. The introduction section of a proposal is intended to give a broad idea of what's being proposed, because it is not reasonable to expect anybody to understand what's being proposed from a title alone.

"Region" refers to regions of an object graph in a program -- a term from the academic paper that this proposal is based on -- and "isolation" means something pretty specific in the context of Swift. The second pitch used "region based isolation" as the title, and it didn't seem to cause any confusion amongst participants of that discussion. This review thread is also tagged with concurrency and sendable to give potential reviewers a clear indication that this proposal is related to concurrency. The title of a proposal is not indicative of how the feature is explained to Swift programmers, e.g. in TSPL, if the proposal is accepted. If you'd like to brainstorm alternative terms to help programmers form a mental model for the semantics, I suggest reading the proposal.

12 Likes

The introduction says the proposal is introducing “isolation regions” for concurrency.

So a title like “Concurrency isolation regions” would make sense.

• • •

(Also, I utterly reject the idea that a person should have to read the introduction before trying to understand the title. The title comes first, and should inform people what the proposal is about, so they can decide if they want to read any more.)

6 Likes

My broad concern about this is that while the proposal claims that programmers won't have to reason about things to the level of detail specified, I have found myself having to refer to the proposal and carefully walk through what exactly the code was doing to understand why somehing I expected to work didn't work.

Despite my worry about the level of complexity involved, I am very strongly in favor of this. I am of the opinion that non-Sendable types are currently effectively unusable with swift concurrency unless you're writing everything in @MainActor functions, and only using Sendable types just isn't realistic. This makes most "obviously safe" operations involving non-Sendable types work, and the things I expected to work which producd errors have usually turned out to be things which should be rejected.

8 Likes

The links to SE-0302 in the "Motivation" section, and to SE-0327 in section "Using transferring to simplify nonisolated actor initializers and actor deinitializers" were wrong. Created a PR:

2 Likes

perhaps it could be changed to something like Flow-sensitive Sendable checking?

2 Likes

This made me chuckle (sympathetically) because I also grew up in the age of DVDs and not in the U.S., so I too carry the scars.

Still, that's a pretty big leap out of context, unless you genuinely can believe that Swift could somehow and somewhy introduce geographical-region restrictions.

I think the title is fine as-is.

1 Like
Process note

The Swift evolution process documents that a proposal title should be the name of the feature being proposed. Many proposals are introducing new concepts into the language, like "parameter packs" or "actors". Many of those new concepts are adapted from other languages or PL research, but some of those features are novel and include terminology that the proposal author came up with themselves, like "init accessors" or "Swift snippets". In most of these cases, the proposal title will not be self evident unless you happen to have followed the pitch or are familiar with a concept in another language. For example, if you have never encountered C++ parameter packs, you would not know that a "parameter pack" proposal is related to the generics system.

I've started a discussion amongst the Language Steering Group about including tags in the header of a proposal document, which could then be surfaced in the Swift Evolution dashboard at Swift.org - Swift Evolution. This makes it obvious when a proposal is in a certain area of the language (like concurrency or generics), and it would also be nice to have the ability to filter the proposal list on the dashboard by area.

I'm happy to continue this process discussion, but let's do it in another thread.

18 Likes

i don’t think init accessors or swift snippets are equivalent here, i agree with @Nevin that the title region-based isolation is unusually abstruse. in comparison, a phrase like swift snippets is reasonably self-explanatory even for someone who is hearing about the feature for the first time.

3 Likes

I don’t want to derail this thread, so I won’t comment further after this. I was just genuinely confused by the title.

I have been involved in Swift Evolution since before the forums existed, and this is the first time I’ve had an experience like this.

Sure, plenty of proposals introduce a new feature with a name I’m not familiar with, but “region based isolation” conveys to me a fairly strong sense of meaning which is completely unrelated to the proposal at hand.

• • •

I am also rather surprised by what I perceive as backlash against the idea that a proposal’s title could possibly be misleading.

I stood up and said, essentially, “I was misled by this title, could we please change it?”

And I expected a response along the lines of “Oh, I guess we didn’t realize it could be read that way, your confusion is totally understandable, let’s see if the authors can come up with a clearer title.”

• • •

I think that any of the following would be better titles for this proposal:

Concurrency isolation regions
Isolation regions for concurrency
Transferring non-Sendable values across isolation boundaries
Safely sending non-Sendable values across isolation domains

That last line was the title of the original pitch thread for this feature, and the previous line is a direct quote from the current proposal text. Those might be the two best options, because they actually describe what the feature is for, rather than how it is implemented.

6 Likes

I downloaded the toolchain, turned on strict concurrency checking, added the compiler flag, and tried a simple example:

public struct NotSendable {
    var s: String
}

func test() async {
    let s = NotSendable(s: "hi")
    let task = Task.detached {
        print(s)
    }
    await task.value
}

I expected this to compile without warning (I create a non-Sendable value, transfer it to another task. Seems like the most basic test of this proposal?), but I get the same warning with and without the flag:

Capture of 's' with non-sendable type 'NotSendable' in a `@Sendable` closure

Is this a bug in the current implementation, or have I misunderstood something?

This is pretty representative of the only instances I've ever had trouble with needing to transfer non-Sendable types, which is needing to transfer the AsyncIterator of an AsyncSequence to another task for the actual iteration.

And I guess this actually gets to the heart of my top concern after reading this proposal: How will I ever know "without trying it", what is legal and what is not? (And if my code is legal when I write it, how will I ever know that adding one line won't make the whole thing illegal?)

4 Likes

Would I be correct in saying that this proposal showcases an advantage of Swift using "concurrency domains" instead of threads as the basis of concurrency safety? If it had used threads instead, then non-Sendable object might use thread-local storage or some other thread-dependent runtime mechanism, and therefore might not be movable to a different region. But since "concurrency domains" are a construct entirely defined by the Swift language/compiler, we can make stronger guarantees about them, such as a non-Sendable value being movable to another actor in the right circumstances.

However, there is a (as far as I know) single hole in this: assumeIsolated. Specifically, something like this could be unsound:

class NonSendableType {
    ...
}

actor Actor1 {
    var x: NonSendableType

    nonisolated func get() -> NonSendableType {
        self.assumeIsolated { isolatedSelf in
            return isolatedSelf.x
        }
    }
}

actor Actor2 {
    var x: NonSendableType
    
    func take(_ x: NonSendableType) {
        self.x = x
    }
}

func unsound(actor1: Actor1, actor2: Actor2) async {
    // Regions: [] 
    let nonSendable = actor1.get()
    // Regions: [(nonSendable)]
    // Not [{(nonSendable), actor1}], because `get()` is nonisolated
    await actor2.take(nonSendable)
    // Regions: [{(nonSendable), actor2}]
    // `nonSendable` crosses actor boundaries!
}

Maybe to fix this problem, we could disallow a non-Sendable value returned from assumeIsolated to escape outside the enclosing function or into another isolation domain. Or we could restrict assumeIsolated to returning Sendable values only, and have an unsafeAssumeIsolated variant for non-Sendable values.

1 Like

i understand this might be getting a bit ahead of ourselves, but assuming this proposal is accepted, will it ship with the 5.10 toolchain? this feature is desperately needed and the answer will have a big impact on my planning for the next several months.

Another thought: before this proposal, I tend to think of "this type is not Sendable" as meaning, "this type can't leave the actor it was constructed on". With this proposal, every type can leave the actor it was constructed on, and Sendable doesn't really mean what it used to, it's more like "SendableByCopying".

This leads me to ask, "what if I have a type that should never be transferred?"

Contrived example: The Ruby VM stores a pointer to the stack it's initialized on, so that it knows the stack addresses it needs to scan during garbage collection. Ruby's all global variables, but imagine implementing this in mostly-Swift, with a tiny bit of unsafe C code to acquire that stack pointer. Now my NotRubyVM type must not be Sendable, and probably should be ~Copyable, and I'm handwaving over the fact that "actors != threads" so I need to do something to ensure that this type is tied to a thread, perhaps using a custom executor. But with this proposal, my NotRubyVM type could still escape to another actor/thread after initialization? There's no way to spell "Not Sendable, not even by moving rather than copying" any more.

All of which leads me to wonder whether we're not hurtling toward Rust's "Pin", here. And maybe that's necessary, but it's incredibly difficult for learners...

2 Likes

(Taking off my review manager hat)

This happens because Task.init and Task.detached currently take @Sendable closures. This proposal does not change the semantics of what you're allowed to capture in a @Sendable closure.

To enable transferring a disconnected region into a Task, we would need the transferring parameter future direction and adoption of transferring parameters for the Task.init and Task.detached closure parameters.

To clarify, are you talking about creating an AsyncSequence of non-Sendable elements, then passing it across isolation boundaries, then iterating over it? Or are you talking about the general Sendable issues with calling AsyncIterator.next() from an actor-isolated context?

If it's the latter, I think that warrants a separate discussion. I noted in the pitch thread that this proposal alone cannot solve the issues with calling AsyncIterator.next() from an actor-isolated context:

My personal opinion is that AsyncIterator.next() should use isolation inheritance so that the function is isolated to whatever context called it, and the the iterator is never passed across isolation boundaries. I know that other people have different opinions, and there are a few alternative approaches to consider, which is why I think it warrants a separate discussion.

3 Likes

A (better?) example:

Over in "Inferring @Sendable for methods and key path literals", I pointed out that currently you can form a KeyPath to a @MainActor property, whilst not on the main actor. You can then use that KeyPath to write to that property from any actor, bypassing the actor isolation.

If I've understood Holly's response correctly, she is suggesting that to fix this hole,

  • The key path literal for a @MainActor property should not be Sendable, and
  • It should be statically illegal to form a key path literal to a @MainActor property outside of a @MainActor scope.

That seems reasonable to me, and I believe it would currently fix the problem.

If I've understood this proposal correctly, you would now be able to do something like this:

actor DoNaughtyThings<Root: AnyObject> {
    var root: Root
    init(root: Root) { self.root = root }
    func setKeyPath<Root, T: Sendable>(_ keyPath: ReferenceWritableKeyPath<Root, T>, to value: T) {
        root[keyPath: keyPath] = value
    }
}

@MainActor
final class OnlyOnMain {
    var s: String = "hi" {
        willSet {
            MainActor.preconditionIsolated() // should be fine, in an @MainActor var...
        }
    }
}

@MainActor
func commitCrimes() async {
    let onlyOnMain = OnlyOnMain()
    let naughty = DoNaughtyThings(root: onlyOnMain) // should be OK, since @MainActor types are Sendable?
    let keyPath = \OnlyOnMain.s // OK, we're on the main actor. keyPath has type `ReferenceWritableKeyPath<OnlyOnMain, String>`. It does not have `& Sendable`
    await naughty.setKeyPath(keyPath, to: "uhoh") // OK, transferring keyPath to the actor, but it's not used again
    // crash in `preconditionIsolated`, since it's called from the `DoNaughtyThings` actor?
}
2 Likes

I was just thinking about this today! You're totally right that we need to consider how key-paths with isolated components interact with this proposal. In that specific case, I think we can consider a key-path value with an isolated path component as always being part of the actor's region, and therefore never transferrable. I've been trying to wrack my brain for any other examples of values that are not formally isolated and cannot ever be transferred... but if we identify other cases, we might be able to use the same approach of making those values formally isolated to the enclosing actor.

2 Likes

I think then, that at the least we should consider adding to this proposal, a way for a non-stdlib type to achieve this behavior, eg. for the "contains self-pointers" or* "contains stack-pointers" kinds of types, which might currently present a safe interface to Swift code over a profoundly unsafe implementation, that would be undone by this change.

* (I realized that self-pointers are never safe in Swift because even a ~Copyable type is still movable)

Perhaps something like a new Transferrable marker protocol, which every type gets by default, but which can be opted out of with ~Transferrable to ensure that the type can never leave the actor where it was created? But then can something be Sendable but not Transferrable? Probably not, which suggests an inheritance relationship between the markers :/

1 Like