Sure. I'll start at the specifics and then talk about generalities. But first, a clarifying note: when I say "unstructured Task
s" I don't just mean those created by Task.detached
, I also mean those created by Task.init
.
The specific pattern used above is:
internal func channelRead(context: ChannelHandlerContext, data: NIOAny) {
let frame = self.unwrapInboundIn(data)
consumer.feedConsumer([frame])
Task {
await processFrame()
}
}
This is a mistake. channelRead
is what I'd call an "unstructured stream": we're going to call this function repeatedly, in order, with the elements of the stream. It's really common, in Swift, to implement what is essentially an "ad-hoc stream" by using unstructured tasks. This implementation is slightly better than the naive one, in that it retains ordering, but it still spawns one Task
per stream chunk.
I think this is an anti-pattern. In Swift concurrency we have a native abstraction for streams, AsyncSequence
. This abstraction behaves better than unstructured Task
s for several reasons. The most obvious is that it retains ordering. The more important ones are that unstructured Task
s do not natively exert backpressure, and that they are "unowned". Both of these are really important.
On the backpressure front, one of the great opportunities of Swift concurrency is that we can hook AsyncSequence
s up to our backpressure mechanisms. This gives us the opportunity to let developers support backpressure without having to actually think about it, which is a really powerful feature.
But in reality I think the most important thing about unstructured Task
s is that they (usually) have no owner, and this is also my wider objection to unstructured Task
s and why I think they are generally an anti-pattern. This lack of ownership makes them unmanageable: they are uncancellable, unmonitorable, and unordered. It is my view that it is extremely rare that this is something you actually want. Do you really want a piece of work to happen unconditionally but not care when it happens or in what order?
In my view, the overwhelming majority of Task
s in a program should be structured (i.e. created via TaskGroup
s or async let
). This makes it easier to understand cancellation and lifecycle. If we use structured concurrency then we can use with
blocks for deterministic cleanup, cancellation should be able to work as expected, and errors always propagate sensibly. If we don't, errors get lost, tasks are uncancellable, and work can pile up in your system.
A wrinkle here is that there are only two mechanisms of communicating between tasks: either your task returns a single value, in which case you communicate using TaskGroup
or async let
, or it produces a stream of values, in which case you use AsyncSequence
.
Importantly, sometimes you need to communicate between separate Task hierarchies. This always needs to get done by way of AsyncSequence
, because it's a "shared" ownership primitive. Task
s have only one parent, but AsyncSequence
s have two, the producer and the consumer. This allows bidirectional communication of interest: if the producer is cancelled or errors, the consumer will find out about it, and vice versa.
So my thesis is: Task
s should (almost) always be structured, and cross-task communication should happen through AsyncSequence
.