What are Sendable and @Sendable closures in Swift? – Donny Wals


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.



Latest articles

spot_imgspot_img

Related articles

Leave a reply

Please enter your comment!
Please enter your name here

spot_imgspot_img