Behind the scenes of async functions

Vitaly BatrakovVitaly Batrakov
18 min read

You can find video version of this article here.

Hey iOS Folks!

Swift Concurrency (SC) is becoming an integral part of every iOS app project, making a strong understanding of it essential. While WWDC sessions offer valuable insights, they often leave some questions unanswered. If your mental model of SC still feels incomplete, I hope this article helps.

In short, I want you to feel like Buzz Lightyear when working with SC — not like Woody, lost in confusion. We'll take a step-by-step approach to see how each SC concept connects, helping you build a solid understanding of how it all works together.

⚠️ : I assume you are familiar with the basic concepts of Swift Concurrency. If not, you can start here. I will not explain every Swift Concurrency concept in detail. My goal is to show how everything works together and try to provide a full picture.

Let’s start with async functions, as they are the part of Swift Concurrency we most often work with directly.

Async functions

What is an async function? Well, it’s a function with async keyword, obviously. But what does that actually mean? The simplest way to understand an async function is to think of it as a special kind of function that can be suspended during it’s execution (specifics of when and how this suspension occurs aren't as important right now as the fact that it can be suspended).

func asyncWork() async {
    //...
}

It may seem counterintuitive, but ordinary synchronous functions can work as asynchronous functions, but not the other way around. Asynchronous functions can be suspended, but they might not always be. Therefore, the compiler can treat a sync function as an async function that simply never suspends.

The reverse, however, is not allowed. Sync functions are expected to never be suspended, so an async function does not meet these requirements.

For example:

  • async method protocol requirement can be implemented ✅ with a sync method.

      protocol SomeProtocol {
          func work() async
      }
    
      struct SomeStruct: SomeProtocol {
          func work() {
              //...
          }
      }
    
  • sync method protocol requirement can’t be implemented ❌ with an async method.

      protocol SomeProtocol {
          func work()
      }
    
      // Compiler Error: Type 'SomeStruct' does not conform to protocol 'SomeProtocol'
      struct SomeStruct: SomeProtocol { 
          func work() async {
              //...
          }
      }
    

That gives us the idea that conforming to a "sync function" is a "stronger" (stricter) requirement than conforming to an “async function”. Sync function can work as async function, but not the other way around.

Async functions can be called only from async context.

💡: Basically, being in async context means that we are inside async function (or closure).

When the app starts running, it is in a synchronous(sync) context by default. Most of the code related to the app or UI lifecycle is also executed in a sync context.

⁉️: How we can switch from sync context to async context? Indeed, if we can call async function only from async function or closure, how we can call async function or closure in the first time? We can do it using unstructured concurrency tasks (Task {}), but we will discuss that a bit later.

To call an asynchronous function, we always need to await (asynchronously wait) it. That fact gives us another example of how sync functions are “stronger” than async functions. You can’t call async function without await. But you can call sync function with await (with warning from compiler, of course, once nothing async is happening there) or without await:

await syncFunc() // Warning: No 'async' operations occur within 'await'
asyncFunc()  // Error: Expression is 'async' but is not marked with 'await'

Async → await

Async/await feature allows to write asynchronous functions and call them asynchronously by awaiting them. In result, we can write asynchronous code in a more sequential and readable manner, like we did with sync functions.

func asyncWork() async -> Int {
    //...
}

let result = await asyncWork() // doesn't block the thread execution

But what does it mean to “asynchronously wait” the function call and how this waiting doesn’t block the thread execution?

Do you remember that an async function can be suspended? Well, this happens only when you use the await keyword to await for another async function. When a function encounters await, its execution can be paused until the awaited function call is complete. In other words, await creates a potential suspension point 😴 in your code.

💡: await is only a potential suspension point. It only will be an actual suspension point if execution contexts of caller and callee functions are different. We will come back to it to discuss how it works more deeply a bit later.

Ok, but how it doesn’t block the thread then?

💡: Simply put, a suspended async function gives up its thread to perform other work while it waits for the awaited function to complete.

When an async function reaches an await point that causes suspension, a continuation is created - a snapshot of the current execution state that can resumed later. This frees up the thread for other tasks. Instead of blocking threads, continuations allow execution to switch between tasks efficiently, incurring only the cost of a function call rather than a full thread context switch.

What is continuation?

The continuation is a snapshot of async function execution state (return address, params, local vars, etc.). In other words, continuation remembers the point in the caller function where execution should resume after the awaited operation is completed. When a suspended function is ready to resume, the runtime can pick up the continuation and continue execution, potentially on a different thread. At runtime, continuation is represented by the list of async frames stored on the heap.

💡: We're not diving too deep here, but I recommend to watch WWDC2021 - Swift concurrency: Behind the scenes (since 14:26) session to understand how the async frames work.

⁉️: Why the heap is needed? Because continuation can potentially be picked up by a thread different from the original thread where the async function was awaited, there is a necessity for the continuation state to be shared across different threads. Therefore, it cannot be bound to the call stack of a single thread.

Tasks

We already mentioned that to switch from sync to async context, we need to create an unstructured concurrency Task:

Task {
    await callAsyncFunc()
}

However, that is not the only use case for tasks. Tasks are, in fact, a much broader concept in Swift Concurrency.

💡: Tasks are units of asynchronous work you can run from your code.

  • Every async function is executing in a task. In other words, a task is to asynchronous functions, what a thread is to synchronous functions.

  • A task runs one function at a time; a single task has no concurrency.

  • Synchronous functions do not necessarily run as part of a task, but they can.

💡: Here you can find more insights.

⁉️: How tasks can be created?

  • Explicitly with Task {} and Task.detached {}.

  • Implicitly by async let and task group (they are gonna be child tasks).

⁉️: Is the task created when an await with suspension point 😴 occurs? No, even if it is an actual suspension point.

  • When a function makes an async call, the called function is still running as part of the same task.

  • Similarly, when a function returns from an async call, the caller resumes running on the same task.

A task can be in one of three states:

  • 🔴 A suspended task has more work to do but is not currently running (initial state).

    • It may be schedulable, meaning that it’s ready to run and is just waiting for the system to instruct a thread to begin executing it.

    • or it may be waiting on some external event before it can become schedulable.

  • 🟡 A running task is currently running on a thread.

    • It will run until it either completes or reaches an actual suspension point 😴 (and becomes suspended again). A task's state can change between running and suspended multiple times during its lifecycle.
  • 🟢 A completed task has no more work to do and will never enter any other state.

    • Code can wait for a task to become completed by awaiting on it.

💡: Do you remember that async functions can be suspended at potential suspension points with await? Well, what actually gets suspended is the task in which the async function is executing. Of course, the async function itself is also suspended since it's part of the task, but it's important to highlight that the task is what's truly being “paused”.

⁉️ : Are we already inside some task when app starts? No, you can check it from any viewDidLoad or AppDelegate :

func checkIfInsideTask() -> Bool {
    withUnsafeCurrentTask { $0 != nil }
}

Again, by default, app runs in synchronous world, so you need to create a task to switch the gears from sync to async.

Jobs

Task are units of async work but not the most basic ones. Tasks consists of the jobs, even more smaller units of asynchronous work. Jobs are the basic units of schedulable synchronous work that occurs between suspension points within a Task. When a Task suspends (for instance, while waiting for an async function to complete), the next job will be scheduled for later execution.

Let’s consider an example:

Task {
    beforeWork()
    await asyncWork()
    afterWork()
}

Every async call with await can be thought of as splitting the code into three different parts:

  • job before the suspension point;

  • job that resumes after the suspension point;

  • job (or jobs) that will be created based on asyncWork structure (asyncWork can have more awaits inside of it).

⚠️ : This isn't documented, so I could be wrong in some details, but this is how I understood it from this Swift forum thread. Let me know in the comments if you have more details.

💡: Simply put, you can think of it as breaking up an async function into synchronous chunks, each of which is a job.

Developers usually don’t work with the jobs directly. Jobs are created by runtime. But their structure is defined at compiling the awaits structure. The compiler generates code for async functions, including how they suspend, resume, and manage continuations. This precompiled structure tells the runtime how to create jobs when needed, but actual job instances are created at runtime when suspension occur.

💡: Single task has no concurrency, meaning the jobs within a single task also have no concurrency with each other. They wait for the previous job to complete before starting. Jobs are designed to be executed sequentially, one after another, with the possibility of the task being suspended if the next job needs to be executed on a thread that is currently busy.

💡: Jobs defined as synchronous which means there are no await’s inside of a job.

Actors

Tasks and jobs can perform work concurrently, which is great. However, when multiple tasks need to access or modify the same shared data, there’s a risk of data races, which can lead to unpredictable behavior. This is where actors come into play. Actors allow you to isolate async function calls to safely share their (actor’s) state with other concurrent tasks or threads. This ensures that the mutable state within the actor is accessed in a thread-safe manner, preventing race conditions and data corruption.

💡: Metaphorically, if a task were a car, an actor would be a garage — only one car can park in the garage at a time. A car can park in a garage, but car doesn’t own it.

You probably already familiar with actors from other articles, but we will consider one simple example to refresh it quickly:

actor SomeActor {
    let immutableState = 1

    var mutableState = 2

    func updateState(_ newValue: Int) {
        mutableState = newValue
    }
}

let actor = SomeActor()

print(actor.immutableState) // accessed without await in sync context

Task.detached {
    await print(actor.mutableState) // accessed mutable state from async context

    actor.mutableState = 3 // Compilation Error: Actor-isolated property 
    // 'mutableState' can not be mutated from a nonisolated context

    await actor.updateState(3) // can be mutated with actor method
}

Main takeaways from example:

  • Actors allow only one async func (~task) to access their mutable state at a time (it will happen asynchronously), which makes it safe for code in multiple tasks to interact with.

  • Immutable state can be accessed synchronously. Yes, you can I access actor’s immutable state from sync context without using await.

  • Mutable state can be accessed only from async context with await.

  • You can change the mutable state only with actor own methods.

Executors

Executor is a special service which accepts the enqueued jobs and arranges some thread to run them. Once job is enqueued to some executor, it will be fully completed there. Executors manage job priorities, ensuring that high-priority tasks are executed before lower-priority ones. For the most part, ios engineers will not have to work directly with executors or jobs unless they are implementing a custom executor, which is probably not very common.

Jobs were defined as schedulable but what does it actually mean?

Swift doesn’t expose an explicit "scheduler" object, meaning the runtime implicitly acts as one. Scheduling involves the runtime enqueueing jobs to the appropriate executor based on the "context" — essentially, the code itself. From the code, we can determine which executor should handle a job. The code also defines the job structure, so each job has a designated target executor. This will become clearer when we discuss different types of executors later.

💡: Later, when we use execution context, we will mean the executor and vice versa.

Executor is also the key concept to understand when potential suspension point becomes actual suspension point 😴.

When async function calls another async function with the same target executor, it continues running without suspension, there is no actual suspension point. It becomes an actual suspension point if the caller and callee should run on different executors, and the runtime switches the execution contexts (hops to another executor) to execute the async function.

When awaited async function call completes, caller function always continues it’s execution on the same executor as it was before the async call:

func exampleFunc() async {
  // hop to executor of firstFunc
  await firstFunc()
  // hop to executor of exampleFunc

  syncWork()

  // hop to executor of secondFunc
  await secondFunc()
  // hop to executor of exampleFunc

  syncWork2()
}

Executors run jobs using threads, but they don’t create or own threads. Instead, they borrow threads from the Cooperative Thread Pool (CTP).

Cooperative Thread Pool

Before Swift Concurrency, we mostly built the concurrency in our apps with Dispatch queues (Grand Central Dispatch aka GCD aka libdispatch) and OperationQueue (which is basically object oriented wrapper around GCD).

Dispatch queues can be either serial or concurrent. Like Executors in Swift Concurrency, they execute submitted work on a system-managed thread pool.

We also had a special queue - Main Queue - that executes code only on the Main Thread.

GCD with queues is a powerful approach, but it can cause issues at scale. GCD schedules tasks onto its threads using a preemptive model. (Keep in mind that a GCD task differs from a Swift Concurrency Task. A GCD task is essentially a closure — a block of code that can be queued and executed later). If too many tasks are queued concurrently, GCD may create new threads as needed. This can overload CPU resources because GCD doesn’t strictly limit the number of active threads to the available CPU cores. This situation, known as a thread explosion, occurs when an app spawns too many threads, often leading to performance degradation, resource exhaustion, or even crashes.

Swift Concurrency introduced the Cooperative Thread Pool (CTP) - a shared pool of threads. While thread pools are not new to GCD, CTP was a new feature added specifically for Swift Concurrency (so SC relies on GCD under the hood). Unlike previous dynamic thread pool, CTP uses a fixed number of threads, limited to the number of CPU cores (e.g., six threads for a six-core iPhone 16 Pro).

You can recognize CTP threads by “cooperative (concurrent)" label there. As you can see, there are also other threads outside CTP doing something there at the same time.

💡: Limiting CTP thread count to CPU cores count allows to avoid thread explosion and scheduling threads overhead of context switching (switching CPU core between threads is expensive operation).

⁉️: Why does it match the thread count to the CPU core count? Isn’t the main thread always there? Why not make it N-1 once the main thread is handling UI updates? The main thread doesn’t necessarily always have work to do. As I mentioned in my article on UI under the hood, the run loop on the main thread puts it into sleep after each iteration if there’s no work to do. When there’s no work for the main thread, it’s better to use the CPU core it was using. That why in SC, I guess, it matches the thread count to the core count - for better performance. It doesn't completely disable thread context switching, but aims to minimize it and keep performance optimal.

In Swift Concurrency, we also continue to use Main thread (and main queue) which is not the part of CTP.

💡: All tasks managed by SC are executed either on the CTP or Main Thread.

💡: SC removes our ability to create or manage threads directly and encourages us to focus on executors and actor isolation instead. When running code with SC, we no longer think about which thread it should run on. Instead, we focus on how it should be executed in terms of actor isolation, which basically means - which type of executor will handle the task.

Types of executors

Before combining all the concepts we've covered so far, we need to understand the different types of executors that exist. Once we already know the roles of actors, the Cooperative Thread Pool (CTP), and the main thread in Swift Concurrency, we can explain how they all connect to executors.

There are 3 types of executors that we need to know about:

  • Default concurrent executor

  • Serial executor

  • Main executor

Default concurrent executor

It’s a default executor and it’s concurrent, obviously.

“Default” means that all non-actor-isolated jobs are executed on this executor by default. Jobs executions happens using threads from Cooperative Thread Pool.

“Concurrent” means that it can execute jobs from different tasks concurrently, using multiple threads from CTP (if needed and if they are available).

Serial executor

Special executor type for actor isolated jobs. Every actor you write in your code in Swift Concurrency has its own serial executor. Serial executor runs jobs on the Cooperative Thread Pool too, but it reserves a single thread from the pool to do it. (This thread can vary at different moments for the same actor, but during any particular job, it remains the same). "Serial" means executing jobs one after another, sequentially, one at a time.

⚠️ : But don’t be misled by the “serial” part of the name of serial executor. Actors are not like serial queues because of reentrancy.

💡: When we say actor context (aka actor executor or actor isolation) it basically means that execution context aka executor of that actor.

Main executor

Special executor that handles main actor isolated jobs and represents the main thread.

Executes jobs on Main thread (technically they simply go to Main Dispatch Queue).

We can visualize how a job is mapped to its executor:

Executors and Threads

Worth to mention that even though execution context after await always remains same as before await, it doesn’t mean that thread will remain the same in this case. At least in most cases.

  • For tasks that are not actor-isolated, the continuation will run on the global concurrent executor (i.e., the cooperative thread pool). However, the thread may differ before and after the continuation, although it will certainly not be the main thread.

      Task.detached {
          // global concurrent executor, some thread from CTP
          someSyncFunction()
    
          await someAsyncFunction() // executor of someAsyncFunction
    
          // still global concurrent executor, maybe different thread from CTP
          someSyncFunction()
      }
    
  • For some actor (not main one) - continuation will run on the same serial actor executor but thread can be different from original one that was executing the task before suspension point.

      actor SomeActor {
          @MainActor
          func someAsyncFunction() async {}
    
          func someSyncFunction() {}
    
          func someMethod() async {
              // same actor executor, some thread from CTP
              someSyncFunction()
    
              await someAsyncFunction() // executor of someAsyncFunction (main executor)
    
              // same actor executor, maybe different thread from CTP
              someSyncFunction()
          }
      }
    
  • But main actor is special - continuation will run on the main actor and main thread as well for both before and after suspension point.

      Task { @MainActor in
          // main actor executor, main thread
          someSyncFunction()
    
          await someAsyncFunction() // executor of someAsyncFunction
    
          // main actor executor, main thread
          someSyncFunction()
      }
    

Recap for Full Picture

Let’s go bottom-up and summarise everything once again.

Cores and Threads

In Swift Concurrency, to avoid thread explosion and for optimal parallel performance, threads live in Cooperative Thread Pool and threads count is limited to CPU cores number.

Main thread is not the part of Cooperative Thread Pool.

Executors

Executor is a service that executes jobs. All executors except main executor borrow threads from CTP.

Main executor uses main queue for main actor isolated jobs/tasks.

Serial executor is a special executor type for actors to do actor isolated jobs/tasks (except main actor of course).

All other jobs goes to default concurrent executor.

Tasks

Tasks are units of asynchronous work you can run from your code. Tasks serve as a bridge between sync and async contexts. Tasks consist of the jobs.

Jobs

Jobs are the basic units of schedulable synchronous work that occurs between suspension points within a Task. Every job has it’s target executor based on it’s actor isolation. Actors allow us to isolate jobs in the tasks to safely share their state with other tasks. Code inside task is separated into jobs based on the structure of awaits for async function inside of it (which are potential suspension points). Jobs are created by runtime. But their structure is defined at compiling the awaits structure of async functions calls.

Async/await

Async function is special function that can be suspended while awaiting another async function to complete. Suspension point is the point in an async function where execution can be paused while another async function call is awaited and resumed later with continuation. The continuation is a snapshot of async function execution state that can be resumed later.

Full scheme:

Final thoughts

Swift concurrency is a powerful tool that eliminates callback hell, offers a cleaner syntax, and reduces concerns like forgotten completion handlers. Its focus on readability, error handling and data-race safety makes it a significant step forward. However, it comes with a steep learning curve. Compared to GCD, which was simpler to grasp initially but exposed its limitations as complexity grew, Swift concurrency demands more effort upfront.

If you interested to know more about Swift Concurrency internals, I recommend this article for reading as well.

Swift concurrency requires time and practice to fully understand. Be patient and prepared, as reading articles alone probably won’t be enough - you’ll need hands-on experience, trial and error, and, yes, a few “grabbles” along the way.

If you spot any inaccuracies or misleading points in this article, please share them in the comments - your feedback helps everyone!

See you in the next article!

0
Subscribe to my newsletter

Read articles from Vitaly Batrakov directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Vitaly Batrakov
Vitaly Batrakov

Senior iOS Engineer at Skip