Swift Concurrency provides a really nice way of writing asynchronous code. Support for async-await
has been to me the most awaited feature in Swift.
However, with great power comes great responsibility. If you learn from tutorials or even from the documentation, it’s really hard to find some details on how it works under the hood. Basically, Swift Concurrency is advertised as safe to use, because in theory the correctness is being checked by the compiler.
This way of “selling” Swift Concurrency encourages people to just jump in, add async-await
to an existing code, and run some Tasks
not really knowing what is going on under the hood. Unfortunately, there are many traps around concurrency, and no… the compiler doesn’t check everything.
To be honest, even after performing tests, reading documentation, and watching WWDC I’m still not fully confident with Swift Concurrency. Although, I will try to share with you some of my observations hopefully making you more aware.
To continue you must first understand the basic problems of async-await
. Maybe “a problem” is not the best word for that. This is just the way Swift Concurrency works, but it may cause problems if you don’t know what’s going on under the hood.
Task
, you can’t make any assumptions about the thread. It could be dispatched on any thread (unless it is started from the Main Actor).@MainActor
). Therefore, you should not make any assumptions about that.@MainActor
attribute to a function, it is not the same as wrapping the whole method in DispatchQueue.main.async
. If your method contains await keyword, the code will be split into two pieces – one before await and one after await, and as mentioned before, once the method hits await another pending call can start running even before the first one is finished. Therefore, you should not assume that the method will be fully processed before it is called again even when using @MainActor
./// Dependency
final class ViewModel: ObservableObject {
private var database: [String: String] = [:]
func updateDatabase() {
database[UUID().uuidString] = UUID().uuidString
}
}
/// ConcurrencyApp
@main
struct ConcurrencyApp: App {
@ObservedObject var viewModel = ViewModel()
var body: some Scene {
WindowGroup {
VStack {
Button("RUN TEST") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
viewModel.updateDatabase()
}
}
}
}
}
}
You are working on a new feature and finally, you are able to use async-await
so you decide to write a new function using Swift Concurrency:
/// ConcurrencyApp
func doSomethingAndUpdateDatabase() async {
try? await Task.sleep(nanoseconds: 1_000_000_000) // do some asynchronous work
updateDatabase()
}
To see the problem try replacing the Button
with the following code and tap it quickly multiple times.
/// ButtonSwift
Button("RUN TEST") {
DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) {
viewModel.updateDatabase()
}
Task {
await viewModel.doSomethingAndUpdateDatabase()
}
}
After just a few attempts you should get a crash. This is of course not a real-life scenario to run such code. It’s just to present you quickly a possible crash.
Normally, you would call viewModel.updateDatabase()
somewhere from the Main Thread and in the meantime, you could run somewhere else a Task
with doSomethingAndUpdateDatabase()
call causing hard to track data races that happen only from time to time when both data modifications are invoked at the same time.
Previously your updateDatabase
function was meant for serial processing on the Main Thread only and now you call it from a function with async keyword without enforcing @MainActor
. This way it could be launched on any thread. If you are using updateDatabase
from the Main Thread and from an async
function, you may get concurrent calls causing a data race.
You may have thought that because you run Task
on the Main Thread the function should also run on the Main Thread. It is not true, the closure of a Task
will be running on the Main Thread but the async function can be dispatched to any thread because it does not have MainActor
requirement.
Remember: the only time when async
function will be guaranteed to run on a specific Thread is when you use @MainActor
attribute. It will be always the Main Thread then.
Sometimes the problem might not be so obvious. For example, you can analyze your code in detail, and decide that you really don’t need MainActor
for doSomethingAndUpdateDatabase
. Later, someone adds an UI call to updateDatabase function which doesn’t even use Swift Concurrency, completely unaware of assumptions in another method using async-await
and here comes the crash.
You should remember to always mark a code with @MainActor
if you require the Main Thread. You shouldn’t make implicit assumptions. The problem is when you suddenly start adding async-await to an existing project that was implemented without using Swift Concurrency. You can easily get into trouble if you don’t pay enough attention when mixing old code with async-await
.
If you need to call some external dependency that can’t be marked as @MainActor
and is not thread-safe, you could just wrap a call with: await MainActor.run { /* call */ }
.
You may also consider marking the whole ViewModel as running on the @MainActor
, usually, you don’t want there concurrent calls and the work is often related to UI operations. If you need some extra concurrency you should consider extracting this code to a dependency that will take care of thread-safety. You could use an actor for that.
The actor is another new feature of Swift Concurrency that seems to be a pure haven. You just replace a class with an actor and you don’t need to be worried about thread safety, concurrency, or synchronization using DispatchQueue
or NSLock
. Is it really like that? Let’s see the following example using a classic synchronization.
/// BankAccount
final class BankAccount {
private var balance = 0
private let queue = DispatchQueue(label: "bank-account")
func transaction(value: Int) {
queue.sync {
guard self.balance >= value else { return print("Balance is too low") }
self.balance -= value
}
}
func deposit(amount: Int) {
queue.sync {
self.balance += amount
}
}
func getBalance() -> Int {
queue.sync { self.balance }
}
}
Now we can transform this class into an actor.
/// BankAccountActor
actor BankAccountActor {
private(set) var balance = 0
func transaction(value: Int) {
guard balance >= value else { return print("Balance is too low") }
balance -= value
}
func deposit(amount: Int) {
balance += amount
}
}
Much simpler, isn’t it? And the code is still correct and thread-safe.
But what if we start messing with async functions? Let’s assume that we want to add some asynchronous operation to transaction
method.
/// BankAccountActorAsync
actor BankAccountActorAsync {
private(set) var balance = 0
func transaction(value: Int) async {
guard balance >= value else { return print("Balance is too low") }
try? await Task.sleep(nanoseconds: 1_000_000_000) // some async operation
balance -= value
}
func deposit(amount: Int) {
balance += amount
}
}
Now we may have assumed that an actor guarantees thread safety. However, the actor only guarantees that two pieces of code won’t be running at the same time. Whenever the code hits a suspension point (await
) it may resume some other pending work. In this case, if you call transaction
multiple times every time it hits await
another transaction
will start running.
As a result, all scheduled transaction calls will check guard
condition first and then they will one by one call balance -= value that will easily go below zero because the condition you checked at the beginning is already outdated after resuming the code below await.
The feature that allows switching between multiple invocations is called “Actor Reentrancy”. You should always be cautious when using await
within a function.
It is very important not to fall into a false assumption that an actor resolves all problems and a compiler guarantees code correctness. You should be really careful when you introduce asynchronous code in your actor.
You could fix this problem here in many ways. One of the common practices across all technologies is so-called “double-check”. You can perform guard
before await
and after await
to make sure that nothing has changed in the meantime.
/// BankAccount
func transaction(value: Int) async {
guard balance >= value else { return print("Balance is too low") }
try? await Task.sleep(nanoseconds: 1_000_000_000) // some async operation
guard balance >= value else { return print("Balance is too low") }
balance -= value
}
If your asynchronous operation doesn’t rely on the guard
condition, you could even skip the first check. However, it is better for performance to avoid unnecessary work.
func foo() async {
isInProgress = true
await backend.sendSomeData()
isInProgress = false
}
Now imagine calling this method multiple times even if it is a part of an actor. When the code hits await
, it allows another call to progress until its await
. This way all calls will first set isInProgress = true
and then will call the backend. As a result, the first call will switch isInProgress = false
causing an unexpected behavior because other calls are still in progress.
So as you can see, there are a lot of possible traps when using async-await
that are not solved even by an actor.
There is no simple way to prevent “Actor Reentrancy”, at least I am not aware of any, so you should keep it in mind when using actors. If it is necessary for you to avoid this then maybe an actor is not something you should use in this case.
I’ve seen that most people (I was one of them too) don’t bother about task priority and just run Task { ... }
. As we know from the documentation, the priority of a Task
is inherited from its parent. Therefore, if we call a Task
from the Main Thread, which we do most of the time, it will automatically set user-initiated
priority.
So what’s the problem you may ask? If we got used to calling Task { ... }
it means that most of the work is done on the same serial (thank you @tclementdev for a great discussion and for providing more details about queues in Swift Concurrency) concurrent queue (com.apple.root.user-initiated-qos.cooperative
). The more we use Task { ... }
the slower our application will be.
To be specific, Swift Concurrency tries to protect you from Thread Explosion and it limits the number of threads per queue so that they don’t exceed the number of CPU cores. As a result, if you start too many tasks, some of them will be suspended until the number of running tasks is lower than the number of CPU cores or until a Task
hits await which will trigger switching to another pending Task
.
For example, if you use iPhone 12 Pro which has 6 cores, you will be able to run concurrently only around 8 tasks! Why 8? Because if you first run 6 tasks with the highest priority then every lower priority will be limited to 1 task. So 6 tasks for user-initiated + 1 for utility + 1 for background. However, you can cheat a little and schedule tasks starting from the lowest priority, then you should be able to get 6 concurrent tasks for each TaskPriority
. It works this way to avoid blocking higher-priority tasks with lower-priority tasks.
The problem won’t be very prominent if you don’t do heavy work within a Task
. But what if you do? What if you put there synchronous image resizing starting a Task
for each file? That’s a bigger issue. Now some work, even a simple one, will be waiting for resizing to finish.
You may not even be aware of the problem at first, because you simply started a Task
in your SwiftUI view, it propagated through 10-layers of code, and suddenly it turns out that the image processing is blocking even simple UI interactions if you use for them the same TaskPriority
.
Therefore, even 6 tasks (iPhone 12 Pro) running concurrently may fully block your UI interactions.
There are a few things that you can do. First of all, you should avoid long-running synchronous work in Task. For that, you should use GCD
and custom queues. This way you will prevent blocking unintentionally some TaskPriority
. However, you should be also aware that having too many custom queues is not recommended because of possible Thread Explosion. Therefore, you may try using OperationQueue
to limit concurrency.
The next thing you should do is to take care of setting the correct priority. For example, very fast user-initiated
calls could be running with user-initiated
priority by just calling Task { ... }
from the Main Thread but for heavier and less important work like cashing data, backup, synchronization, etc., you should consider other priorities like background or utility. Example: Task(priority: .background) { ... }
.
If you really want to run some synchronous heavy work in a Task make sure to do it using lower priority. Also, if you process some data in a loop for example, then you could call from time to time await Task.yield()
. This operation does nothing, just allows switching to another pending Task. This way you can ensure that your queue won’t be fully blocked because at least you will have some task-switching in the meantime.
To see those problems on your own, try playing with the sample application. Just make sure to run it on a device. I will explain why in the next section.
final class TestClass {
private let queue = DispatchQueue(label: "heavy-work", attributes: .concurrent)
func start() async {
while true {
longRunningWork(seconds: 1)
}
}
func startImproved() async {
while true {
longRunningWork(seconds: 1)
await Task.yield()
}
}
func startFixed() async {
await withCheckedContinuation { continuation in
queue.async {
self.longRunningWork(seconds: 10)
continuation.resume(with: .success(()))
}
}
}
private func longRunningWork(seconds: Int) {
usleep(useconds_t(seconds * 1_000_000))
}
}
@main
struct ConcurrencyApp: App {
let testClass = TestClass()
var body: some Scene {
WindowGroup {
VStack(spacing: 50) {
// Tap next
Button("Test UI action") {
Task {
print("It works!")
}
}
// To see a blocked UI issue - tap first
Button("Start long-running Tasks") {
(0..<20).forEach { _ in
Task {
await testClass.start()
}
}
}
Button("Start improved long-running Tasks") {
(0..<20).forEach { _ in
Task {
await testClass.startImproved()
}
}
}
Button("Start long-running Tasks in background") {
(0..<20).forEach { _ in
Task(priority: .background) {
await testClass.start()
}
}
}
Button("Start long-running Tasks using DispatchQueue") {
(0..<20).forEach { _ in
Task {
await testClass.startFixed()
}
}
}
}
}
}
}
As you can see, Swift Concurrency is not as simple as adding async-await and checking if the code compiles. There are many hidden problems that require a deep understanding of what is happening under the hood. Although, for some reason, the documentation and tutorials assume that this is “extra” knowledge, not something required to know before using Swift Concurrency.
I think this approach will cause many issues with concurrency. In the past, when you dispatched a code explicitly on a background thread there was instantly a red alert in your head telling you to think about dispatching UI-related code to the Main Thread and to think about data races. Now you can easily miss this because of a false sense of security created by a compiler-checked concurrency.
async
keyword, make sure that your function is prepared for:
Task
priority to avoid dispatching everything on a single queue.Task
. Use custom DispatchQueue
when heavy work like image processing is required.Task.yield()
to allow more often task switching.