How do barriers, groups, semaphores and dispatch sources coordinate work?

10.1.2 · L3 · Interview

Predict first

A barrier write is followed by a read on the same concurrent queue. What does the read see?

import Dispatch

let rw = DispatchQueue(label: "rw", attributes: .concurrent)
nonisolated(unsafe) var store = 0
rw.async(flags: .barrier) { store = 5 }
print(rw.sync { store })
  • 0
  • 5
  • It can be either

Predict first

What does a semaphore wait with a timeout return when nobody signals?

import Dispatch

let sem = DispatchSemaphore(value: 0)
print(sem.wait(timeout: .now() + 0.01) == .timedOut)
  • true
  • false
  • It blocks forever

Mental model. A barrier is the cleaning crew closing the shop floor: everyone finishes, the crew works alone, then doors reopen. A group is a headcount at the door; a semaphore is the number of free seats.

A barrier block (flags: .barrier) on a concurrent queue waits for all earlier blocks to finish, runs alone, and then lets later blocks resume. That gives the classic reader–writer lock: reads are plain sync or async blocks that may overlap, writes are barriers that get exclusive access. It only works on your own concurrent queues (a barrier on a global queue behaves like a normal block). A dispatch group tracks a set of tasks: group.enter() and leave() (or submitting with group:) count them, notify(queue:) runs a block when the count reaches zero without blocking anyone, and wait() blocks (never on the main thread). Each enter needs exactly one leave, or the group never completes.

A semaphore (DispatchSemaphore(value:)) is a counter: wait() decrements and blocks at zero, signal() increments. Use it to limit concurrency (value: 3 allows three at a time) or, carefully, to turn a callback into a blocking call; it has no priority donation, so a low-priority task holding it can cause priority inversion, and a wait with a timeout returns .timedOut instead of blocking forever. Dispatch sources deliver system events to a queue without polling: timers (makeTimerSource), file descriptor readiness, process events, signals and memory pressure. A source starts suspended, needs resume(), and must be cancelled with cancel(); suspending and resuming must be balanced before you release it. For new code, prefer actors and async sequences over these primitives, but you must read them, and they remain the right tool for low-level event sources.

The barrier finishes before any later block starts, so the read sees the write.

A timed wait gives up after the interval and reports .timedOut.

Common traps

  • Unbalanced enter/leave on a group. A missing leave hangs notify and wait forever; an extra one crashes.
  • Using a barrier on a global queue. It is not exclusive there; create your own concurrent queue.

They might follow up with

  • How do you cap concurrent downloads at three? A semaphore of value 3, waiting before each start and signalling when done (or an OperationQueue limit).
  • Why must a source be resumed? It is created suspended, so that you can configure its handler first.

Related questions

  • 10.1.1 What is the difference between serial and concurrent queues and between sync and async, and what do QoS and…
  • 10.2.1 When do you use OperationQueue instead of GCD, and how do dependencies, state and cancellation work?
  • 6.6.1 What should an app do on a memory warning, and how reliable is the warning?

← What is the difference between serial and concurrent queues and…   What are thread explosion and priority inversion, and how do QoS and… →