- Proposal: SE-0430
- Authors: Michael Gottesman, Holly Borla, John McCall
- Review Manager: Becca Royal-Gordon
- Status: Implemented (Swift 6.0)
- Previous Proposal: SE-0414: Region-based isolation
- Previous Revisions: 1 2
- Review: (pitch) (first review) (returned for revision) (second review) (acceptance with modifications) (amendment pitch) (amendment review)
This proposal extends region isolation to enable the application of an explicit
sending
annotation to function parameters and results. A function parameter
or result that is annotated with sending
is required to be disconnected at
the function boundary and thus possesses the capability of being safely sent
across an isolation domain or merged into an actor-isolated region in the
function's body or the function's caller respectively.
SE-0414 introduced region isolation to enable non-Sendable
typed values to be
safely sent over isolation boundaries. In most cases, function argument and
result values are merged together into the same region for any given call. This
means that non-Sendable
typed parameter values can never be sent:
// Compiled with -swift-version 6
class NonSendable {}
@MainActor func main(ns: NonSendable) {}
func trySend(ns: NonSendable) async {
// error: sending 'ns' can result in data races.
// note: sending task-isolated 'ns' to main actor-isolated
// 'main' could cause races between main actor-isolated
// and task-isolated uses
await main(ns: ns)
}
Actor initializers have a special rule that requires their parameter values to be
sent into the actor instance's isolation region. Actor initializers are
nonisolated
, so a call to an actor initializer does not cross an isolation
boundary, meaning the argument values would be usable in the caller after the
initializer returns under the standard region isolation rules. SE-0414 consider
actor initializer parameters as being sent into the actor's region to allow
initializing actor-isolated state with those values:
class NonSendable {}
actor MyActor {
let ns: NonSendable
init(ns: NonSendable) {
self.ns = ns
}
}
func send() {
let ns = NonSendable()
let myActor = MyActor(ns: ns) // okay; 'ns' is sent into the 'myActor' region
}
func invalidSend() {
let ns = NonSendable()
// error: sending 'ns' may cause a data race
// note: sending 'ns' from nonisolated caller to actor-isolated
// 'init'. Later uses in caller could race with uses on the actor.
let myActor = MyActor(ns: ns)
print(ns) // note: note: access here could race
}
In the above code, if the local variable ns
in the function send
was instead
a function parameter, it would be invalid to send ns
into myActor
's region
because the caller of send()
may use the argument value after send()
returns:
func send(ns: NonSendable) {
// error: sending 'ns' may cause a data race
// note: task-isolated 'ns' to actor-isolated 'init' could cause races between
// actor-isolated and task-isolated uses.
let myActor = MyActor(ns: ns)
}
func callSend() {
let ns = NonSendable()
send(ns: ns)
print(ns)
}
The "sending parameter" behavior of actor initializers is a generally
useful concept, but it is not possible to explicitly specify that functions
and methods can send away specific parameter values. Consider the following
code that uses CheckedContinuation
:
@MainActor var mainActorState: NonSendable?
nonisolated func test() async {
let ns = await withCheckedContinuation { continuation in
Task { @MainActor in
let ns = NonSendable()
// Oh no! 'NonSendable' is passed from the main actor to a
// nonisolated context here!
continuation.resume(returning: ns)
// Save 'ns' to main actor state for concurrent access later on
mainActorState = ns
}
}
// 'ns' and 'mainActorState' are now the same non-Sendable value;
// concurrent access is possible!
ns.mutate()
}
In the above code, the closure argument to withCheckedContinuation
crosses an
isolation boundary to get onto the main actor, creates a non-Sendable
typed
value, then resumes the continuation with that non-Sendable
typed value. The
non-Sendable
typed value is then returned to the original nonisolated
context,
thus crossing an isolation boundary. Because resume(returning:)
does not
impose a Sendable
requirement on its argument, this code does not produce any
data-race safety diagnostics, even under -strict-concurrency=complete
.
Requiring Sendable
on the parameter type of resume(returning:)
is a harsh
restriction, and it's safe to pass a non-Sendable
typed value as long as the value
is in a disconnected region and all values in that disconnected region are not
used again after the call to resume(returning:)
.
This proposal enables explicitly specifying parameter and result values as
possessing the capability of being sent over an isolation boundary by annotating
the value with a contextual sending
keyword:
public struct CheckedContinuation<T, E: Error>: Sendable {
public func resume(returning value: sending T)
}
public func withCheckedContinuation<T>(
function: String = #function,
_ body: (CheckedContinuation<T, Never>) -> Void
) async -> sending T
A type that conforms to the Sendable
protocol is a thread-safe type: values of
that type can be shared with and used safely from multiple concurrent contexts
at once without causing data races. If a value does not conform to Sendable
,
Swift must ensure that the value is never used concurrently. The value can still
be sent between concurrent contexts, but the send must be a complete transfer of
the value's entire region implying that all uses of the value (and anything
non-Sendable
typed that can be reached from the value) must end in the source
concurrency context before any uses can begin in the destination concurrency
context. Swift achieves this property by requiring that the value is in a
disconnected region and we say that such a value is a sending
value.
Thus a newly-created value with no connections to existing regions is always a
sending
value:
func f() async {
// This is a `sending` value since we can transfer it safely...
let ns = NonSendable()
// ... here by calling 'sendToMain'.
await sendToMain(ns)
}
Once defined, a sending
value can be merged into other isolation
regions. Once merged, such regions, if not disconnected, will prevent the value
from being sent to another isolation domain implying that the value is no longer
a sending
value:
actor MyActor {
var myNS: NonSendable
func g() async {
// 'ns' is initially a `sending` value since it is in a disconnected region...
let ns = NonSendable()
// ... but once we assign 'ns' into 'myNS', 'ns' is no longer a sending
// value...
myNS = ns
// ... causing calling 'sendToMain' to be an error.
await sendToMain(ns)
}
}
If a sending
value's isolation region is merged into another disconnected
isolation region, then the value is still considered to be sending
since two
disconnected regions when merged form a new disconnected region:
func h() async {
// This is a `sending` value.
let ns = Nonsending()
// This also a `sending` value.
let ns2 = NonSendable()
// Since both ns and ns2 are disconnected, the region associated with
// tuple is also disconnected and thus 't' is a `sending` value...
let t = (ns, ns2)
// ... that can be sent across a concurrency boundary safely.
await sendToMain(t)
}
A sending
function parameter requires that the argument value be in a
disconnected region. At the point of the call, the disconnected region is no
longer in the caller's isolation domain, allowing the callee to send the
parameter value to a region that is opaque to the caller:
@MainActor
func acceptSend(_: sending NonSendable) {}
func sendToMain() async {
let ns = NonSendable()
// error: sending 'ns' may cause a race
// note: 'ns' is passed as a 'sending' parameter to 'acceptSend'. Local uses could race with
// later uses in 'acceptSend'.
await acceptSend(ns)
// note: access here could race
print(ns)
}
What the callee does with the argument value is opaque to the caller; the callee may send the value away, or it may merge the value to the isolation region of one of the other parameters.
A sending
result requires that the function implementation returns a value in
a disconnected region:
@MainActor
struct S {
let ns: NonSendable
func getNonSendableInvalid() -> sending NonSendable {
// error: sending 'self.ns' may cause a data race
// note: main actor-isolated 'self.ns' is returned as a 'sending' result.
// Caller uses could race against main actor-isolated uses.
return ns
}
func getNonSendable() -> sending NonSendable {
return NonSendable() // okay
}
}
The caller of a function returning a sending
result can assume the value is
in a disconnected region, enabling non-Sendable
typed result values to cross
an actor isolation boundary:
@MainActor func onMain(_: NonSendable) { ... }
nonisolated func f(s: S) async {
let ns = s.getNonSendable() // okay; 'ns' is in a disconnected region
await onMain(ns) // 'ns' can be sent away to the main actor
}
For a given type T
, sending T
is a subtype of T
. sending
is
contravariant in parameter position; if a function type is expecting a regular
parameter of type T
, it's perfectly valid to pass a sending T
value
that is known to be in a disconnected region. If a function is expecting a
parameter of type sending T
, it is not valid to pass a value that is not
in a disconnected region:
func sendingParameterConversions(
f1: (sending NonSendable) -> Void,
f2: (NonSendable) -> Void
) {
let _: (sending NonSendable) -> Void = f1 // okay
let _: (sending NonSendable) -> Void = f2 // okay
let _: (NonSendable) -> Void = f1 // error
}
sending
is covariant in result position. If a function returns a value
of type sending T
, it's valid to instead treat the result as if it were
merged with the other parameters. If a function returns a regular value of type
T
, it is not valid to assume the value is in a disconnected region:
func sendingResultConversions(
f1: () -> sending NonSendable,
f2: () -> NonSendable
) {
let _: () -> sending NonSendable = f1 // okay
let _: () -> sending NonSendable = f2 // error
let _: () -> NonSendable = f1 // okay
}
A protocol requirement may include sending
parameter or result annotations:
protocol P1 {
func requirement(_: sending NonSendable)
}
protocol P2 {
func requirement() -> sending NonSendable
}
Following the function subtyping rules in the previous section, a protocol
requirement with a sending
parameter may be witnessed by a function with a
non-sending
parameter:
struct X1: P1 {
func requirement(_: sending NonSendable) {}
}
struct X2: P1 {
func requirement(_: NonSendable) {}
}
A protocol requirement with a sending
result must be witnessed by a function
with a sending
result, and a requirement with a plain result of type T
may
be witnessed by a function returning a sending T
:
struct Y1: P1 {
func requirement() -> sending NonSendable {
return NonSendable()
}
}
struct Y2: P1 {
let ns: NonSendable
func requirement() -> NonSendable { // error
return ns
}
}
A sending
parameter can also be marked as inout
, meaning that the argument
value must be in a disconnected region when passed to the function, and the
parameter value must be in a disconnected region when the function
returns. Inside the function, the inout sending
parameter can be merged with
actor-isolated callees or further sent as long as the parameter is
re-assigned a value in a disconnected region upon function exit.
When a call passes an argument to a sending
parameter, the caller cannot
use the argument value again after the callee returns. By default sending
on a function parameter implies that the callee consumes the parameter. Like
consuming
parameters, a sending
parameter can be re-assigned inside
the callee. Unlike consuming
parameters, sending
parameters do not
have no-implicit-copying semantics.
To opt into no-implicit-copying semantics or to change the default ownership
convention, sending
may also be paired with an explicit consuming
ownership modifier:
func sendingConsuming(_ x: consuming sending T) { ... }
There are several APIs in the concurrency library that send a parameter across
isolation boundaries and don't need the full guarantees of Sendable
. These
APIs will instead adopt sending
parameters:
CheckedContinuation.resume(returning:)
UnsafeContinuation.resume(returning:)
Async{Throwing}Stream.Continuation.yield(_:)
Async{Throwing}Stream.Continuation.yield(with:)
- The
Task
creation APIs
In the Swift 5 language mode, sending
diagnostics are suppressed under
minimal concurrency checking, and diagnosed as warnings under strict concurrency
checking. The diagnostics are errors in the Swift 6 language mode, as shown in
the code examples in this proposal. This diagnostic behavior based on language
mode allows sending
to be adopted in existing Concurrency APIs including
CheckedContinuation
.
This proposal does not change how any existing code is compiled.
Adding sending
to a parameter is more restrictive at the caller, and
more expressive in the callee. Adding sending
to a result type is more
restrictive in the callee, and more expressive in the caller.
For libraries with library evolution, sending
changes name mangling, so
any adoption must preserve the mangling using @_silgen_name
. Adoping
sending
must preserve the ownership convention of parameters; no
additional annotation is necessary if the parameter is already (implicitly or
explicitly) consuming
.
sending
requires parameter and result values to be in a disconnected
region at the function boundary, but there is no way to preserve that a value
is in a disconnected region through stored properties, collections, function
calls, etc. To preserve that a value is in a disconnected region through the
type system, we could introduce a Disconnected
type into the Concurrency
library. The Disconnected
type would suppress copying via ~Copyable
, it
would conform to Sendable
, constructing a Disconnected
instance would
require the value it wraps to be in a disconnected region, and a value of type
Disconnected
can never be merged into another isolation region.
This would enable important patterns that take a sending T
parameter, store
the value in a collection of Disconnected<T>
, and later remove values from the
collection and return them as sending T
results. This would allow some
AsyncSequence
types to return non-Sendable
typed buffered elements as
sending
without resorting to unsafe opt-outs in the implementation.
This proposal originally used the word transferring
for sendable
. The idea
was that this would superficially match parameter modifiers like consuming
and
borrowing
. But, this ignored that we are not actually transferring
the
parameter into another isolation domain at the function boundary point. Instead,
we are requiring that the value at that point be in a disconnected region and
thus have the capability to be sent to another isolation domain or merged into
actor isolated state. This is in contrast to consuming
and borrowing
which
actively affect the value at the function boundary point by consuming or
borrowing the value. Additionally, by using transferring
would introduce a new
term of art into the language unnecessarily and contrasts with already
introduced terms like @Sendable
and the Sendable
protocol.
It was also suggested that perhaps instead of renaming transferring
to
sendable
, it should have been renamed to sending
. This was rejected by the
authors since it runs into the same problem as transferring
namely that it is
suggesting that the value is actively being moved to another isolation domain,
when we are expressing a latent capability of the value.
An earlier version of this proposal excluded
UnsafeContinuation.resume(returning:)
from the list of standard library
APIs that adopt sending
. This meant that UnsafeContinuation
didn't
require either the return type to be Sendable
or the return value to be
sending
. Since UnsafeContinuation
is unconditionally Sendable
, this
effectively made it a major hole in sendability checking.
This was an intentional choice. The reasoning was that UnsafeContinuation
was already an explicitly unsafe type, and so it's not illogical for it
to also work as an unsafe opt-out from sendability checks. There are some
uses of continuations that do need an opt-out like this. For example, it
is not uncommon for a continuation to be resumed from a context that's
isolated to the same actor as the context that called withUnsafeContinuation
.
In this situation, the data flow through the continuation is essentially
internal to the actor. This means there's no need for any sendability
restrictions; both sending
and Sendable
would be over-conservative.
However, the nature of the unsafety introduced by this exclusion is very
different from the normal unsafety of an UnsafeContinuation
.
Continuations must be resumed exactly once; that's the condition that
CheckedContinuation
checks. If a programmer can prove that
they will do that to their own satisfaction, they should be able to use
UnsafeContinuation
instead of CheckedContinuation
in full confidence.
Making UnsafeContinuation
also a potential source of concurrency-safety
holes is likely to be surprising to programmers.
Conversely, if a programmer needs to opt out of sendability checks but
is not confident about how many times their continuation will be
resumed --- for example, if it's resumed from an arbitrary callback ---
forcing them to adopt UnsafeContinuation
in order to achieve their
goal is actively undesirable.
Not requiring sending
in UnsafeContinuation
also makes the high-level
interfaces of UnsafeContinuation
and CheckedContinuation
inconsistent.
This means that programmers cannot always easily move from an unsafe to a
checked continuation. That is a common need, for example when fixing
a bug and trying to prove that the unsafe continuation is not implicated.
Swift has generally resisted adding new dimensions of unsafety to unsafe
types this way. For example, UnsafePointer
was originally specified as
unconditionally Sendable
in SE-0302, but that conformance was
removed in SE-0331, and pointers are now unconditionally
non-Sendable
. The logic in both of those proposals closely parallels
this one: at first, UnsafePointer
was seen as an unsafe type that should
not be burdened with partial safety checks, and then the community
recognized that this was actually adding a new dimension of unsafety to
how the type interacted with concurrency.
Finally, there is already a general unsafe opt-out from sendability
checking: nonisolated(unsafe)
. It is better for Swift to encourage
consistent use of a single unsafe opt-out than to build ad hoc
opt-outs into many different APIs, because it is much easier to find,
recognize, and audit uses of the former.
For these reasons, UnsafeContinuation.resume(returning:)
now requires
its argument to be sending
, and the result of withUnsafeContinuation
is correspondingly now marked as sending
.