SE-0430: `transferring` isolation regions of parameter and result values

I've been thinking about this for a couple days, and I think there are a lot of things to like about it — it really keeps the focus on the specific question of whether the value can be sent, and the idea that "this is a value of a non-Sendable type that can still be sent (because it's known to still be disconnected from any other region)" is really intuitive. The only problem is that I think it might make it really difficult to talk about sendability. That might be a big problem, though.

3 Likes

Yeah, I really worry that it would become difficult to discuss/teach the difference between @Sendable as a type attribute and sendable as a parameter modifier when they mean very different things. It's not the case, for instance, that a sendable T may be passed to a function requiring a U: Sendable, so it's not the case that sendable T is Sendable; huh?

This is why I lean towards sending—it invokes the idea of sendability while still being nominally distinct. And we already have the -ing pattern for things that happen 'at the boundary'.

9 Likes

Is there any chance that it’s worth it to rename Sendable to something like ImplicitlySendable InherentlySendable (with Sendable as a deprecated type alias with a fix-it) while we still can, prior to the great source breakage of Swift 6? Perhaps with all the new work on smoothing over the rough edges of strict concurrency checking, explicitly writing out InherentlySendable will be uncommon enough that the extra word is not such a burden? It would certainly fix the teachability problem, no? No one would expect a sendable T to be InherentlySendable.

2 Likes

No, I don't think there's much of a chance that we're going to rename Sendable.

2 Likes

One could argue that the current narrative for "talking about sendability" is incomplete, and focusses too much on "making types sendable" anyway. (mainly because it was started before region analysis and improved control around isolation)

In my book, managing the flow of "non-sendable" types through isolation domains is just as important an aspect of "sendability" as actors or Sendable types are.

I understand the worry, it is getting a bit crowded under the @?[Ss]endable umbrella. The question is whether a brand new transferring keyword - which will need its own explanation - is truly better.

I can understand that line of thinking, but I personally feel that both transferring and sending really collide with the ownership keywords. I find consuming transferring or borrowing sending tough to read and process - consuming sendable feels very natural to me.

So one could argue the -ing keywords (and inout) are for ownership, but sendable is not part of the ownership family, so no -ing.

2 Likes

Could something like ownregion work? I think adjectival forms (ownregion, noregion, disconnected) make more sense than verbal forms (transferring, sending), since it’s more a property of the value than something the function is doing to the value, and if you’re familiar with isolation regions then something being in its own region is fairly intuitive.

1 Like

Sure, I think you can come up with a consistent explanation either way. My point is less "it would be confusing if we didn't use an -ing" and more "I think there's an analogy with other -ing modifiers that would potentially be elucidating for this feature if we deliberately drew the connection". Same thing goes for working send into the name somehow; I totally agree with your point above:

On this note:

I do wonder how common we'd really expect this to be. Since consuming is the default I expect consuming transferring to be fairly rare (since it would only be useful to opt in to explicit copying semantics), and I don't have a great intuition for what borrowing transferring would be used for. You'd need to maintain the disconnectedness of the parameter, but you can't ever restore that disconnectedness by reassiging (as with inout transferring)... so all you could really do is call methods with Sendable local values? Which I guess could still be useful if you have, like, a non-Sendable type which you want to use to collect some 'breadcrumbs' from a process that takes place across an isolation domain. Perhaps there's a use case for borrowing transferring that I'm not imagining right now.

3 Likes

Here’s an argument against my suggestion that Sendable be renamed to InherentlySendable (even if the source breakage of renaming were not a concern), which I post because I think this line of thinking could possibly stimulate ideas which are relevant to the specific debate at hand:

“The word “Inherently” in InherentlySendable is redundant. All protocols, by their very nature, imply the prefix “Inherently”. Equatable should be thought of as InherentlyEquatable, etc. Thus, the simple fact of the capital “S” in Sendable is sufficient.”

I was writing a continuation of this type of argumentation as it applies to the meaning of the lowercase “s”, attempting to defend the choice of sendable T for this parameter modifier, but I failed to convince myself.

Instead, I began to think about sent as a possibly attractive candidate.

3 Likes

borrowing transferring as proposed in the current proposal is almost useless —borrowing only changes parameter ownership, and ownership of the region is still transferred to the callee. As a result, the caller can no longer use the value other than having the responsibility to destroy it. The only reason to have this combination is if you need to adopt transferring to express a function's region-transfer semantics without breaking binary compatibility.

borrowing disconnected as I described above, where the borrowing also changes ownership of the region, is much more generally useful. Consider a function that does a "deep copy" of a non-Sendable value; such a function should probably express that the return value is disconnected from the argument. func deepCopy(value: T) -> T doesn't do that; the return value will be in the same region as the argument. Both func deepCopy(value: T) -> disconnected T and func deepCopy(value: borrowing disconnected T) -> T express the same thing: there's a default region that all the return values and parameters are in, and the annotation separates a specific return value / parameter (respectively) from that region, and since there are only two return values and parameters in the first place, you end up with the same effective "region ownership signature". But there's a big difference when you have other parameters or when the function is isolated. For example, a fully-general deepCopy on a graph would need some sort of context that maps old objects to their copies. The return value of that deepCopy is definitely not disconnected from that context, and I would say that calling the old object borrowing disconnected is more appropriate.[1]


  1. This is a little bit tricky because the context would need to hold references to the old objects as keys in its dictionary. However, since it doesn't mutate those objects or build new references to them in the new objects, it does preserve the separateness of their region. It would be safe to use ObjectIdentifier or a similar trick in the implementation of the context. ↩︎

4 Likes

Just to throw another keyword option into the mix, how about reisolating? It sticks with the "isolate" base used in SE-0313/0420 and I think adequately describes that the parameter/return value is being moved from one isolation to another.

3 Likes

Nothing interesting! I wanted to demonstrate a successful conversion, but you're right that this isn't a conversion at all.

Yes, that's correct. The compiler will diagnose the point of concurrency where the disconnected region is transferred away (i.e. at the await acceptTransfer(...), then note the potential for concurrent access at print(ns2). @Michael_Gottesman is also working on diagnostics for region history so the compiler can show a note at the point where ns2 and ns1 are assumed to reference each other at the initialization of ns2.

I think this is a good question, and it's worth a section in the proposal about how to choose between Sendable and transferring in API design. In general, Sendable is a subset of transferring because a value with Sendable type is always safe to transfer. The times when you should prefer Sendable over transferring is if you truly need to share the value across multiple isolation domains at once. For example, if you were writing a concurrent map API, you'd want the closure parameter to be @Sendable instead of transferring because you'd want to invoke the closure in multiple child tasks concurrently, which isn't possible with a transferring closure parameter.

I think in your case, using a transferring result instead of requiring that the result type be Sendable will make your API usable with more inputs.

I don't quite understand this question. How exactly are continuations involved here? I know you were comparing your API to the resume(returning:) API, but I'm not sure what you mean by "locking" your implementation to continuations.

Because a type conforming to Sendable satisfies the requirements of transferring, I can't think of a way the API would be less ergonomic by using transferring instead of Sendable.

1 Like

That is a great sentence :+1:

I still have doubts, and I'll try to rewrite my initial questions, hoping they are more clear. In the sample codes below, I'll use logger.print as a placeholder for "stuff that the developer wants to do when debugging/benchmarking/tracing" and ideally would not come in the way.

First, from the point of view of the developer who provides a () -> transferring T closure. Would the compiler refuse to compile the code below?

makeValue {
    let v = NonSendable()
    logger.print(v)
    return v
}

Same question, for the async variant () async -> transferring T:

makeValue {
    let v = NonSendable()
    await logger.print(v)
    return v
}

Next, from the point of view of the developer who declares a () -> transferring T input. Would the compiler refuse to compile the code below?

public func makeValue<T>(
  _ value: @escaping @Sendable (Context) -> transferring T
) async -> transferring T
{
    let context = await getContext()
    let v = value(context)
    await logger.print(v)
    return v
}

Should we have to try this chance with a separate pitch? Swift 6 seems to be a great point for such renaming after Concurrency incubation period. For me this is similar to the case when AnyObject was introduced as a better option with deprecation of class.

1 Like

I like the idea very much, +1. One more variant - detachedRegion.
Something with region word is rather specific and unique to not overlap with domain and current language terms.

also +1

I think sending makes perfect sense. It’s sending a value, which means that the value must be sendable. Either because it conforms to Sendable, or because it’s disconnected.

1 Like

I personally do not think there is any value in trying to rename Sendable. The name of Sendable is fine, and no matter what name it has, there will be a learning curve that folks go through to internalize the term of art and what it means semantically in Swift's concurrency model. Many people have already internalized what a conformance to Sendable means, and I think changing the name of it now is actively harmful to those who have already put in the effort to internalize Sendable. The only thing that's changing now is the ability to pass non-Sendable values over isolation boundaries when there's no potential for concurrent access, and we can bikeshed how to explain the difference between a "sendable value" versus a "sendable type".

I think that the value versus type distinction is one that people have been confused by in the past (e.g. type-level abstraction versus value-level abstraction), and the learning curve may be easier if we use a different term for sendable values whose type does not conform to Sendable. On the other hand, we use somewhat different terms and very different syntax for global-actor isolation and actor-instance isolation, and people are still confused by the difference between the type-dependence versus value-dependence in actor isolation.

2 Likes

I still think sending is not the right term because what's needed is a stricter condition than a "sendable value". If this call crosses an isolation boundary, all argument values have to be "sendable", either because they're disconnected or the type is Sendable. But with transferring as proposed here, the condition is that the value has to be in a separate region from all other argument values so the callee can merge that value into a region that's different in some way from the other argument values.

3 Likes

That’s a good point. If we want these semantics, I’d say we need something that communicates this properly, i.e. disconnected.

1 Like

To me this seems to be the biggest problem with choosing sendable T. I think the confusion could be attributed to the fact that it looks like the value is of a new type that is related to T, but which has been made sendable, but that’s not quite right, at least according to how types have always worked up until now, because the simple fact of passing the value to another transferring function suddenly “changes its type”, in the sense that the transferability has been removed and now it’s just a normal value of type T. The sendable in this case denotes a statically assessed attribute of the value, but rather than the attribute being fixed for the lifetime or the value (like its type is) it can change under a variety of conditions.

Maybe we can change the syntax to somehow reflect this a bit better. At the moment, however, no obviously clearer syntax occurs to me.

I'm not sure I totally grasp the distinction you're drawing here... is it that in the first case ("sendable values") you could hypothetically be passing some non-Sendable arguments which are all in a region together, which doesn't work with transferring as-proposed?