WWDC Notes: Swift concurrency: Behind the scenes
Meet async / await, explore structured concurrency, protect mutable state with actors should be watched first.
Compares GCD to Swift. It’s not built on top of GCD. It’s a whole new thread pool.
GCD is very eager to bring up threads whenever we kick off work on queues. When a queue blocks its thread, a new thread will be spawned to handle work.
This means that the system can overcommit with more threads than there are CPU cores. This is also called Thread explosion and can lead to memory and performance issues.
There’s a lot of scheduling overhead during threads. There will likely be a ton of context switching which in turn will make the CPU run less efficiently.
Swift concurrency was designed to be more efficient than GCD.
The goal is to have no more threads than CPU cores. Instead, there are continuations that can be blocked. Instead of having the CPU context switch, the thread does this. It’s as simple as a method call so the overhead is much, much lower.
To make this happen, the language has to be able to guarantee that threads do not block through language features:
awaitand non-blocking of threads
- Tracking of dependencies in Swift task model
await does not block a thread like GCD’s
Every thread has a stack that keeps track of function calls. There are several stack frames. One for each function call. When a function returns, its stack frame is popped.
When an async function is called with
await, it’s tracked as an async frame on the heap. The async frames keep track of state that’s needed when the awaited function returns. When another function is called, the topmost stack frame on the thread is replaced. Because async frames are stored on the heap, they can be put back on a thread and resumed. Async frames will be put back on the stack as needed. Calling sync code in an async fuck will add frames to the thread’s stack.
The block of code that runs after the
await is called a continuation When execution should resume, the continuation is put back on a thread’s stack.
Interesting stuff, try to find out more and properly understand this.
Async work is modeled with tasks. Tasks can have child tasks. Tasks can only await other tasks in swifts. Awaited tasks are either continuations or child tasks.
Threads can track these task dependencies and they’ll know how to suspend tasks and schedule other work until the task can be resumed.
A cooperative thread pool is the default executor for Swift. The number of threads is limited to the number of CPU cores. Threads will always make forward progress and avoids thread explosion and excessive task switching.
Where with GCD you needed to be mindful of the number of queues you use, Swift concurrency ensures that you don’t have to worry about this anymore.
Concurrency always comes with a cost. It takes additional memory allocation and logic in the Swift runtime. Concurrency should only be used when its cost outweighs the cost of managing it.
For example, reading for user defaults is a super small task that should not be spawned into its own async task unless needed.
Measure performance to understand when you need concurrency.
await explicitly breaks atomicity because in the time between your await and calling the continuation, things might change. You should never hold locks across
await. Thread specific data is also not preserved across
await because you might resume on a different thread than the one you were suspended on.
The Swift language upholds the runtime contract that threads can always make forward progress. You have to make sure you don’t break this contract so the thread pool can do its best work.
- Use primitives like await, actors, and task groups so the compiler can enforce the contract.
- Locks can be used in sync code with caution. There’s no compiler support but does not break the contract as long as the thread is not fully blocked (for too long)
- Semaphores and
NSConditionare not safe to use. They hide dependency information from the runtime so it cannot schedule work correctly which might result in blocking.
Don’t use unsafe primitives to wait across task boundaries. Like for example using a semaphore in an async context. This is not save.
LIBDISPATCH_COOPERATIVE_POOL_STRICT=1 environment variable will run the app under a debug runtime that enforces forward progress for thread.
Synchronization with Actors
Actors synchronize access to their state through mutual exclusion.
DispatchQueue.sync, a current thread can be reused when there’s no contention. When there is,
DispatchQueue.sync is blocking and new threads are spawned.
When you use
DispatchQueue.async, you’re non-blocking under contention, but a new thread is always spawned.
Swift Actors always reuse threads and are non-blocking. If the thread is free, code is run. If not, function is suspended and run later.
Serial queues can be replaced with actors to manage access.
When you switch between different actors, you are thread hopping. An actor can be suspended and threads can easily hop from a running actor to a currently suspended actor. The runtime can handle this by creating work items for the thread without spawning a new thread.
Actor work items can remain pending until an in progress work item is completed.
Actors are designed to allow the system to prioritize work.
Actor reentrancy means that an actor might have pending work when it schedules and executes new work items. This can happen if a task is awaiting something, and this other thing awaits something on the actor.
The main actor runs on the main thread. The main thread is separated from the rest of the threads in the cooperative pol.
When you hop on and off the main actor often, you force hopping from and to the mean thread. This is expensive. If this happens it’s better to bundle work and run a bigger task to update UI from the main actor. For that reason, it’s not adviced to jump onto an actor and away from the main actor for small bits of work (often).