---
title: 'Queue-Based Concurrency'
source: 'https://academia.sh/en/courses/data-networking-and-concurrency/queue-based-concurrency'
course: 'Data, Networking and Concurrency'
language: en
updated: '2026-10-21T05:03:08+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - 'Tells a serial queue apart from a concurrent queue'
    - 'Measures that appending to a queue is sensitive to both repetition and order'
    - 'Measures that assigning a value in a queue is sensitive only to order'
    - 'Shows that measuring the state in sorted form hides the order difference'
---

# Queue-Based Concurrency

A measurement showing that jobs added to a serial queue keep their order, but that appending and assigning a value fall into different classes under repetition and order.

In the previous lesson, a version check kept a late, old response from overwriting the new result, while giving the number in the wrong place made the check ineffective. Before concurrent Swift was written, work was run with **dispatch queues**, and a great deal of code is still written that way. In this lesson we measure two kinds of jobs added to a queue.

The core file is the same in every lesson of the topic.

```swift
// Replay.swift — classifies an operation's sensitivity to repetition and order
struct Trial<State: Equatable & CustomStringConvertible> {
    let label: String
    let once: State
    let twice: State
    let forward: State
    let backward: State

    var sensitivity: String {
        switch (once != twice, forward != backward) {
        case (false, false): return "robust"
        case (true, false): return "repeat-sensitive"
        case (false, true): return "order-sensitive"
        case (true, true): return "doubly-sensitive"
        }
    }

    func report() -> String {
        print("\(label) | once=\(once) twice=\(twice) ab=\(forward) ba=\(backward) -> \(sensitivity)")
        return sensitivity
    }
}

func replay<State>(_ label: String, from start: State, _ a: (State) -> State, then b: (State) -> State) -> Trial<State> {
    Trial(label: label, once: a(start), twice: a(a(start)), forward: b(a(start)), backward: a(b(start)))
}

func countSensitivities(_ results: [String]) {
    for name in ["robust", "repeat-sensitive", "order-sensitive", "doubly-sensitive"] {
        print("\(name): \(results.filter { $0 == name }.count)")
    }
}
```

In this lesson the operations are added to a queue, the queue is drained, and the result is read. The four runs are each set up on a new queue and given directly to `Trial`. The model builds what the operation does to the state; it is not the network or the runtime itself.

## Serial and Concurrent Queues

A **dispatch queue** runs the jobs added to it on a pool of threads. A **serial queue** runs jobs in the order they were added and one at a time: the next one does not start until one finishes. A **concurrent queue** can start jobs at the same time; only the start order is the order of addition, and the finish order is not defined.

The result a serial queue produces is deterministic if the order of addition is known, and so it can be measured. On a concurrent queue the finish order depends on timing, so it does not enter the measurement. In this lesson only the serial queue is measured.

```swift
// Queued.swift — append and assign operations running in order on a serial queue
import Dispatch

final class Cell: @unchecked Sendable {
    var lines: [String] = []
    var current = "-"
}

func makeSerialQueue() -> DispatchQueue {
    .init(label: "serial")
}

func runAppends(_ labels: [String]) -> [String] {
    let cell = Cell()
    let queue = makeSerialQueue()
    for label in labels {
        queue.async { cell.lines.append(label) }
    }
    queue.sync {}
    return cell.lines
}

func runAssigns(_ labels: [String]) -> String {
    let cell = Cell()
    let queue = makeSerialQueue()
    for label in labels {
        queue.async { cell.current = label }
    }
    queue.sync {}
    return cell.current
}
```

`Cell` is the shared state the queue works on; it carries `@unchecked Sendable` to tell the compiler "I am protecting this", so the guarantee comes from the queue, not from the compiler. `runAppends` adds each label to the queue as a job. `runAssigns` writes each label to a single field. At the end an empty `sync {}` block drains the queue: on a serial queue this block runs after all the jobs added, so by the time it returns, all of them have finished.

## Appending and Assigning a Value

We measure two jobs, labeled `a` and `b`, with two separate operations: appending and assigning a value.

```swift
// main.swift — appending to a queue and assigning a value
let appendOnce = runAppends(["a"])
let appendTwice = runAppends(["a", "a"])
let appendForward = runAppends(["a", "b"])
let appendBackward = runAppends(["b", "a"])
let appending = Trial(label: "append", once: appendOnce, twice: appendTwice, forward: appendForward, backward: appendBackward).report()

let assignOnce = runAssigns(["a"])
let assignTwice = runAssigns(["a", "a"])
let assignForward = runAssigns(["a", "b"])
let assignBackward = runAssigns(["b", "a"])
let assigning = Trial(label: "assignment", once: assignOnce, twice: assignTwice, forward: assignForward, backward: assignBackward).report()

countSensitivities([appending, assigning])
```

```
append | once=["a"] twice=["a", "a"] ab=["a", "b"] ba=["b", "a"] -> doubly-sensitive
assignment | once=a twice=a ab=b ba=a -> order-sensitive
robust: 0
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 1
```

In the append line, `once` and `twice` differ: the second append writes one more line to the list. `ab` and `ba` differ too: the list carries the order of appending. The operation is **doubly sensitive**. In the assignment line, `once` and `twice` are the same, because assigning the same value again changes nothing. But `ab` is `b` and `ba` is `a`: the last one assigned wins. The operation is **order-sensitive**. The serial queue kept the order; but because the order was kept, the result depended on the order. The count is one order-sensitive, one doubly sensitive.

**CC15.** This measurement assumes that the jobs added run on a single queue, in the order they were written. The case where several queues reach the same `Cell` was not measured.

**CC16.** Two append jobs with the same label count as the same job. The case where the two jobs can be told apart from each other and carry different labels exists only in the `ab` and `ba` lines.

## Eliminating Repeats

Part of the double sensitivity comes from repetition: when the same label is appended twice, the list grows. In practice this is often unwanted; a list recording that a notification was sent should not contain the same notification twice. The remedy is to check whether the label is in the list before appending. What matters is that the check and the append are done in the same job of the queue; if the two are separate jobs, another job can slip in between them.

```swift
// Unique.swift — a queue operation that appends only a new label
import Dispatch

func runUniqueAppends(_ labels: [String]) -> [String] {
    let cell = Cell()
    let queue = makeSerialQueue()
    for label in labels {
        queue.async {
            if !cell.lines.contains(label) { cell.lines.append(label) }
        }
    }
    queue.sync {}
    return cell.lines
}
```

```swift
// main.swift — an append that eliminates repeats
let uniqueOnce = runUniqueAppends(["a"])
let uniqueTwice = runUniqueAppends(["a", "a"])
let uniqueForward = runUniqueAppends(["a", "b"])
let uniqueBackward = runUniqueAppends(["b", "a"])
let unique = Trial(label: "unique-append", once: uniqueOnce, twice: uniqueTwice, forward: uniqueForward, backward: uniqueBackward).report()

countSensitivities([unique])
```

```
unique-append | once=["a"] twice=["a"] ab=["a", "b"] ba=["b", "a"] -> order-sensitive
robust: 0
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0
```

The second append of the same label no longer changes the list; `once` and `twice` are equal. But `ab` and `ba` are still different, because the list carries the order of appending. The operation is **order-sensitive**. From the double sensitivity of the previous line, the repeat sensitivity has gone and the order sensitivity has stayed. This shows what eliminating repeats solves and what it does not: a repeat no longer leads to a copy, but the question of which label will come first is not answered either.

**CC17.** Eliminating repeats relies on the exact equality of labels. Labels that mean the same but are written differently (for example, a difference in letter case) were not measured.

## Concurrent Queue and Barrier

On a concurrent queue reads can run together, but a write must run alone. For this, the write job carries a **barrier** flag: a job with a barrier does not run at the same time as any job that is ahead of it or behind it in the queue.

```swift
// Concurrent queue and barrier: reads together, write alone
let shared = DispatchQueue(label: "shared", attributes: .concurrent)
nonisolated(unsafe) var values: [String] = []

func read() -> Int {
    shared.sync { values.count }
}

func write(_ value: String) {
    shared.async(flags: .barrier) { values.append(value) }
}
```

The barrier prevents a write from overlapping with reads. Which read will run before or after which write still depends on the order of addition and on timing; for this reason the fragment is not measured.

## In a Real Application

In old code a queue is mostly used in place of a lock. A class has a shared dictionary; every read and write is sent to the same serial queue, and the dictionary is touched only from that queue. This setup has worked for decades in structures such as caches and session information. The queue keeps the order and runs only one job at a time; that is, it does exactly what the previous two lessons measured, in its form from before actors.

The real trap of the queue is that the protection depends on discipline. Code that touches the dictionary from outside the queue with a single line makes the whole protection void, and the compiler does not say so. Another common mistake is writing to the queue and trying to read the result at the same moment.

```swift
// Wrong: the write is in the queue, the read is outside the queue
func record(_ label: String, in cell: Cell, on queue: DispatchQueue) -> Int {
    queue.async { cell.lines.append(label) }
    return cell.lines.count
}
```

The `async` call leaves the job in the queue and returns immediately; the job may not have run yet. The number returned is sometimes the count from before the append and sometimes from after it, and which one it is depends on timing. On top of that, because the read is done outside the queue, it can run at the same time as the job writing to `cell.lines`; this is a data race. The fix is to give the read to the same queue as well and wait for the result with `sync`. But entering a queue with `sync` from the main thread keeps the screen waiting as long as that queue takes; for this reason, in new code the same job is written with an actor and the read is done with `await`.

Another trap is entering the same queue with `sync` from inside the queue: the queue waits for its own running job to finish, and the job waits for the queue to empty. For this reason, helper functions that do not know which queue the caller is on should take a callback with `async` instead of `sync`.

The re-reading of the measurements is this: a serial queue gives a guarantee of order, but this guarantee does not make the result independent of order. Where order matters, the queue may be enough; where it does not matter, what will make the result independent of order is the operation itself, that is, merging instead of assigning a value, a set instead of appending. The queue protects, the shape of the operation makes it robust.

## Common Mistakes

- Taking a queue's keeping the order for the result being robust. The order is kept, but the result can still depend on the order.
- Adding a job to the same queue from inside it with `sync`. The queue waits for itself and deadlocks.
- Writing to a concurrent queue without a barrier. Reads overlap with the write.
- Reading the result without draining the queue. The append jobs may not have finished yet.

## The Bounding Measurement

This time we compare the same append operation with the sorted form of the appended lines and with the line count. The operation did not change; only the view of the state changed.

```swift
// main.swift — the same append, three choices of state
let rawTrial = Trial(label: "raw-list", once: runAppends(["a"]), twice: runAppends(["a", "a"]), forward: runAppends(["a", "b"]), backward: runAppends(["b", "a"]))
let raw = rawTrial.report()

let sortedTrial = Trial(label: "sorted-list", once: rawTrial.once.sorted(), twice: rawTrial.twice.sorted(), forward: rawTrial.forward.sorted(), backward: rawTrial.backward.sorted())
let sortedView = sortedTrial.report()

let sizeTrial = Trial(label: "line-count", once: rawTrial.once.count, twice: rawTrial.twice.count, forward: rawTrial.forward.count, backward: rawTrial.backward.count)
let sizeView = sizeTrial.report()

countSensitivities([raw, sortedView, sizeView])
```

```
raw-list | once=["a"] twice=["a", "a"] ab=["a", "b"] ba=["b", "a"] -> doubly-sensitive
sorted-list | once=["a"] twice=["a", "a"] ab=["a", "b"] ba=["a", "b"] -> repeat-sensitive
line-count | once=1 twice=2 ab=2 ba=2 -> repeat-sensitive
robust: 0
repeat-sensitive: 2
order-sensitive: 0
doubly-sensitive: 1
```

On the raw list the operation is **doubly sensitive**. When the list is sorted, `ab` and `ba` become equal and only the repetition difference remains; the operation comes out **repeat-sensitive**. The same result holds for the line count. The rule does not see the cause, it sees the inequality; sorting the state hid the order difference, but could not hide the repetition difference, because repetition really does increase the line count. The count is two repeat-sensitive, one doubly sensitive.

**CC18.** In the sorted view the elements are assumed to be comparable. For elements whose order has a meaning, for example lines with timestamps, what information sorting erases was not measured.

## Moving from Queue to Actor

Turning an old queue-based class into an actor often looks mechanical: make the class an `actor`, remove the queue, add `await` to the calls. The real work is in the callers. Where the old code waited with `sync`, `await` is now needed, and this requires the caller itself to be in an asynchronous context. Callers that work with a callback are either wrapped in a `Task` or the actor call is wrapped in the callback; in both cases the finish time of the call is no longer the return time of the call, and the code must be rethought accordingly.

The second difference is order. A serial queue ran jobs in the order they were added with `async`; an actor, on the other hand, does not guarantee to process calls in arrival order, because the order in which two callers reach the actor depends on the timing between them. The order guarantee the old code silently relied on can disappear during the move. For this reason, before the move, the places where order is really needed are determined. If order is needed, the calls are made one after another from a single task; if it is not needed, the move is trouble-free. The measurements of this lesson are a tool for finding which places need order: every operation that comes out order-sensitive is an operation that asks for attention in the move.

Finally, testing. Testing queue-based code requires a wait call that drains the queue; in actor-based code this need disappears because each call returns its own result with `await`. Tests become simpler, but for operations with order sensitivity the test still needs to contain a scenario with two orders.

## Queue Labels and Debugging

Naming queues is a small habit and makes a big difference in debugging. In a crash trace or a hang report the name of the queue is visible; from a queue named `serial` it is not possible to work out which subsystem is responsible, but a name like `reservations.state` points the way directly. For this reason the name `serial` in the measurements is only for the example; in real code the name of the queue should describe the state it protects.

For the same reason, care is taken that a queue protects only one state. Gathering several unrelated states in a single queue produces a setup in which the slow job of one holds up the others. A separate queue for each state keeps independent problems independent; but an operation touching several queues no longer gives a single guarantee about order, and the first caveat in the measurement applies here: the case where several queues reach the same state was not measured. Choosing queue names in a way the next person reading the code will understand at first glance is the cheapest debugging investment; because in hang reports only this name is visible and it narrows down the place to look. The same holds for test outputs: if a failing test can say from its name which queue broke which state, finding the bug takes seconds instead of minutes. Not counting naming as an ornament to be added at the end of the code is a small decision to be made at first writing, and adding it afterwards is mostly forgotten.

## Summary

- A serial queue runs jobs in the order they were added and one at a time; a concurrent queue can start jobs at the same time.
- Appending to a queue is sensitive to both repetition and order.
- Assigning a value in a queue is sensitive only to order; assigning the same value again is idempotent.
- A barrier prevents a write from overlapping with reads on a concurrent queue.
- Measuring the state in sorted form hides the order difference.
- This lesson's sensitivity: **doubly sensitive** (append) and **order-sensitive** (assignment).

## Next Step

Queues put jobs in a line; but a value flowing through time and reaching several subscribers is a different model. In the next lesson, on reactive streams, we will measure what subscribing to the same publisher twice and operators that drop consecutive values do.
