On Android, threading is mostly left to the application. You create threads, manage thread pools, use Executor services, and handle synchronisation yourself. The OS provides basic primitives (POSIX threads, futexes) and stays out of the way.
iOS takes a more opinionated approach. The system provides Grand Central Dispatch (GCD) — a framework for concurrent programming that manages thread pools, schedules work blocks, and handles thread lifecycle on your behalf. GCD doesn't just provide threads. It provides a policy for how work should be scheduled, prioritised, and executed across CPU cores.
For Flutter developers, GCD matters because it's the concurrency model that the platform layer — all Objective-C/Swift code in the Flutter engine embedding and plugins — uses. Understanding it explains why certain plugin operations block the main thread, how background work is scheduled, and how iOS decides which thread gets CPU time.
Dispatch queues: the abstraction
GCD's central concept is the dispatch queue — a FIFO queue of work blocks that the system executes on managed threads. You submit work to a queue; GCD decides when and on which thread to execute it.
There are two types of queues:
Serial queues execute one block at a time, in order. The next block doesn't start until the current one finishes. The main queue (the UI thread's queue) is a serial queue.
Concurrent queues execute multiple blocks simultaneously. Blocks are started in order but may finish in any order, because they run on different threads in GCD's thread pool.
// Serial queue — one at a time
let serialQueue = DispatchQueue(label: "com.app.processing")
serialQueue.async {
// This runs alone
}
serialQueue.async {
// This waits until the above finishes
}
// Concurrent queue — multiple simultaneously
let concurrentQueue = DispatchQueue(label: "com.app.network", attributes: .concurrent)
concurrentQueue.async {
// These two may run
}
concurrentQueue.async {
// at the same time
}The main queue is special. It's a serial queue tied to the main thread (the platform thread in Flutter terms). All UIKit operations must happen on the main queue. All platform channel callbacks from Flutter arrive on the main queue. Blocking this queue blocks the UI — the same constraint as on Android.
Quality of Service: priority without numbers
GCD doesn't use numeric thread priorities. It uses Quality of Service (QoS) classes — semantic categories that tell the system how important the work is and how to schedule it:
| QoS class | Purpose | CPU priority | Timer coalescing | I/O priority |
|-----------|---------|-------------|-----------------|-------------|
| .userInteractive | Work the user is waiting for right now | Highest | Minimal | Highest |
| .userInitiated | Work the user triggered and expects soon | High | Low | High |
| .default | General work | Medium | Medium | Medium |
| .utility | Long-running work with a progress bar | Low | Aggressive | Low |
| .background | Work the user doesn't know about | Lowest | Maximum | Lowest |
The QoS class affects more than just CPU scheduling:
- Timer coalescing. Low-priority work has its timers coalesced — the system delays timer fires to batch them with other work, reducing CPU wake-ups and saving power. A
.backgroundtimer set for 10 seconds might actually fire at 10.5 seconds because the system batched it with other wake-ups. - I/O priority. Disk reads/writes from low-priority queues are deprioritised. A
.backgroundfile operation yields to a.userInteractiveone. - CPU core assignment. On Apple Silicon's big.LITTLE architecture (performance cores + efficiency cores), QoS influences core assignment.
.userInteractivework runs on performance cores..backgroundwork may run on efficiency cores, which are slower but use less power.
This is a fundamentally different model from Android's thread scheduling. On Android, you set thread priorities with setPriority() or nice() — numeric values that the kernel's CFS scheduler uses for time-slice allocation. On iOS, you declare the intent of the work, and the system makes scheduling decisions holistically.
How Flutter's threads map to GCD
The Flutter engine creates its threads using POSIX pthread_create(), not GCD queues. But the threads exist within the iOS threading environment and interact with GCD-managed threads.
The platform thread (main thread). This is the iOS main thread, which is also the main dispatch queue's thread. It's a serial queue with .userInteractive QoS. When the Flutter engine receives platform channel messages, it dispatches them to this thread. When plugin Objective-C/Swift code handles a method channel call, it runs here. Any UIKit operation — presenting a view controller, updating the status bar, handling deep links — must happen here.
The Dart UI thread. Created by the Flutter engine as a POSIX thread. The engine sets its thread priority to match .userInteractive QoS, because it's doing frame-critical work (building the widget tree, running layout, producing the layer tree). This thread doesn't use GCD queues — it has its own event loop managed by the Dart VM.
The raster thread. Also a POSIX thread created by the engine. Set to high priority because it's rendering frames. On iOS, this is where Impeller submits Metal commands to the GPU.
The I/O thread. Lower priority POSIX thread for background operations (image decoding, asset loading). The engine sets this to a priority roughly equivalent to .utility QoS.
Plugin threads. This is where GCD becomes directly relevant. When a plugin needs to do work off the main thread, it typically uses GCD:
// Plugin code — moving heavy work off the main thread
public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) {
switch call.method {
case "processImage":
DispatchQueue.global(qos: .userInitiated).async {
let processed = self.heavyImageProcessing(call.arguments)
DispatchQueue.main.async {
result(processed) // Must call result on the main thread
}
}
default:
result(FlutterMethodNotImplemented)
}
}The pattern: receive the platform channel call on the main thread, dispatch heavy work to a GCD concurrent queue, then dispatch the result back to the main thread. The FlutterResult callback must be called on the main thread — the engine expects it there.
The main thread problem
The main thread constraint is the single most common source of platform-layer performance issues in Flutter iOS apps.
The main thread handles:
- Platform channel callbacks from Dart
- UIKit events (lifecycle, rotation, keyboard)
- System service callbacks (location, notifications, Bluetooth)
- GCD main queue work from plugins
If any of these operations is slow, everything else on the main thread waits. A plugin that does synchronous file I/O on a method channel callback blocks the main thread for the duration of the I/O. During that time:
- No other platform channel messages can be processed
- No UIKit events are handled
- The Flutter engine can't receive VSync signals through the main thread
- If the block lasts more than a few seconds, the watchdog kills the app (see below)
The iOS watchdog is stricter than Android's ANR detection. If the main thread is blocked during certain system transitions (launch, resume from background, suspension), the watchdog terminates the process after approximately 20 seconds. This termination generates a crash report with the exception code 0x8badf00d ("ate bad food") — one of the more memorable error codes in computing.
Exception Type: EXC_CRASH (SIGKILL)
Exception Codes: 0x8badf00dIf you see 0x8badf00d in crash reports, a plugin or native code blocked the main thread during a system transition.
Thread explosion: the hidden cost
GCD manages a thread pool, and it creates new threads on demand. If you submit many blocks to a concurrent queue faster than they complete, GCD creates more threads to handle the backlog. This can lead to thread explosion — dozens or hundreds of threads competing for CPU time.
Thread explosion is expensive:
- Each thread has a stack (typically 512KB on iOS). A hundred threads consume ~50MB of stack memory alone — pure overhead.
- Context switching between many threads burns CPU cycles and pollutes CPU caches.
- If the threads are all waiting on a shared resource (a lock, a database connection), you have hundreds of threads doing nothing except consuming memory.
In Flutter apps, thread explosion typically comes from plugins that use GCD carelessly — submitting concurrent work without limits — or from Dart Isolate.spawn() calls. Each Dart isolate creates a POSIX thread. Spawning 50 isolates creates 50 threads.
The fix is straightforward: limit concurrency. For plugins, use a serial queue or a concurrent queue with a semaphore. For Dart, use a compute pool or limit the number of concurrent isolates.
Priority inversion
GCD is susceptible to priority inversion — a scenario where high-priority work waits for low-priority work, effectively running at the low-priority level.
Example: a .background queue holds a lock while doing a slow operation. The main thread (.userInteractive) tries to acquire the same lock and blocks. The main thread is now waiting for .background work to finish. But the .background thread is getting minimal CPU time because of its low QoS. The high-priority main thread is stuck waiting for low-priority work that the system isn't scheduling aggressively.
GCD mitigates this with priority boosting — when it detects that a high-QoS thread is waiting on a lock held by a low-QoS thread, it temporarily boosts the low-QoS thread's priority so it can finish and release the lock. This happens automatically for os_unfair_lock and GCD semaphores, but not for all synchronisation primitives.
For Flutter, priority inversion most commonly manifests when:
- A plugin does work on a background queue while holding a resource the main thread needs
- The Dart UI thread and the raster thread contend for a shared resource (the engine has internal mutexes for this)
- Multiple plugins use the same serial queue for different operations, and a slow operation in one plugin blocks another
The symptom is jank that appears in profiling as the main thread waiting (not working). The thread isn't busy — it's idle, waiting for a lock. This looks different from jank caused by heavy computation.
GCD vs Dart concurrency
Dart has its own concurrency model — isolates and async/await — that runs independently of GCD. Understanding how they relate:
Dart `async/await` is single-threaded cooperative concurrency within the Dart UI thread. An await yields to the event loop but doesn't create a new thread. This is event-loop concurrency, like JavaScript's. It's handled entirely by the Dart VM and doesn't interact with GCD at all.
Dart `Isolate` creates a new POSIX thread with its own Dart heap. This thread is managed by the Dart VM, not by GCD. The thread exists in the same process and can communicate with other isolates via message passing. From iOS's perspective, it's just another thread in the process.
GCD `DispatchQueue.async` submits a closure to a managed thread pool. The closure runs on one of GCD's pool threads, which are created and destroyed by the system. This is how Objective-C/Swift code in plugins does concurrent work.
The two models don't interact directly. Dart isolates don't submit work to GCD queues. GCD queues don't schedule Dart closures. The only point of interaction is the main thread, where GCD's main queue and the Flutter engine's platform thread are the same thread.
This separation is generally clean, but it means that a Flutter app on iOS has two independent concurrency systems running simultaneously. The total thread count is the sum of both systems' threads — Dart isolates plus GCD pool threads plus the engine's threads. Monitoring thread count in Instruments shows the combined picture.
Practical implications
Keep platform channel handlers fast. They run on the main thread. If you need to do heavy work, dispatch to a background GCD queue and call the result callback on the main queue when done. Don't block the main thread for I/O, computation, or network operations.
Match QoS to intent. When writing plugin code, use .userInitiated for work the user is waiting for (photo processing after tapping "save") and .utility or .background for work the user doesn't see (analytics upload, log rotation). The system uses QoS to make intelligent scheduling decisions — wrong QoS means wrong scheduling.
Don't fight the scheduler. If your background work isn't getting CPU time, it might be because iOS is correctly deprioritising it. On efficiency cores, .background work might run 2-3x slower than on performance cores. This is by design — battery life over throughput for work the user doesn't care about.
Monitor thread count. In Instruments' System Trace, watch the thread count over time. If it grows steadily during use, something is creating threads without limits. Flutter apps typically stabilise at 20-30 threads. More than 50 suggests a thread pool misconfiguration or isolate leak.
The next post examines iOS's inter-process communication — Mach messages and XPC — and how your Flutter app talks to system services.
This is Post 5 of the iOS Under the Surface series. Previous: Memory: Jetsam, Compression, and No Second Chances. Next: Mach Messages and XPC: Inter-Process Communication.