Hi @FranzBusch, thanks for the detailed read! Below are my summaries of your concerns (please correct me if misinterpreted), and my responses.
At a high level, I believe it's worth clarifying one important descriptive point about this approach. Protocols such as as Sendable
(which I believe is equivalent to Rust Sync
) that provide type-level guarantees about the safety of offering aliases of local references to other threads are useful, but this SendNonSendable
pass is meant to be a catch-all for allowing concurrent sharing of values for which no type-level information alone suffices to conclude the sharing is safe. Onto more specific responses:
Viability of usage analysis
Concern:
Summary: Should we rely on analyzing the usage of an object to determine if it's safe to send?
Quote:
Response:
Summary: the analysis is based on usage not type - I believe this is valuable in addition to type-based analysis like protocol conformance; the analysis is intended to be sound but not complete
Longer form: To be very clear, this is a conservative analysis. Let's say we reach a point where a local reference x
is sent to another isolation domain. We want to determine whether this send is safe or not. By safe, we mean that there is no possible program execution resulting in a race over data accessed through x
. The SendNonSendable
pass does not guarantee that sends it reports as unsafe indeed can yield such a race. It instead uses static analysis to compute a region including all values that could be accessed through x
at the point of the send. Namely, this transitively includes all possible aliases and references of x
. This region is then marked as transferred at that program point. The send is declared safe iff dataflow analysis determines no values in that region are accessed after that program point. This analysis has nothing to do with the type of x
or anything else in its region. This analysis can have false positives, it cannot have false negatives. In this sense, "type-independent inference from the usage of an object" very much can determine it can be safely transferred across isolation domains/tasks. It cannot determine that a usage is definitely unsafe, but that's par for the course.
Let's say we scrapped even the Sendable
protocol, and solely relied on this flow-sensitive, region-based analysis in the SendNonSendable
pass. You'd have a somewhat annoying world, in which even very surely non-mutable types like integers would need unsafe copying functions to be sent to other threads then read again. But it wouldn't be an unusable world: any functions that typecheck in current Swift and don't send values to other isolations would still typecheck, and when sends were inserted you'd just have to be very careful to only send data you really don't need in the sending thread again. This is a purely usage-based way of achieving safe concurrency, and it would work.
Sendable
steps in and makes this world much better because now, everything from primitive types like Int
, to more complex types like [Int]
, and internally-synchronized types like actors and unsafe Sendable
user-defined types can be sent to other threads and still used by the sending thread. These Sendable
types are types whose values are always safe to send between threads because even concurrent access after the send would not yield a race. Values of other types are not always safe to send, so usage analysis like the SendNonSendable
pass steps in to ensure concurrent access after the send does not actually occur.
Swift with both the Sendable
protocol and the SendNonSendable
pass is thus a hybrid world in which there are two ways to safely send a value to another thread: inspect its type, and realize it's Sendable
and therefore safe to concurrently access after the send; or inspect its usage, and realize it will not be concurrently accessed after the send. This is flexible and ergonomic, and I believe that offering both type-based safe sending and usage-based safe sending is a valuable approach.
Comparison to Rust Sync/Send
Concern:
Summary: Should we just lift Sync/Send
from Rust to Swift as closely as we can instead?
Quote:
Response:
Summary: I think SendNonSendable
is more expressive than a lifting of Sync/Send to Swift, but it's an orthogonal feature.
Longer form: I believe that the usefulness of Sync/Send in Rust, and the extent to which it's the "right" solution in their context, is directly tied to the nature of their brand of linearity: pervasive single ownership. As discussed a bit above in my response to @wadetregaskis, allowing move-only types to be safely sent between threads as long as the send is treated as a consume would be safe. Since Rust's brand of linearity closely resembles move-only types in Swift, Sync/Send would be a useful distinction for move-only types. Sendable
would be Sync, and deeply move-only would be Send. These deeply move-only "Sync" types would be safe to send because their move-only nature would prevent aliases being available to the sender after the send, and the "deeply" caveat would ensure that no references accessible from the shared type are aliased in the sending context either. (deeply meaning that any stored properties of the type must also be deeply move-only)
To summarize, I believe that the truest way to lift Sync/Send to Swift would be to make only move-only types be able to conform to the equivalent of Send, and only if they are deeply move-only. My response to @wadetregaskis now nearly directly applies: we could do this, but it would inhibit natural programming patterns, and many of the examples of code that it would allow to typecheck already typecheck with SendNonSendable
. It has the downsides of requiring more programmer-provided annotation, and of making move-only more prevalent in general - which makes Swift code more complex. For these reasons SendNonSendable
seems more appealing to me than co-opting the ownership semantics of move-only to make more patterns of concurrency safe. Let me know what you think though!
Finally, on this point, it's worth addressing that if we wanted to add something like a Swift Transferrable
protocol that is to Rust Send
what Swift Sendable
is to Rust Sync
, and that is only applicable to deeply move-only types as mentioned above, we could! I have various personal reasons to think it's a bad idea, but it would play just as nicely with the SendNonSendable
pass as Sendable
currently does: values of Transferrable
type would simply be ignored by the pass just like values of Sendable
type currently are.
API impacts
Concern:
Summary: Does this yield issues with the ability of API designers to control downstream concurrent usage of their types?
Quote:
Response:
Please adjust my interpretation of your concern if it's off, but I believe that you're imagining a developer who wants to expose a library type T
with no intention that it ever be used outside of the isolation domain it was defined in (in a sense, never "used with concurrency"). If the reason that the developer wanted this was that they could not guarantee that values of the type could be concurrently accessed without yielding races (in Rust: not Sync
), or that they could not guarantee that values of the type could be transferred out of their original domain without yielding races through ancillary references accessible through the value (in rust: not Send
) , then there's no issue with the SendNonSendable
pass stepping in and letting downstream users of T
communicate values of its type across isolations. This is because the nature of the pass, combining regions and flow-sensitivity, ensures conservatively that no values that could possibly alias or be references by the sent value are used after the send. There is thus no concern in sending values of type T
even if the implementer of T
never intended for them to be sent.
If you're envisioning other reasons that an API designer could want to ensure that no values of a type T
they provide are ever accessed outside the isolation domain they're designed in, then that could be a useful feature to provide - e.g. a truly NonTransferrable
protocol or equivalent unavailability - but I'd argue it's once again orthogonal to the SendNonSendable
pass. I'd love to hear more about the specific situations you're imagining in which a developer would want this feature though, so we could prioritize whether bundling it with SendNonSendable
is necessary.
Conclusion
I hope I didn't mischaracterize any of your concerns. Thanks for your time in reading this proposal. I think we all agree that something that increases the expressiveness of Swift Concurrency beyond the current strict Sendable
requirement is warranted, so hopefully as a community we can converge on what that right thing is!