Sending vs Sendable in Swift

Published on: December 18, 2024

With 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 be sending instead of @Sendable at a language level regardless of language mode.

So, even in Swift 5 language mode, Task takes a sending 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.

Further reading

Subscribe to my newsletter