Skip to main content

Swift Sendable Protocol

Introduction

When working with concurrent Swift code, sharing data between tasks can lead to data races – one of the most common and challenging bugs in concurrent programming. Swift's Sendable protocol, introduced as part of the Swift Concurrency model, helps prevent these data races by ensuring values can be safely transferred across concurrency boundaries.

In this guide, we'll explore what the Sendable protocol is, why it's important, and how to use it effectively in your Swift applications.

What is the Sendable Protocol?

Sendable is a marker protocol in Swift that indicates types that can be safely passed between different actors or threads without creating data races. Think of it as a "thread-safe transfer" certificate for your data.

swift
public protocol Sendable {
// This is a marker protocol with no requirements
}

When you mark a type as Sendable, you're telling the Swift compiler that instances of this type can be used concurrently without data races. The compiler will then verify this claim and warn you if it detects potential issues.

Why Do We Need Sendable?

Consider this scenario:

swift
// Without Sendable
class UserData {
var name: String
var score: Int

init(name: String, score: Int) {
self.name = name
self.score = score
}
}

func processUserConcurrently() async {
let user = UserData(name: "Taylor", score: 100)

// This could lead to a data race!
async let task1 = {
user.score += 10
}()

async let task2 = {
user.name = "Taylor Swift"
}()

// Wait for both tasks
await task1
await task2

// What is user.score and user.name now?
}

In this example, we have two tasks potentially modifying the UserData instance simultaneously. This is a classic data race situation that could lead to unexpected results or even app crashes.

The Sendable protocol helps prevent these issues by making the compiler warn us when we try to pass non-thread-safe types across concurrency boundaries.

Making Types Sendable

Value Types (Structs and Enums)

Many value types in Swift are automatically Sendable when all their stored properties are also Sendable:

swift
struct UserScore: Sendable {
let userId: String // String is Sendable
let score: Int // Int is Sendable
// This struct is automatically Sendable!
}

Reference Types (Classes)

Classes are trickier because they can be mutated from multiple places. To make a class Sendable, you need to ensure it's immutable or properly synchronized:

swift
// Option 1: Make the class final and immutable
final class ImmutableUser: Sendable {
let name: String
let id: UUID

init(name: String, id: UUID = UUID()) {
self.name = name
self.id = id
}

// No mutable properties or methods
}

// Option 2: Use @unchecked Sendable when you manually ensure thread safety
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]

func setValue(_ value: Any, forKey key: String) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}

func getValue(forKey key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
}

Using @Sendable for Functions

Functions that are passed across concurrency boundaries should also be marked as @Sendable:

swift
func processUser(handler: @Sendable (User) -> Void) {
Task {
let user = fetchUser()
handler(user) // This function will run on a different thread
}
}

// Using the function
processUser { user in
// This closure must follow Sendable rules
print("Processing user: \(user.name)")
}

Common Sendable Types in Swift

Many Swift standard library types are already Sendable:

  • Value types like Int, String, Bool, Double, etc.
  • Collections of Sendable types: Array<Element>, Dictionary<Key, Value>, Set<Element> (where Element, Key, and Value are Sendable)
  • Optionals of Sendable types
  • Tuples whose elements are all Sendable
  • Actor types (since they have their own synchronization)

Practical Example: Building a Thread-Safe Counter

Let's build a thread-safe counter application using the Sendable protocol:

swift
// First, create a sendable counter message
enum CounterAction: Sendable {
case increment
case decrement
case reset
}

// Create an actor to manage our counter
actor CounterManager {
private var count: Int = 0

func perform(action: CounterAction) -> Int {
switch action {
case .increment:
count += 1
case .decrement:
count -= 1
case .reset:
count = 0
}
return count
}

func currentCount() -> Int {
return count
}
}

// Using the counter across tasks
func counterExample() async {
let counter = CounterManager()

// These tasks can run concurrently without data races
async let increment1 = counter.perform(action: .increment)
async let increment2 = counter.perform(action: .increment)
async let increment3 = counter.perform(action: .increment)

let results = await [increment1, increment2, increment3]
print("Counter results: \(results)")
print("Final count: \(await counter.currentCount())")

// Output:
// Counter results: [1, 2, 3]
// Final count: 3
}

Real-World Application: Thread-Safe Data Service

Here's a more practical example of using Sendable in a real-world application context:

swift
// A User model that can be passed across concurrency boundaries
struct User: Sendable {
let id: UUID
let name: String
let email: String
}

// Network request result
enum FetchResult<T: Sendable>: Sendable {
case success(T)
case failure(Error)
}

// Make Error Sendable
extension Error {
// Most errors conform to Sendable
}

// A data service using async/await and Sendable
actor UserDataService {
private var cache: [UUID: User] = [:]

func fetchUser(id: UUID) async -> FetchResult<User> {
// Check cache first
if let cachedUser = cache[id] {
return .success(cachedUser)
}

// Simulate network request
do {
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second

// Create a user
let user = User(
id: id,
name: "User \(id.uuidString.prefix(4))",
email: "user\(id.uuidString.prefix(4))@example.com"
)

// Update cache
cache[id] = user

return .success(user)
} catch {
return .failure(error)
}
}

func updateUserCache(with users: [User]) {
for user in users {
cache[user.id] = user
}
}
}

// Example usage
func fetchMultipleUsersExample() async {
let service = UserDataService()
let userIds = [UUID(), UUID(), UUID()]

// Create multiple concurrent fetch tasks
var fetchTasks: [Task<FetchResult<User>, Never>] = []

for id in userIds {
let task = Task {
await service.fetchUser(id: id)
}
fetchTasks.append(task)
}

// Wait for all fetches to complete and process results
for task in fetchTasks {
let result = await task.value
switch result {
case .success(let user):
print("Fetched user: \(user.name) - \(user.email)")
case .failure(let error):
print("Failed to fetch user: \(error.localizedDescription)")
}
}
}

Debugging Sendable Conformance Issues

When you encounter Sendable conformance errors, the compiler usually provides helpful messages. Here are common issues and how to fix them:

Class with Mutable Properties

swift
// ❌ Error: Class 'MutableUser' cannot conform to 'Sendable' because it has mutable state
class MutableUser: Sendable {
var name: String

init(name: String) {
self.name = name
}
}

// ✅ Fixed: Use an actor instead for mutable shared state
actor UserActor {
var name: String

init(name: String) {
self.name = name
}

func updateName(_ newName: String) {
name = newName
}
}

Non-Final Classes

swift
// ❌ Error: Class 'BaseUser' cannot conform to 'Sendable' because it's not final
class BaseUser: Sendable {
let id: UUID

init(id: UUID = UUID()) {
self.id = id
}
}

// ✅ Fixed: Mark the class as final
final class FinalUser: Sendable {
let id: UUID

init(id: UUID = UUID()) {
self.id = id
}
}

Using @unchecked Sendable Correctly

swift
// ❌ Unsafe: Don't use @unchecked without proper synchronization
final class UnsafeCache: @unchecked Sendable {
var data: [String: Any] = [:] // No synchronization!
}

// ✅ Safe: Use proper synchronization with @unchecked Sendable
final class SafeCache: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.example.cache", attributes: .concurrent)
private var data: [String: Any] = [:]

func setValue(_ value: Any, forKey key: String) {
queue.async(flags: .barrier) { [self] in
self.data[key] = value
}
}

func getValue(forKey key: String) -> Any? {
var result: Any?
queue.sync {
result = self.data[key]
}
return result
}
}

Summary

The Sendable protocol is a powerful tool in Swift's concurrency model that helps prevent data races by ensuring types can be safely transferred across concurrency boundaries. Key points to remember:

  1. Sendable is a marker protocol that indicates a type is safe to use across different tasks or actors
  2. Value types (structs and enums) are often automatically Sendable if their properties are Sendable
  3. Classes need special care - they should be final and immutable to be Sendable
  4. Use @unchecked Sendable when you manually ensure thread safety with locks or other synchronization mechanisms
  5. Mark closures with @Sendable when they cross concurrency boundaries

By leveraging the Sendable protocol, you can write safer concurrent code that's less prone to subtle threading bugs, making your Swift applications more reliable and maintainable.

Further Resources

Exercises

  1. Create a Sendable struct called GameScore with properties for player name and points.
  2. Try to make a class Player conform to Sendable and fix any compiler errors.
  3. Write an actor that manages a leaderboard of GameScore objects.
  4. Create a function that accepts a @Sendable closure to process player scores in the background.
  5. Experiment with @unchecked Sendable by creating a thread-safe wrapper around a mutable dictionary.


If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)