Updating `ImageDownloader` actor sample to support cancellation?

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?

2 Likes

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.

1 Like

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.

2 Likes

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 :thinking:

1 Like

Ahh… good idea! I think I have something working along these lines:

struct RemoteImage {
  let url: String
}

func downloadImage(from url: String) async throws -> RemoteImage {
  try await Task.sleep(for: .seconds(2))
  return RemoteImage(url: url)
}

actor ImageDownloader {
  private var tasks: [String: Task<RemoteImage, any Error>] = [:]
  private var counts: [String: Int] = [:]
  private var images: [String: RemoteImage] = [:]
  
  func image(from url: String) async throws -> RemoteImage? {
    if let image = images[url] {
      return image
    }
    if let task = tasks[url] {
      counts[url, default: 0] += 1
      let image = try await withTaskCancellationHandler {
        try await task.value
      } onCancel: {
        Task {
          await self.onCancel(from: url)
        }
      }
      return image
    }
    
    let task = Task {
      print("Task Started")
      let image = try await downloadImage(from: url)
      print("Task Finished")
      return image
    }
    
    tasks[url] = task
    
    do {
      counts[url, default: 0] += 1
      let image = try await withTaskCancellationHandler {
        try await task.value
      } onCancel: {
        Task {
          await self.onCancel(from: url)
        }
      }
      images[url] = image
      return image
    } catch {
      tasks[url] = nil
      throw error
    }
  }
  
  private func onCancel(from url: String) {
    counts[url, default: 0] -= 1
    if let task = tasks[url],
       let count = counts[url],
       count == 0 {
      print("Task Cancelled")
      task.cancel()
    }
  }
}

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.

2 Likes

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 …

1 Like

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.

2 Likes

Thank you, but the beauty of the original example is lost. :slight_smile:

Is there a way to bring it back and still support the cancellation?

1 Like

Something like this might work:

actor CountedTask<Success> where Success : Sendable {
  private let task: Task<Success, any Error>
  private var count = 0
  
  init(_ operation: sending @escaping () async throws -> Success) {
    self.task = Task(operation: operation)
  }
  
  var value: Success {
    get async throws {
      self.count += 1
      let image = try await withTaskCancellationHandler(
        operation: {
          try await task.value
        },
        onCancel: self.cancel
      )
      return image
    }
  }
  
  private func cancel() {
    self.count -= 1
    if self.count == 0 {
      self.task.cancel()
    }
  }
}

struct RemoteImage {
  let url: String
}

func downloadImage(from url: String) async throws -> RemoteImage {
  try await Task.sleep(for: .seconds(2))
  return RemoteImage(url: url)
}

actor ImageDownloader {
  
  private enum CacheEntry {
    case inProgress(CountedTask<RemoteImage>)
    case ready(RemoteImage)
  }
  
  private var cache: [String: CacheEntry] = [:]
  
  func image(from url: String) async throws -> RemoteImage? {
    if let cached = cache[url] {
      switch cached {
      case .ready(let image):
        return image
      case .inProgress(let task):
        return try await task.value
      }
    }
    
    let task = CountedTask {
      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
    }
  }
}

let downloader = ImageDownloader()

let s = "1234"

let t1 = Task {
  try await downloader.image(from: s)
}
let t2 = Task {
  try await downloader.image(from: s)
}

// try await Task.sleep(for: .seconds(1))
// t1.cancel()
// t2.cancel()

let v1 = try await t1.value
let v2 = try await t2.value

print(v1 ?? "nil")
print(v2 ?? "nil")
2 Likes

How do I tackle the above error? The following compiles, but I am not sure it has the effect intended above.

enum DownloadImage2 {
    actor CountedTask<Success> where Success : Sendable {
      private let task: Task<Success, any Error>
      private var count = 0
      
      init(_ operation: sending @escaping () async throws -> Success) {
        self.task = Task {
            try await operation ()
        }
      }
1 Like

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.

1 Like
Xcode Version 26.2 (17C52)

swiftc -v
Apple Swift version 6.2.3 (swiftlang-6.2.3.3.21 clang-1700.6.3.2)
Target: x86_64-apple-macosx15.0

Could this error have something to do with Target: x86_64-apple-macosx15.0?

1 Like

Hmm… maybe!

The Task constructors come from gyb:

What if you try the @Sendable version?

func f(_ operation: @Sendable @escaping () async throws -> ()) {
  Task(operation: operation)
}

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)?

1 Like

Tried it:

init (_ operation: @Sendable @escaping () async throws -> Success) {

#if true
  self.task = Task (operation: operation)
#else
  self.task = Task {
      try await operation ()
  }
#endif
}

but got the error: Cannot convert value of type 'nonisolated(nonsending) @Sendable () async throws -> Success' to expected argument type '@isolated(any) () async throws -> Success' :confused:

1 Like

Maybe you need the isolated(any) as explicit?

func f(_ operation: sending @escaping @isolated(any) () async throws -> ()) {
  Task(operation: operation)
}

I'm not sure why that would break specifically on Intel. Have you tried from 6.3?

Do you have nonisolated(nonsending)-by-default enabled? I wonder if that's why you're seeing different errors.

2 Likes

Ahh… good idea. I see the error now after enabling NonisolatedNonsendingByDefault.

This compiled for me with no errors.

1 Like

Thank you, @bbrk24

You hit the nail on the head.

With Approachable Concurrency: No in Xcode, don't see the error anymore.

But, now, my head hurts! :confused:

What do the following functions each mean?

// Requires Approachable Concurrency: No
// (NonisolatedNonsendingByDefault.)

func f(_ operation: sending @escaping () async throws -> ()) {
  Task (operation: operation)
}

func g(_ operation: @Sendable @escaping () async throws -> ()) {
  Task (operation: operation)
}


func h (_ operation: sending @escaping @isolated(any) () async throws -> ()) {
  Task (operation: operation)
}


func k (_ operation: @Sendable @escaping @isolated(any) () async throws -> ()) {
  Task (operation: operation)
}

1 Like

One POV that might help is that AFAIK all Sendable closures are sending:

func f(_ operation: sending @escaping () async throws -> ()) {
//  g(operation)
}

func g(_ operation: @Sendable @escaping () async throws -> ()) {
  f(operation)
}

But not all sending closures are Sendable:

func f(_ operation: sending @escaping () async throws -> ()) {
  g(operation)
//  `- error: passing non-Sendable parameter 'operation' to function expecting a '@Sendable' closure
}

func g(_ operation: @Sendable @escaping () async throws -> ()) {
//  f(operation)
}

I'm not completely sure I understand under what specific examples and situations you would need for this closure to be sending and not Sendable… but it's there for you if you need that support.

1 Like