Swift 6: Sendable, @unchecked Sendable, @Sendable, sending and nonsending

Published on

Swift’s concurrency model introduces numerous keywords, some of which are similar in naming and purpose, often causing confusion among developers. This article examines several keywords related to cross-isolation domain passing in Swift concurrency: Sendable, @unchecked Sendable, @Sendable, sending, and nonsending, helping you understand their respective roles and use cases.

Isolation Domains

Before diving into these keywords, we need to understand the concept of isolation domains in Swift’s concurrency model.

Data races have always been a thorny issue in concurrent programming, where a single misstep can lead to data corruption or even application crashes. To fundamentally solve this problem, Swift introduced the concept of isolation domains when building its new concurrency model. Developers can declare an isolation domain using actor, @MainActor, or other custom Global Actors. Each isolation domain instance maintains and manages a serial queue through its Executor, ensuring that only one task can access the protected state at any given time, thereby transforming data races from runtime errors into compile-time errors.

With clearly defined isolation domain boundaries, developers need to add specific annotations to data types or receivers, enabling the compiler to verify whether data can be safely passed between different isolation domains. This gave rise to a series of keywords and protocols containing “Send” semantics.

Sendable

Value types are widely used in Swift, and their copy semantics naturally suit passing between different isolation domains. However, since custom types may contain members that cannot be safely passed, we can add the Sendable annotation to let the compiler help verify their safety. Although Sendable appears as a protocol, it’s essentially a compile-time contract declaring that a type can be safely passed across isolation domains, with the compiler verifying the truthfulness of this declaration.

Swift
// Sendable is a marker protocol
protocol Sendable { }

// It tells the compiler: "This type is safe to pass across isolation domains"

For types that obviously conform to Sendable characteristics, the compiler will automatically infer conformance. Even without explicitly adding the Sendable annotation, the compiler will still ensure they meet the requirements for safe cross-isolation domain passing:

Swift
func getSendable<T: Sendable>(value: T) {}

struct People { // Value type
    var name: String // All properties are Sendable
    var age: Int
}

let people = People(name: "fat", age: 10)
getSendable(value: people) // ✅ Automatically inferred as Sendable

Note that in Swift 6, certain scenarios that previously supported automatic inference are no longer supported:

Swift
final class NoNeedMarkSendable {
    let name = "fat"
}

let a = NoNeedMarkSendable()
getSendable(value: a) // ✅ Before Swift 6 or without strict concurrency checking
getSendable(value: a) // ❌ Swift 6: Type 'NoNeedMarkSendable' does not conform to the 'Sendable' protocol

Therefore, to ensure code robustness, developers are advised to explicitly add the Sendable annotation.

Additionally, if a type has an explicit isolation domain (such as an actor type or a class marked with @MainActor), it’s automatically considered Sendable because its internal state is already protected by Swift’s concurrency model:

Swift
@MainActor
final class NoNeedMarkSendable {
    let name = "fat"
}

Task { @MainActor in
    let a = NoNeedMarkSendable()
    getSendable(value: a) // ✅ @MainActor types are automatically Sendable
}

Sendable is commonly used as a generic constraint, ensuring at compile time that only safely passable types are accepted.

@unchecked Sendable

In certain scenarios, particularly when dealing with legacy code, we may have already ensured thread safety through other mechanisms (such as locks or queues), but the compiler cannot understand these implementations. In such cases, developers can use @unchecked Sendable to guarantee the type’s safety to the compiler, thereby bypassing automatic compiler checks:

Swift
final class ThreadSafeCache: @unchecked Sendable {
    private var cache: [String: Sendable] = [:]
    // Although there's mutable state, thread safety is ensured through queues
    private let queue = DispatchQueue(label: "cache", attributes: .concurrent)
    
    func get(_ key: String) -> Sendable? {
        queue.sync {
            cache[key]
        }
    }
    
    func set(_ key: String, value: Sendable) {
        queue.async(flags: .barrier) {
            self.cache[key] = value
        }
    }
}

Be cautious that some developers might abuse @unchecked Sendable just to make their code compile, which defeats the purpose of strict concurrency checking.

Starting with Swift 6, the Mutex type provided by the Synchronization framework enables true Sendable implementation without relying on @unchecked:

Swift
import Synchronization

final class ThreadSafeCache: Sendable {
    private let cache = Mutex<[String: Sendable]>([:])

    func get(_ key: String) -> Sendable? {
        cache.withLock {
            $0[key]
        }
    }

    func set(_ key: String, value: Sendable) {
        cache.withLock {
            $0[key] = value
        }
    }
}

@Sendable

Since the Sendable protocol can only be used for type declarations, Swift provides a dedicated attribute @Sendable for closures. It indicates that a closure can be safely passed across isolation domains:

Swift
func getSendableClosure(perform: @escaping @Sendable () -> Void) {}

The compiler will check closure safety at compile time:

Swift
final class NoNeedMarkSendable {
    let name = "fat"
}

let a = NoNeedMarkSendable()
getSendableClosure(perform: { a }) // ❌ Capture of 'a' with non-Sendable type 'NoNeedMarkSendable' in a '@Sendable' closure

getSendableClosure(perform: {
    let a = NoNeedMarkSendable() // ✅ Created inside the closure, no cross-domain passing
})

sending

The restrictions of @Sendable closures can sometimes be overly strict. Consider the following scenario:

Swift
Task {
    let nonSendable = NonSendableClass()
    getSendableClosure {
        nonSendable.value += 1 // Capture of 'nonSendable' with non-Sendable type 'NonSendableClass' in a '@Sendable' closure
    }
    // This is actually safe since nonSendable won't be modified elsewhere
}

Swift 6 introduces the sending parameter modifier to address this issue:

Swift
// Original
func getSendableClosure(perform: @escaping @Sendable () -> Void) {}

// New
func getSendableClosure(sending perform: @escaping () -> Void) {}

Task {
    let nonSendable = NonSendableClass()
    getSendableClosure {
        nonSendable.value += 1 // ✅ Works with `sending`
    }
}

However, sending doesn’t bypass Swift compiler checks like @unchecked Sendable does. The compiler will still warn us if unsafe usage patterns appear:

Swift
actor MyActor {
    var storage: NonSendableClass?
    
    // Using sending allows receiving non-Sendable parameters
    func store(_ object: sending NonSendableClass) {
        storage = object
    }
}

Task {
    let obj = NonSendableClass()
    let myActor = MyActor()
    await myActor.store(obj) // ✅ Ownership transferred to actor
    // obj.value += 1 // ❌ 'obj' used after being passed as a 'sending' parameter
}

This is because the core concept of sending is “ownership transfer”. While the compiler no longer checks for Sendable conformance, it does check ownership flow. After transfer, we cannot use the transferred value elsewhere!

Therefore, sending is most suitable for:

  • Migrating non-Sendable legacy code
  • Implementing the builder pattern
  • Task result passing (in Swift 6, Task implementations have changed from @Sendable to sending)

@Sendable is about type-safely “declaring thread safety,” while sending is about ownership semantics of “guaranteeing single use rights.” They are not mutually exclusive but apply to different contexts.

nonsending

nonsending has fundamentally different semantics from the previous keywords. It’s used with nonisolated: nonisolated(nonsending), indicating that an async method should inherit the caller’s isolation domain rather than escaping from the current isolation domain.

This represents a significant adjustment to nonisolated behavior in Swift 6.2. Before Swift 6.2:

Swift
@MainActor
class RunInMainActor {
    var name: String = "example"
    
    nonisolated func processData() async {
        // Before Swift 6.2: Always escapes MainActor (runs on non-main thread)
    }
}

Using nonisolated(nonsending):

Swift
@MainActor
class RunInMainActor {
    var name: String = "example"
    
    nonisolated(nonsending) func processData() async {
        // Swift 6.2: Inherits caller's isolation domain
        // Runs on main thread when called from MainActor
        // Runs on corresponding isolation domain when called from other actors
    }
}

Developers can also enable the NonisolatedNonsendingByDefault flag to make this behavior the default.

For more on nonisolated(nonsending) and Swift 6.2 changes, see Default Actor Isolation: New Problems from Good Intentions

Summary

🔑 Keyword🧭 Purpose🎯 Typical Use Cases🛡️ Compiler Behavior✅ Safety Guarantee
SendableType markingCross-isolation domain type passing✅ Compiler verifies type thread-safety✅ Automatic or explicit declaration
@unchecked SendableSkip type verificationLegacy code, manually ensured thread safety⚠️ Bypasses checks, fully relies on developer⚠️ Developer guaranteed
@SendableClosure attributeCross-isolation domain closure passing✅ Compiler checks if captured values are Sendable✅ Automatic checking
sendingParameter modifierOwnership transfer, builder pattern🚫 Doesn’t check Sendable, but prevents reuse🚫 Ownership semantics (prevents misuse)
nonsendingIsolation inheritanceMaintain caller’s isolation, async adaptation✅ Controls runtime isolation context✅ Behavior determined by caller context

Admittedly, the numerous keywords in Swift’s concurrency model steepen the learning curve. However, understanding these concepts is crucial for writing safe concurrent code. Even if some keywords are rarely used in daily development, knowing their meanings helps us quickly identify and resolve issues when they arise.

Notice: It’s time for my annual summer break ⛱️. Blog posts will be on hiatus for the next 4-5 weeks, while Fatbobman’s Swift Weekly will continue as usual during this period.

If this article helped you, feel free to buy me a coffee ☕️ . For sponsorship inquiries, please check out the details here.

Weekly Swift & SwiftUI highlights!