Sitemap
Hobin

Hobin

iOS developer in South Korea, always studying, exploring, and excited to share what I learn. And here's my linkedin profile: www.linkedin.com/in/hobin1019

Swift Concurrency — Concurrency and Threads (Part 5)

9 min readMay 22, 2026

--

reference: WWDC21: Swift concurrency: Behind the scenes | Apple

While studying Swift Concurrency, I thought I had a rough understanding of async/await, tasks, and actors.

But once I started looking into how Swift Concurrency actually works with threads, things became much more complicated.

This post is based on WWDC21’s “Swift concurrency: Behind the scenes,” and I’ll organize the parts that felt most important to me: GCD, thread explosion, continuations, the cooperative thread pool, runtime contracts, and how actors help with synchronization.

Press enter or click to view image in full size
Photo by Sajjad Ahmadi on Unsplash

A Quick Refresher on Threads

Before comparing GCD and Swift Concurrency, it helps to briefly review a few basics.

A CPU has one or more cores.
Roughly speaking, one core can execute one thread at a time. Some CPUs support hyper-threading, but I’ll ignore that here to keep the explanation simple.

For example:

  • Apple Watch has 2 cores
  • iPhone commonly has 6 cores

This matters because concurrency is not just about writing async-looking code. At runtime, the system still has to decide how work is scheduled onto real CPU cores and threads.

Serial Queue vs Concurrent Queue

Serial queue and concurrent queue are different from sync and async.

Serial Queue

A serial queue executes work items one by one. Because the work is processed in FIFO order, a serial queue can provide mutual exclusion.

This is why serial queues have often been used to protect shared state.

For example, database writes are often serialized because write operations should be exclusive.

Concurrent Queue

A concurrent queue can execute multiple work items at the same time. It is still a queue, but because multiple threads can access and execute work from it, it does not provide mutual exclusion.

This is useful when the work does not require exclusive access to shared state.

For example, downloading multiple images at the same time can be a good use case.

sync vs async

sync means the caller waits until the work finishes.

async means the caller does not wait. The work is scheduled, and the caller can continue doing something else.

So the rough distinction is:

  • serial / concurrent: how many pieces of work can start and run at the same time
  • sync / async: whether the caller waits for completion
Press enter or click to view image in full size

Comparing GCD and Swift Concurrency

Imagine we need to implement this flow:

  1. When a button is tapped, fetch list data.
  2. If the database does not have the list data, request it from the server.
    (Write the received data to the database)
  3. If the database has the data, read it from the database.
  4. Update the UI.

With GCD, this might be implemented with multiple queues:

  • button tap: main serial queue
  • API request: global concurrent queue
  • database write: global serial queue
  • database read: global serial queue
  • UI update: main serial queue

The code might look roughly like this:

let urlSession = URLSession(configuration: .default,
delegate: self,
delegateQueue: concurrentQueue)

let dataTask = urlSession.dataTask(with: url) { data, response, error in
guard let data else { return }

do {
let list = try deserializeList(from: data)

databaseQueue.sync {
updateDatabase(with: list)
}
} catch {
// handle error
}
}

dataTask.resume()

At first glance, this may look reasonable. But this code can cause thread explosion.

Thread Explosion

Thread explosion means the system becomes overcommitted with more threads than the number of available CPU cores.

Why can this happen?

When a thread running work from a concurrent queue becomes blocked by something like a lock, semaphore, or synchronous queue hop, the CPU core cannot make progress with that thread.

So GCD may create another thread to keep executing other work.

In the example above, databaseQueue.sync can block the current thread while waiting for the serial database queue.

If this happens once, it may not look like a big problem.

But if the same code runs for many URLs at the same time, many threads can become blocked, and GCD may keep creating more threads.

On a 6-core iPhone, running 100 similar operations can easily create far more threads than the number of available cores — roughly 16 times as many threads as cores.

That is thread explosion.

The problems include:

  • possible deadlocks
  • memory overhead from storing blocked thread stacks
  • scheduling overhead from excessive thread context switching
  • worse overall performance

How Swift Concurrency Approaches This Differently

Swift Concurrency was designed to avoid this kind of thread explosion.

Instead of creating more and more threads, Swift Concurrency uses a cooperative thread pool.

The key idea is:

Do not block threads. Suspend tasks instead.

In Swift Concurrency, the runtime generally uses a limited number of worker threads, based on the number of CPU cores.

When async work reaches an await, the thread is not blocked. Instead, the task is suspended, and the thread can go do other work.

Rewriting the GCD Flow with Swift Concurrency

Using Swift Concurrency, the previous flow can be written with async functions and a task group:

try await withThrowingTaskGroup(of: [Article].self) { group in
for feed in feedsToUpdate {
group.addTask {
let (data, response) = try await URLSession.shared.data(from: feed.url)
let articles = try deserializeArticles(from: data)
await updateDatabase(with: articles, for: feed)
return articles
}
}
}

With async/await, we no longer have to manually combine serial queues, concurrent queues, sync calls, and async callbacks in the same way.

The runtime can see the task structure more clearly. And that visibility is important.

Await and Continuation

So how can Swift Concurrency avoid blocking threads?

The answer is continuation.

Imagine this async function:

func updateDatabase(with article: Article, for feed: Feed) async {
await feed.add(article)
}

When execution reaches await feed.add(article), the task is suspended. The code that needs to run after the await is stored as a continuation. Then the current thread is released and can execute other work. Later, when the awaited operation completes, the runtime resumes the continuation.

This means Swift Concurrency can perform asynchronous work without blocking the underlying thread.

And a continuation switch is much cheaper than a thread context switch.

Tracking Task Dependencies

Swift Concurrency can also track task dependencies.

For example:

  • code after an await depends on the awaited async operation completing
  • a parent task depends on its child tasks completing before the parent can finish

Because these dependencies are visible to the Swift runtime, it can preserve forward progress.

This is an important runtime contract.

In short:

Swift tasks should suspend instead of blocking threads.

If tasks block cooperative pool threads, the runtime loses one of the core assumptions that makes Swift Concurrency efficient.

Cooperative Thread Pool

The cooperative thread pool supports this runtime contract.

Get Hobin’s stories in your inbox

Join Medium for free to get updates from this writer.

It has several important characteristics:

  • async code such as async functions, tasks, and actors generally runs on the cooperative thread pool unless a specific executor such as MainActor is involved
  • unlike GCD, it does not keep creating threads without limit
  • worker threads are expected not to block
  • thread explosion and excessive context switching can be avoided

This is why Swift Concurrency APIs are designed around suspension.

await, actors, and task groups all help the runtime understand dependencies and keep threads moving forward.

Things to Be Careful About

1. Performance

Concurrency is not free.

Creating and managing tasks requires memory allocation and runtime work.

So it is not always a good idea to create a child task for very small operations. For example, creating a separate task just to read a small integer from UserDefaults may be unnecessary.

When introducing concurrency, it is better to measure with tools like Instruments and confirm that performance actually improves.

2. Atomicity Around await

await breaks atomicity.

The thread that runs the code before await and the thread that resumes the continuation after await may be different.

Therefore:

  • do not hold a lock across an await
  • do not assume thread-local data will still be available after an await

This point is easy to miss because async/await makes code look sequential. But at every await, execution can suspend and resume later.

3. Runtime Contract

When using Swift Concurrency, we should avoid breaking the runtime contract of forward progress.

Safe tools include:

  • await
  • actors
  • task groups

These tools allow the runtime to understand dependencies and suspend work without blocking threads.

Locks such as os_unfair_lock or NSLock can be acceptable in synchronous code, but they require care because the compiler cannot help much.

Tools such as semaphores or condition variables can be dangerous when used across task boundaries because they hide dependencies from the Swift runtime and can block cooperative pool threads.

For example, this kind of pattern should be avoided:

func updateDatabase(_ asyncUpdateDatabase: @Sendable @escaping () async -> Void) {
let semaphore = DispatchSemaphore(value: 0)

Task {
await asyncUpdateDatabase()
semaphore.signal()
}

semaphore.wait()
}

The task dependency exists, but the runtime cannot see it clearly because it is hidden behind a semaphore.

Synchronization

So how should synchronization be handled in a concurrent environment?

Swift actors are the preferred tool in many cases. Actors provide mutual exclusion by making sure that only one actor-isolated operation can access the actor’s mutable state at a time.

This prevents data races at the language level.

Serial Queue vs Actor

Consider a serial queue:

databaseQueue.sync {
updateDatabase(articles, for: feed)
}

If there is no contention, the calling thread may be reused to run the queued work without a context switch.

But if there is contention, the calling thread blocks. That blocking can contribute to thread explosion.

What about async dispatch?

databaseQueue.async {
// background work
}

This avoids blocking when there is contention.

But when there is no contention, the work still gets scheduled asynchronously, and another thread may run it. If this pattern is used heavily, it can still increase thread creation and context switching.

Actors avoid these drawbacks because they are managed by Swift’s cooperative runtime.

With actors:

  • if there is no contention, the current thread can often continue running the work
  • if there is contention, the work suspends instead of blocking the thread

So, how does it work when actor hopping happens? 😲
(Actor hopping means execution moves from one actor to another)

For example, code running on Actor A may need to call Actor B.

If there is no contention, the runtime can hop directly from Actor A to Actor B while keeping the thread moving.

If there is contention, the current actor work can be suspended, and the thread can be released to run other available work.

This is different from blocking a thread while waiting for another queue or lock.

Reentrancy and Prioritization

When there is heavy contention, the system also needs to decide which work should run first.

Usually, user-initiated work should have higher priority than background work.

With GCD serial queues, work is usually processed in strict FIFO order:

// User Initiated
databaseQueue.async {
fetchLatestForDisplay()
}

// Background
databaseQueue.async {
backUpToiCloud()
}

If low-priority work is already queued before high-priority UI work, the high-priority work may wait for a long time.

This is a form of priority inversion.

GCD may try to address this by raising the priority of the queue’s work, but the strict FIFO nature of the queue still limits scheduling flexibility.

Actors are designed differently.

Because actors are reentrant, the runtime can choose among available actor work items more flexibly instead of blindly following strict FIFO ordering.

This allows Swift Concurrency to reprioritize actor work and reduce priority inversion.

Main Actor and Context Switching

The main actor is special because it represents the main thread.

UI updates must happen through the main actor.

But the main actor is separate from the cooperative thread pool, so hopping between the main actor and other actors can require context switching.

For example:

@MainActor
func updateArticles(for ids: [ID]) async throws {
for id in ids {
let article = try await database.loadArticle(with: id)
await updateUI(for: article)
}
}

This code repeatedly hops:

  • from the main actor to the database actor
  • from the database actor back to the main actor

If this happens many times in a loop, the context switching overhead can become noticeable.

A better approach is to batch the work:

@MainActor
func updateArticles(for ids: [ID]) async throws {
let articles = try await database.loadArticles(with: ids)
await updateUI(for: articles)
}

The key idea is to reduce unnecessary actor hopping, especially when the main actor is involved.

Wrap-up

Today, we looked at how Swift Concurrency works with threads behind the scenes.

The biggest takeaway for me is that Swift Concurrency is not about creating more threads. It is about keeping a small number of threads moving by suspending tasks instead of blocking them.

Here is the final summary:

  • GCD can suffer from thread explosion when blocked threads cause more threads to be created.
  • Swift Concurrency uses continuations and a cooperative thread pool to avoid unnecessary blocking.
  • await suspends a task and lets the underlying thread do other work.
  • The Swift runtime tracks task dependencies to preserve forward progress.
  • Blocking cooperative pool threads can break Swift Concurrency’s runtime contract.
  • Actors provide synchronization without blocking threads.
  • Actor reentrancy allows more flexible prioritization than strict FIFO serial queues.
  • Main actor hopping can be expensive, so batching work can reduce context switching.

If you found this useful, feel free to leave a comment or clap — it means a lot 😊

If you’d like to read the original post in Korean, check it out here:
👉 한국어 블로그 원문

--

--

Hobin
Hobin

Written by Hobin

iOS developer in South Korea, always studying, exploring, and excited to share what I learn. And here's my linkedin profile: www.linkedin.com/in/hobin1019