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

Hello Swift community,

The review of "transferring isolation regions of parameter and result values" begins now and runs through April 1, 2024. The proposal is available here:

https://github.com/apple/swift-evolution/blob/main/proposals/0430-transferring-parameters-and-results.md

This proposal builds upon SE-0414 Region-based Isolation, which was accepted in February 2024.

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 the review manager by email. When emailing the review manager directly, please include "SE-0430" in the subject line.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it here. Any trunk development snapshot dated March 20, 2024 or later should do (older snapshots may not have the most recent diagnostic refinements). You will need to add -enable-experimental-feature TransferringArgsAndResults to your build flags.

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

swift-evolution/process.md at main · apple/swift-evolution · GitHub

Happy reviewing,

—Becca Royal-Gordon
SE-0430 Review Manager

10 Likes

Looks good to me Becca!

  • Minor typo: "SE-0414 consider actor initializer parameters" should be "considers"

  • In the transferringParameterConversions() and transferringResultConversions() examples there is no type change happening in the first line of either of these. If that's intentional, what's it demonstrating?

  • My understanding of "the disconnected region is transferred away" means the whole region is now invalid, so this would also error? What would the error message look like?

@MainActor func acceptTransfer(_: transferring NonSendable) {}

func f(_: NonSendable) -> NonSendable { return NonSendable() }

func transferToMain() async {
  let ns1 = NonSendable()
  let ns2 = f(ns1)

  // ns2 is in the same region as ns1, right?
  await acceptTransfer(ns1)

  // so this access is also invalid after the transfer?
  print(ns2)
}

Thanks!

1 Like

Hello,

Thank you for this new proposal aiming at improving compiler diagnostics regarding non-sendable values.

A few weeks ago, I was wondering if a generic async method that returns the result of an input closure should constraint the result type to be Sendable or not:

// 1. No constraint
public func makeValue<T>(
  _ value: @escaping @Sendable () -> T
) async -> T

// 2. Sendable constraint
public func makeValue<T: Sendable>(
  _ value: @escaping @Sendable () -> T
) async -> T

The reasoning was muddled by the fact that the continuation resume(returning:) method does not require its input to be Sendable, despite the fact that it will cross isolation domains.

At that time, I concluded that my best option was to constrain the return type to Sendable (solution 2), even though the continuation api does not require it.

Now that this proposal updates resume(returning:) so that it transfers the value, I have a third way to write makeValue:

// 3. No Sendable constraint, transferring output
public func makeValue<T>(
  _ value: @escaping @Sendable () -> transferring T
) async -> transferring T

What do you think?

  • Should I prefer this third solution?
  • Would it "lock" my implementation to continuations (with difficulties making it evolve in the future, at constant api)?
  • Could it create difficulties for callers (can it be less ergonomic to require a closure with a transferring output that just constraining T to be Sendable)?
Appendix: a real example of such an api

The kind of method discussed in this question is found in the GRDB library, which provides async methods that asynchronously acquire SQLite connections:

/// - parameter value: A closure that fetches
///   values from the database
public func read<T>(
  _ value: @escaping @Sendable (Database) -> T
) async throws-> T

/// - parameter updates: A closure that can
///   read and write from the database.
public func write<T>(
  _ updates: @escaping @Sendable (Database) -> T
) async throws-> T

Usage:

let playerCount = try await connection.read { db in
  try Player.fetchCount(db)
}

let newPlayerCount = try await connection.write { db in
  try Player(name: "Chiara").insert(db)
  return try Player.fetchCount(db)
}

I was auditing those methods for Swift concurrency when I started wondering if T should be Sendable or not.

SE-0430 transferring just adds a new option.

I want to design an api that can be compiled in the Swift 6 mode, is ergonomic for the users (i.e. they don't even have to think about it), and last a few years, without breaking changes.

1 Like

Regarding the naming (haven't seen this mentioned / discussed in the pitch phase): could it potentially be better to call the annotation sending instead of transferring? It builds better on the Sendable analogy, perhaps underlining that we're still sending a value that's not otherwise sendable on its own.

4 Likes

Yeah, I agree that carving out a second highly generic word as a concurrency term of art is pretty concerning. It would be better to either build on the existing term or use a less generic term.

The problem with sending is that, naively, I would imagine that Sendable values are the things that you can send, when in fact it's almost the opposite — you specifically only ever need to explicitly send a non-Sendable value, because Sendable values can always be sent and there's no need to make a fuss of it. Like transferring, it also doesn't really read well as a variable modifier or when combined with inout, and we will need both of those (although I think only the latter is in this proposal).

Personally, I think disconnected reads quite nicely:

  • func foo() -> disconnected Thing — this function returns a value that's disconnected from everything else
  • func foo(thing: disconnected Thing) — this function receives a value that's disconnected from everything else and can take advantage of that (it's implicitly consuming both the value and its disconnected-ness)
  • func foo(thing: borrowing disconnected Thing) — this function receives a value that's disconnected from everything else, but it doesn't get to consume its disconnected-ness, it just has to preserve it
  • var x: disconnected Thing — this variable maintains an invariant of holding a disconnected value
  • func foo(thing: inout disconnected Thing) — this function receives a reference to a variable that maintains an invariant of holding a disconnected value

There is one annoyance with this syntax that I can see: because it uses borrowing/consuming to specify both whether the function borrows or consumes the disconnected-ness of a parameter and whether the function borrows or consumes the parameter as a value, it's not possible to write "this parameter is borrowed for ownership purposes, but its disconnectedness is actually consumed". But there's no abstract reason to want to do that — if you hand off a disconnected value, you can't use it anymore, so you might as well transfer ownership as well. The only reason it's necessary is if you need to adopt disconnected on an existing API that you can't change for binary compatibility reasons, and that seems like enough of a corner case that we both can and should give it a less obvious spelling in source. Maybe we could just us the existing, never-officially-acknowledged _shared annotation for that.

The proposal does talk about adding disconnected types as a first-class type in the future. It's possible that using disconnected as a modifier this way would be incompatible with that, if we specifically wanted to use the spelling disconnected T for it. But the more I think about it, the more problematic I think it would be to have disconnected types that are implicitly both wrapped and unwrapped, which seems to be the vision there. Having a pervasive ambiguity about whether we should bind types to T or disconnected T seems like a serious problem for type-checking, just in terms of the usability and predictability of the resulting language; it seems like it raises the question of whether a value is currently disconnected into the type system. Having disconnected just be a modifier on specific declarations that doesn't affect their formal type seems somewhat more appealing as a general design approach, the same way that we handle weak. To allow the generic uses — arrays of disconnected values and so on — we could just have a Disconnected<T> type in the standard library, which of course would need to be explicitly wrapped and unwrapped. (Having a Weak<T> in the library was part of the original proposal for weak, for what it's worth.)

9 Likes

With a different reading I have some sympathies towards sending: we have some values which are Sendable (indicating that we never need to worry about sending them), so it's fine to be lax whether we're sending them or not in function signatures. OTOH, for things that are not Sendable on a type-wide basis, we need more care, because you could pass such values in ways that are not notionally 'sending' them to another isolation domain.

IOW, we are permitted to send a non-Sendable value as long as the function is explicit about the fact that we really are sending it rather than, e.g., duplicating a handle to mutable state across isolation domains. From this view, sending T is a way to recover some measure of sendability for an otherwise non-Sendable type by being specific about what is happening at the boundary.

5 Likes

Yeah, I think programmers could absolutely puzzle that out and see the sense of it. Maybe it some ways it would be specifically good in terms of eventually leading them to the right mental model for sendability. On the other hand, I think they'd probably all also have an immediate "oh, that's really weird" reaction that would take some time to get over, and experience suggests that some people won't get over it.

Also, we've generally been using send as a term of art for the specific act of sharing a value across concurrent domains, which isn't necessarily a part of just passing it as a sending parameter or result.

3 Likes

To me disconnected isn't more specific because without the context of this discussion I don't really know in what sense it is meant, although I do really like how this is a keyword describing the status of the sent parameter or returned value.

It's too bad isolated is already in use. Any way to fit it in here somewhere?

6 Likes

disconnected name looks more aligned to me with the region based isolation, I would prefer it over transferring in general. It can be associated together with disconnected regions, so the value that is passed is disconnected from the isolation region, and as term suits well in my opinion to make a distinction. And since all Sendable types under current proposal implicitly satisfy "transferring", I think "disconnected" could be more meaningful description when talking about sendable types.

transferring is a good name as well, and it is more suitable than sending which has more association, and therefore cognitive load, bounded to Sendable. It also fits within pattern of consuming/borrowing.

I think that would be a mistake; the senses in which we mean them are too different.

People would probably have to look it up, but once they do, it has a clear technical meaning: the value is the only reference to a disconnected subgraph of the non-sendable values in the program, so as long as we maintain that property, we can safely move it around between concurrent contexts without introducing any possibility of data races. There's a reason it's the term the region-based proposals already use when describing regions.

Yes, Sendable for newcomers in context of other standard protocols (like Codable) can be interpreted as "have an ability to be sended" while in fact it's almost the opposite. But this is no more surprising than possibility of assigning a value-type instance to variable of type AnyObject, for example. Those who begin to learn concurrency should just simply remember what Sendable exactly means.
Thus choosing between only two words I would prefer sending instead of transferring , because Sendability as a term itself is already about “Transferring Objects Between Concurrency Domains". Finally Sendable is about type level and sending is about value level.
Nevertheless, all words with -ing suffix are felt a bit misleading and expressing an idea that something is already happening with value.
Seems that right feeling and meaning can be either expressed by sendable / transferable (lowercased) or disconnected / detached.
Finally, disconnected / detached is felt for me like the best choice because:

  • it covers cases that differs significantly from Sendable
  • while learning this new term people will also need to learn about isolation regions in common and disconnected regions specifically. Any of regions are not expressed or handled syntatically / explicitly, so one should keep them in mind. In contrast, sendable can be felt like something familiar and well known which can finally have negative impact because of incorrect understanding.

The only downside of disconnected / detached is the lack of specificity – these terms are about nothing concrete. Here are some alternatives from my mind:

  • isolationDetached
  • isolationNeutral
  • isolationMalleable
  • isolationTransitory
  • freeIsolated
2 Likes

The way it reads to me in my imagination was the opposite: it is redundant to start sending a Sendable, but for non-sendable values, it looks like "the other thing" that can enable it being sent to a different isolation region. In other words, a programmer has two choices: either have a Sendable value to start with, or "send" it explicitly. Diagnostics for the redundant case can also look more logical: an error saying "sending a Sendable value has no (additional) effect" is more straightforward to figure out than "transferring a Sendable value has no effect".

I agree somewhat that it collides with the notion that Sendable is otherwise meant to imply "sharing" more than one-way "passing" in Swift, but I wonder if people generally do perceive it this way (or whether, OTOH, transferring implies the exclusivity better).

Since we both used the word "passing", however, it might be that indeed passing or simply pass could be the way. Another thing that I dislike just a tiny bit about transferring is how long the word is for where it has to appear lexically (although we have the precedent of nonisolated, to be fair).

4 Likes

Anything using 'pass' terminology seems like a non-starter to me; it feels far too difficult to try to explain/redefine what non-passing parameters are doing if not 'passing' a value.

1 Like

I'm afraid this might be a problem for any choice of the verb to some extent, as nothing comes to mind that would exclusively imply "the context of concurrency" especially in terms of their general meaning in English.

Unless it's explicitly (and verbosely) spelled like isolationtransferring or actordisconnected, it looks like there'll always be a potential to ask questions like "passing from where? Transferring between what? Sending where to?" etc., so it's likely just not achievable to have a term that "reads on its own" and replaces any supplemental documentation / education on its purpose.

I vaguely remember the discussion for the original Sendable where it was also regarded as potentially too generic (which is why my original suggestion was to hammer on that one because at least send*** already has a recogniseable learned meaning here).

1 Like

I’m sure that changing the name of Sendable is not an option, even if we determined that it would be desirable aside from source compatibility issues, but… perhaps it would be helpful to this debate to acknowledge that perhaps Sendable turns out to not have been the right name, because in fact things that don’t conform to Sendable can be “sent”. The more verbose ImplicitlySendable (or something similar) might have been a more correct name.

This is great. +1. I like “sending”. It makes it feel like you are explicitly sending the value somewhere else. Transferring is second place.

The proposal reads great and clearly addresses missing piece of the isolation puzzle - nice and crisp!

The transferring spelling also does not spark joy for me. (A bit long, does not compose great, the double r doesn't help either)

I like John's argument how disconnected would work well, but I feel that the word only makes sense if you are thinking in terms of "region analysis", and I doubt the majority of developers have this mental model while writing code. I would even say that hardly anybody thinks about "disconnected regions" while coding (other than the compiler).

I also like how sending would tie it all to the Sendable terminology (by far the most dominant word in this area).

So, how about just sendable?

class Thing {} // non-sendable

// Takes a Thing but it must be in a "sendable situation" (ie: disconnected region)
func take(_ a: sendable Thing)

// The return value must be in a "sendable situation". (ie: disconnected region)
func give() -> sendable Thing

Types that are Sendable are aways in a "sendable situation", all other values must be in a disconnected region to earn their sendable-ness.

I think it works fantastically, even without lowering into the technical depths of "disconnected regions". In the spirit of progressive discloser, I think sendable won't throw you down that rabbit whole quite so early.

It would compose a lot better too I find:

func f(_ value: consuming sendable Thing)
func f(_ value: borrowing sendable Thing)
5 Likes

sendable is too close to Sendable. It's going to cause too much confusion (IMO) with people intuiting [incorrectly] that it just means the argument has to be Sendable.

It also doesn't fit the pattern of similar keywords, borrowing, consuming, etc.

sending does fit the pattern and is just far enough removed from Sendable to be okay. And there's precedence for the "sending is a superset of Sendable" with how move-only types work, too.

12 Likes

[review manager hat off]

I would feel more comfortable with sending if we could say that Sendable means “automatically, implicitly sending with no special checks necessary”, but given that adding sending/transferring breaks ABI, I don’t think we can get away with that claim.

[review manager hat on]

2 Likes

I was initially less excited about this than the previous region isolation proposal—concurrency checking is definitely running a high complexity budget, and adding another keyword to enable one more checked pattern rather than just saying "sorry, you have to use an unchecked pattern here" didn't necessarily seem worth it. But I'm swayed somewhat by the existing APIs that are unsafe without it. :-(

The "ABI compatibility" section of this proposal is where the notes on mangling should have gone. But if adding transferring can be done in a binary-compatible way (as long as the existing API is consuming), why does it change the mangling? Are we allowing people to overload on transferring vs not?

EDIT: Adding transferring to an existing parameter is source-breaking, since it has variance as described in the proposal. And it's nice when source-breaking changes are reflected in the mangled name, because it prevents a client from trying to use the symbol under different assumptions. But removing transferring from a parameter isn't source-breaking*, and neither is adding it to a return value, and it's also nice if changes that aren't source-breaking aren't reflected in the mangled name. (Though you'd have to be careful that the new signature would have been valid for previous versions of the library.)

* not counting protocol requirements or overridable functions, where clients are both users and providers of an interface.


Some of the diagnostic examples seem incorrect, like this one:

@MainActor
func acceptTransfer(_: transferring NonSendable) {}

func transferToMain() async {
  let ns = NonSendable()

  // error: value of non-Sendable type 'NonSendable' accessed after transfer to main actor
  await acceptTransfer(ns)

  // note: access here could race
  print(ns)
}

I'd expect the error to be on the second use and the note on the first use.

2 Likes