Swift Concurrency – Things They Don’t Tell You

Posted on Posted in iOS

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.

1. Async-Await – Basic Problems


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.

  • If you add an async keyword to your function, it can be called on a background thread even if you start the function from the Main Thread.
  • If you run a code from 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).
  • If you call await within an asynchronous function, it creates a suspension point that may switch execution to any pending code, even to the same function if it was called multiple times.
  • If your asynchronous function resumes after await, the thread is not guaranteed to be the same as before await (unless you use @MainActor). Therefore, you should not make any assumptions about that.
  • Because of internal thread management, when using Swift Concurrency, you should not mix it with classic synchronization methods like locks, semaphores, etc. It may result in an unexpected behavior because some of those features have implementations that rely on threads.
  • If you add @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.

2. Async – What Does It Mean For Your Code?


Sample Application

Possible Case

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:

Problem

To see the problem try replacing the Button with the following code and tap it quickly multiple times.

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.

Explanation

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.

Solution

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.

3. Actor – The Silent Assassin of Your Code


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.

Now we can transform this class into an actor.

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.

Problem

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.

Solution

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.

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.

Another Example

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.

Although, here you can find a solution by keeping a reference to a Task. This solution is limited to cases when you don’t need to schedule multiple calls and you run only one until it is finished.

4. Tasks – The Hidden Bottleneck


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.

Problem

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.

Solution

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.

Sample Application

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.

5. Swift Concurrency + Simulator = No Concurrency


Another trap set by Swift Concurrency is the difference between a simulator and a device. Initially, I thought that Swift Concurrency is starting for each TaskPriority a dedicated serial queue. Fortunately, @tclementdev pointed out based on some discussion from Swift Forums that Swift Concurrency is using actually concurrent queues but only on devices! And because I was doing my experiments on a simulator I thought it is a serial queue.

Therefore, if you test your code on a simulator each TaskPriority will be limited to only 1 task running at the same time. Of course, some task-switching could occur once the code hits an await. Also, as a result, apps running on the simulator will be slower.

This is a very significant and strange decision! It means that a simulator now behaves differently from a device. It will affect manual testing, unit testing, profiling, early bug detection, etc. Not saying that bugs related to data races are really hard to find anyway and now it will be only possible on a device… Thanks, Apple.

In addition, Thread Sanitizer becomes almost useless because it doesn’t work on devices and you can’t test concurrency (assuming the same TaskPriority) on a simulator. Good luck with tracking down data races…

As far as I know, we are currently unable to change this behavior for the simulator. Although, it is possible to limit a device to run only 1 task concurrently to spot some bottlenecks. You can do it by setting an environmental variable LIBDISPATCH_COOPERATIVE_POOL_STRICT=1.

Summary


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.

Hopefully, this article will show you at least some parts of Swift Concurrency Iceberg :). If you have other interesting cases that I did not mention, feel free to post them in the comments.

Key Takeaways


  • If you add async keyword, make sure that your function is prepared for:
    • being called on a background thread (otherwise, add @MainActor attribute)
    • dealing with “Actor Reentrancy” when your function is called multiple times. The part of your code before await might be called again before the code after await is called. You can have also a problem when another function is called before this one is finished.
    • thread switching – the code before await may be dispatched on a different thread than the code after await.
  • Don’t assume that the actor prevents data races. The actor only guarantees that two pieces of code won’t run at the same time, but still, methods can be switched back and forth without any guarantee that the asynchronous method will be fully processed before calling another method.
  • Avoid mixing classic synchronization methods like locks and semaphores with Swift Concurrency.
  • Remember to set Task priority to avoid dispatching everything on a single queue.
  • Test your asynchronous code on a device to make sure that your app doesn’t introduce any data races.
  • Avoid heavy synchronous work within Task. Use custom DispatchQueue when heavy work like image processing is required.
  • If needed, use await Task.yield() to allow more often task switching.

What’s Next?


Below you can find some really good content to see if you want to better understand Swift Concurrency:

Do you need an app to boost your productivity?


Snippety is a tool that can make daily tasks more enjoyable by providing quick access to your snippets. Snippety works flawlessly with every text field! Just press ⌘⇧Space, find your snippet, and hit ↩︎. You can define also your keywords and use snippets by just typing without even opening the app!

Snippety works on macOS, iPhone, and iPad. All devices are kept up to date using iCloud synchronization. On mobile devices you also get a dedicated keyboard to access your snippets wherever you type.

Snippety Keyboard
App Store - Snippety