May 14, 2024·6 min read·engineeringswiftconcurrencylanguage-design

Swift accepts sending for isolated transfers of mutable values

SE-0430 distinguishes a value that can safely change isolation domains from a type whose values can be shared concurrently. The accepted keyword follows two rounds of review.

A mutable object does not need to support concurrent access if only one isolation domain can use it at a time. Requiring every transferred value to have a thread-safe type would reject that useful case. Allowing an unrestricted reference to cross the boundary would permit the sender to keep using the same object. Swift's accepted sending annotation expresses the stronger condition needed for a safe handoff.

The May 14 acceptance of SE-0430 chooses sending for parameter and result annotations. The second review ran from May 7 through May 13. Its outcome also excludes combining borrowing and sending, preserving a possible future meaning for that spelling.

I find this distinction consistent with Swift's approach to mutation. A reference can be safe to transfer without being safe to share indefinitely. Describing the transfer explicitly avoids making an entire type promise more than a particular call requires.

Sendable describes a type-level guarantee

The proposal at its acceptance stage distinguishes sendable types from values that can be sent. A type conforming to Sendable supports safe use of its values across concurrent contexts. A value of a non-Sendable type needs a different argument for safety. The compiler must establish that the transfer prevents concurrent use.

That argument concerns the value's isolation region. A region groups values that the compiler must treat as potentially connected through non-sendable state. Sending one reference cannot be safe if another reachable reference lets the original context continue mutating the same object. The transfer must account for the region, including connected values.

A disconnected region has no connection tying it to another active isolation domain. A newly created non-sendable object can begin in such a region. It may then be transferred, provided the sender's subsequent uses do not conflict with the destination's access. The object's type remains non-sendable throughout this reasoning.

This avoids an unnecessary synchronization requirement. A transfer does not make the object's methods thread-safe. It makes the program's access pattern safe by preventing simultaneous use from independent contexts. The distinction gives ordinary mutable objects a place in checked concurrent programs without adding locks solely to satisfy a type-level sharing requirement.

Function boundaries need an explicit promise

Region-based isolation already supplies reasoning about connections between values. SE-0430 extends that reasoning to function interfaces. An ordinary parameter does not grant the callee unrestricted permission to send the argument away, because the caller might retain access after the function returns.

The proposal illustrates a function that receives a non-sendable object and uses it to initialize an actor's stored state. If the caller then uses its original reference, both contexts could reach that object. The function signature needs to communicate the obligation imposed on the caller by placing the object in the actor's region.

A sending parameter supplies that information. The argument must be in a disconnected region at the call boundary, and the caller cannot continue using it in a way that would race with its new use. The callee gains permission to place it into an isolation domain that the caller cannot inspect.

The accepted syntax can describe an operation of this form. This fragment illustrates the interface in the proposal's language model, rather than claiming support in an already released stable toolchain.

final class Payload {
    var count = 0
}

@MainActor
func accept(_ payload: sending Payload) {
    payload.count += 1
}

func deliver() async {
    let payload = Payload()
    await accept(payload)
}

The local object has no earlier connection to actor state, and the function does not use it after the call. Adding later access in the sender changes the safety argument. A declaration that accepts a sending value must be read with that call-site obligation in mind.

A result can promise disconnection too

The annotation also applies to function results. A sending result requires the implementation to return a value in a disconnected region. The caller can then rely on that property when using the returned value across an isolation boundary.

The proposal contrasts returning a newly created object with returning an object retained in actor-isolated storage. A new object can satisfy the requirement. Returning the retained object would expose the same non-sendable state to another context, so the result cannot truthfully promise disconnection.

These are opposite directions of responsibility. A sending parameter constrains the caller and gives the implementation more freedom. A sending result constrains the implementation and gives the caller more freedom. Both describe a property at a function boundary that ordinary parameter and result types do not establish by themselves.

That boundary-level guarantee also explains why sending is not a permanent property attached to every future use of the value. Once a disconnected object becomes part of actor-isolated state, its region is connected to that actor. The earlier permission to transfer it does not mean the actor can later send the same retained reference somewhere else without accounting for that connection.

Continuations expose the missing contract

One motivating example involves CheckedContinuation. A task creates a non-sendable object on the main actor, resumes a continuation with it, and also stores it in main-actor state. The resumed task then receives access to the same mutable object. The continuation interface needs to describe that movement even though the communication is indirect.

Requiring the continuation's generic result type to conform to Sendable would prohibit useful transfers of disconnected objects. A sending parameter on resume(returning:) instead requires the producer to relinquish conflicting access. A sending result on the suspending function communicates the corresponding guarantee to the resumed caller.

The proposal identifies checked continuations, stream continuation yields and task creation APIs as places that can adopt the annotation. These operations pass values between execution contexts without always needing unrestricted concurrent sharing. The stronger description belongs in their interface because application callers cannot inspect their implementation to determine the transfer behavior.

This also explains why manually adding @unchecked Sendable is a poor substitute for the intended contract. An unchecked conformance asserts a property of the whole type. It does not express that this specific object must stop being used by its sender. The annotation preserves that distinction for the compiler.

The spelling followed a semantic argument

The proposal passed through transferring and sendable terminology before the acceptance selected sending. These choices reflected a real difficulty. The annotation requires a capability to transfer safely at the boundary, while the callee need not immediately move the value onto a different actor.

The review manager's acceptance says sending keeps the relationship to Sendable recognizable while allowing the two concepts to be discussed separately. It also fits the grammatical form of other parameter annotations. The decision therefore concerns how developers understand a boundary obligation, as well as how the declaration reads.

The accepted modification excluding borrowing sending is particularly informative. Some reviewers wanted that spelling available for a possible design in which the callee borrows a region temporarily and returns it intact. The steering group chose not to assign it a narrower meaning now. The acceptance states that standard library adoption will use a temporary compiler-internal facility where needed to preserve ABI compatibility.

The acceptance-stage proposal still contains the earlier borrowing combination in its ownership discussion. On that point, the acceptance announcement is decisive. Accepted syntax bans the combination, so examples copied from that portion of the proposal would misrepresent the outcome of review.

Interface changes carry compatibility obligations

The proposal's source-compatibility model distinguishes Swift language modes. In Swift 5 mode, minimal checking suppresses sending diagnostics and strict checking reports warnings. The proposed Swift 6 mode reports errors. This lets existing concurrency interfaces adopt the annotation while preserving a staged path for source migration.

ABI compatibility is a separate issue. The proposal discusses name mangling and preserving the parameter's ownership convention when adding sending to an ABI-stable library. Those requirements concern how compiled clients call the library. They are separate from whether a source caller satisfies the region transfer rule.

Protocol requirements can carry the annotation as well. A requirement promising a sending result needs an implementation that supplies a disconnected result. An ordinary result cannot satisfy that stronger promise merely because its nominal type matches. This matters for abstractions over producers of mutable values, where the caller sees the protocol requirement and relies on it without knowing the concrete implementation. The guarantee must survive that abstraction boundary.

I prefer this explicit treatment to treating every concurrency restriction as a reason to make more types sendable. Swift's reference types remain useful precisely because they can express local mutable state. The accepted annotation lets an interface expose when that state can change isolation domains, with the compiler checking the sender's remaining access to the connected region.