Skip to content
academia.sh

Lesson 08 / 19

Tasks and Task Groups

A measurement showing that when the results of jointly started work are collected as a set they are independent of arrival order, while collected as an array they depend on order.

Published Updated

In this lesson

  • Distinguishes the place of a task and a task group in structured concurrency
  • Measures that the operation collecting the results of child tasks in a set is independent of arrival order
  • Measures that the operation arranging the same results in arrival order is order-sensitive
  • Shows with the same data that the choice of the compared state changes the class
Contents

In the previous lesson, two reservation requests left different stock in two orders; the remaining stock came out the same in both orders, and only the operation that changed the state was order-sensitive. Now we ask the same question of code that starts several pieces of work together: whichever order the work finishes in, does the result we collect stay the same?

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

// 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)")
    }
}

This lesson measures two operations. The first is asynchronous, so its four runs are built by hand and given straight to Trial. The second only adds an item to an array in the state, so it is measured with replay. The model builds what the operation does to the state; it is not the network or the runtime itself.

Three Task Forms

There are three ways to start asynchronous work, and the difference is whose scope the work is tied to. async let starts child tasks whose number is known when the code is written: each async let spawns a child, and the result is taken when it is awaited. A task group is for children whose number is known only at run time; one child is added for each element of a list. An unstructured task (Task { }) is independent of the caller’s scope; the task continues even if the caller finishes, and its cancellation is managed by hand.

// async let: two child tasks of known number start together
func loadDashboard() async throws -> (stock: Int, orders: Int) {
    async let stock = fetchStockCount()
    async let orders = fetchOrderCount()
    return try await (stock, orders)
}
func fetchStockCount() async throws -> Int { 12 }
func fetchOrderCount() async throws -> Int { 7 }

The two children start at the same time; the return line waits for both. None of the children is left behind when the function finishes: when the scope ends, all of them have either finished or been cancelled. This is the guarantee of structured concurrency, and a task created with Task { } does not have it. A task left unattended can keep sending network requests even after the screen is closed.

Task and Task Group

A task (Task) is the unit that starts asynchronous work. In structured concurrency a task does not outlive its own scope: before the scope ends, all the child tasks inside it have finished. The most common tool for this is the task group: as many child tasks as you want are added inside the scope, and the results are collected in a loop. The children can run at the same time, so it is not known which finishes first.

We do not bring this uncertainty into the measurement. Which child finishes first can change in every run, and building the output on it would make the measurement unstable. What is measured is whether the collected result depends on arrival order.

// Groups.swift — the task group collects results in a set, arranges them on arrival
func collectNames(_ names: [String]) async -> [String] {
    await withTaskGroup(of: String.self) { group in
        for name in names {
            group.addTask { name }
        }
        var collected = Set<String>()
        for await name in group {
            collected.insert(name)
        }
        return collected.sorted()
    }
}

func record(_ name: String) -> ([String]) -> [String] {
    { list in list.contains(name) ? list : list + [name] }
}

collectNames returns each name in a child task and receives them in arrival order with for await; but it puts the result into a Set and returns it sorted. A set keeps neither order nor repetition. record, on the other hand, is an operation that adds a name to the state: if the name is not in the list it appends it to the end, and if it is there it does not touch the list.

Collecting in a Set and Collecting in an Array

First the operation that collects into a set is measured in four runs: a single name, the same name twice, alpha then beta, beta then alpha. Then the same two names are collected into an array with record.

// main.swift — the same two names, two ways of collecting
let setOnce = await collectNames(["alpha"])
let setTwice = await collectNames(["alpha", "alpha"])
let setForward = await collectNames(["alpha", "beta"])
let setBackward = await collectNames(["beta", "alpha"])
let grouped = Trial(label: "group-set", once: setOnce, twice: setTwice, forward: setForward, backward: setBackward).report()

let arrival = replay("arrival-order", from: [], record("alpha"), then: record("beta")).report()

countSensitivities([grouped, arrival])
group-set | once=["alpha"] twice=["alpha"] ab=["alpha", "beta"] ba=["alpha", "beta"] -> robust
arrival-order | once=["alpha"] twice=["alpha"] ab=["alpha", "beta"] ba=["beta", "alpha"] -> order-sensitive
robust: 1
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0

In the set line, each of the four values is ["alpha"] or ["alpha", "beta"]: the repeated name melts away in the set, and the two orders give the same sorted list. The operation is robust. In the arrival-order line once and twice are the same, because record does not add an existing name again; but ab and ba differ. If alpha comes first the list is ["alpha", "beta"], and if beta comes first it is ["beta", "alpha"]. The operation is order-sensitive. The count is one robust, one order-sensitive.

CC4. This measurement counts the orders in which the children can finish as just two. In a real group, as the number of children grows, the number of possible orders grows too; not all of them were measured.

CC5. The measurement assumes that all the child tasks return successfully. The case where a child throws an error or is cancelled is not in the measured model.

Counting Instead of Collecting

A set erases repetition; this is part of what makes the collection robust. If we write the numbers returned by the children not into a set but into a sum, repetition is no longer erased. The same measurement is made this time with a sum: one value, the same value twice, two different values in two orders.

// main.swift — summing the group results
func sumValues(_ values: [Int]) async -> Int {
    await withTaskGroup(of: Int.self) { group in
        for value in values { group.addTask { value } }
        var total = 0
        for await value in group { total += value }
        return total
    }
}

let sumOnce = await sumValues([4])
let sumTwice = await sumValues([4, 4])
let sumForward = await sumValues([4, 5])
let sumBackward = await sumValues([5, 4])
let summed = Trial(label: "group-sum", once: sumOnce, twice: sumTwice, forward: sumForward, backward: sumBackward).report()
countSensitivities([summed])
group-sum | once=4 twice=8 ab=9 ba=9 -> repeat-sensitive
robust: 0
repeat-sensitive: 1
order-sensitive: 0
doubly-sensitive: 0

In a sum, order does not matter: ab and ba are both nine. But once is four and twice is eight; adding the same value twice changes the sum. The operation is repeat-sensitive. In the previous set the same repetition melted away. So the two ways of collecting come from the same child tasks, and one is robust while the other is repeat-sensitive. The task group does not say which is correct; the meaning of the collection says it. A set is needed when counting unique users and a sum when adding order amounts, and the wrong choice silently produces a wrong result.

CC6. The sum measurement used integer addition; integer addition is commutative and associative. The sum of floating-point numbers can differ in the last digit depending on order; this was not measured.

Cancellation

In task groups, cancellation is part of the structure. If a child throws an error and the group scope propagates it, the remaining children are cancelled; this can also be done by hand with group.cancelAll(). Cancellation does not kill a task, it tells the task that it is no longer wanted. The task itself checks for cancellation and exits cleanly.

// Cancellation: when the group is cancelled, children can be cut short
func fetchAll(_ ids: [Int]) async throws -> [Int] {
    try await withThrowingTaskGroup(of: Int.self) { group in
        for id in ids {
            group.addTask {
                try Task.checkCancellation()
                return id * 2
            }
        }
        var results: [Int] = []
        for try await value in group {
            results.append(value)
            if results.count == 2 { group.cancelAll() }
        }
        return results
    }
}

Task.checkCancellation() in the fragment throws an error in a cancelled task. Cancellation can determine how many of the results are collected; which children get in at what position depends on timing. This is why the fragment is only read, not run.

How Cancellation Works

Cancellation is cooperative: the task carries a cancelled mark, but it does not stop by itself. The task’s code checks this mark in two ways. Task.checkCancellation() throws an error and ends the function if the task has been cancelled. Task.isCancelled only returns a Bool; this way the task can leave a partial result and exit cleanly. The system’s own asynchronous functions, such as a network request, already check for cancellation and throw an error when cancelled.

Cancellation propagates downward. When a parent task is cancelled, its children are cancelled too; if a group scope exits by throwing an error, the children that have not yet finished are cancelled. It does not propagate upward: the cancellation of one child does not stop its siblings or its parent. If this asymmetry is not known, two kinds of mistakes appear. The first is not putting a cancellation check in a long child; the mark is carried and nobody looks at it. The second is mistaking cancellation for an error; a cancelled request should not be shown to the user as an error, because the user has already moved on to something else.

Common Mistakes

  • Taking the finishing order of child tasks as the order of the results that arrive. The order is not the order in which the tasks were added.
  • Collecting the result as an array and forgetting to sort it. If order carries no meaning, a sorted set is a safe collection; if it does, the source of the order must be fixed separately.
  • Writing a long child that does not check for cancellation. Even if cancellation has been requested, the child continues until it finishes.
  • Trying to carry the task group outside its scope. The group lives only for its scope.

When Order Is Needed

If arrival order carries meaning, for example if the results are to be shown in a list in input order, the solution is to carry the index with the result. The children still finish in whatever order they like, but each one knows where it belongs.

// If the order must be kept, the index is carried in the result
func loadInOrder(_ ids: [Int]) async -> [Int] {
    await withTaskGroup(of: (Int, Int).self) { group in
        for (index, id) in ids.enumerated() {
            group.addTask { (index, id * 2) }
        }
        var slots = [Int?](repeating: nil, count: ids.count)
        for await (index, value) in group { slots[index] = value }
        return slots.compactMap { $0 }
    }
}

Here the result no longer depends on arrival order but on input order. If the input list is fixed, the output is the same in every run. Order sensitivity does not disappear, its source changes: the order of the result is taken out of timing and carried into the input itself. The arrival-order line we measured classified arrival order; in the version written with an index, what is classified is now input order. This was not measured, it is only a conclusion drawn from the structure of the code.

A common wrong setup is to append results to an array in the order they arrive without carrying an index, and to show this array on screen as if it were in input order. On the developer’s machine the network is fast and steady, the children mostly finish in the order they were started, and the bug goes unnoticed. On a slow connection the third request returns before the first and the list swaps places before the user’s eyes. The fix is to carry the position in the result as above; since the position is written once, the array comes out the same in every run.

In a Real Application

When a product page opens, the price, stock, and reviews are requested at the same time; the screen is drawn when all three arrive, or it shows each piece the moment it arrives. In the first case async let is enough. In the second, a separate task is needed for each piece, and the order in which the screen is updated is now a design decision. If the user leaves the page, all of these tasks must be cancelled; a task tied to a view (the task modifier in SwiftUI is an example) is cancelled when the view disappears, while an unattended Task is not.

The choice of collection method follows from this too. Counters are collected with a sum, unique identifiers with a set, and the rows to be displayed with an array tied to input order. All three are built on the same task group; the difference is only in the line that collects the result, and the measurement makes this difference visible.

The Bounding Measurement

The same operation can fall into two different classes if it is compared with two different states. Take the arrival-order operation again: the same additions of alpha and beta, but this time we compare not the list as the state but only the number of elements in the list.

// main.swift — the same operation, two state choices
let listTrial = replay("list-view", from: [], record("alpha"), then: record("beta"))
let listView = listTrial.report()

let countTrial = Trial(label: "count-view", once: listTrial.once.count, twice: listTrial.twice.count, forward: listTrial.forward.count, backward: listTrial.backward.count)
let countView = countTrial.report()

countSensitivities([listView, countView])
list-view | once=["alpha"] twice=["alpha"] ab=["alpha", "beta"] ba=["beta", "alpha"] -> order-sensitive
count-view | once=1 twice=1 ab=2 ba=2 -> robust
robust: 1
repeat-sensitive: 0
order-sensitive: 1
doubly-sensitive: 0

In the list view ab and ba differ and the operation is order-sensitive. In the count view both are two; the rule cannot see the difference and the operation comes out robust. The rule does not see the cause, it sees the inequality; the choice of the compared state changes the class. Before saying that a collection is robust, what was compared must be written down. The count is one robust, one order-sensitive.

Which Form to Choose

The choice comes down to three questions. If the number of children is known when the code is written, async let does the same job with the least noise. If the number is determined at run time, a task group is needed. If the work must not be tied to the lifetime of the caller, for example a load that will continue in the background after a user action, an unstructured task is used; but in that case cancellation and error handling are no longer the caller’s responsibility but yours. The typical sign of a wrong choice is opening a Task { } for everything and writing the results to a shared variable. The scope guarantee is lost; which task will finish when, who will hear about it if it fails, and who will cancel it when the screen closes remain unclear; the three sensitivity questions of this lesson are reopened separately for each task.

In short, the guarantee a task group gives is structural: no child leaks outside the group. Whether the result is meaningful is a decision made on the line that collects.

Summary

  • A task group starts child tasks within a scope and waits for all of them to finish before the scope ends.
  • When the results are collected in a set and sorted, the operation is independent of arrival order and comes out robust.
  • When the same results are arranged in arrival order, the operation is order-sensitive.
  • The same operation looks robust if only the number of elements is compared; the choice of state changes the class.
  • Cancellation is part of the structure; if a child does not check for cancellation, it continues.
  • This lesson’s sensitivity: robust (in a set) and order-sensitive (in an array).

Next Step

A collection hiding order does not mean that work sharing state is safe. In the next lesson we will see how actor isolation puts work that reaches the same data from several places into a line, and that the order between two requests remains measurable.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close