Skip to content

VOL 09 / CH 03 / LESSON 03

3.3 Task Concurrency: Async, Structured Concurrency, and STM ​

Locks and messaging models primarily address "how to coordinate state"; task models must also answer "how long do concurrent tasks run, how is failure propagated, and when does a parent task consider itself complete." Many production leaks aren't due to data races, they occur when requests have finished, but child tasks continue running in the background.

Async/Await is a suspension protocol, not automatic parallelism ​

Calling a Python async def function creates a coroutine object; that call alone does not run its body. Awaiting or scheduling it starts the work. JavaScript async functions return promises, while Rust async functions produce futures that need polling. When an awaitable is pending, the task can suspend and let the scheduler run other work; awaiting an already-ready value need not suspend.

python
async def load_dashboard(user_id: str):
    profile, orders = await asyncio.gather(
        load_profile(user_id),
        load_orders(user_id),
    )
    return build_dashboard(profile, orders)

The dashboard example is a fragment: import asyncio and provide the three application functions before calling it. With default gather, the first exception is propagated to the caller, but other children are not automatically cancelled. Cancelling gather itself has different behavior. Use TaskGroup below when sibling cancellation and cleanup are part of the policy.

This model works well for scenarios involving many I/O-bound operations, because suspension doesn't require holding an operating system thread for each connection for an extended period. However, it's important to distinguish three key concepts:

  • Concurrency: The lifetimes of multiple tasks overlap;
  • Parallelism: Multiple tasks are actually executed simultaneously;
  • Asynchrony: An operation is initiated without blocking the current execution context while waiting for a result.

In a single-threaded event loop, two coroutines can be concurrent, yet Python code snippets still execute in turn. If a coroutine performs a long-running CPU-intensive computation or blocking I/O directly, the entire loop will be stalled. Such work should be broken into smaller chunks, replaced with non-blocking APIs, or offloaded to a controlled thread or process pool.

Why Unstructured Tasks Are Difficult to Reason About ​

python
async def handle_request(request):
    asyncio.create_task(write_audit_log(request))
    return {"ok": True}

This code immediately returns, but leaves a series of unanswered questions:

  • Who receives the exception when auditing fails?
  • Does the audit task continue after the request is canceled?
  • Is the task waited on when the process shuts down?
  • How long does the captured request object hold onto memory?

Give work that outlives a request an explicit owner. If it must survive process failure, use a persistent queue and a retry policy. For deliberately temporary work, a managed task set may be enough, but keep strong references to tasks, observe their errors, and await or cancel them during shutdown. The fragment above does none of that.

Structured Concurrency: Let Tasks Form a Tree ​

Structured concurrency requires that the lifetimes of child tasks are bounded by a clear scope: before the parent task exits its scope, it must collect the child results or cancel the children and await their cleanup.

text
Handle request
├── Query account
├── Query order
└── Query recommendation

Parent task succeeds: results are aggregated according to strategy
Any critical child task fails: sibling tasks are canceled and the failure is propagated
Parent task times out: the entire subtree of child tasks is canceled

The key benefits it provides are:

  • Lifecycle behavior is immediately visible from the code structure;
  • Failures, cancellations, and timeouts propagate along the task tree;
  • Debuggers and monitoring tools can visualize parent-child relationships;
  • It's difficult to leave behind orphaned tasks that are no longer awaited.

Java SE 26’s StructuredTaskScope is a preview API. The fragment below requires java.util.concurrent.StructuredTaskScope, application methods and result types, an enclosing method that handles interruption, and JDK 26 with preview enabled at compilation and execution (javac --enable-preview --release 26 ... and java --enable-preview ...). Its open() policy cancels unfinished siblings when a child fails; another joiner can select a different policy:

java
try (var scope = StructuredTaskScope.open()) {
    var profile = scope.fork(() -> loadProfile(userId));
    var orders = scope.fork(() -> loadOrders(userId));

    scope.join();
    return new Dashboard(profile.get(), orders.get());
}

Structured concurrency does not equate to virtual threads. Virtual threads reduce the cost of mapping tasks to platform threads; structured concurrency enforces constraints on the lifecycle relationships between tasks. The two can work together, or they can exist independently.

Cancellation is typically a cooperative agreement: tasks receiving a cancellation or interruption signal must promptly stop blocking, release resources, and terminate. A child that ignores interruption can prevent scope closure indefinitely, even after a timeout has cancelled the scope.

Observe Sibling Cancellation and Cleanup ​

Save this as task_group_demo.py and run python3 task_group_demo.py with Python 3.11 or later. Events ensure that the waiting child has started before the other child fails, so the example needs no timing guesses:

python
import asyncio

async def main():
    started = asyncio.Event()
    cleaned = asyncio.Event()

    async def slow():
        try:
            started.set()
            await asyncio.Event().wait()
        finally:
            cleaned.set()

    async def fail():
        await started.wait()
        raise RuntimeError("dependency failed")

    try:
        async with asyncio.TaskGroup() as group:
            group.create_task(slow(), name="slow-query")
            group.create_task(fail(), name="failing-query")
    except* RuntimeError as errors:
        print("failures", len(errors.exceptions))
    print("cleaned", cleaned.is_set())

asyncio.run(main())

The output is failures 1, then cleaned True. The task group cancels the waiting child and awaits its finally block before propagating the exception group. Do not swallow CancelledError in real child tasks. A timeout requests cancellation; cleanup that blocks or ignores cancellation can still delay return. The scope tracks the tasks you create in it, not arbitrary background work started elsewhere.

STM: Making Shared State Updates Into Transactions ​

Software Transactional Memory (STM) allows programs to read and modify managed shared references within a transaction. If a conflict is detected upon commit, the implementation may retry the transaction, ensuring that a group of modifications appears as an atomic commit.

This Clojure 1.12 REPL example transfers a fixed amount between two Refs:

clojure
(def checking (ref 1000))
(def savings  (ref 500))

(dosync
  (alter checking - 200)
  (alter savings  + 200))

After evaluation, [@checking @savings] is [800 700], preserving the total of 1500. This fixed transfer has enough funds; a general debit must check its balance and amount inside the same transaction.

Transactional semantics do not prescribe one implementation algorithm. STMs can employ optimistic, pessimistic, or hybrid strategies; Clojure's STM uses multiversion concurrency control (MVCC) with snapshot isolation, but this does not mean all STMs should be defined as MVCC.

Transactions may automatically retry, so transaction bodies should avoid irreversible external side effects:

clojure
;; Do not directly send email or call payment interfaces within retryable transactions
(dosync
  (alter balance - amount)
  (send-payment-email))

Otherwise, a single function call could result in multiple external actions. Keep irreversible I/O out of retryable transactions. An after-commit action avoids replay inside the transaction but introduces a crash window between commit and the external action. Durable delivery needs a durable handoff and idempotent handling; committing in-memory Refs does not create that guarantee.

The advantage of STM is that it naturally expresses combinations of managed state; the cost includes repeated execution on conflict, unpredictable performance, difficulty in debugging, and restrictions on side effects. It is not a replacement for database transactions and cannot automatically provide consistency across processes.

Using Constraints to Select a Model ​

Problem PatternCandidate ModelPrimary Validation Points
Limited shared state, composite invariantsLocks / Database TransactionsCritical sections and lock ordering
Frequent single-variable state transitionsAtomic OperationsRace conditions, retries, and memory ordering
Entities with identity and private stateActorMailbox limits, idempotency, and recovery
Multi-stage data pipelinesChannel / CSPBackpressure, closure, and cancellation
High I/O waitingAsync/AwaitBlocking calls and task leaks
Requests split into multiple subqueriesStructured ConcurrencyFailure strategies and timeouts
Multiple managed references updated atomically within a processSTMConflict rate and side-effect isolation

Models can be combined, but each additional model increases debugging and observability overhead. When a service simultaneously uses thread pools, event loops, Actors, and callback queues, it must clearly define ownership boundaries between them, otherwise, "asynchronous everywhere" eventually becomes "no one knows where the tasks are."

Checklist for Verifying Concurrent Code ​

  • Expand the race condition window with stress testing, but don't treat "running 10,000 times without failure" as proof of correctness;
  • Write tests for timeouts, cancellations, duplicate messages, full queues, and partial failures;
  • Track metrics like queue depth, task age, rejection count, cancellation count, and execution duration, rather than thread count alone;
  • Retain identifiable task names in thread dumps or task tracing;
  • For every asynchronous boundary, document the owner, capacity, failure handling, and shutdown protocol.

Completion Check ​

An interface must concurrently query three suppliers and return the first result that meets a price condition. Design the following:

  1. The overall deadline and individual supplier deadlines;
  2. How to cancel remaining tasks once a result is obtained;
  3. Which error to return if all suppliers fail;
  4. How to isolate resources when a supplier ignores cancellation;
  5. How to observe the full task tree in tracing.

Being able to answer these questions demonstrates a far greater understanding of production-grade concurrency than simply replacing sequential calls with three async.

References ​

Built with VitePress | Software Systems Atlas