I've been watching some of the original lecture videos on async-await concurrency in Swift. The Protect mutable state with Swift actors video has a code snippet with a basic implementation for an image downloader cache: an actor that can perform asynchronous work and also cache that asynchronous work to prevent duplicate operations:
actor ImageDownloader {
private enum CacheEntry {
case inProgress(Task<Image, Error>)
case ready(Image)
}
private var cache: [URL: CacheEntry] = [:]
func image(from url: URL) async throws -> Image? {
if let cached = cache[url] {
switch cached {
case .ready(let image):
return image
case .inProgress(let task):
return try await task.value
}
}
let task = Task {
try await downloadImage(from: url)
}
cache[url] = .inProgress(task)
do {
let image = try await task.value
cache[url] = .ready(image)
return image
} catch {
cache[url] = nil
throw error
}
}
}
This works… but I'm having trouble understanding how this example could be expanded to include work that is requested and then cancelled. If image(from:) is called from a parent task and that parent task is cancelled then AFAIK the "child" task here would not cancel. Correct?
AFAIK both async let and TaskGroup are not "escapable" in the sense that we could pack them in a collection to be returned again outside of our current scope. Correct? I then have no clear understanding how we could both save an asynchronous unit of work in a collection and also automatically cancel when the parent task is cancelled.
IIUC we can either get unstructured concurrency with the ability to escape and cache our asynchronous work or we can get structured concurrency with the ability to automatically cancel when the parent task is cancelled. But I see no clear way here to get both the ability to escape and cache our asynchronous work and automatically cancel when the parent task is cancelled.
A interesting side quest is that I would also want for cancellation to cancel the task only if nobody else is waiting for it. So that cancelling the "original" image(from:) would not cancel the underlying child task if more than one caller is waiting for the same image(from:) result.
Is something like this possible using the primitive async-await tools in production swift today? Or would image(from:) need to return some kind of higher level helper instead of an Image directly from an async function?
Yeah, you would need to wrap the let image = try await task.value in a withTaskCancellationHandler and propagate cancellation to the task yourself. This example needs unstructured concurrency because you want other things coming in to await on the same task result from their own tasks if they come in before the resource has been fetched. Once opting into unstructured concurrency, cancellation propagation is necessary.
Oh, one thing to consider. What happens if you have two tasks waiting on the same resource being downloaded…and only one of them gets cancelled. Should this cancellation affect the other one waiting, too? In many cases I would assume not. But that’s up to the semantics of your cache.
I guess if more than one task is waiting for the download, cancellation should only cancel the „await“ but not the actual work of fetching the image so that the other task(s) can still consume it - not so straight forward how to do this properly
AFAIK the onCancel handler is synchronous and not isolated to the actor… hence the unstructured task. But some kind of lock here might also work to decrement the count and check if it is zero.
Hm I guess you could do this with continuations that represent the waiters. Once the download task finishes you complete all waiters with the downloaded value. If a waiter cancels, you only „cancel“ their continuation and remove it. This way „waiting“ and „work“ are decoupled. Just thinking out loud though …
Depending on your use case, it might be worth considering to create the download tasks with low priority and then the task that awaits it would be high priority, so that priority is propagated to the download task.
Like if you have a list of items and you need thumbnails downloaded for them, you still want to download all but prioritize the ones that are currently on screen.
I do not see that error. Which toolchain are you compiling from? I can compile from 6.2 and the 6.3 RC with no errors. I do see a 6.4 error… but this is answered from the other thread:
If you wanted to ship a legit CountedTask in a real product I think it would just need to copy over the different flavors of Task constructors and then forward those parameters to its task variable.
The error from Intel does not make a lot of sense. You are explicitly passing a parameter that is sending… so why then would the compiler tell you it is nonisolated(nonsending)?
but got the error: Cannot convert value of type 'nonisolated(nonsending) @Sendable () async throws -> Success' to expected argument type '@isolated(any) () async throws -> Success'
I'm not completely sure I understand under what specific examples and situations you would need for this closure to be sending and notSendable… but it's there for you if you need that support.