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
async Functions typically return a Future, Promise, or Task; when execution reaches an await that hasn't yet resolved, the function saves its state and yields control back to the scheduler. Once the awaited I/O operation becomes ready, the task resumes from the point where it was suspended.
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)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
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?
If background work that spans the request lifecycle is truly needed, it should be handed over to a dedicated background system with ownership, persistent queues, and retry strategies. "Just create a task on the fly" is not a reliable queue.
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, either the child tasks must produce results or they must be canceled and wait for completion.
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 canceledThe 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.
The StructuredTaskScope feature in Java SE 26 remains a Preview API and must be enabled with preview features, its use should not imply it is a finalized, stable standard interface. Its basic form is as follows:
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. Child tasks that ignore interruptions will cause the scope closure to wait unnecessarily.
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.
Take Clojure's Ref as an example:
(def checking (ref 1000))
(def savings (ref 500))
(dosync
(alter checking - 200)
(alter savings + 200))What matters is the transactional semantics, not a specific 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:
;; 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. The correct approach is typically to have the transaction compute and commit internal state, then trigger side effects reliably after commit.
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 Pattern | Candidate Model | Primary Validation Points |
|---|---|---|
| Limited shared state, composite invariants | Locks / Database Transactions | Critical sections and lock ordering |
| Frequent single-variable state transitions | Atomic Operations | Race conditions, retries, and memory ordering |
| Entities with identity and private state | Actor | Email queue limits, idempotency, and recovery |
| Multi-stage data pipelines | Channel / CSP | Backpressure, closure, and cancellation |
| High I/O waiting | Async/Await | Blocking calls and task leaks |
| Requests split into multiple subqueries | Structured Concurrency | Failure strategies and timeouts |
| Multiple managed references updated atomically within a process | STM | Conflict 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:
- The overall deadline and individual supplier deadlines;
- How to cancel remaining tasks once a result is obtained;
- Which error to return if all suppliers fail;
- How to isolate resources when a supplier ignores cancellation;
- 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.