Welcome to the next module which will focus on anything Sendable. Just like with GCD, objects will be passed around threads. The big difference is that we’re not talking about threads, but isolation domains. When you pass a value or reference type from one async function into another, the value could potentially be transfered between two different isolation domains. For this to be thread-safe, the object needs to be sendable so the compiler can guarantee thread-safetyness. Let’s dive into the details by starting to understand different isolation domains.
What are isolation domains?
In Swift Concurrency, an isolation domain defines a boundary within which a value or reference can be safely accessed without data races. These domains prevent concurrent modifications by ensuring that code executes in a controlled environment. Swift provides three key isolation domains:
1. Nonisolated (Unrestricted Access)
By default, code is non-isolated, meaning it has no enforced concurrency restrictions. This is fine, as not all functions and variables in Swift need to belong to a specific isolation domain. However, due to Swift’s data isolation rules, non-isolated code cannot modify state that belongs to a different isolation domain.
Note
This is currently the default isolation domain, but SE-466 might change this once implemented.
An example of a nonisolated piece of code could look as follows:
func computeValue(a: Int, b: Int) -> Int {
return a + b
}
The function has no shared state and is safe to be called from any thread. It can safely interact with other non-isolated functions and variables, but it cannot access mutable state from an isolated domain, such as an actor. You can explicitly mark methods as nonisolated using the nonisolated keyword:
nonisolated func computeValue(a: Int, b: Int) -> Int {
return a + b
}
However, since it’s currently the default, this would only be needed when defined inside another isolation domain.
2. Actor-isolated
Actors in Swift allow you to create a dedicated isolation domain, ensuring that all stored properties and methods operate within a single, thread-safe environment. This prevents data races and makes concurrent programming more predictable.
When you define an actor, all of its stored properties are actor-isolated, meaning they can only be accessed from within the same actor’s execution context. External access requires asynchronous calls (await), ensuring controlled execution.
An example actor could look as follows:
actor Library {
var books: [String] = []
func addBook(_ title: String) {
books.append(title)
}
func getBookList() -> [String] {
return books
}
}
Note that we’ll have a dedicated module about actors later on, so take this as a short introduction and a promise of much more in-depth explanation.
Every instance of Library forms its own isolation domain, meaning only code inside the actor can modify the books collection directly. In this example, the addBook(_:) method is actor-isolated and can safely modify the books property. Any calls from outside the actor require using the await keyword to ensure thread safety.
let library = Library()
Task {
await library.addBook("Swift Concurrency in Action")
let books = await library.getBookList()
print(books) // Prints: ["Swift Concurrency in Action"]
}
You’re basically awaiting access to the actor-isolated domain.
And, like mentioned before, you can use the nonisolated keyword to manually step out of the isolation context. For example, in case we want to return a static description of the library itself:
actor Library {
var books: [String] = []
func addBook(_ title: String) {
books.append(title)
}
func getBookList() -> [String] {
return books
}
nonisolated func libraryName() -> String {
"A library of books"
}
}
The libraryName() method becomes nonisolated and can now be accessed without await, without async suspension, like a regular function.
3. Global-actor isolated
Global actors work similarly to regular actors, but instead of creating an isolated execution domain for a single instance, they provide a shared isolation domain for multiple types, properties, or functions. This makes them particularly useful when multiple parts of an app need to operate under the same constraints, such as UI updates on the main thread.
For the latter, you can make use of the default available global actor @MainActor. To assign a type or function to a global actor, Swift provides annotations that match the actor name, like @MainActor.
@MainActor
func updateUI() {
print("Updating UI on the main thread")
}
You can define custom global actors if you will, but we’ll cover that in the dedicated module on actors. For now, it’s important to be aware of the three different isolation domains, including global actors.
Passing data between isolation domains
Now that we know that multiple isolation domains exist, the next step is to understand that values can travel between isolation domains. Since Swift Concurrency maps tasks onto threads, it also needs to know that a value can be passed around different threads.
As we’ve learned, Swift Concurrency results in compile-time concurrency checking. To be able to indicate thread-safety for values, we can make use of the Sendable protocol.
The Sendable protocol indicates whether the passed value’s public API is thread-safe for the compiler. A public API is safe to use across concurrency domains when there are no public mutators, an internal locking system is in place, or mutators implement copy-on-write, like with value types.
Many standard library types already support the Sendable protocol, removing the requirement to add conformance to many types. As a result of the standard library support, the compiler can implicitly create support for your custom types.
For example, integers support the protocol:
extension Int: Sendable {}
Once we create a value type struct with a single property of type int, we implicitly get support for the Sendable protocol:
// Implicitly conforms to Sendable
struct Article {
var views: Int
}
At the same time, the following class example of the same article would not have implicit conformance:
// Does not implicitly conform to Sendable
class Article {
var views: Int
}
The class does not conform because it is a reference type and therefore mutable from other concurrent domains. In other words, the class article is not thread-safe to pass around, and the compiler can’t implicitly mark it as Sendable.
There’s much more to it, which is why we’ll have dedicated lessons in this module that dive deeper into defining functions and types as thread-safe.
Summary
In this lesson, we’ve learned about the different isolation domains that exist. Since functions and values can travel between isolation domains and threads, we need to indicate sendability. We can do this by adopting the Sendable protocol or by using the @Sendable attribute. The latter was not mentioned yet, but this module will make sure you know all about sendability in Swift Concurrency.
But before we dive deeper into those details, I want you to understand what we’re really solving here. Without thread-safety, data races could occur. In the next lesson, we’ll dive deeper into what they mean and why it makes it important to benefit from compile-time safety with Swift Concurrency.