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.
// 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:
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:
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:
@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:
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
:
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:
func getSendableClosure(perform: @escaping @Sendable () -> Void) {}
The compiler will check closure safety at compile time:
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:
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:
// 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:
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
tosending
)
@Sendable
is about type-safely “declaring thread safety,” whilesending
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:
@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)
:
@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 |
---|---|---|---|---|
Sendable | Type marking | Cross-isolation domain type passing | ✅ Compiler verifies type thread-safety | ✅ Automatic or explicit declaration |
@unchecked Sendable | Skip type verification | Legacy code, manually ensured thread safety | ⚠️ Bypasses checks, fully relies on developer | ⚠️ Developer guaranteed |
@Sendable | Closure attribute | Cross-isolation domain closure passing | ✅ Compiler checks if captured values are Sendable | ✅ Automatic checking |
sending | Parameter modifier | Ownership transfer, builder pattern | 🚫 Doesn’t check Sendable, but prevents reuse | 🚫 Ownership semantics (prevents misuse) |
nonsending | Isolation inheritance | Maintain 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.