Swift Sendable Protocol
Introduction
When working with concurrent Swift code, sharing data between tasks can lead to data races – one of the most common and challenging bugs in concurrent programming. Swift's Sendable
protocol, introduced as part of the Swift Concurrency model, helps prevent these data races by ensuring values can be safely transferred across concurrency boundaries.
In this guide, we'll explore what the Sendable
protocol is, why it's important, and how to use it effectively in your Swift applications.
What is the Sendable Protocol?
Sendable
is a marker protocol in Swift that indicates types that can be safely passed between different actors or threads without creating data races. Think of it as a "thread-safe transfer" certificate for your data.
public protocol Sendable {
// This is a marker protocol with no requirements
}
When you mark a type as Sendable
, you're telling the Swift compiler that instances of this type can be used concurrently without data races. The compiler will then verify this claim and warn you if it detects potential issues.
Why Do We Need Sendable?
Consider this scenario:
// Without Sendable
class UserData {
var name: String
var score: Int
init(name: String, score: Int) {
self.name = name
self.score = score
}
}
func processUserConcurrently() async {
let user = UserData(name: "Taylor", score: 100)
// This could lead to a data race!
async let task1 = {
user.score += 10
}()
async let task2 = {
user.name = "Taylor Swift"
}()
// Wait for both tasks
await task1
await task2
// What is user.score and user.name now?
}
In this example, we have two tasks potentially modifying the UserData
instance simultaneously. This is a classic data race situation that could lead to unexpected results or even app crashes.
The Sendable
protocol helps prevent these issues by making the compiler warn us when we try to pass non-thread-safe types across concurrency boundaries.
Making Types Sendable
Value Types (Structs and Enums)
Many value types in Swift are automatically Sendable
when all their stored properties are also Sendable
:
struct UserScore: Sendable {
let userId: String // String is Sendable
let score: Int // Int is Sendable
// This struct is automatically Sendable!
}
Reference Types (Classes)
Classes are trickier because they can be mutated from multiple places. To make a class Sendable
, you need to ensure it's immutable or properly synchronized:
// Option 1: Make the class final and immutable
final class ImmutableUser: Sendable {
let name: String
let id: UUID
init(name: String, id: UUID = UUID()) {
self.name = name
self.id = id
}
// No mutable properties or methods
}
// Option 2: Use @unchecked Sendable when you manually ensure thread safety
final class ThreadSafeCache: @unchecked Sendable {
private let lock = NSLock()
private var storage: [String: Any] = [:]
func setValue(_ value: Any, forKey key: String) {
lock.lock()
defer { lock.unlock() }
storage[key] = value
}
func getValue(forKey key: String) -> Any? {
lock.lock()
defer { lock.unlock() }
return storage[key]
}
}
Using @Sendable for Functions
Functions that are passed across concurrency boundaries should also be marked as @Sendable
:
func processUser(handler: @Sendable (User) -> Void) {
Task {
let user = fetchUser()
handler(user) // This function will run on a different thread
}
}
// Using the function
processUser { user in
// This closure must follow Sendable rules
print("Processing user: \(user.name)")
}
Common Sendable Types in Swift
Many Swift standard library types are already Sendable
:
- Value types like
Int
,String
,Bool
,Double
, etc. - Collections of
Sendable
types:Array<Element>
,Dictionary<Key, Value>
,Set<Element>
(whereElement
,Key
, andValue
areSendable
) - Optionals of
Sendable
types - Tuples whose elements are all
Sendable
- Actor types (since they have their own synchronization)
Practical Example: Building a Thread-Safe Counter
Let's build a thread-safe counter application using the Sendable
protocol:
// First, create a sendable counter message
enum CounterAction: Sendable {
case increment
case decrement
case reset
}
// Create an actor to manage our counter
actor CounterManager {
private var count: Int = 0
func perform(action: CounterAction) -> Int {
switch action {
case .increment:
count += 1
case .decrement:
count -= 1
case .reset:
count = 0
}
return count
}
func currentCount() -> Int {
return count
}
}
// Using the counter across tasks
func counterExample() async {
let counter = CounterManager()
// These tasks can run concurrently without data races
async let increment1 = counter.perform(action: .increment)
async let increment2 = counter.perform(action: .increment)
async let increment3 = counter.perform(action: .increment)
let results = await [increment1, increment2, increment3]
print("Counter results: \(results)")
print("Final count: \(await counter.currentCount())")
// Output:
// Counter results: [1, 2, 3]
// Final count: 3
}
Real-World Application: Thread-Safe Data Service
Here's a more practical example of using Sendable
in a real-world application context:
// A User model that can be passed across concurrency boundaries
struct User: Sendable {
let id: UUID
let name: String
let email: String
}
// Network request result
enum FetchResult<T: Sendable>: Sendable {
case success(T)
case failure(Error)
}
// Make Error Sendable
extension Error {
// Most errors conform to Sendable
}
// A data service using async/await and Sendable
actor UserDataService {
private var cache: [UUID: User] = [:]
func fetchUser(id: UUID) async -> FetchResult<User> {
// Check cache first
if let cachedUser = cache[id] {
return .success(cachedUser)
}
// Simulate network request
do {
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
// Create a user
let user = User(
id: id,
name: "User \(id.uuidString.prefix(4))",
email: "user\(id.uuidString.prefix(4))@example.com"
)
// Update cache
cache[id] = user
return .success(user)
} catch {
return .failure(error)
}
}
func updateUserCache(with users: [User]) {
for user in users {
cache[user.id] = user
}
}
}
// Example usage
func fetchMultipleUsersExample() async {
let service = UserDataService()
let userIds = [UUID(), UUID(), UUID()]
// Create multiple concurrent fetch tasks
var fetchTasks: [Task<FetchResult<User>, Never>] = []
for id in userIds {
let task = Task {
await service.fetchUser(id: id)
}
fetchTasks.append(task)
}
// Wait for all fetches to complete and process results
for task in fetchTasks {
let result = await task.value
switch result {
case .success(let user):
print("Fetched user: \(user.name) - \(user.email)")
case .failure(let error):
print("Failed to fetch user: \(error.localizedDescription)")
}
}
}
Debugging Sendable Conformance Issues
When you encounter Sendable conformance errors, the compiler usually provides helpful messages. Here are common issues and how to fix them:
Class with Mutable Properties
// ❌ Error: Class 'MutableUser' cannot conform to 'Sendable' because it has mutable state
class MutableUser: Sendable {
var name: String
init(name: String) {
self.name = name
}
}
// ✅ Fixed: Use an actor instead for mutable shared state
actor UserActor {
var name: String
init(name: String) {
self.name = name
}
func updateName(_ newName: String) {
name = newName
}
}
Non-Final Classes
// ❌ Error: Class 'BaseUser' cannot conform to 'Sendable' because it's not final
class BaseUser: Sendable {
let id: UUID
init(id: UUID = UUID()) {
self.id = id
}
}
// ✅ Fixed: Mark the class as final
final class FinalUser: Sendable {
let id: UUID
init(id: UUID = UUID()) {
self.id = id
}
}
Using @unchecked Sendable Correctly
// ❌ Unsafe: Don't use @unchecked without proper synchronization
final class UnsafeCache: @unchecked Sendable {
var data: [String: Any] = [:] // No synchronization!
}
// ✅ Safe: Use proper synchronization with @unchecked Sendable
final class SafeCache: @unchecked Sendable {
private let queue = DispatchQueue(label: "com.example.cache", attributes: .concurrent)
private var data: [String: Any] = [:]
func setValue(_ value: Any, forKey key: String) {
queue.async(flags: .barrier) { [self] in
self.data[key] = value
}
}
func getValue(forKey key: String) -> Any? {
var result: Any?
queue.sync {
result = self.data[key]
}
return result
}
}
Summary
The Sendable
protocol is a powerful tool in Swift's concurrency model that helps prevent data races by ensuring types can be safely transferred across concurrency boundaries. Key points to remember:
Sendable
is a marker protocol that indicates a type is safe to use across different tasks or actors- Value types (structs and enums) are often automatically
Sendable
if their properties areSendable
- Classes need special care - they should be final and immutable to be
Sendable
- Use
@unchecked Sendable
when you manually ensure thread safety with locks or other synchronization mechanisms - Mark closures with
@Sendable
when they cross concurrency boundaries
By leveraging the Sendable
protocol, you can write safer concurrent code that's less prone to subtle threading bugs, making your Swift applications more reliable and maintainable.
Further Resources
- Swift Documentation: Sendable Protocol
- WWDC 2021 - Protect mutable state with Swift actors
- Swift Evolution Proposal: SE-0302 Sendable and @Sendable closures
Exercises
- Create a
Sendable
struct calledGameScore
with properties for player name and points. - Try to make a class
Player
conform toSendable
and fix any compiler errors. - Write an actor that manages a leaderboard of
GameScore
objects. - Create a function that accepts a
@Sendable
closure to process player scores in the background. - Experiment with
@unchecked Sendable
by creating a thread-safe wrapper around a mutable dictionary.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)