DEV Community

ArshTechPro
ArshTechPro

Posted on

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! 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

Built-in Types

Most of Swift's fundamental types are Sendable:

  • Int, Double, Bool, String
  • Array<T> where T is Sendable
  • Dictionary<K, V> where both K and V are Sendable
  • Optional<T> where T 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 } 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

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) } } } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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] } } 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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) } } 
Enter fullscreen mode Exit fullscreen mode

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) } 
Enter fullscreen mode Exit fullscreen mode

Decision Framework

When working with Sendable, use this decision framework:

  1. Is it a value type (struct/enum)?

    • Ensure all properties are Sendable
    • Prefer immutable properties
    • Consider functional update patterns
  2. 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
  3. Does it cross concurrency boundaries?

    • Yes → Must be Sendable
    • No → Sendable not required (but still beneficial)
  4. 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] } } 
Enter fullscreen mode Exit fullscreen mode

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 } } 
Enter fullscreen mode Exit fullscreen mode

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() } } 
Enter fullscreen mode Exit fullscreen mode

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 } 
Enter fullscreen mode Exit fullscreen mode

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)