Join us as we explore one of the core concepts in Swift concurrency: isolation of tasks and actors. We'll take you through Swift's approach to eliminating data races and its effect on app architecture. We'll also discuss the importance of atomicity in your code, share the nuances of Sendable checking to maintain isolation, and revisit assumptions about ordering work in a concurrent system.
I'm Doug from the Swift team, and I'm here to talk about Swift Concurrency's approach to eliminating data races.
We introduced Swift Concurrency as a set of language features that make it easier to write concurrent programs.
For the mechanics of these individual language features, we refer you to the 2021 WWDC talks covering each of them.
This talk takes a different, more holistic view of Swift Concurrency as a way of structuring your program to make efficient use of concurrency without introducing data races.
But to do so, we need a great analogy, so we invite you to sail with us on the high seas of concurrency.
The sea of concurrency is unpredictable, with many things going on at once, but with you at the helm and Swift helping you navigate the waters, it can produce amazing things.
Let's dive in! We'll start by talking about isolation, which is one of the key ideas of Swift's concurrency model, ensuring that data is not shared in a manner that can introduce data races.
Let's start with task isolation.
In our sea of concurrency, tasks are represented by boats.
Boats are our main workers -- they have a job to do, which they perform sequentially from start to finish.
They are asynchronous, and their work can be suspended any number of times at "await" operations in the code.
Finally, they are self-contained: each task has its own resources, so it can operate by itself, independently of all of the other boats in the sea.
If our boats are completely independent, we have concurrency without data races, but it's not very useful without some way to communicate.
Let's add some communication! For example, one boat might have a pineapple that it wants to share with another boat.
So the boats meet on the open sea, and we transfer the pineapple from one boat to the other.
Now, this is where the physical analogy breaks down a little bit, because this pineapple is not a physical item that moves from one boat to the next.
It's data, and in Swift we have a couple of different ways we could represent that data.
How do we define our pineapple type? We like value types in Swift, so let's make the pineapple a struct that's defined by its weight and ripeness.
Let's see how this works.
When the boats meet on the open sea, we're really passing a copy of the pineapple instance from one boat to the next, and each boat goes away with its own copy.
If you were to mutate the copies, such as by calling the slice() and ripen() methods, it won't have any effect on the other one.
Swift has always preferred value types for exactly this reason -- mutations have only local effects.
That principle helps value types maintain isolation.
Now, let's extend our data model a bit and add chickens! Unlike pineapples, which are pretty much only good for eating, chickens are beautiful creatures with their own unique personalities.
So, we're going to model them with a class, like this.
Let's have our intrepid seafarers exchange a chicken.
When our boats meet, we share the chicken, except that copying a reference type like chicken doesn't give you another full copy of the chicken, it gives you a reference to that specific object.
So once our boats have gone their separate ways, we can see that we have a problem: both boats are doing their work concurrently, but they are not independent because they both reference the same chicken object.
That shared mutable data is prone to data races, such as when one boat is trying to feed the chicken and the other wants to play with it, leading to one very confused chicken.
We need a way to know that it was safe to share pineapples amongst boats, but not chickens.
And then we need some checking in place in the Swift compiler to ensure that chickens aren't accidentally passed from one boat to another.
Swift protocols are a great way of categorizing types so you can reason about their behavior.
The Sendable protocol is used to describe types that can safely be shared across different isolation domains, without creating data races.
A type can be made Sendable by writing a conformance.
The Pineapple struct conforms to Sendable because it's a value type, but the Chicken class cannot because it's an unsynchronized reference type.
Modeling Sendable as a protocol allows us to describe the places where data is going to be shared across isolation domains.
For example, when a task returns a value, this value is provided to any of the tasks that are waiting for that value.
Here, we are trying to return a Chicken from our Task, and we get an error stating that this is unsafe because Chicken is not Sendable.
The actual Sendable constraint comes from the definition of the Task struct itself, which specifies that the result type of a Task, called Success, must conform to the Sendable protocol.
You should use Sendable constraints where you have generic parameters whose values will be passed across different isolation domains.
Now, let's revisit the idea of sharing data among boats.
When two boats meet on the high seas and want to share data, we need someone to consistently check all of the goods to make sure they're safe to share.
That's the role of our friendly customs inspector -- played here by the Swift compiler -- to make sure that only Sendable types are exchanged.
The pineapple is fine and can be exchanged freely, because it's Sendable.
However, the chicken cannot be exchanged, and our friendly customs inspector will prevent us from making that mistake.
The compiler is involved in checking Sendable correctness at many different points.
Sendable types must be correct by construction, and cannot allow any shared data to be smuggled through them.
Enums and structs generally define value types, which copy all of their instance data along with them to produce independent values.
Therefore, they can be Sendable so long as all of their instance data is also Sendable.
Sendable can be propagated through collections and other generic types using conditional conformance.
An array of Sendable types is Sendable, so a Crate full of pineapples is also Sendable.
All of these Sendable conformances can even be inferred by the Swift compiler for non-public types, so Ripeness, Pineapple, and Crate are all implicitly Sendable.
But let's say we create a coop to house our flock of chickens.
This type cannot be marked as Sendable, because it contains non-Sendable state: Chicken isn't Sendable, so the array of chickens isn't Sendable.
We'll get an error message from our compiler to indicate that this type cannot safely be shared.
Classes are reference types, so they can only be made Sendable under very narrow circumstances, such as when a final class only has immutable storage.
Our attempt to make the Chicken class Sendable will produce an error because it contains mutable state.
Now, it is possible to implement reference types that do their own internal synchronization, for example, by using a lock consistently.
These types are conceptually Sendable, but there is no way for Swift to reason about that.
Use unchecked Sendable to disable the compiler's checking.
Be careful with this, because smuggling mutable state through @unchecked Sendable undermines the data race safety guarantees Swift is providing.
Task creation involves executing a closure in a new, independent task, like sending off a rowboat from your boat.
When we do this, we can capture values from the original task and pass them into the new task, so we need Sendable checking to ensure we don't introduce data races.
If we do try to share a non-Sendable type across this boundary, the Swift compiler has us covered, producing an error message like this one.
This isn't magic for task creation.
The closure is being inferred to be a Sendable closure, which could have been written explicitly with At-Sendable.
Sendable closures are values of Sendable function type.
At-Sendable can be written on a function type to indicate that the function type conforms to the Sendable protocol.
That implies that values of that function type can be passed to other isolation domains and called there without introducing data races on their captured state.
Normally, function types cannot conform to protocols, but Sendable is special because the compiler validates the semantic requirements for it.
There is similar support for tuples of Sendable types conforming to the Sendable protocol, which allows Sendable to be used throughout the entire language.
The system we've described has many concurrently executing tasks that are isolated from each other.
The Sendable protocol describes types that can be safely shared among tasks, and the Swift compiler checks Sendable conformances at every level to maintain isolation of the tasks.
However, without any notion of shared mutable data anywhere, it's hard for the tasks to coordinate in a meaningful manner.
So we need some way to share data amongst our tasks that doesn't re-introduce data races.
This is where actors come in.
Actors provide a way to isolate state that can be accessed by different tasks, but in a coordinated manner that eliminates data races.
Actors are the islands in our sea of concurrency.
Like boats, each island is self-contained, with its own state that is isolated from everything else in the sea.
To access that state, your code needs to be running on the island.
For example, the advanceTime method is isolated to this island.
It lives on the island and has access to all of the island's state.
To actually run code on an island, you need a boat.
A boat can visit the island to run code on the island, at which point it has access to that state.
Only one boat can visit the island to run code at a time, which ensures that there is no concurrent access to the island's state.
If other boats show up, they must await their turn to visit the island.
And because it might be a long time before a given boat gets a chance to visit the island, entering into an actor is a potential suspension point marked by the “await” keyword.
Once the island frees up -- again, at a suspension point -- another boat can visit.
Just like with two boats meeting on the open sea, interactions between a boat and an island need to maintain isolation of both, by making sure that non-Sendable types don't pass between the two.
For example, perhaps we try to add a chicken from our boat to the flock on the island.
This would create two references to the same chicken object from different isolation domains, so the Swift compiler rejects it.
Similarly, if we try to adopt a pet chicken from the island and take it away on our boat, Sendable checking ensures that we cannot create this data race.
Actors are reference types, but unlike classes, they isolate all of their properties and code to prevent concurrent access.
Therefore, having a reference to an actor from a different isolation domain is safe.
It's like having a map to an island: you can use the map to go visit the island, but you still need to go through the docking procedure to access its state.
Therefore, all actor types are implicitly Sendable.
You might be wondering how to know what code is isolated to the actor and what code isn't.
Actor isolation is determined by the context you're in.
The instance properties of an actor are isolated to that actor.
Instance methods on the actor or an extension of the actor are also isolated by default, like this advanceTime method.
Closures that are not Sendable, such as the closure passed to the reduce algorithm, stay on the actor and are actor-isolated when they are in an actor-isolated context.
The task initializer also inherits actor isolation from its context, so the created task will be scheduled on the same actor as it was initiated from.
Here, that grants access to the flock.
On the other hand, a detached task does not inherit actor isolation from its context, because it is completely independent of the context where it was created.
We can see that the code in the closure here is considered to be outside the actor because it needs to use “await” to refer to the isolated “food” property.
We have a term for this closure: it's non-isolated code.
No-isolated code is code that does not run on any actor at all.
You can explicitly make a function that's within an actor non-isolated by using the non-isolated keyword, putting it outside of the actor.
Just like what happened implicitly with the closure used for the detached task.
That means if we want to read some of the state that's isolated to the actor, we'll need to use “await” to visit the island and grab a copy of the state we need.
Non-isolated async code always runs on the global cooperative pool.
Think of it as only running when a boat is out on the open sea, so you have to leave the island you're visiting to do the work.
That means checking to ensure that you aren't taking any non-Sendable data with you! Here, the compiler detects the potential data race, where an instance of the non-Sendable Chicken is trying to leave the island.
Let's consider one more case of non-isolated code.
The “greet” operation is non-isolated, synchronous code.
It knows nothing about boats or islands or concurrency in general.
And here, we're calling it from the actor-isolated greetOne function, and that's okay! This synchronous code, when called from the island, will stay on the island, so it's free to operate on the chicken from the flock.
If instead we had a non-isolated async operation that calls “greet,” then “greet” will run there, on a boat, in the open sea.
Most Swift code is like this: synchronous, non-isolated to any actor, and only operates on the parameters it's been given, so it stays in the isolation domain where it is called.
Actors hold state that is isolated from the rest of the program.
Only one task can run on an actor at a time, so there is no concurrent access to that state.
Sendable checking applies any time a task enters or exits an actor to ensure that no unsynchronized mutable state escapes.
Altogether, this makes actors one of the building blocks for a concurrent program in Swift.
There's another special actor we often talk about called the main actor.
Think of the main actor as a big island in the middle of the sea.
It represents the main thread, where all of the drawing and interaction for your user interface occurs.
So if you want to draw something, you need to run the code on the main actor's island.
It's so important for your UI, that maybe we should even call it the “U-I-land." When we say that the main actor is “big,” what we mean is that it contains a lot of state related to the program's user interface.
There's a lot of code, both in the UI frameworks and in your apps, that needs to run on it.
However, it's still an actor, so it only runs one job at a time.
So you have to be careful not to put too much or long-running work on the main actor, because it can make your UI unresponsive.
Isolation to the main actor is expressed with the MainActor attribute.
This attribute can be applied to a function or closure to indicate that the code must run on the main actor.
Then, we say that this code is isolated to the main actor.
The Swift compiler will guarantee that main-actor-isolated code will only be executed on the main thread, using the same mechanism that ensures mutually exclusive access to other actors.
If one calls updateView from a context that isn't isolated to the main actor, it will need to introduce an “await” to account for the switch over to the main actor.
The main actor attribute can also be applied to types, in which case the instances of those types will be isolated to the main actor.
Again, this is just like any other actor -- the properties are only accessible while on the main actor, and the methods are isolated to the main actor unless they explicitly opt out.
Like normal actors, references to main-actor classes are themselves Sendable, because their data is isolated.
This makes the main actor annotation suitable for your UI views and view controllers, which are necessarily tied to the main thread by the frameworks themselves.
You can share a reference to your view controller with other tasks and actors in your program, and they can asynchronously call back into the view controller to post results.
This has a direct effect on your app's architecture.
In your app, your views and view controllers will be on the main actor.
Other program logic should be separated from that main actor, using other actors to safely model shared state and tasks to describe independent work.
And those tasks can shuttle between the main actor and other actors as necessary.
There's a lot going on in a concurrent app, so we've built some great tools to help you make sense of it.
I invite you to check out the "Visualize and Optimize Swift Concurrency" talk to learn more.
Let's dive into some deeper waters to talk about atomicity.
The goal of the Swift Concurrency model is to eliminate data races.
What that really means is that it eliminates low-level data races, which involve data corruption.
You still need to reason about atomicity at a high level.
As we've talked about before, actors only run one task at a time.
However, when you stop running on an actor, the actor can run other tasks.
This ensures that the program makes progress, eliminating the potential for deadlocks.
However, it requires you to consider your actor's invariants carefully around await statements.
Otherwise, you can end up with a high-level data race where the program is in an unexpected state, even though no data is actually corrupted.
Let's break down an example of this.
Here we have a function that intends to deposit some additional pineapples on an island.
It's outside of an actor, so it's non-isolated async code.
That means it runs out here in the open sea.
It's been given some pineapples and a map to the island where it should deposit those pineapples.
The first interesting operation here gets a copy of the food array from the island.
To do that, the boat needs to visit the island, signaled by the “await” keyword.
As soon as it has a copy of the food, the boat heads back out to the open sea to continue its work.
That means adding the pineapple from the pineapples parameter to the two it got from the island.
Now, we can move along to the last line of the function.
Our boat now needs to visit the island again to set the island's food array to those three pineapples.
Here, everything worked out fine, and we have the three pineapples on the island! But things could have gone a bit differently.
Let's say a pirate ship snuck in and stole all of the pineapples while our first boat was waiting its turn to visit the island.
Now, our original ship deposits its three pineapples on the island, and we notice a problem.
The three pineapples have suddenly turned into five pineapples! What happened here? Well, notice that we have two awaits for access to state on the same actor, and we're making an assumption here that the food array on the island doesn't change between those two awaits.
But these are awaits, meaning that our task could get suspended here and the actor could do other higher-priority work, like battling pirates.
In this specific case, the Swift compiler will reject an attempt to outright modify the state on another actor.
However, we should really rewrite our deposit operation as synchronous code on the actor, like this.
Because this is synchronous code, it will run on the actor without interruption.
So we can be sure that the state of the island will be unchanged by anyone else throughout the entire function.
When you are writing your actor, think in terms of synchronous, transactional operations that can be interleaved in any way.
Every one of them should ensure that the actor is in a good state when it exits.
For async actor operations, keep them simple, forming them primarily from your synchronous, transactional operations, and take care that your actor is in a good state at each await operation.
This way, you can make full use of actors to eliminate both low-level and high-level data races.
In a concurrent program, many things are happening at once, so the order in which those things happen can vary from one execution to the next.
And yet programs often rely on handling events in a consistent order.
For example, the stream of events that come in from user input or messages from a server.
When these event streams come in, we expect their effects to happen in order.
Swift Concurrency provides tools for ordering operations, however, actors are not the tool for doing so.
Actors execute the highest-priority work first, to help the overall system stay responsive.
This eliminates priority inversions where lower-priority work ends up happening before higher-priority work on the same actor.
Note that this is a significant difference from serial Dispatch queues, which execute in a strictly First-In, First-Out order.
Swift Concurrency has several tools for ordering work.
The first we've been talking about a lot already -- tasks.
Tasks execute from beginning to end, with the normal control flow you're used to, so they naturally order work.
AsyncStream can be used to model an actual stream of events.
One task can iterate over the stream of events with a for-await-in loop, processing each event in turn.
An AsyncStream can be shared with any number of event producers, which can add elements to the stream while maintaining order.
We've talked a lot about how Swift's concurrency model is designed to eliminate data races using the notion of isolation, which is maintained by Sendable checking at task and actor boundaries.
However, we cannot all just stop what we are doing to go mark all of the Sendable types everywhere.
Instead, we need an incremental approach.
Swift 5.7 introduces a build setting to specify how strictly the Swift compiler should check for Sendability.
The default setting is Minimal meaning that the compiler will only diagnose places where one has explicitly tried to mark something as Sendable.
This is similar to how Swift 5.5 and 5.6 behaved, and for the above, there won't be any warnings or errors.
Now, if you add a Sendable conformance, the compiler will complain that the Coop type cannot be Sendable because Chicken isn't Sendable.
However, this -- and other Sendable-related problems -- will be presented as warnings in Swift 5, not errors, to make it easier to work through the problems one by one.
To move further toward data race safety, enable the “targeted” strict concurrency setting.
This setting enables Sendable checking for code that has already adopted Swift Concurrency features like async/await, tasks, or actors.
This will identify, for example, attempts to capture values of non-Sendable type in a newly created task.
Sometimes the non-Sendable types come from another module.
Perhaps it's some package that hasn't been updated for Sendable yet, or even your own module that you just haven't gotten around to.
For those, you can temporarily disable the Sendable warnings for types that come from that module using the @preconcurrency attribute.
This will silence Sendable warnings for the Chicken type within this source file.
At some point, the FarmAnimals module will get updated with Sendable conformances.
Then, one of two things will happen: either Chicken becomes Sendable somehow, in which case the preconcurrency attribute can be removed from the import.
Or Chicken will be known to be non-Sendable, in which case the warning will come back, indicating that your assumptions about Chicken being Sendable are, in fact, not correct.
The targeted strictness setting tries to strike a balance between compatibility with existing code and identifying potential data races.
However, if you'd like to see everywhere that races could occur, there is one more option: complete checking.
Complete checking approximates the intended Swift 6 semantics to completely eliminate data races.
It checks everything that the earlier two modes check but does so for all code in the module.
Here, we're not actually making use of Swift's concurrency features at all.
Rather, it's performing work on a dispatch queue, which will execute that code concurrently.
The async operation on a dispatch queue is actually known to take a Sendable closure, so the compiler produces a warning indicating that there is a data race when the non-Sendable body is captured by the code running on the dispatch queue.
We can fix this by making the body parameter Sendable.
That change eliminates this warning, and now all of the callers of doWork know that they need to provide a Sendable closure.
That means we get better checking for data races, and we can see that the visit function now is the source of a data race.
Complete checking will help flush out the potential data races in your program.
To achieve Swift's goal of eliminating data races, we'll eventually need to get to complete checking.
We encourage you to work incrementally toward that goal: adopt Swift's concurrency model to architect your app for data race safety, then enable progressively stricter concurrency checking to eliminate classes of errors from your code.
And don't fret over marking your imports with @preconcurrency to suppress warnings for imported types.
As those modules adopt stricter concurrency checking, the compiler will recheck your assumptions.
At the end of this road, your code will benefit from both memory safety and data race safety, helping you focus on building great apps.
And thank you for sailing with me on the sea of concurrency.