When working with Swift concurrency, developers often encounter compiler warnings about "sending non-Sendable types across actor boundaries." These warnings aren't just noise—they're preventing serious runtime crashes and data corruption. Understanding Sendable is crucial for building reliable concurrent iOS applications.
When you mark something as Sendable, you're telling Swift:
This is safe to send from one place to another without special precautions.
The ability to safely "send" data across concurrency boundaries.
The Core Problem Sendable Solves
Modern iOS apps need to perform multiple operations simultaneously: downloading data, updating the UI, processing images, and handling user interactions. Without proper synchronization, these concurrent operations can interfere with each other, leading to unpredictable behavior.
Consider this seemingly innocent code:
class ShoppingCart { var items: [Product] = [] var totalPrice: Double = 0.0 func addItem(_ product: Product) { items.append(product) totalPrice += product.price } func removeItem(at index: Int) { let removedProduct = items.remove(at: index) totalPrice -= removedProduct.price } } // Multiple parts of the app accessing the same cart let cart = ShoppingCart() // Background task adding items Task { cart.addItem(Product(name: "iPhone", price: 999.0)) } // Another task removing items Task { if !cart.items.isEmpty { cart.removeItem(at: 0) // Potential crash! } } // Main thread displaying total print("Total: $\(cart.totalPrice)") // Unpredictable value!
This code contains multiple data races. The items
array and totalPrice
property are being modified from different concurrent contexts simultaneously, which can result in:
- Array index out of bounds crashes
- Incorrect total calculations
- Memory corruption
- Unpredictable app behavior
Understanding the Sendable Concept
Sendable
is a protocol that marks types as safe to transfer across concurrency boundaries. When a type conforms to Sendable, the compiler guarantees it can be safely shared between different actors, tasks, or threads without causing data races.
The key insight is that not all data is safe to share concurrently. Sendable helps distinguish between:
- Safe data: Can be shared without synchronization
- Unsafe data: Requires careful handling to prevent races
Think of Sendable as a safety certification that tells the compiler: "This type won't cause problems when used concurrently."
Types That Are Automatically Sendable
Swift automatically considers certain types as Sendable because they're inherently thread-safe:
Value Types with Sendable Properties
// Automatically Sendable - all properties are immutable and Sendable struct User: Sendable { let id: UUID let name: String let email: String let registrationDate: Date } // Also automatically Sendable struct APIResponse: Sendable { let statusCode: Int let data: Data let timestamp: Date }
Enums with Sendable Associated Values
enum NetworkResult: Sendable { case success(Data) case failure(NetworkError) case loading } enum UserAction: Sendable { case login(username: String, password: String) case logout case updateProfile(User) }
Built-in Types
Most of Swift's fundamental types are Sendable:
-
Int
,Double
,Bool
,String
-
Array<T>
whereT
is Sendable -
Dictionary<K, V>
where bothK
andV
are Sendable -
Optional<T>
whereT
is Sendable
Making Custom Types Sendable
Structs and Enums
For value types, ensuring Sendable conformance is straightforward—all stored properties must be Sendable:
// ✅ Valid Sendable struct struct BlogPost: Sendable { let title: String let content: String let author: User // User must also be Sendable let publishDate: Date let tags: [String] } // ❌ Invalid - contains non-Sendable property struct InvalidPost { let title: String let databaseConnection: DatabaseManager // Class, not Sendable } // ✅ Fixed version struct ValidPost: Sendable { let title: String let databaseConnectionID: String // Store identifier instead }
Classes - The Complex Case
Classes are reference types, making them inherently more dangerous for concurrent access. There are several strategies to make classes Sendable:
Immutable Classes
final class Configuration: Sendable { let apiKey: String let baseURL: URL let timeout: TimeInterval let maxRetries: Int init(apiKey: String, baseURL: URL, timeout: TimeInterval, maxRetries: Int) { self.apiKey = apiKey self.baseURL = baseURL self.timeout = timeout self.maxRetries = maxRetries } }
Thread-Safe Classes with Internal Synchronization
final class AtomicCounter: Sendable { private let lock = NSLock() private var _value: Int = 0 var value: Int { lock.withLock { _value } } func increment() { lock.withLock { _value += 1 } } func decrement() { lock.withLock { _value -= 1 } } } extension NSLock { func withLock<T>(_ body: () throws -> T) rethrows -> T { lock() defer { unlock() } return try body() } }
Actors: The Natural Sendable Solution
Actors provide built-in thread safety and are automatically Sendable:
actor DataCache { private var cache: [String: Any] = [:] private var lastUpdated: [String: Date] = [:] func store(key: String, value: Any) { cache[key] = value lastUpdated[key] = Date() } func retrieve(key: String) -> Any? { return cache[key] } func clearExpired(olderThan timeInterval: TimeInterval) { let cutoffDate = Date().addingTimeInterval(-timeInterval) let expiredKeys = lastUpdated.compactMap { key, date in date < cutoffDate ? key : nil } for key in expiredKeys { cache.removeValue(forKey: key) lastUpdated.removeValue(forKey: key) } } }
Function Parameters and Sendable
Functions that work with concurrent code should specify Sendable requirements:
// Function parameter must be Sendable func processInBackground<T: Sendable>(_ data: T) async -> ProcessedResult { // Safe to use 'data' across await boundaries await someAsyncOperation() return ProcessedResult(from: data) } // Closure must be Sendable for concurrent execution func executeAsync( operation: @Sendable @escaping () async throws -> Void ) { Task { try await operation() } } // Generic constraint ensures type safety func cacheResult<T: Sendable & Codable>( key: String, value: T ) async { let cache = DataCache() await cache.store(key: key, value: value) }
Common Pitfalls and Solutions
Mutable Properties in Structs
// ❌ Problematic - mutable properties can cause issues struct ProblemUser { var name: String // Mutable property let id: UUID } // ✅ Better - immutable properties struct SafeUser: Sendable { let name: String let id: UUID } // ✅ Alternative - functional updates extension SafeUser { func withName(_ newName: String) -> SafeUser { SafeUser(name: newName, id: id) } }
Collections of Non-Sendable Types
// ❌ Array of non-Sendable elements class DatabaseConnection { func execute(_ query: String) { /* ... */ } } let connections: [DatabaseConnection] = [] // Not Sendable // ✅ Use Sendable identifiers instead struct ConnectionID: Sendable { let id: UUID } let connectionIDs: [ConnectionID] = [] // Sendable // ✅ Or use actor for managing connections actor ConnectionPool { private var connections: [UUID: DatabaseConnection] = [:] func getConnection(id: UUID) -> DatabaseConnection? { return connections[id] } }
Closures Capturing Non-Sendable Values
class NonSendableService { func performOperation() { /* ... */ } } let service = NonSendableService() // ❌ Capturing non-Sendable value Task { service.performOperation() // Compiler warning } // ✅ Extract Sendable data first let operationData = service.extractSendableData() Task { await processData(operationData) } // ✅ Or use actor to wrap the service actor ServiceWrapper { private let service = NonSendableService() func performOperation() { service.performOperation() } }
Simple Practical Examples
Here are some concise examples showing proper Sendable usage:
// Basic API request model struct APIRequest: Sendable { let url: URL let method: String let headers: [String: String] } // Simple data cache using actor actor DataCache { private var cache: [String: Data] = [:] func store(key: String, data: Data) { cache[key] = data } func retrieve(key: String) -> Data? { return cache[key] } } // Sendable result type enum Result<T: Sendable>: Sendable { case success(T) case failure(Error) }
Debugging Sendable Issues
When encountering Sendable-related compiler errors, follow this systematic approach:
1. Identify the Non-Sendable Type
// Compiler error: "Cannot pass argument of non-Sendable type..." struct MyStruct { let manager: DatabaseManager // This is the problem } // Fix: Replace with Sendable alternative struct MyStruct: Sendable { let managerID: String // Sendable identifier }
2. Check Property Mutability
// Problem: Mutable properties struct Settings { var theme: String // Mutable var notifications: Bool // Mutable } // Solution: Make immutable or use functional updates struct Settings: Sendable { let theme: String let notifications: Bool func withTheme(_ newTheme: String) -> Settings { Settings(theme: newTheme, notifications: notifications) } }
3. Verify Generic Constraints
// Problem: Generic type without Sendable constraint func process<T>(_ items: [T]) async { // Compiler error if T is not Sendable await doSomethingWith(items) } // Solution: Add Sendable constraint func process<T: Sendable>(_ items: [T]) async { await doSomethingWith(items) }
Decision Framework
When working with Sendable, use this decision framework:
-
Is it a value type (struct/enum)?
- Ensure all properties are Sendable
- Prefer immutable properties
- Consider functional update patterns
-
Is it a reference type (class)?
- Can it be made immutable? → Make it immutable
- Does it need mutation? → Consider using an actor instead
- Must it be a class? → Add proper synchronization
-
Does it cross concurrency boundaries?
- Yes → Must be Sendable
- No → Sendable not required (but still beneficial)
-
Are there compiler warnings?
- Address them immediately
- Don't suppress with @unchecked unless absolutely necessary
- Consider architectural changes
Performance Considerations
Sendable types can impact performance in various ways:
Value Type Copying
// Large structs may have copying overhead struct LargeDataSet: Sendable { let values: [Double] // Could be thousands of elements } // Consider using reference types with proper synchronization for large data actor LargeDataManager { private let values: [Double] init(values: [Double]) { self.values = values } func getValue(at index: Int) -> Double? { guard index < values.count else { return nil } return values[index] } }
Synchronization Overhead
// Fine for occasional access final class SynchronizedCounter: Sendable { private let lock = NSLock() private var _value: Int = 0 var value: Int { lock.withLock { _value } } } // Better for frequent access actor CounterActor { private var value: Int = 0 func getValue() -> Int { return value } func increment() { value += 1 } }
Advanced Patterns
Sendable Wrappers
// Wrapper for non-Sendable types struct SendableWrapper<T>: Sendable where T: Sendable { let value: T init(_ value: T) { self.value = value } } // Atomic wrapper for simple values @propertyWrapper struct Atomic<T: Sendable>: Sendable { private let lock = NSLock() private var _value: T init(wrappedValue: T) { _value = wrappedValue } var wrappedValue: T { get { lock.withLock { _value } } set { lock.withLock { _value = newValue } } } } // Usage actor SettingsManager { @Atomic private var isDarkMode: Bool = false @Atomic private var notificationsEnabled: Bool = true func toggleDarkMode() { isDarkMode.toggle() } }
Protocol Extensions for Sendable
protocol SendableIdentifiable: Sendable, Identifiable where ID: Sendable {} extension SendableIdentifiable { func isSame(as other: Self) -> Bool { return self.id == other.id } } // Usage struct Product: SendableIdentifiable { let id: UUID let name: String let price: Double }
Understanding Sendable is essential for building robust, concurrent iOS applications. The protocol serves as a compiler-enforced safety net that prevents data races and ensures thread-safe data sharing. While it may seem complex initially, the patterns and principles outlined here provide a solid foundation for working confidently with Swift's concurrency features.
Top comments (0)