Greetings, traveler!
Questions around concurrency often drift into terminology. Thread, queue, executor — the words sound related, and many explanations blur the boundaries between them. That usually works until the first real bug appears, and then the model falls apart.
A more reliable way to approach this topic is to separate the concepts by responsibility. Each of them answers a different question about how code runs.
Start with the right mental model
These three concepts live at different levels of abstraction:
- a thread is where code executes
- a queue defines how work is scheduled
- an actor executor defines where actor-isolated work is allowed to run
Once you keep those roles separate, most confusion disappears.
What a thread actually represents
A thread is a unit of execution managed by the operating system. It has its own call stack and executes instructions sequentially. Threads can be suspended, resumed, and reused by the system scheduler.
In Swift, threads are not a good place to attach meaning to your logic. An async function may suspend at an await, release the current thread, and later resume on a different one.
func loadData() async {
print(Thread.current)
try? await Task.sleep(nanoseconds: 1_000_000_000)
print(Thread.current)
}Running this function often prints different threads before and after suspension. That behavior is expected. The thread is a resource, not a logical owner of your code.
Dispatch queues: scheduling work
A dispatch queue from Grand Central Dispatch defines how work is executed. It does not execute anything by itself. Instead, it submits blocks to a pool of threads.
let queue = DispatchQueue(label: "com.example.serial")
queue.async {
print("Task 1")
}
queue.async {
print("Task 2")
}A serial queue guarantees that tasks do not run at the same time. The system may still execute them on different threads.
That distinction matters. A queue controls ordering. It does not guarantee a specific thread.
Concurrent queues relax the ordering:
let queue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
queue.async {
print("Task A")
}
queue.async {
print("Task B")
}Both tasks may run in parallel, depending on system resources.
The main queue is a special case. It is tied to the main thread, which makes it the right place for UI updates.
Sync, async and barriers in dispatch queues
Dispatch queues expose a small set of primitives that define how work is submitted and coordinated. The two most commonly used are async and sync.
async submits a block and returns immediately. The caller does not wait for completion.
let queue = DispatchQueue(label: "com.example.queue")
queue.async {
print("Executed later")
}
print("Continues immediately")
This pattern is used when work can run independently from the caller.
sync submits a block and waits until it finishes. Execution of the current context is paused until the block completes.
queue.sync {
print("Executed before returning")
}
print("Runs after sync block")This makes sync useful when a result is required before moving forward, though it needs to be used carefully. Calling sync on the same serial queue from within one of its tasks leads to a deadlock because the queue waits for itself to finish work that cannot start.
Concurrent queues introduce another tool: barrier blocks. A barrier allows you to temporarily serialize access within a concurrent queue.
let queue = DispatchQueue(label: "com.example.concurrent", attributes: .concurrent)
var storage: [Int] = []
queue.async {
storage.append(1)
}
queue.async {
storage.append(2)
}
queue.async(flags: .barrier) {
storage.removeAll()
}
All tasks submitted before the barrier complete first. The barrier block then runs exclusively. Tasks submitted after the barrier wait until it finishes.
This pattern works well for read-heavy scenarios with occasional writes. Regular tasks can run in parallel, while mutations are protected by barriers without switching to a fully serial queue.
These primitives define how work is coordinated at the queue level. They operate independently from threads and remain separate from actor executors, which focus on isolation rather than scheduling policies.
Why a queue is not a thread
It is tempting to think about a queue as “a thread with extra features”. That mental shortcut causes subtle bugs.
A queue:
- schedules work
- defines execution order
- may use many threads over time
A thread:
- executes instructions
- has a lifetime controlled by the system
The same queue may execute tasks on different threads at different moments. The same thread may execute work from different queues. The main queue is a special case, as it is bound to the main thread.
Actors and isolation
Swift Concurrency introduces actors to manage shared mutable state. An actor guarantees that only one piece of actor-isolated code runs at a time.
actor Counter {
private var value = 0
func increment() {
value += 1
}
func currentValue() -> Int {
value
}
}This looks similar to a serial queue at first glance. Both provide serialized access. The similarity ends once suspension enters the picture.
What an actor executor does
Every actor has an executor. The executor decides how to run jobs associated with that actor.
You can think of it as a scheduler dedicated to preserving actor isolation. It receives units of work and ensures they execute according to the actor’s rules.
The default executor for most actors relies on the Swift Concurrency runtime and a shared thread pool. It does not bind the actor to a specific thread.
Why “actor equals serial queue” breaks down
The comparison helps at a very early stage, but it hides important details.
Actors allow suspension:
actor ImageLoader {
func load(from url: URL) async throws -> Data {
let (data, _) = try await URLSession.shared.data(from: url)
return data
}
}When execution reaches await, the task suspends. The thread becomes free to execute other work. Later, the continuation resumes through the actor’s executor.
A serial queue does not behave this way. A synchronous block on a queue occupies a thread for its entire duration.
This difference changes how you reason about performance and deadlocks. Actor-based code tends to avoid thread blocking and scales better under load.
Actor isolation and threads
Actor isolation does not imply a single thread.
actor Logger {
func log(_ message: String) {
print(Thread.current)
}
}Multiple calls to log may run on different threads. The executor ensures that only one call accesses the actor’s state at a time. The thread itself can vary between executions.
The only widely used exception is MainActor.
MainActor and the main thread
MainActor is a global actor tied to the main thread. Code annotated with @MainActor runs on the main dispatch queue.
@MainActor
final class ViewModel {
var title: String = ""
func update() {
title = "Updated"
}
}This provides a safe bridge between Swift Concurrency and UIKit or SwiftUI, where UI updates must happen on the main thread.
Actor reentrancy and suspension points
Actor isolation guarantees that only one piece of actor-isolated code runs at a time. That guarantee applies to uninterrupted execution segments. Once a function reaches an await, the current task suspends and the actor becomes available for other work.
actor BankAccount {
private var balance: Int = 100
func withdraw(_ amount: Int) async {
guard balance >= amount else { return }
try? await Task.sleep(nanoseconds: 1_000_000_000)
balance -= amount
}
func deposit(_ amount: Int) {
balance += amount
}
}The withdraw method checks the balance before suspension. While it is waiting, another call may update the same state. When execution resumes, the original assumption may no longer hold.
This behavior is known as reentrancy. It requires treating any code after an await as operating on potentially changed state.
Custom executors and queues
Swift allows custom executors. In advanced scenarios, an actor can use a custom SerialExecutor, potentially backed by a dispatch queue.
That does not make actors equivalent to queues. It shows that queues can be one possible implementation detail of an executor.
What happens at await
The interaction between threads and executors becomes clearer around suspension points.
actor DataStore {
func fetch() async -> String {
print("Before:", Thread.current)
try? await Task.sleep(nanoseconds: 500_000_000)
print("After:", Thread.current)
return "Done"
}
}Before await, the code runs on some thread. At suspension, the thread is released. After resumption, execution continues through the actor’s executor, potentially on another thread.
The actor still guarantees correct isolation. The thread is free to change.
A concise explanation that works well
A clear explanation usually separates responsibilities.
A thread is an execution resource managed by the system. A dispatch queue schedules work and controls ordering. An actor executor runs actor-isolated jobs and enforces isolation rules within Swift Concurrency.
Queues may use threads. Executors may use queues. These relationships exist without collapsing the concepts into one.
Conclusion
Once the roles are separated, the model becomes easier to apply in real code. Bugs related to race conditions, unexpected thread hops, or blocked execution tend to come from mixing these abstractions.
Keeping a clean mental boundary between execution, scheduling, and isolation helps reason about both legacy GCD code and modern Swift Concurrency.
