|

swift-async-algorithms: Backpressure at the Handoff

A producer and consumer connected by a channel that passes one value at a time

Imagine one task reading records from a file while another sends them to a server. Reading may be quick, while sending takes longer. Both tasks can use async/await, but that alone does not answer an important question: what stops the reader from getting too far ahead?

A queue is one answer. It lets the reader continue while the sender works. But if the reader keeps producing data, the queue needs a limit, a way to discard values, or a way to make the reader wait.

Swift Async Algorithms takes another approach with AsyncChannel. Instead of placing values in a separate buffer, it makes sending an asynchronous operation. A producer waits until a consumer asks for the value. This is backpressure: the consumer’s demand controls when the producer can continue.

The interesting part is how the package makes that promise work when several tasks are waiting, one of them is cancelled, or the channel is finished.

Sending is part of the flow control

The public API makes the handoff look simple:

import AsyncAlgorithms

let channel = AsyncChannel<Int>()

let producer = Task {
  for number in 1...3 {
    await channel.send(number)
  }
  channel.finish()
}

for await number in channel {
  print("Received \(number)")
}

await producer.value

If a consumer is already waiting in next(), a call to send can hand it a value immediately. Otherwise, the producer suspends until a consumer requests that value. One producer that awaits each send cannot run ahead of demand through this channel.

There is a limit to what this means. The consumer receives the value when next() returns, not when the work inside the for await body finishes. After handing off one value, the producer may reach its next send, but that next call waits while the consumer is still processing the previous element.

This is also not a broadcast stream. The channel supports several producers and consumers, but one sent element goes to one waiting consumer. The test test_asyncChannel_delivers_elements_when_several_producers_and_several_consumers checks that ten concurrent sends are received, not that each consumer receives all ten.

The queue contains waiting operations

The public AsyncChannel<Element> type is a small Sendable reference type conforming to AsyncSequence. Its send(_:), finish(), and iterator methods delegate to ChannelStorage. That storage wraps ChannelStateMachine in ManagedCriticalState.

When a producer calls send, the state machine checks for a waiting consumer. If it finds one, the storage resumes that consumer with the element. Otherwise, it registers the producer as a suspended operation. A consumer calling next() follows the opposite path: it takes the first waiting producer’s value, or waits for a future producer.

send(value)
    |
    +-- consumer waiting --> hand off value
    |
    +-- nobody waiting --> suspend producer
                               |
next() ------------------------+--> receive value; resume producer

The state machine stores suspended producers and consumers in separate OrderedSet collections. A producer entry holds its ID, continuation, and value. A consumer entry holds its ID and continuation. Entries are taken in the order they registered, which is not necessarily the order in which independent tasks were created.

Another useful detail is the separation between deciding an action and performing it. ChannelStateMachine.send() and next() return actions such as “suspend” or “resume this continuation.” ChannelStorage resumes continuations after leaving the critical region. The lock protects the short state transition, not the code that runs after another task wakes up.

So the channel is not simply a value queue. Its state includes the operations that are waiting for a match on the other side.

A cancellation race you can miss

Suppose a producer finds no consumer and is about to suspend. Its task can be cancelled before the producer’s continuation is registered. If cancellation only searched the list of registered producers, it would find nothing. The producer could then register after cancellation and remain stuck.

The implementation handles both orders. Every pending operation gets an ID. sendCancelled(producerID:) removes an already registered producer and resumes it. If that producer has not registered yet, the state machine remembers its ID in cancelledProducers. When sendSuspended(...) runs, it sees the earlier cancellation and resumes instead of waiting.

Consumers use the same pattern through cancelledConsumers, nextCancelled(consumerID:), and nextSuspended(...). ChannelStorage connects these transitions to withTaskCancellationHandler and continuations. Cancellation of one sender or receiver leaves other pending operations active.

The tests cover both sides of the contract. test_asyncChannel_resumes_producer_when_task_is_cancelled cancels one sender, then confirms that a second sender can still deliver its value. A matching test cancels one waiting consumer, gets nil from that iterator, and lets another consumer receive a later value.

The original channel proposal also explains why the authors did not make this type an actor. Handling cancellation through actor-isolated state would have required extra tasks. Here, a lock-protected state machine allows the cancellation handler to change state synchronously. That is a specific implementation trade-off, not a rule that actors are unsuitable for channels.

Finishing does not drain suspended senders

Calling finish() moves the channel to a terminal state and resumes pending producers and consumers. Waiting consumers receive nil; later next() calls also return nil. A later send returns without delivering anything.

This does not mean every value from a suspended sender will be delivered first. Finishing discards those pending values. The test test_asyncChannel_resumes_producers_and_discards_additional_elements_when_finish_is_called makes that behavior explicit.

The distinction matters when shutting down a pipeline. If every item must be processed, let the producer complete its sends before it calls finish(). If the consumer exits early, arrange to stop the producer too. Otherwise, it may remain suspended waiting for a next() that will never happen. Cancelling the producer or finishing the channel is part of managing its lifetime.

The package also provides AsyncThrowingChannel, which shares the storage design but can finish with an error. It is a separate public type, not an error mode switched on for AsyncChannel.

Backpressure has a boundary

With one sequential producer, a channel prevents the producer from pushing hundreds of values ahead of the consumer. But if hundreds of tasks each call send at once, the state machine can hold hundreds of suspended producers. Each waiting operation retains its element and continuation. There is no separate configurable element buffer, yet the application can still use substantial memory through concurrent sends.

So backpressure limits progress within a producer that awaits its sends. It does not limit how many producer tasks the application creates.

The package also contains a separate MultiProducerSingleConsumerAsyncChannel with explicit buffering and backpressure strategies, including watermarks. It has a different API and lifecycle. That is a useful alternative when controlled buffering matters more than a strict handoff. AsyncStream can also be a better match for values arriving from synchronous callbacks that cannot naturally wait on an asynchronous send.

Where to look in the source

Start with Sources/AsyncAlgorithms/Channels/AsyncChannel.swift, then follow send and next into ChannelStorage.swift and ChannelStateMachine.swift. Read Tests/AsyncAlgorithmsTests/TestChannel.swift beside them, especially the cancellation and finish tests. The design discussion in Evolution/0012-channel.md covers the original reasoning and the actor alternative.

The project is licensed under Apache 2.0 with the Swift Runtime Library Exception. As of October 12, 2026, the latest GitHub release shown is 1.1.5, published June 29, 2026. The current main-branch manifest uses Swift tools 6.2 and depends on Swift Collections. It also defines an AsyncStreaming product with an unstable opt-in trait, so the exact product and toolchain matter when evaluating the package.

The strongest idea in AsyncChannel is where it places control. A producer does not need to guess how much work the consumer can accept. It waits at the handoff. The state machine then makes that wait safe across arrival, cancellation, and termination. That turns backpressure from a vague property of a stream into a concrete rule about when an asynchronous operation may finish.

Sources