Safe Data Sharing with Sendable
In Swift's concurrency environment, multiple tasks can execute simultaneously, which means we need to ensure that data is safely shared across different threads. To address this, Swift introduced the Sendable protocol.
In this post, we'll dive deep into the concept of Sendable, how to use it, and practical ways to apply it in the real world.
What is Sendable?
Sendable is a protocol in Swift that guarantees whether data can be safely passed between tasks.
In Swift, data race issues can occur when objects are shared across multiple tasks in asynchronous code. Data races often lead to unpredictable bugs and can severely compromise the stability of your program. By utilizing Sendable, you can prevent this because it helps verify data safety at compile time.
Example of Applying Sendable
struct SafeCounter: Sendable {
let value: Int
}By applying Sendable like above, the SafeCounter struct can be safely shared across multiple tasks. All value types in Swift (struct, enum) implicitly conform to Sendable by default. However, classes (class) are a different story.
Applying Sendable to Classes
In Swift, an actor is a reference type similar to a class. However, an actor automatically guarantees concurrency safety, and you must always use await when accessing its internal properties.
🔹 class Is Unsafe
class Counter {
var value: Int = 0
func increment() {
value += 1
}
}In this code, if multiple tasks call increment() concurrently on Counter, data collisions can occur. Therefore, you can't simply apply Sendable in this scenario; developers must handle synchronization manually.
final class SafeClass: Sendable {
let value: Int
init(value: Int) {
self.value = value
}
}By using the final keyword and declaring internal properties with let, your class can conform to Sendable. However, since the values inside the class cannot be mutated, this approach only applies to immutable objects.
🔹 Using Mutable Sendable Classes
If you need to mutate values inside a class, you can consider the following approaches:
1️⃣ Use an actor (The safest method)
actor SafeCounter {
private var value: Int = 0
func increment() {
value += 1
}
func getValue() -> Int {
return value
}
}✅ An actor automatically serializes internal access, preventing data races.
Differences Between actor and class
In Swift, a
classis a reference type, which runs the risk of data races when sharing the same object in a multi-threaded environment. In contrast, anactoris also a reference type like aclass, but it internally serializes concurrent access to guarantee safety.
2️⃣ Use NSLock or DispatchQueue.sync
final class NSLockCounter: Sendable {
private let lock = NSLock()
private var value: Int = 0
func increment() {
lock.lock()
value += 1
lock.unlock()
}
func getValue() -> Int {
lock.lock()
let result = value
lock.unlock()
return result
}
}
final class DispatchQueueCounter: Sendable {
private let queue = DispatchQueue(label: "com.example.counterQueue")
private var value: Int = 0
func increment() {
queue.sync {
value += 1
}
}
func getValue() -> Int {
queue.sync {
return value
}
}
}✅ You can safely mutate values by handling synchronization using NSLock or DispatchQueue.sync.
3️⃣ Manually guarantee safety using @unchecked Sendable
final class UnsafeCounter: @unchecked Sendable {
private var value: Int = 0
func increment() {
value += 1 // 데이터 레이스 발생 가능!
}
}❌ When using @unchecked Sendable, the compiler does not check for safety, so the developer must manually handle synchronization.
Among these methods, using an actor is highly recommended. Leveraging NSLock is also a viable option when you need to make existing classes that require synchronization conform to Sendable.
static Properties and Sendable
static properties are shared data belonging to a class or struct, meaning there's a high chance they will be accessed concurrently by multiple threads. Because of this, it's crucial to verify whether your static properties guarantee concurrency safety.
When static Properties Conform to Sendable
Basically, static properties declared with let inherently conform to Sendable because they cannot be mutated.
struct SafeConfig: Sendable {
static let apiEndpoint = "https://api.example.com"
}In this case, apiEndpoint is an immutable value, making it perfectly safe to share across multiple tasks.
Existing Singleton Patterns and actor
For code that relies on the traditional Singleton pattern, you can guarantee concurrency safety by declaring it as a static let shared inside an actor.
Traditional Singleton Pattern (Unsafe)
final class Logger {
static let shared = Logger()
private init() { }
func log(_ message: String) {
print("[LOG]: \(message)")
}
}However, in the code above, safety cannot be guaranteed if the log(_:) method is called simultaneously in a multi-threaded environment.
Safe Singletons Using actor
actor Logger {
static let shared = Logger()
private init() { }
func log(_ message: String) {
print("[LOG]: \(message)")
}
}By doing this:
When calling
Logger.shared.log(_:), concurrent access is automatically serialized.It is safe even without synchronization mechanisms like the traditional
DispatchQueue.syncorNSLock.You get code optimized for Swift Concurrency while keeping the Singleton pattern intact.
(Of course, eliminating singletons altogether would be ideal, but sometimes it's just unavoidable...)
Utilizing @unchecked Sendable
@unchecked Sendable is a keyword that tells Swift not to automatically check the concurrency safety of a specific class. In other words, the developer must manually guarantee that the object will operate safely. It's a way to bypass Swift's Sendable conformance checks, but it should be used with extreme caution.
final class UnsafeClass: @unchecked Sendable {
var value: Int = 0
}However, when using @unchecked Sendable, the compiler no longer guarantees concurrency safety, so you must manually review the code for any potential data races.
Why You Should Be Careful With @unchecked Sendable
Potential for data races: Since the Swift compiler doesn't check for safety, developers must guarantee thread safety themselves.
Debugging concurrency errors gets hard: As the codebase grows, tracking down data race conditions can become incredibly difficult.
Consider safe alternatives: Whenever possible, you should look into using an
actoror explicitSendableconformance.
How to Access and Call Sendable Objects Externally
1️⃣ Accessing Standard Sendable Objects
In Swift, objects that conform to Sendable (e.g., value types) can be safely shared across multiple tasks.
struct SafeCounter: Sendable {
let value: Int
}
let counter = SafeCounter(value: 10)
Task {
print(counter.value) // 여러 Task에서 안전하게 접근 가능
}✅ Value types (struct, enum) implicitly conform to Sendable, so there's no issue.
❌ However, reference types (class) do not conform to Sendable, so you need to be careful.
2️⃣ Calling an actor Externally (Requires await)
An actor automatically guarantees concurrency safety, but you must use await when accessing it from the outside.
actor SafeCounter {
var value: Int = 0
func increment() {
value += 1
}
}
let counter = SafeCounter()
Task {
await counter.increment() // ✅ 반드시 await 필요
print(await counter.value) // ✅ actor 내부 속성 접근 시에도 await 필요
}✅ The properties and methods inside an actor are automatically serialized for safety.
❌ But when calling them externally, you must always prepend await.
3️⃣ Accessing @unchecked Sendable Objects Externally
Using @unchecked Sendable means the compiler bypasses concurrency safety checks.
Therefore, when accessing it from the outside, the developer must manually guarantee its safety.
final class UnsafeCounter: @unchecked Sendable {
var value: Int = 0
func increment() {
value += 1
}
}
let counter = UnsafeCounter()
Task {
counter.increment() // ❌ 데이터 레이스 발생 가능 (안전하지 않음)
print(counter.value)
}❌ Even if you apply @unchecked Sendable, data races can still occur if accessed concurrently in a multi-threaded environment.
✅ You must manually add a synchronization mechanism like NSLock to make it safe.
final class SafeCounter: @unchecked Sendable {
private let lock = NSLock()
private var value: Int = 0
func increment() {
lock.lock()
value += 1
lock.unlock()
}
}✅ Utilizing synchronization mechanisms (NSLock, DispatchQueue.sync) is necessary for safety.
When Should I Use Sendable?
When sharing objects between tasks in an asynchronous function → Value types (
struct) are inherently safe, but classes (class) must conform toSendableor utilize anactor.When data integrity is critical in a multi-threaded environment → Applying
Sendableincreases the reliability of your code.When you encounter
Sendablewarnings → These warnings usually mean there's a high chance of data races, so they need to be addressed.When integrating with third-party asynchronous libraries → Conforming to
Sendablehelps guarantee safe behavior.
Conclusion
Swift's Sendable is a crucial tool for preventing data races in a Concurrency environment. Value types conform to Sendable by default, but classes must explicitly conform to it. Utilizing an actor guarantees safety by default, and you can also fall back on @unchecked Sendable when necessary.
It's vital to actively utilize Sendable to implement safe concurrent programming. I hope you'll now start applying Sendable to your own code and write robust Swift code free of data races!