Published on: September 13, 2022
One of the goals of the Swift team with Swift’s concurrency features is to provide a model that allows developer to write safe code by default. This means that there’s a lot of time and energy invested into making sure that the Swift compiler helps developers detect, and prevent whole classes of bugs and concurrency issues altogether.
One of the features that helps you prevent data races (a common concurrency issue) comes in the form of actors which I’ve written about before.
While actors are great when you want to synchronize access to some mutable state, they don’t solve every possible issue you might have in concurrent code.
In this post, we’re going to take a closer look at the Sendable
protocol, and the @Sendable
annotation for closures. By the end of this post, you should have a good understanding of the problems that Sendable
(and @Sendable
) aim to solve, how they work, and how you can use them in your code.
Understanding the problems solved by Sendable
One of the trickiest aspects of a concurrent program is to ensure data consistency. Or in other words, thread safety. When we pass instances of classes or structs, enum cases, or even closures around in an application that doesn’t do much concurrent work, we don’t need to worry about thread safety a lot. In apps that don’t really perform concurrent work, it’s unlikely that two tasks attempt to access and / or mutate a piece of state at the exact same time. (But not impossible)
For example, you might be grabbing data from the network, and then passing the obtained data around to a couple of functions on your main thread.
Due to the nature of the main thread, you can safely assume that all of your code runs sequentially, and no two processes in your application will be working on the same referencea at the same time, potentially creating a data race.
To briefly define a data race, it’s when two or more parts of your code attempt to access the same data in memory, and at least one of these accesses is a write action. When this happens, you can never be certain about the order in which the reads and writes happen, and you can even run into crashes for bad memory accesses. All in all, data races are no fun.
While actors are a fantastic way to build objects that correctly isolate and synchronize access to their mutable state, they can’t solve all of our data races. And more importantly, it might not be reasonable for you to rewrite all of your code to make use of actors.
Consider something like the following code:
class FormatterCache {
var formatters = [String: DateFormatter]()
func formatter(for format: String) -> DateFormatter {
if let formatter = formatters[format] {
return formatter
}
let formatter = DateFormatter()
formatter.dateFormat = format
formatters[format] = formatter
return formatter
}
}
func performWork() async {
let cache = FormatterCache()
let possibleFormatters = ["YYYYMMDD", "YYYY", "YYYY-MM-DD"]
await withTaskGroup(of: Void.self) { group in
for _ in 0..<10 {
group.addTask {
let format = possibleFormatters.randomElement()!
let formatter = cache.formatter(for: format)
}
}
}
}
On first glance, this code might not look too bad. We have a class that acts as a simple cache for date formatters, and we have a task group that will run a bunch of code in parallel. Each task will grab a random date format from the list of possible format and asks the cache for a date formatter.
Ideally, we expect the formatter cache to only create one date formatter for each date format, and return a cached formatter after a formatter has been created.
However, because our tasks run in parallel there’s a chance for data races here. One quick fix would be to make our FormatterCache
an actor and this would solve our potential data race. While that would be a good solution (and actually the best solution if you ask me) the compiler tells us something else when we try to compile the code above:
Capture of ‘cache’ with non-sendable type ‘FormatterCache’ in a
@Sendable
closure
This warning is trying to tell us that we’re doing something that’s potentially dangerous. We’re capturing a value that cannot be safely passed through concurrency boundaries in a closure that’s supposed to be safely passed through concurrency boundaries.
⚠️ If the example above does not produce a warning for you, you’ll want to enable strict concurrency checking in your project’s build settings for stricter Sendable checks (amongst other concurrency checks). You can enable strict concurrecy settings in your target’s build settings. Take a look at this page if you’re not sure how to do this.
Being able to be safely passed through concurrency boundaries essentially means that a value can be safely accessed and mutated from multiple tasks concurrently without causing data races. Swift uses the Sendable
protocol and the @Sendable
annotation to communicate this thread-safety requirement to the compiler, and the compiler can then check whether an object is indeed Sendable
by meeting the Sendable
requirements.
What these requirements are exactly will vary a little depending on the type of objects you deal with. For example, actor
objects are Sendable
by default because they have data safety built-in.
Let’s take a look at other types of objects to see what their Sendable
requirements are exactly.
Sendable and value types
In Swift, value types provide a lot of thread safety out of the box. When you pass a value type from one place to the next, a copy is created which means that each place that holds a copy of your value type can freely mutate its copy without affecting other parts of the code.
This a huge benefit of structs over classes because they allow use to reason locally about our code without having to consider whether other parts of our code have a reference to the same instance of our object.
Because of this behavior, value types like structs and enums are Sendable
by default as long as all of their members are also Sendable
.
Let’s look at an example:
// This struct is not sendable
struct Movie {
let formatterCache = FormatterCache()
let releaseDate = Date()
var formattedReleaseDate: String {
let formatter = formatterCache.formatter(for: "YYYY")
return formatter.string(from: releaseDate)
}
}
// This struct is sendable
struct Movie {
var formattedReleaseDate = "2022"
}
I know that this example is a little weird; they don’t have the exact same functionality but that’s not the point.
The point is that the first struct does not really hold mutable state; all of its properties are either constants, or they are computed properties. However, FormatterCache
is a class that isn’t Sendable
. Since our Movie
struct doesn’t hold a copy of the FormatterCache
but a reference, all copies of Movie
would be looking at the same instances of the FormatterCache
, which means that we might be looking at data races if multiple Movie
copies would attempt to, for example, interact with the formatterCache.
The second struct only holds Sendable
state. String
is Sendable
and since it’s the only property defined on Movie
, movie is also Sendable
.
The rule here is that all value types are Sendable
as long as their members are also Sendable
.
Generally speaking, the compiler will infer your structs to be Sendable
when needed. However, you can manually add Sendable
conformance if you’d like:
struct Movie: Sendable {
let formatterCache = FormatterCache()
let releaseDate = Date()
var formattedReleaseDate: String {
let formatter = formatterCache.formatter(for: "YYYY")
return formatter.string(from: releaseDate)
}
}
Sendable and classes
While both structs and actors are implicitly Sendable
, classes are not. That’s because classes are a lot less safe by their nature; everybody that receives an instance of a class actually receives a reference to that instance. This means that multiple places in your code hold a reference to the exact same memory location and all mutations you make on a class instance are shared amongst everybody that holds a reference to that class instance.
That doesn’t mean we can’t make our classes Sendable
, it just means that we need to add the conformance manually, and manually ensure that our classes are actually Sendable
.
We can make our classes Sendable
by adding conformance to the Sendable
protocol:
final class Movie: Sendable {
let formattedReleaseDate = "2022"
}
The requirements for a class to be Sendable
are similar to those for a struct.
For example, a class can only be Sendable
if all of its members are Sendable
. This means that they must either be Sendable
classes, value types, or actors. This requirement is identical to the requirements for Sendable
structs.
In addition to this requirement, your class must be final
. Inheritance might break your Sendable
conformance if a subclass adds incompatible overrides or features. For this reason, only final
classes can be made Sendable
.
Lastly, your Sendable
class should not hold any mutable state. Mutable state would mean that multiple tasks can attempt to mutate your state, leading to a data race.
However, there are instances where we might know a class or struct is safe to be passed across concurrency boundaries even when the compiler can’t prove it.
In those cases, we can fall back on unchecked Sendable
conformance.
Unchecked Sendable conformance
When you’re working with codebases that predate Swift Concurrency, chances are that you’re slowly working your way through your app in order to introduce concurrency features. This means that some of your objects will need to work in your async code, as well as in your sync code. This means that using actor
to isolate mutable state in a reference type might not work so you’re stuck with a class that can’t conform to Sendable
. For example, you might have something like the following code:
class FormatterCache {
private var formatters = [String: DateFormatter]()
private let queue = DispatchQueue(label: "com.dw.FormatterCache.\(UUID().uuidString)")
func formatter(for format: String) -> DateFormatter {
return queue.sync {
if let formatter = formatters[format] {
return formatter
}
let formatter = DateFormatter()
formatter.dateFormat = format
formatters[format] = formatter
return formatter
}
}
}
This formatter cache uses a serial queue to ensure synchronized access to its formatters
dictionary. While the implementation isn’t ideal (we could be using a barrier or maybe even a plain old lock instead), it works. However, we can’t add Sendable
conformance to our class because formatters
isn’t Sendable
.
To fix this, we can add @unchecked Sendable
conformance to our FormatterCache
:
class FormatterCache: @unchecked Sendable {
// implementation unchanged
}
By adding this @unchecked Sendable
we’re instructing the compiler to assume that our FormatterCache
is Sendable
even when it doesn’t meet all of the requirements.
Having this feature in our toolbox is incredibly useful when you’re slowly phasing Swift Concurrency into an existing project, but you’ll want to think twice, or maybe even three times, when you’re reaching for @unchecked Sendable
. You should only use this feature when you’re really certain that your code is actually safe to be used in a concurrent environment.
Using @Sendable on closures
There’s one last place where Sendable
comes into play and that’s on functions and closures.
Lots of closures in Swift Concurrency are annotated with the @Sendable
annotation. For example, here’s what the declaration for TaskGroup
‘s addTask
looks like:
public mutating func addTask(priority: TaskPriority? = nil, operation: @escaping @Sendable () async -> ChildTaskResult)
The operation
closure that’s passed to addTask
is marked with @Sendable
. This means that any state that the closure captures must be Sendable
because the closure might be passed across concurrency boundaries.
In other words, this closure will run in a concurrent manner so we want to make sure that we’re not accidentally introducing a data race. If all state captured by the closure is Sendable
, then we know for sure that the closure itself is Sendable
. Or in other words, we know that the closure can safely be passed around in a concurrent environment.
Tip: to learn more about closures in Swift, take a look at my post that explains closures in great detail.
Summary
In this post, you’ve learned about the Sendable
and @Sendable
features of Swift Concurrency. You learned why concurrent programs require extra safety around mutable state, and state that’s passed across concurrency boundaries in order to avoid data races.
You learned that structs are implicitly Sendable
if all of their members are Sendable
. You also learned that classes can be made Sendable
as long as they’re final
, and as long as all of their members are also Sendable
.
Lastly, you learned that the @Sendable
annotation for closures helps the compiler ensure that all state captured in a closure is Sendable
and that it’s safe to call that closure in a concurrent context.
I hope you’ve enjoyed this post. If you have any questions, feedback, or suggestions to help me improve the reference then feel free to reach out to me on Twitter.