Sending vs Sendable in Swift
Published on: December 18, 2024With Swift 6, we have an entirely new version of the language that has all kinds of data race protections built-in. Most of these protections were around with Swift 5 in one way or another and in Swift 6 they've refined, updated, improved, and expanded these features, making them mandatory. So in Swift 5 you could get away with certain things where in Swift 6 these are now compiler errors.
Swift 6 also introduces a bunch of new features, one of these is the sending
keyword. Sending
closely relates to Sendable
, but they are pretty different in terms of why they're used, what they can do, and which problems they tend to solve.
In this post, I would like to explore the similarities and differences between Sendable
and sending
. By the end of this post, you will understand why the Swift team decided to change the closures that you pass to tasks, continuations, and task groups to be sending
instead of @Sendable
.
If you're not fully up to date on Sendable
, I highly recommend that you check out my post on Sendable
and @Sendable
closures. In this post, it's most relevant for you to understand the @Sendable
closures part because we're going to be looking at a comparison between a @Sendable
closure and a sending
argument.
Understanding the problem that’s solved by sending
In Swift 5, we didn't have the sending
keyword. That meant that if we wanted to pass a closure or a value from one place to another safely, we would do that with the sendable
annotation. So, for example, Task
would have been defined a little bit like this in Swift 5.
public init(
priority: TaskPriority? = nil,
operation: @Sendable @escaping () async -> Success
)
This initializer is copied from the Swift repository with some annotations stripped for simplicity.
Notice that the operation
argument takes a @Sendable
closure.
Taking a @Sendable
closure for something like a Task
means that that closure should be safe to call from any other tasks or isolation context. In practice, this means that whatever we do and capture inside of that closure must be safe, or in other words, it must be Sendable
.
So, a @Sendable
closure can essentially only capture Sendable
things.
This means that the code below is not safe according to the Swift 5.10 compiler with strict concurrency warnings enabled.
Note that running the example below in Xcode 16 with the Swift 6 compiler in Swift 5 mode will not throw any errors. That's because
Task
has changed its operation to besending
instead of@Sendable
at a language level regardless of language mode.So, even in Swift 5 language mode,
Task
takes asending
operation.
// The example below requires the Swift 5 COMPILER to fail
// Using the Swift 5 language mode is not enough
func exampleFunc() {
let isNotSendable = MyClass()
Task {
// Capture of 'isNotSendable' with non-sendable type 'MyClass' in a `@Sendable` closure
isNotSendable.count += 1
}
}
If you want to explore this compiler error in a project that uses the Swift 6 compiler, you can define your own function that takes a @Sendable
closure instead of a Task
:
public func sendableClosure(
_ closure: @Sendable () -> Void
) {
closure()
}
If you call that instead of Task
, you’ll see the compiler error mentioned earlier.
The compiler error is correct. We are taking something that isn't sendable and passing it into a task which in Swift 5 still took a @Sendable
closure.
The compiler doesn't like that because the compiler says, "If this is a sendable closure, then it must be safe to call this from multiple isolation contexts, and if we're capturing a non-sendable class, that is not going to work."
This problem is something that you would run into occasionally, especially with @Sendable
closures.
Our specific usage here is totally safe though. We're creating an instance of MyClass
inside of the function that we're making a task or passing that instance of MyClass
into the task.
And then we're never accessing it outside of the task or after we make the task anymore because by the end of exampleFunc
this instance is no longer retained outside of the Task
closure.
Because of this, there's no way that we're going to be passing isolation boundaries here; No other place than our Task
has access to our instance anymore.
That’s where sending
comes in…
Understanding sending arguments
In Swift 6, the team added a feature that allows us to tell the compiler that we intend to capture whatever non-sendable state we might receive and don't want to access it elsewhere after capturing it.
This allows us to pass non-sendable objects into a closure that needs to be safe to call across isolation contexts.
In Swift 6, the code below is perfectly valid:
func exampleFunc() async {
let isNotSendable = MyClass()
Task {
isNotSendable.count += 1
}
}
That’s because Task
had its operation
changed from being @Sendable
to something that looks a bit as follows:
public init(
priority: TaskPriority? = nil,
operation: sending @escaping () async -> Success
)
Again, this is a simplified version of the actual initializer. The point is for you to see how they replaced @Sendable
with sending
.
Because the closure is now sending instead of @sendable, the compiler can check that this instance of MyClass that we're passing into the task is not accessed or used after the task captures it. So while the code above is valid, we can actually write something that is no longer valid.
For example:
func exampleFunc() async {
let isNotSendable = MyClass()
// Value of non-Sendable type ... accessed after being transferred;
// later accesses could race
Task {
isNotSendable.count += 1
}
// Access can happen concurrently
print(isNotSendable.count)
}
This change to the language allows us to pass non-sendable state into a Task
, which is something that you'll sometimes want to do. It also makes sure that we're not doing things that are potentially unsafe, like accessing non-sendable state from multiple isolation contexts, which is what happens in the example above.
If you are defining your own functions that take closures that you want to be safe to call from multiple isolation contexts, you’ll want to mark them as sending
.
Defining your own function that takes a sending
closure looks as follows:
public func sendingClosure(
_ closure: sending () -> Void
) {
closure()
}
The sending
keyword is added as a prefix to the closure type, similar to where @escaping
would normally go.
In Summary
You probably won't be defining your own sending
closures or your own functions that take sending
arguments frequently. The Swift team has updated the initializers for tasks, detached tasks, the continuation APIs, and the task group APIs to take sending
closures instead of @Sendable
closures. Because of this, you'll find that Swift 6 allows you to do certain things that Swift 5 wouldn't allow you to do with strict concurrency enabled.
I think it is really cool to know and understand how sending
and @Sendable
work.
I highly recommend that you experiment with the examples in this blog post by defining your own sending
and @Sendable
closures and seeing how each can be called and how you can call them from multiple tasks. It's also worth exploring how and when each options stops working so you're aware of their limitations.