You are currently viewing How Does Swift Concurrency Prevent Thread Explosions?

How Does Swift Concurrency Prevent Thread Explosions?

A few weeks back, I read an article by Wojciech Kulik, where he talks about some of the pitfalls in the Swift Concurrency framework. In one of the sections, Wojciech briefly mentioned thread explosion, and how Swift Concurrency can prevent it from happening by limiting us from overcommitting the system with more threads than the CPU cores.

This makes me wonder… Is that really the case? How does this work behind the scenes? Can we somehow cheat the system to create more threads than the CPU cores?

We are going to answer all these questions in this article. So without further ado, let’s jump right in.


Understanding Thread Explosion 💥

So, what is thread explosion? Thread explosion is a situation where a huge amount of threads are running concurrently in a system and eventually causes performance issues and memory overhead.

There is no clear answer to how many threads are considered too many. As a general benchmark, we can refer to the example given in this WWDC video, whereby a system that is running 16 times more threads than its CPU cores is considered undergoing thread explosion.

Since Grand Central Dispatch (GCD) does not have a built-in mechanism that prevents thread explosion, it is pretty easy to create one using a dispatch queue. Consider the following code:

final class HeavyWork {
    static func dispatchGlobal(seconds: UInt32) {
        DispatchQueue.global(qos: .background).async {
            sleep(seconds)
        }
    }
}

// Execution:
for _ in 1...150 {
    HeavyWork.dispatchGlobal(seconds: 3)
}

Once executed, the code above will spawn a total of 150 threads, causing thread explosion to occur. This can be verified by pausing the execution and checking the debug navigator.

Xcode debug navigator that shows thread explosion when using GCD
Debug navigator that shows thread explosion

Now that you have learned how to trigger a thread explosion, let’s try to execute the same code using Swift Concurrency and see what will happen.


How Swift Concurrency Is Managing Threads

As we all know, there are 3 levels of task priority in Swift Concurrency, mainly userInitiated, utility, and background, where userInitiated is having the highest priority, followed by utility and background with the lowest priority. Thus let’s go ahead and update our HeavyWork class accordingly:

class HeavyWork {
    
    static func runUserInitiatedTask(seconds: UInt32) {
        Task(priority: .userInitiated) {
            print("🥸 userInitiated: \(Date())")
            sleep(seconds)
        }
    }
    
    static func runUtilityTask(seconds: UInt32) {
        Task(priority: .utility) {
            print("☕️ utility: \(Date())")
            sleep(seconds)
        }
    }
    
    static func runBackgroundTask(seconds: UInt32) {
        Task(priority: .background) {
            print("⬇️ background: \(Date())")
            sleep(seconds)
        }
    }
}

Every time when a task is created, we will print out the creation time. We can then use it to visualize what’s happening behind the scene.

With the updated HeavyWork class in place, let’s get started with the first test.

Test 1: Creating Tasks with Same Priority Level

This test is basically the same as the dispatch queue example we saw earlier on, but instead of using GCD, we will use Task from Swift Concurrency to create a thread.

// Test 1: Creating Tasks with Same Priority Level
for _ in 1...150 {
    HeavyWork.runUserInitiatedTask(seconds: 3)
}

The following are the logs captured from the Xcode console.

Swift concurrency running maximum 6 threads at a time
Swift concurrency running maximum 6 threads at a time

As you can see (from the task creation time), the creation of threads stopped when the thread count reached 6, which perfectly matches the number of CPU cores of my 6-core iPhone 12. The creation of tasks will continue only after one of the running tasks completed its execution. As a result, there can only be a maximum of 6 threads running concurrently at one time.

Note:

The iOS simulator will always limit the maximum thread count to 1 regardless of the selected device. Therefore, make sure to run the above test using a real device for a more accurate outcome.

To have a clearer picture of what’s really happening behind the scenes, let’s pause the execution.

Swift Concurrency tasks with 'userInitiated' priority running on a concurrent queue
Tasks with ‘userInitiated’ priority running on a concurrent queue

It seems like everything we saw just now is controlled by a concurrent queue named “com.apple.root.user-initiated-qos.cooperative“.

Based on the above observation, it is safe to say that this is how Swift Concurrency prevents thread explosion from occurring: Have a dedicated concurrent queue to limit the maximum number of threads so that it won’t exceed CPU cores.

Test 2: Creating Tasks from High to Low Priority Level All at Once

Now, let’s dive a little bit deeper by adding tasks with different priorities to the test.

// Test 2: Creating Tasks from High to Low Priority Level All at Once
for _ in 1...30 {
    HeavyWork.runUserInitiatedTask(seconds: 3)
}

for _ in 1...30 {
    HeavyWork.runUtilityTask(seconds: 3)
}

for _ in 1...30 {
    HeavyWork.runBackgroundTask(seconds: 3)
}

Notice that we are creating tasks with the highest priority level (userInitiated) first, followed by utility and background. Based on our previous observation, I was expecting to see 3 queues having 6 threads running concurrently in each queue, which means we will see a total of 18 threads being spawned. Surprisingly that is not the case. Take a look at the following screenshots:

Swift Concurrency tasks distribution when starting from high to low priority level all at once
Tasks distribution when starting from high to low priority level all at once

As you can see, both the utility and background queues are limiting the maximum thread allowed to 1 when the higher priority queue (userInitiated) is saturated. In other words, the maximum number of threads we can have in this test is 8.

This is such an interesting finding! Saturating the high-priority queue will somehow suppress the other lower-priority queues from spawning more threads.

But, what will happen if we reverse the ordering of the priority level? Let’s find out!

Test 3: Creating Tasks from Low to High Priority Level All at Once

First thing first, let’s update the execution code:

// Test 3: Creating Tasks from Low to High Priority Level All at Once
for _ in 1...30 {
    HeavyWork.runBackgroundTask(seconds: 3)
}

for _ in 1...30 {
    HeavyWork.runUtilityTask(seconds: 3)
}

for _ in 1...30 {
    HeavyWork.runUserInitiatedTask(seconds: 3)
}

Here comes the result:

Swift Concurrency tasks distribution when starting from low to high priority level all at once
Tasks distribution when starting from low to high priority level all at once

The result we get is exactly the same as “Test 2”.

It seems like the system is smart enough to give way to the higher priority tasks to run first even though we started the lowest priority tasks first. On top of that, the system is still limiting us from creating more than 8 concurrent threads, therefore we are still not able to create a thread explosion for this test. Good job Apple! 👍🏻

Test 4: Creating Tasks from Low to High Priority Level with Break in Between

In real-life situations, it is very unlikely that we start a bunch of tasks with different priority levels all at once. So let’s create a more realistic condition by adding a small break between each for loop. Notice that we are still using the low to high ordering in this test.

// Test 4: Creating Tasks from Low to High Priority Level with Break in Between
for _ in 1...30 {
    HeavyWork.runBackgroundTask(seconds: 3)
}

sleep(3)
print("⏰ 1st break...")

for _ in 1...30 {
    HeavyWork.runUtilityTask(seconds: 3)
}

sleep(3)
print("⏰ 2nd break...")

for _ in 1...30 {
    HeavyWork.runUserInitiatedTask(seconds: 3)
}

The result we get is quite interesting.

Swift Concurrency tasks distribution when starting from low to high priority level with breaks in between
Tasks distribution when starting from low to high priority level with breaks in between

As you can see, after the 2nd break, all 3 queues are running multiple threads. Seems like if we started the lower priority queue first and let it run for a while, the higher priority queue will not suppress the lower priority queue performance.

I have executed this test a couple of times, the maximum number of threads might vary a little bit, but it is more or less equal to 3 times the CPU cores.

Is this considered a thread explosion?

I don’t think so, because 3 times more threads than the CPU cores is still way less than the 16 times threshold I mentioned earlier. In fact, I think Apple allows this to happen intentionally to have a better balance between execution performance & multi-threading overhead. Do hit me up on Twitter if you have other points of view, I would really like to hear your thoughts.


Conclusion

Swift concurrency is doing a pretty good job of preventing thread explosion, but we cannot deny the fact that it will cause a very significant bottleneck if we keep saturating the userInitiated queue.

Based on the result we get in “Test 4”, it is safe to say that we should use the background and utility queue more often, and only use the userInitiated queue when necessary.

Wanted to try out the sample code? Here you go!


If you like this article, make sure to check out my other Swift Concurrency related articles. You can also follow me on Twitter and subscribe to my newsletter so that you won’t miss out on any of my upcoming articles.

Thanks for reading. 👨🏻‍💻


👋🏻 Hey!

While you’re still here, why not check out some of my favorite Mac tools on Setapp? They will definitely help improve your day-to-day productivity. Additionally, doing so will also help support my work.

  • Bartender: Superpower your menu bar and take full control over your menu bar items.
  • CleanShot X: The best screen capture app I’ve ever used.
  • PixelSnap: Measure on-screen elements with ease and precision.
  • iStat Menus: Track CPU, GPU, sensors, and more, all in one convenient tool.