Swift Sendable: Mastering Thread Safety in iOS Development
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.