Heavy Synchronous Code
Categories:
A Guide to Handling Heavy Synchronous Code in Swift Concurrency
1. The Core Problem: The Swift Cooperative Thread Pool
To understand why heavy synchronous code breaks modern Swift, you have to understand the difference between older Apple code (Grand Central Dispatch / GCD) and new Swift Concurrency.
- GCD (
DispatchQueue) uses a dynamic thread pool. If a thread gets blocked doing heavy work, GCD notices and simply spawns a new thread. This prevents deadlocks but causes Thread Explosion (which drains memory and battery). - Swift Concurrency (
async/await/Task) uses a fixed-size cooperative thread pool. It strictly limits the number of background threads to exactly the number of CPU cores your device has (e.g., 6 cores = exactly 6 threads). It will never spawn more.
Because there are so few threads, Swift relies on cooperation. When an async function hits an await, it says: “I’m pausing to wait for something. Take my thread and give it to another task!” This allows 6 threads to juggle thousands of concurrent tasks.
The “Choke” (Thread Pool Starvation)
If you run heavy synchronous code (code without await) on the Swift thread pool, it hijacks the thread and refuses to give it back.
If you request 6 heavy image extractions at the same time, all 6 Swift threads are paralyzed. Your entire app’s concurrency system freezes until an image finishes. Network requests halt, and background tasks deadlock.
2. What exactly is “Blocking Synchronous Code”?
Synchronous code executes top-to-bottom without ever pausing (it lacks the await keyword). Blocking code is synchronous code that takes a “long time” to finish (usually >10–50 milliseconds), thereby holding a thread hostage.
The 3 Types of Blocking Code:
- Heavy CPU-Bound Work: Number crunching, image processing (
CoreGraphics,ImageIO), video encoding, parsing massive JSON files. - Synchronous I/O: Reading massive files synchronously (e.g.,
Data(contentsOf: URL)) or older synchronous database queries. The thread is completely frozen waiting for the hard drive. - Locks and Semaphores: Using
DispatchSemaphore.wait()orNSLockintentionally pauses a thread. (Apple strictly forbids these inside Swift Concurrency).
The Checklist to Identify Blocking Code:
Ask yourself these questions about a function:
- Does it lack the
asynckeyword in its signature? - Does it lack internal
awaitcalls (orawait Task.yield())? - Does it take more than a few milliseconds to run?
- Is it a “Black Box” from an Apple framework (like
ImageIO) or C/C++?
If the answer is Yes, it is blocking synchronous code and does not belong in the Swift Concurrency thread pool.
3. The Traps: Why Task and Actor Don’t Fix It
It is highly intuitive to try and fix blocking code using modern Swift features. However, these common approaches are dangerous traps:
Trap 1: Using Task or Task.detached
// ❌ TRAP: Still causes Thread Pool Starvation!
func extract() async throws -> CGImage {
return try await Task.detached {
return try Self.extractSync() // Blocks one of the 6 Swift threads
}.value
}
Task and Task.detached do not create new background threads. They simply place work onto that same strict 6-thread cooperative pool. It might seem to “work” if you only test one image at a time, but at scale, it will deadlock your app.
Trap 2: Putting it inside an actor
Actors process their work one-by-one to protect state. However, Actors do not have their own dedicated threads. They borrow threads from the cooperative pool. If you run heavy sync code inside an Actor, you cause a Double Whammy:
- Thread Pool Starvation: You choked one of the 6 Swift workers.
- Actor Starvation: The Actor is locked up and cannot process any other messages until the heavy work finishes.
Trap 3: Using nonisolated
Marking an Actor function as nonisolated just means “this doesn’t touch the Actor’s private state.” It prevents Actor Starvation, but the function still physically runs on the exact same 6-thread pool, causing Thread Pool Starvation.
4. The Correct Solution: The GCD Escape Hatch
Apple’s official stance is that if you have heavy, blocking synchronous code that you cannot modify, Grand Central Dispatch (GCD) is still the correct tool for the job.
By wrapping the work in DispatchQueue.global().async and withCheckedThrowingContinuation, you push the heavy work out of Swift’s strict 6-thread pool and into GCD’s flexible thread pool (which is allowed to spin up extra threads).
This leaves the precious Swift Concurrency threads completely free to continue juggling all the other await tasks in your app.
The Final, Correct Code:
actor ExtractSonyThumbnail {
/// Extract thumbnail using generic ImageIO framework.
func extractSonyThumbnail(
from url: URL,
maxDimension: CGFloat,
qualityCost: Int = 4
) async throws -> CGImage {
// `extractSync` is a heavy synchronous blocking operation.
// If we run it directly inside this async function, Task, or Task.detached,
// it will hijack a thread in Swift's limited cooperative thread pool (Thread Pool Starvation).
// Therefore, we explicitly offload the blocking work to GCD.
try await withCheckedThrowingContinuation { continuation in
// Push the heavy work out of Swift Concurrency and into GCD
DispatchQueue.global(qos: .userInitiated).async {
do {
let image = try Self.extractSync(
from: url,
maxDimension: maxDimension,
qualityCost: qualityCost
)
// Bridge the result back into the Swift Concurrency world
continuation.resume(returning: image)
} catch {
continuation.resume(throwing: error)
}
}
}
}
}
or as enum
import AppKit
import Foundation
enum enumExtractSonyThumbnail {
/// Extract thumbnail using generic ImageIO framework.
/// - Parameters:
/// - url: The URL of the RAW image file.
/// - maxDimension: Maximum pixel size for the longest edge of the thumbnail.
/// - qualityCost: Interpolation cost.
/// - Returns: A `CGImage` thumbnail.
static func extractSonyThumbnail(
from url: URL,
maxDimension: CGFloat,
qualityCost: Int = 4
) async throws -> CGImage {
// We MUST explicitly hop off the current thread.
// Since we are an enum and static, we have no isolation of our own.
// If we don't do this, we run on the caller's thread (the Actor), causing serialization.
try await withCheckedThrowingContinuation { continuation in
DispatchQueue.global(qos: .userInitiated).async {
do {
let image = try Self.extractSync(
from: url,
maxDimension: maxDimension,
qualityCost: qualityCost
)
// Bridge the result back into the Swift Concurrency world
continuation.resume(returning: image)
} catch {
continuation.resume(throwing: error)
}
}
}
}
5. The “Modern Swift” Alternative (If you own the code)
If extractSync was your own custom Swift code (and not an opaque framework like ImageIO), the truly “Modern Swift” way to fix it is to rewrite the synchronous loop to be cooperative.
You do this by sprinkling await Task.yield() inside heavy loops to voluntarily give the thread back:
func extractSyncCodeMadeAsync() async -> CGImage {
for pixelRow in image {
process(pixelRow)
// Every few rows, pause and let another part of the app use the thread!
if pixelRow.index % 10 == 0 {
await Task.yield()
}
}
}
If you can do this, you don’t need DispatchQueue! But if you are using black-box code that you can’t add await to, the GCD Escape Hatch is the perfect, Apple-approved architecture.