Skip to Content
Course content

107: Data Race Safety with Sendable Types

Click on the "Edit" button in the top corner of the screen to edit your slide content.

You've probably spent some time now working with async/await and Actors, and you might have encountered a warning that looks something like this: "Capture of 'x' with non-sendable type 'Y' in a function bounded to 'Sendable'". When this happens, a common instinct is to think: "I know this is safe because I'm not actually mutating the object inside the Task, so the compiler is just being over-cautious."

I'll be honest: that's a dangerous way to think. The compiler isn't being "over-cautious"; it's pointing out that you've created a hole in your safety net. Let me show you why your "I'm being careful" approach fails.

"I'm not mutating it, so it's safe" vs. The Reality of Shared Reference State

class UserSession {
    var username: String
    init(username: String) { self.username = username }
}

func updateUI(with session: UserSession) {
    Task {
        // I'm only reading the name! How can this be a data race?
        print("Welcome back, \(session.username)")
    }
}

In the code above, you aren't changing username inside the Task. But here is the problem: UserSession is a class. It's a reference type. Both the main thread (where updateUI is running) and the background thread (where the Task is executing) now hold a pointer to the exact same spot in memory.

While the Task is reading the username, some other piece of code—perhaps a network callback or a timer—could be writing to that same username property. That is a data race. It doesn't matter that the Task itself is "behaving"; the fact that the type is capable of being mutated from multiple places simultaneously makes it unsafe to move across concurrency boundaries.

Using Sendable to Prove Thread-Safety to the Compiler

To fix this, we use the Sendable protocol. Think of Sendable as a stamp of approval. It tells the Swift compiler, "I guarantee that this value can be safely passed from one thread to another without causing a data race."

For most of your data models, the easiest path is to use a struct. Because structs are value types, they are copied when passed around. If a Task gets its own copy of a struct, it doesn't matter what the original thread does with its version—they aren't sharing memory. Swift automatically makes structs Sendable if all their properties are also Sendable.

struct UserSession: Sendable {
    let username: String // String is Sendable, so this works
}

Note that I changed var to let. While a struct with var properties is still technically Sendable (because the whole struct is copied), using constants makes your intent clear: this data is a snapshot, not a shared mutable object.

Handling Classes and the @unchecked Sendable Escape Hatch

You can't just slap Sendable onto a standard class because the compiler knows that classes are reference types. If you really need a class to be Sendable, you have a few choices. The "correct" modern choice is usually to turn that class into an actor, which protects its state through isolation.

However, you'll occasionally run into legacy code or low-level C-interop where you know a class is thread-safe (maybe it uses an internal DispatchQueue or a lock), but the compiler can't possibly prove it. In those rare cases, you use @unchecked Sendable.

class ThreadSafeCache: @unchecked Sendable {
    private let lock = NSLock()
    private var storage: [String: String] = [:]

    func set(_ value: String, for key: String) {
        lock.lock()
        storage[key] = value
        lock.unlock()
    }
}

When you use @unchecked, you are telling Swift: "Stop checking this. I'm taking full responsibility for the synchronization." I strongly suggest you avoid this unless you have no other option. It's essentially turning off the safety features of the language for that specific type. If you mess up the locking logic, you're back to square one with hard-to-debug crashes.




📋 Practical Task

Exercise: Fixing the Concurrent ImageCache

You are working on a photo app. There is an ImageCache class that is causing concurrency warnings because it is being passed into several Task blocks to preload images. Currently, the cache is a standard class with a dictionary, which is not Sendable.

Your goal: Transform the ImageCache into a thread-safe Sendable type. While you could use @unchecked Sendable, the best practice here is to convert the class into an actor to ensure that the cache dictionary is never accessed by two threads at once.

// --- START OF BROKEN CODE ---
class ImageCache {
    var cache: [String: String] = [:]

    func save(imagePath: String, id: String) {
        cache[id] = imagePath
    }

    func get(id: String) -> String? {
        return cache[id]
    }
}

func preloadImages(cache: ImageCache) {
    Task {
        // Warning: Capture of 'cache' with non-sendable type 'ImageCache'
        let path = cache.get(id: "profile_pic")
        print("Preloaded: \(path ?? "none")")
    }
}
// --- END OF BROKEN CODE ---

Requirements:

  • Convert ImageCache from a class to an actor.
  • Update the preloadImages function to handle the fact that accessing actor methods now requires await.
  • Ensure the code compiles without "non-sendable" warnings.
Rating
0 0

There are no comments for now.

to be the first to leave a comment.