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> 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
}

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:

  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]
    }
}

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.

Similar Posts