-
Notifications
You must be signed in to change notification settings - Fork 0
12 — Barriers
The kitchen runs at full speed. Multiple chefs are cooking at once — one at the grill, another at sauté, another at the cold station — all their hands moving at the same time, orders flowing through, dishes flying out. That's the normal state: concurrent, fast, everyone working together.
But once a year, the health inspector comes in, and an inventory recount happens. Not a quick count of stock while someone keeps cooking — a full recount. Everything stops. Every single chef steps back from their station. Nothing cooks. The head chef and a food runner walk the kitchen with clipboards, counting every canned tomato, every bottle of olive oil, every wheel of cheese on the shelf. Once they finish — five minutes, an hour, whatever it takes — they put down the clipboard, the recount is done, and the chefs return to their stations.
The moment the recount is over, the kitchen roars back to life. Orders line up immediately; chefs grab tickets and start cooking concurrently again.
Notice what happened: the kitchen temporarily switched from concurrent to exclusive, then back to concurrent. Only the recount block had exclusive access. Before it started, all concurrent work drained out of the system. While it ran, nobody else could cook. Once it finished, everyone came back.
👉 A barrier is a moment when concurrent work stops, one exclusive task runs alone, and then concurrent work resumes.
On a concurrent queue, you can submit work in two ways:
- Regular async — the block jumps into the concurrent flow with all the other blocks. Multiple regular async blocks can run at the same time on the same queue.
-
Async with
.barrierflag — the block waits for all previously submitted work on that queue to finish. Then this block runs alone — no other blocks run concurrently with it on this queue. Only when this block finishes do subsequently submitted blocks start running again.
let queue = DispatchQueue(label: "work", attributes: .concurrent)
queue.async { print("concurrent task 1") } // runs
queue.async { print("concurrent task 2") } // runs alongside task 1
queue.async(flags: .barrier) { print("barrier") } // waits for 1 & 2, then runs alone
queue.async { print("concurrent task 3") } // waits for barrier, then runs
queue.async { print("concurrent task 4") } // runs alongside task 3The .barrier flag only makes sense on a concurrent queue. On a serial queue, everything is already exclusive — every block waits for the previous one anyway — so .barrier would be redundant and is simply ignored.
The classic use case: thread-safe reads and writes to shared state. Multiple readers (concurrent async blocks) are safe at once — they're not modifying anything. But a writer must be exclusive — it needs the whole queue to itself so no reader can see a half-written value. So reads use regular queue.sync, and writes use queue.async(flags: .barrier):
queue.async(flags: .barrier) {
cache[key] = value // write runs alone, no concurrent readers see a torn value
}A read() call uses .sync — it needs the answer now, before the next line of code runs. A read() call blocks the calling thread until the read finishes and returns. A write() uses .async(flags: .barrier) because the caller doesn't need to wait for the write to complete before continuing — fire it off and move on. The barrier flag ensures that by the time the next read() or write() touches cache, the previous write's barrier has already drained and completed.
👉 Without .barrier, a writer could set a value while a reader is mid-read, and the reader sees a corrupted, partially-written value. .barrier forces writes to run alone so readers always see a complete, coherent snapshot.
CONCURRENT QUEUE WITH BARRIER
concurrent tasks ──┐
├──> 🧑🍳 (task 1, reads)
concurrent tasks ──┤
├──> 🧑🍳 (task 2, reads) } both run
│ } at same time
│ 🚫 BARRIER AWAITS } wait for 1 & 2
│
├──> (barrier block: write, runs alone)
│
├──> 🧑🍳 (task 3, reads)
concurrent tasks ──┤
├──> 🧑🍳 (task 4, reads) } both run
│ } at same time
└
Before the barrier: tasks 1 and 2 run concurrently. The barrier waits for both to finish. The barrier's exclusive block runs alone. After the barrier: tasks 3 and 4 run concurrently. The queue funnels from parallel lanes to a single exclusive point, then fans back out to parallel lanes again.
let queue = DispatchQueue(label: "cache", attributes: .concurrent)
var cache: [String: String] = [:]
func read(key: String) -> String? {
queue.sync { cache[key] }
}
func write(key: String, value: String) {
queue.async(flags: .barrier) {
cache[key] = value
}
}
// Multiple threads can safely read at once
DispatchQueue.global().async { print(read(key: "name")) }
DispatchQueue.global().async { print(read(key: "age")) }
// Write happens alone, then reads resume
write(key: "name", value: "Alice")
DispatchQueue.global().async { print(read(key: "name")) } // sees "Alice"👉 The two concurrent read tasks run in parallel without coordination. The write task waits for them, runs alone, and only then do subsequent reads see the updated value — no data corruption, no torn writes, no coordination hassle.
Swift Concurrency replaces manual barrier coordination with actor mutability isolation. An actor's var properties can only be modified by methods inside the actor's isolation domain, and the compiler enforces that at compile time:
actor CacheActor {
private var cache: [String: String] = [:]
func read(key: String) -> String? {
cache[key] // allowed — reading from inside the actor
}
func write(key: String, value: String) {
cache[key] = value // allowed — mutating inside the actor
}
}No .barrier flag, no manual queue coordination, no synchronization to think about. The compiler guarantees that writes don't overlap with reads because only the actor's own methods can touch cache. When a task from outside the actor calls read() or write(), it's suspended until any previous task inside the actor finishes — automatic exclusive access, no manual barrier logic.
The deeper difference: a .barrier on a concurrent queue is a runtime discipline — the programmer writes .barrier in the right places, and if they make a mistake (a write without .barrier, say), the mistake silently corrupts the shared state. An actor's isolation is a compile-time guarantee — the compiler rejects code that tries to mutate cache from outside, before it ever runs. There's no way to forget the "barrier" — the compiler won't let you write the code that would need one.
👉 A barrier is a manual gate you have to remember to use; an actor's mutability isolation is an automatic gate the compiler enforces and you cannot accidentally bypass.
A barrier is the moment everyone stops so one task can run alone.
A real inventory recount stops the entire restaurant — every kitchen, every station, waiters, hosts, everyone. A GCD barrier only stops that one queue.
- If the head chef decided today that the sauté station needs a recount, the grill station and cold prep station keep cooking full speed. Only sauté stops. Other queues in the same app run completely independently; a barrier on
queue1doesn't affectqueue2at all. - A real recount is total and visible — once the clipboard comes out, you can see it's happening. A barrier is invisible to any code except the queue itself; nothing "notices" that a barrier just drained from the queue, no logging, no signal, no way to ask "what's the status of that barrier task right now?"
- A real recount could fail or get interrupted — someone notices a mistake, the head chef says "start over."
DispatchQueuehas no concept of barrier failure or cancellation. Once a barrier block is submitted, it will run and finish, period.
So "everyone stops for one exclusive task" captures the serialization — but not the fact that a real kitchen is a social system that coordinates and communicates, while a barrier is a silent, invisible gate enforced by the queue itself.
Why does a barrier only make sense on a concurrent queue and not on a serial queue? What would .barrier do if you submitted it to a serial queue, and why is it pointless?