I once was a proponent of the unstructured task approach, where each would await
the prior one. In retrospect, this feels a bit brittle to me. Increasingly (with credit to Rob Napier for nudging me in this direction), I now use asynchronous sequences, specifically AsyncChannel
from Apple’s swift-async-algorithms
. I think it is a more robust behavior and is more consistent with the asynchronous sequences of modern Swift concurrency.
Before we come to your example, consider this serial downloader, where we have one process (the user button clicking) send URL
objects to another process monitoring the channel for URLs in a for
-await
-in
loop:
struct DownloadView: View {
@StateObject var viewModel = DownloadViewModel()
var body: some View {
VStack {
Button("1") { Task { await viewModel.appendDownload(1) } }
Button("2") { Task { await viewModel.appendDownload(2) } }
Button("3") { Task { await viewModel.appendDownload(3) } }
}
.task {
await viewModel.monitorDownloadRequests()
}
}
}
@MainActor
class DownloadViewModel: ObservableObject {
private let session: URLSession = …
private let baseUrl: URL = …
private let folder: URL = …
private let channel = AsyncChannel<URL>() // note, we're sending URLs on this channel
func monitorDownloadRequests() async {
for await url in channel {
await download(url)
}
}
func appendDownload(_ index: Int) async {
let url = baseUrl.appending(component: "\(index).jpg")
await channel.send(url)
}
func download(_ url: URL) async {
do {
let (location, _) = try await session.download(from: url)
let fileUrl = folder.appending(component: url.lastPathComponent)
try? FileManager.default.removeItem(at: fileUrl)
try FileManager.default.moveItem(at: location, to: fileUrl)
} catch {
print(error)
}
}
}
We start monitorDownloadRequests
and then append
download requests to the channel.
This performs the requests serially (because monitorDownloadRequests
has a for
-await
loop). E.g., in Instruments’ “Points of Interest” tool, I have added some Ⓢ signposts where I clicked these buttons, and show intervals where the requests happen, and you can see that these three requests happen sequentially.
But the wonderful thing about channels is that they offer serial behaviors without introducing the problems of unstructured concurrency. They also handle cancelation automatically (if you want that behavior). If you cancel the for
-await
-in
loop (which the .task {…}
view modifier does for us automatically in SwiftUI when the view is dismissed). If you have a bunch of unstructured concurrency, with one Task
awaiting the prior one, handling cancelation gets messy quickly.
Now, in your case, you are asking about a more general queue, where you can await tasks. Well, you can have an AsyncChannel
of closures:
typealias AsyncClosure = () async -> Void
let channel = AsyncChannel<AsyncClosure>()
E.g.:
typealias AsyncClosure = () async -> Void
struct ExperimentView: View {
@StateObject var viewModel = ExperimentViewModel()
var body: some View {
VStack {
Button("Red") { Task { await viewModel.addRed() } }
Button("Green") { Task { await viewModel.addGreen() } }
Button("Blue") { Task { await viewModel.addBlue() } }
}
.task {
await viewModel.monitorChannel()
}
}
}
@MainActor
class ExperimentViewModel: ObservableObject {
let channel = AsyncChannel<AsyncClosure>()
func monitorChannel() async {
for await block in channel {
await block()
}
}
func addRed() async {
await channel.send { await self.red() }
}
func addGreen() async {
await channel.send { await self.green() }
}
func addBlue() async {
await channel.send { await self.blue() }
}
func red() async { … }
func green() async { … }
func blue() async { … }
}
That yields:
Here again, I am using Instruments to visualize what is going on. I clicked the “red”, “green”, and “blue” buttons quickly, in succession, twice. I then watched the six corresponding intervals for these three second tasks. I then repeated that six-click process a second time, but this time I dismissed the view in question before they finished, mid-way through the green task of the second series of button taps, illustrating the seamless cancelation capabilities of AsyncChannel
(and asynchronous sequences in general).
Now, I hope you forgive me, as I omitted the code to create all of these “Points of Interest” signposts and intervals, as it adds a lot of kruft that really is not relevant to the question at hand (but see this if you are interested). But hopefully these visualizations help illustrate what is going on.
The take-home message is that AsyncChannel
(and its sibling AsyncThrowingChannel
) is a great way to remain within structured concurrency, but get serial (or constrained behavior, like shown at the end of this answer) that we used to get with queues, but with asynchronous tasks.
I must confess that this latter AsyncClosure
example, while it hopefully answers your question, feels a little forced to my eye. I have been using AsyncChannel
for a few months now, and I personally always have a more concrete object being handled by the channel (e.g., URLs, GPS locations, image identifiers, etc.). This example with closures feels like it is trying just a little too hard to reproduce old fashioned dispatch/operation queue behaviors.
await
and other Tasks may be scheduled on the same queue. – Lauralee