Big +1 for adding a type like this, it seems very natural and useful. I can't envision RBI permitting the UniqueDeque case without a type like this.
I agree with @OneSadCookie that take may not be the best name, I like consume. The doc comment for take even describes it as consuming the wrapper, which implies it would be an intuitive choice. It is a little longer though. consume is already a contextual keyword, but it describes the same operation as this method, so maybe that's ok? Potentially confusing. We should decide what we call the "remove and destroy" method on noncopyable types and be consistent.
I think this proposal's type name and mechanism forces us to decide how we feel about regions in the model of concurrency developers need to understand, so the rest of my review focuses on that. Maybe discussion of this should be its own thread, but I think we need to resolve it to commit to a name here.
I'm unconvinced that Disconnected is the right name. I agree with @mattie that we may be able to avoid talking about regions to have a type like this, and also feel that it could be valuable to do so (I'll come back to this). I think it is possible, although strained, to try to explain Disconnected only in terms of isolation; a type is "disconnected" when it doesn't have any references into or out of an isolation? Disconnected would be a state, rather than a kind of region. This isn't as cohesive with our other non-region concurrency language, but there is a path here that doesn't require naming regions. I think this is rather confusing to explain though.
I do not think that a concept being used in previous language evolution proposals is sufficient justification for it to be introduced as a developer facing type. There are lots of academic words (covariance!) we use in evolution documents that would make Swift feel unapproachable to developers. Today, we only surface 'region' in extremely narrow cases, and my understanding is that we intentionally do not name it in diagnostics and userdocs. SE-0414 did not suggest we would need to use these concepts in diagnostics or mental models:
NOTE: While this proposal contains rigorous details that enable the compiler to prove the absence of data races, programmers will not have to reason about regions at this level of detail. The compiler will allow transfers of non-Sendable values between isolation domains where it can prove they are safe and will emit diagnostics when it cannot at potential concurrent access points so that programmers don't have to reason through the data flow themselves.
SE-0414
Unless we are reframing concurrency in terms of regions, I would argue for Sent. I don't agree with this rationale in the proposal against Sent:
sending describes a property of values at function boundaries (a transfer event), not a stable region state. A wrapper that simply holds a value living in a disconnected region is not mid-transfer, so naming the type after the transfer event would misrepresent what the wrapper is.
In my opinion, this is only an argument against naming the type Sending (gerund). Sent (past participle) is certainly a stable state, a thing has been sent. Even initializing this type requires sending the initial value, such that it "has been" sent to put it into the wrapper. It is being sent to wherever you pass it! This leaves us with a very coherent, linguistically grouped set of answers for "how to send this type" that do not require making regions developer facing:
Sendable types are always "able to be sent" and so can be sent freely!
- a
sending parameter or return value is contextually able to be sent, because it was made unavailable in the isolation it came from.
- A value wrapped in
Sent<T> has already been made unavailable in the isolation it came from, so it is able to be sent further.
Sent does not reflect the internal compiler understanding as well as Disconnected, but I think including it in this family might be worth it. If we choose Sent we could rename take to receive (as in, receive the sent value). I know disconnected is seen as a term of art, but I don't think we are required to use the most theoretically correct term in developer facing types if another choice makes the language easier to learn.
Coming back to why we should (maybe) avoid describing things in terms of regions; as @mattie says, concurrency is already really complicated! Even though regions are the internal model, we are not obligated to surface it, and if we can explain things with the terms we already have our language will be simpler to use and teach if we can show restraint here. If we can't explain things with the terms we have, then this would be very frustrating and we should reframe things in terms of regions. I see two decent futures (and a bad one) for the developer facing story here:
Everything is defined in terms of regions
Isolations are named regions based on annotations (global actor isolation) and types (actor instances) as well as scopes (tasks). However, there are also unnamed, flow sensitive regions that the compiler pessimistically determines to allow more code to compile. We would say Sendable allows a type to be shared between arbitrary regions (or maybe "isolation regions", but I feel this muddles everything). non-Sendable types can only be sent between regions if they are disconnected. Sometimes the compiler can prove values are disconnected and allow you to send them between regions, even though they aren't Sendable or sending. You can also wrap your value in Disconnected to ensure this is the case.
Everything is defined in terms of regions, so Disconnected makes sense.
We continue defining things in terms of isolation
An isolation protects its contents. Isolation comes from annotations (global actors), types (actor instances), and scopes (tasks). Sendable allows a type to be shared between arbitrary isolations. non-Sendable types can only be sent between isolations if they have no references / dependencies to or from other values in their current isolation, which the compiler will check for you. Once a non-Sendable value has been sent, it isn't available in its old isolation. You can use the sending annotation to require this to be the case for a parameter or a return value. You can also send a non-Sendable value into Sent<T> to lose access to it in the current isolation, but store it in a Sendable wrapper that you can take it out of in another isolation later. In this world, regions don't exist as far as the developer is concerned.
Developers don't see regions, so Disconnected is weirder than Sent in this world.
Both!
We could have isolation be the 'simple concurrency' and regions be the 'advanced concurrency' and not commit to a developer exposed relationship between the two of them. I'd prefer either of the above to this. It's easier to teach and understand a single, consistent story. We want a consistent way to talk about why sharing non-Sendable state is wrong, and sending it is only wrong sometimes. Whether we can talk about that in a satisfying way without regions isn't settled, but I'll try to steelman what diagnostics for that can look like below.
How could we explain RBI errors without saying 'region'
@Jon_Shier brought up a misleading diagnostic (ty!) in the pitch thread (where RBI pessimistically rejects code) that is roughly:
class NS { // something non-Sendable
var count = 0 // 🚨 ahhh! shared mutable state!
}
func myScope() { // block of code, many more lines in actual programs (making these relationships less obvious to the developer)
let one = NS()
let two = NS() // two non-Sendable values, right now in separate regions
someOperation(one, two) // compiler must merge those regions due to lack of type level information about 'someOperation', in case these values are mutated to reference each other
Task { one.count += 1 } // the region containing 'one' and 'two' is sent into this task
// the region containing 'one' and 'two' is no longer accessible
two.count += 2 // compiler must diagnose this as illegal, since the region containing 'two' is no longer accessible
// ^ this mutation could also mutate 'one', which would be a data race
}
// compiler does not know based on the signature if these parameters mix / entangle / become dependent!
// it would prevent changing the body of functions if we inferred this quality, so we must assume the worst
func someOperation(_ left: NS, _ right: NS) {
// what if left was mutated to hold a reference to right?
}
We get here because Swift doesn't statically enforce mutable xor aliased for reference types. I agree that the diagnostic we give today is actively misleading for many reasons, but the part I think is most interesting; how do we justify why this is not allowed to the developer? The argument made in the pitch was that regions are already exposed since pessimistic merging is the reason this isn't accepted, and we need to expose it more to diagnose that well. We could speculate on a (maybe unrealistically?) "good" diagnostic that describes things in terms of regions, and even gives them temporary names:
Diagnostic that describes regions
func myScope() {
let one = NS()
// ^ note: 'one' defined here in region 'a
let two = NS()
// ^ note: 'two' defined here in region 'b
someOperation(one, two)
// ^ note: regions 'a and 'b must be merged into 'c after 'someOperation' since values in 'a and 'b may have become connected
Task { one.count += 1 }
// ^ note: sending 'one' into task-isolated region sends region 'c
two.count += 2
// ^ error: 'two' is in 'c which is no longer accessible
}
func someOperation(_ left: NS, _ right: NS) {
// ^ note: 'someOperation' could mutate parameters to reference each other
}
This reads somewhat like a Rust lifetime error, because RBI is a system that pessimistically determines region membership / connectivity (potential to reference) via flow analysis (which is reminiscent of lifetime relationships). But I don't think we need to talk about regions, or name what region these types are stored in to justify this error:
func myScope() {
let one = NS()
// ^ note: 'one' defined here
let two = NS()
// ^ note: 'two' defined here
someOperation(one, two)
// ^ note: 'one' and 'two' may reference each other after 'someOperation'
Task { one.count += 1 }
// ^ note: 'one' may reference or be referenced by 'two', and is sent into task-isolated scope here
// note: 'two' is sent here to prevent a data race
two.count += 2
// ^ error: 'two' cannot be accessed after being sent
}
func someOperation(_ left: NS, _ right: NS) {
// ^ note: 'someOperation' could mutate parameters to reference each other
}
This diagnostic could use some new term (dependency? entangled?) to be more terse, or frame the problem differently, but my point is that we can explain the issue without reformulating the developer facing concurrency model in terms of regions. RBI is "just" the internal mechanism the compiler uses to accept additional, provably race free code. If we can explain the errors that RBI leads to (since there are cases which are simply unsafe and must be diagnosed) clearly, and in ways that help developers understand how to fix them, without invoking regions, I think we should do that and avoid naming any types in ways that only make sense in terms of regions. Such diagnostics still make sense when read with knowledge of regions (potential reference forces merging), but don't require understanding them.
Note that this diagnostic does introduce a new idea, this concept of "potential to reference", to the concurrency system (this is part of the definition of regions from SE-0414). I'd argue the problem with developer facing regions is explaining how they are different from isolation, which is which and when. Programmers already think about references / dependencies and when they are safe (like for avoiding retain cycles). You could say this renames regions as a friendlier thing--but I think that's fine, and potentially how we can square the need to diagnose these cases clearly with a model that is easy to teach. We will need language for why "regions" are merged, but maybe it isn't regions...
Having said all that, I'm really open to the possibility that surfacing regions is the way forward on these issues; I just want to make sure we consider that thoroughly first.
Speculative musing about lifetimes and RBI annotations
What if we had region annotations? I'd like to briefly highlight that, since regions and lifetimes talk about similar questions (do these values share dependency) from different perspectives, they share a deep correspondence and we may be able to express any interesting RBI annotations as lifetime dependencies (if we allow them on copyable types):
@_lifetime(left: copy left, right: copy right) // left and right will not alias! this can't actually be expressed today, and doesn't make sense on the values we allow lifetimes on right now.
func someOperation(_ left: NS, _ right: NS) {
// left and right will not depend on each other, since signature promises no reference is taken
}
In a sentence, lifetime dependency implies RBI aliasing / referencing potential, but mutual lifetime independence requires non-aliasing / non-referencing.
Putting the non-aliasing / non-referencing 'promise' in the type system allows RBI to avoid merging the two regions. I think it would probably be bad for progressive disclosure to tie regions and lifetimes, but my point is that even if you think we want RBI annotations, we may be able to express them as dependencies (lifetimes are just how we add dependency relation to function and data types in Swift) and avoid complicating concurrency with the concept of regions.