This article refers to Swift’s development version Trunk Development Snapshot (March 22, 2022) and uses -warn-concurrency flag in Xcode Build Settings – Other Swift Flags in order to experience new async/await restrictions as swift 6 has not yet been implemented.
Swift concurrency using Async/Await is not only a way to create a readable and clean concurrency mechanism but also a safe one.
One of the most common pitfalls of concurrency is when two or more threads try to use the same memory address at the same time, for example, when one thread is reading a value and the other is attempting to edit it – this situation is called data race and most likely ends up as an application crash.
In new async/await every Task and Actor is accessed at most by one thread at a time so there is no longer a possibility for data race inside.
Note – Even though Task/Actor is accessed by one thread only, there is no guarantee that it will be always the same thread
someFunction and otherFunction may be executed using different threads, every await is a potential suspension point where task can give up its thread and got different one to continue its job.
The problem often occurs when we decide to pass some data between Tasks or Actors.
Swift is trying to solve it by statically checking if code is safe to be accessed concurrently. Thanks to this mechanism, we will be informed about possible data races already during build time as a compiler error that we need to either solve or ensure the compiler that we know what we are doing.
That’s where Swift comes with a new marker protocol Sendable and function attribute @Sendable
Sendable protocol in Swift 6
Sendable protocol in Swift indicates that type is safe to be used concurrently within new swift concurrency and The main rule is that ONLY Sendable types can escape an Actor or Task to another Actor or Task.
[Update 2025] – The rule stated above is still generally correct but has slightly changed with the implementation of ‘Region-based isolation’– SE-0414
That’s pretty much it! Let’s see an example:
class MyClass {
var name: String = "Muniek"
}
actor MyActor {
// Non-Sendable type MyClass
var myClass: MyClass = .init()
func someFunction() async {
// Function inside an actor can edit its own variables
// Actor types has own synchronization mechanism ensuring
// exclusive access to his own properties
// Every Actor property is isolated to an Actor
// and can be accessed only from inside that actor
myClass.name = "Bob"
// Based on our rule we cannot pass actor-isolated
// non-sendable properties to other Task or Actor
let otherActor = OtherActor()
// Error: Non-sendable type 'MyClass' passed in implicitly
// asynchronous call to actor-isolated instance method
// 'otherFunction(myClass:)' cannot cross actor boundary
await otherActor.otherFunction(myClass: myClass)
}
}
Passing non-sendable types outside an actor or Task is restricted because Tasks can execute their code in parallel with other Tasks. Using non-sendable instances in parallel may end up in an application crash caused by a data race.
To solve the problem in our example we need to inform the compiler that the value can be safely passed concurrently, and we can do so by conforming to the Sendable protocol.
Simple types like integers, strings, tuples, structs, and enums implicitly conform to Sendable because there are value types and are safe to be passed between concurrency domains as long as every one of its members conforms to the Sendable protocol.
Actors are internally synchronized protecting their mutable states so also they implicitly conform to Sendable.
Generic types also conform to Sendable when all types inside conform to Sendable.
We can just change the declaration of MyClass to be a struct and everything will be okay, because as a struct it will implicitly conform to Sendable.
The problem appears when dealing with reference types like Classes and Closures.
Sendable in Classes
Classes do not implicitly conform to Sendable, we must explicitly mark them as Sendable and final class, sendable cannot inherit from classes other than NSObject (for Objective-C interoperability).
final class MyModel: Sendable { }
As in the case of other types in class all members must be Sendable, we can easily add some immutable properties like so:
final class MyModel: Sendable {
let name: String = "Bob"
}
It is safe to be passed concurrently because there cannot be a data race in immutable values, multiple thread reading at the same time cannot create data race. Let’s add a variable:
final class MyModel: Sendable {
let name: String = "Bob"
// Error: Stored property 'address' of 'Sendable'-conforming
// class 'MyModel' is mutable
var address: String = "Krk"
}
How can we make sure that concurrent access to the variable is safe?
We need to add a synchronization mechanism on our own, using locks, queues, etc.
Let’s imagine that we have a type named SuperThreadSafeString which is thread safe String type:
final class MyModel: Sendable {
let name: String = "Bob"
// Error: Stored property 'address' of 'Sendable'-conforming
// class 'MyModel' is mutable
var address: SuperThreadSafeString = .init()
}
Even now we still got error that address variable is non-Sendable because compiler is unable to check if it is really thread safe, in this case there is only one option left – to tell the compiler that we know what we are doing by using @unchecked Sednable:
class MyModel: @unchecked Sendable {
let name: String = "Bob"
var address: SuperThreadSafeString = .init()
}
Now the compiler is satisfied, it does not care at all about the Sendability, you can forget any restrictions, but all responsibility is in your hands.
@Sendable attribute for functions
Functions can also be problematic when passing between concurrency domains, because they can capture other values with them:
class MyClass {
func doSomething() {
var name: String = "Muniek"
let function = {
name = UUID().uuidString
print(name)
}
for _ in 0...1000 {
Task {
function()
}
}
}
}
We passed a function to a 1000 Tasks which can run concurrently to each other causing the application to crash by data race because the function captured String variable name by reference and all of Tasks are referencing the same memory address.
Swift requires closure that is passed between Tasks/Actor to be Sendable, there are few rules to make it to conform to Sendable:
- Marking as @Sendable, like in case of @escaping
- All values captured by closure must be captured by-value
- All captured values must be Sendable
Sendable closures in Actor
The closure created inside an actor can be passed to another actor/task and executed there concurrently to an actor.
actor MyActor {
var actorIsolatedVariable: String = "Bob"
func doSomething() {
Task.detached {
// Error: Expression is 'async' but is not marked with 'await'
self.change(name: "Michael")
}
change(name: "Tom")
}
func change(name: String) {
actorIsolatedVariable = "Michael"
}
}
Task.detached Closure will run concurrently to the rest of the code allowing simultaneous mutation of actorIsolatedVariable causing a potential data race.
To avoid this problem, Swift comes with a rule that @Sendable closures are NOT isolated, so actor’s isolated properties cannot be accessed synchronously.
But there is an exception:
Even though closure passed to an Task.init is @Sendable it inherits Actor isolation and can access actor properties synchronously whether there are Sendable or not:
actor MyActor {
var name = "Muniek"
func myFunction() {
name = "Michael"
Task {
name = "Bob"
}
}
}
Every actor uses his own instance of SerialExecutor which can execute at most one job at a time, this synchronization protects actor internal mutable states from data race.
Non-Sendable types cannot escape concurrency in which it was created so non-sendable closures created inside an Actor must stay inside an actor and execute on Actor’s executor.
Closures executing on the actor’s executor are safe to use the actor’s properties synchronously and that’s why non-sendable closures created inside an Actor are actor-isolated.
Let’s see an example of both sendable and non-sendable closures inside an actor:
actor MyActor {
var actorIsolatedVariable: String = "Bob"
func someFunction(nonSendableClosure: () -> Void) {
nonSendableClosure()
}
func otherFunction(sendableClosure: @Sendable () -> Void) {
sendableClosure()
}
func doSomethingInside() {
someFunction(nonSendableClosure: {
actorIsolatedVariable = "Michael" // Ok
})
otherFunction(sendableClosure: {
// Error: Actor-isolated property 'actorIsolatedVariable'
// can not be mutated from a Sendable closure
actorIsolatedVariable = "Michael"
})
}
}
There is a new Swift proposal SE-0338 which is accepted but not yet implemented, so we can only learn this theoretically
SE-0338 Specify that actor-isolated async functions always execute on actor’s executor and non actor-isolated async functions never executes on actor’s executor.
[Update 2025] This change was implemented, but later reverted in Swift 6.2 — use @concurrency to hop off the calling actor.
Without those restrictions calling some outside async functions from an actor will run this function on an actor executor until suspends or switch to another actor, let see an example:
class MyClass {
func longOperation() async {
// Long async operation that do not need actor isolation
}
}
actor MyActor {
func doSomething() async {
// Calling non-actor isolated async function of MyClass, which will execute on actor’s executor
await myClass.longOperation()
}
func doSomethingImportant() async {
// Something really important here, that need actor isolation
}
}
If doSomething function would be called first, actor executor will be kept busy during whole longOperation execution time which can take a few seconds, and during that time actor is unavailable for other actor-isolated functions.
But of course, there is an exception:
actor MyActor {
var data: Data?
func updateData() async {
return await withCheckedContinuation { continuation in
Server.fetchData { data in
self.data = data
continuation.resume()
}
}
}
}
withCheckedContinuation is an non actor-isolated async function so should not be executed on actor executor but closure inside is non-sendable so should be an actor-isolated.
Swift engineer told me that they gonna make withCheckedContinuation an exception to SE-0338.
Known bugs
There is possible way to pass non-sendable actor-isolated variable outside an actor and mutate it synchronously:
class MyClass {
var name = "Muniek"
}
actor MyActor {
// non-sendable actor-isolated variable
var myClass: MyClass = .init()
func inside_one() async {
await someFunctionOutside(myclass: myClass)
}
func changeName() async {
myClass.name = "Michael"
}
}
func someFunctionOutside(myclass: MyClass) async {
// with SE-0338 implemented, this function will switch
// from actor executor to a generic executor so there wont be any actor isolation
// without SE-0338 function will switch to generic executor after await
await Task.yield()
// With or without SE-0338 this mutation of actor-isolated variable
// occurs without any isolation
myclass.name = "Bob"
}
Special thanks to Przemek Błasiak for help with editing.