0

Picture an image loading function with a closure completion. Let's say it returns a token ID that you can use to cancel the asynchronous operation if needed.

@discardableResult
func loadImage(url: URL, completion: @escaping (Result<UIImage, Error>) -> Void) -> UUID? {
    
    if let image = loadedImages[url] {
        completion(.success(image))
        return nil
    }
    
    let id = UUID()
    
    let task = URLSession.shared.dataTask(with: url) { data, response, error in
        
        defer {
            self.requests.removeValue(forKey: id)
        }
        
        if let data, let image = UIImage(data: data) {
            DispatchQueue.main.async {
                self.loadedImages[url] = image
                completion(.success(image))
            }
            return
        }
        
        if let error = error as? NSError, error.code == NSURLErrorCancelled {
            return
        }
        
        //TODO: Handle response errors
        print(response as Any)
        completion(.failure(.loadingError))
    }
    task.resume()
    
    requests[id] = task
    return id
}

func cancelRequest(id: UUID) {
    requests[id]?.cancel()
    requests.removeValue(forKey: id)
    
    print("ImageLoader: cancelling request")
}

How would we accomplish this (elegantly) with swift concurrency? Is it even possible or practical?

Matjan
  • 3,591
  • 1
  • 33
  • 31
  • We could use the url string as the identifier but that get's complicated if we want to handle multiple requests for the same image. – Matjan Dec 29 '22 at 13:57
  • Well, I wouldn't use the URL string; that's a crappy identifier. But a data task _gives_ you an identifier _exactly_ so that you can cancel the right task. I would simply take advantage of that. – matt Dec 29 '22 at 14:21
  • If you take a look at https://github.com/mattneub/Programming-iOS-Book-Examples/blob/d4c84b61b9634f206cb1a5df73a8c16b6200820d/iOS14bookExamples/bk2ch24p842downloader/ch37p1099downloader/Downloader.swift you'll see that I've already developed an image downloader class that does just what you say: it returns the task to the caller immediately so it can be cancelled. I don't think it would be very hard to re-express that in async/await form. – matt Dec 29 '22 at 14:22
  • The _real_ problem is that if the caller gets a return value immediately, the caller and the downloading code have become "disconnected"; you cannot return a value both now and later _from the same call_. Therefore you'd need to devise a two-call strategy (because you can't do what I do in my code, namely store the necessary info so you can call a completion handler later — that would be totally against the spirit of async/await). – matt Dec 29 '22 at 14:23
  • Thanks @matt, your observation about the real problem is what this question is trying to get at. I'm wondering if there'a a practical/elegant way of dong this in that new paradigm. Otherwise I would consider this a notable limitation of async/await. – Matjan Dec 29 '22 at 14:50
  • 2
    I think the piece of the puzzle you might be missing is that when you say `Task {...}` you have created an object that you can retain so that later you can say `cancel` to it. – matt Dec 29 '22 at 15:24
  • Downvoted because the example doesn't match the question. The example already shows how to answer the listed question. The actual question seems to be about cancelling Tasks. –  Dec 29 '22 at 15:34
  • @Jessy, the question is very clear. Asking if there's a way to write the included method (using closures) with a different swift API (async/await). Yes, the example matches the question. – Matjan Dec 29 '22 at 16:24
  • @Matjan Everybody thinks their own question is clear. You may not like to hear a comment like mine, but it's a waste of time to respond like Ralphie in A Christmas Story, "No! You're wrong! Everybody can understand my perfectly-communicated masterpiece." Instead, rewrite it to be a more universally lovable question. –  Dec 29 '22 at 18:50

4 Answers4

4

I haven't done much testing on this, but I believe this is what you're looking for. It allows you to simply await an image load, but you can cancel using the URL from somewhere else. It also merges near-simultaneous requests for the same URL so you don't re-download something you're in the middle of.

actor Loader {
    private var tasks: [URL: Task<UIImage, Error>] = [:]

    func loadImage(url: URL) async throws -> UIImage {
        if let imageTask = tasks[url] {
            return try await imageTask.value
        }

        let task = Task {
            // Rather than removing here, you could skip that and this would become a
            // cache of results. Making that correct would take more work than the
            // question asks for, so I won't go into it
            defer { tasks.removeValue(forKey: url) }

            let data = try await URLSession.shared.data(from: url).0
            guard let image = UIImage(data: data) else {
                throw DecodingError.dataCorrupted(.init(codingPath: [],
                                                        debugDescription: "Invalid image"))
            }
            return image
        }
        tasks[url] = task

        return try await task.value
    }

    func cancelRequest(url: URL) {
        // Remove, and cancel if it's removed
        tasks.removeValue(forKey: url)?.cancel()
        print("ImageLoader: cancelling request")
    }
}

Calling it looks like:

let image = try await loader.loadImage(url: url)

And you can cancel a request if it's still pending using:

loader.cancelRequest(url: url)

A key lesson here is that it is very natural to access task.value multiple times. If the task has already completed, then it will just return immediately.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • Thanks for the answer. I did consider just using the url as key and this is perfectly fine in some cases. But if multiple UI elements request the same image, one may end up cancelling the request of another. While being scrolled off screen for example. So having unique request ids is safer in that regard, from the perspective of a dependable API. An image cache would help with this concern, but not eliminate it. – Matjan Dec 29 '22 at 23:19
  • Then have the caller generate the token (UUID is fine) and pass it into `loadImage`. Then `loadImage` doesn't need to return anything synchronously. – Rob Napier Dec 30 '22 at 14:38
  • 3
    You can also track how many outstanding requests there are in the Loader, and not cancel until it reaches 0. I'm still not certain what syntax you're envisioning. "Use await" isn't itself a goal. Using structured concurrency is a goal, because it works differently than GCD, so there are good reasons to use Tasks to manage things. Tasks are much more than just "wrappers around closures." – Rob Napier Dec 30 '22 at 14:55
  • 3
    @Matjan - You say, “But if multiple UI elements request the same image, one may end up cancelling the request of another.” - This issue, that you may request the same image multiple times, is precisely why you *should* key by the URL: You likely don’t want to download the same image multiple times. This is why RobN is right, key by URL, but use a counter to know whether to really cancel the task or not. – Rob Dec 30 '22 at 16:36
  • 1
    I appreciate the extra comments. As far as implementing the async/await version of this, I think you're right about using the url and keeping count and I see how that may work. It does simplify the interface. This discussion has been very helpful, thank you all. – Matjan Dec 30 '22 at 21:03
3

Return a task in a tuple or other structure.

In the cases where you don't care about the ID, do this:

try await imageTask(url: url).task.value
private var requests: [UUID: Task<UIImage, Swift.Error>] = [:]

func imageTask(url: URL) -> (id: UUID?, task: Task<UIImage, Swift.Error>) {
  switch loadedImages[url] {
  case let image?: return (id: nil, task: .init { image } )
  case nil:
    let id = UUID()
    let task = Task {
      defer { requests[id] = nil }

      guard let image = UIImage(data: try await URLSession.shared.data(from: url).0)
      else { throw Error.loadingError }

      try Task.checkCancellation()
      Task { @MainActor in loadedImages[url] = image }
      return image
    }

    requests[id] = task
    return (id: id, task: task)
  }
}
  • 2
    I agree this is the simplest answer to the question, but it does really drive home the point that `id` is doing no work here. If you got rid of the ID, and just stored the Tasks in a `Set`, this would be identical. (Task is helpfully Hashable/Equatable to facilitate exactly this use case.) That said, `requests` should probably actually be keyed on URL so that if there's already a request in progress, you can just return the in-progress Task and not make a second request. This is a really common Loader pattern. – Rob Napier Dec 29 '22 at 20:14
  • This is close to the spirit of the question (Ralphie nods contently). But I notice that we no longer have an async function. So we end up replacing a closure function with a function that returns a closure (the task). I don't know the answer to this, but it's an interesting problem to me. – Matjan Dec 29 '22 at 21:36
2

Is it even possible or practical?

Yes to both.

As I say in a comment, I think you may be missing the fact that a Task is an object you can retain and later cancel. Thus, if you create an architecture where you apply an ID to a task as you ask for the task to start, you can use that same ID to cancel that task before it has returned.

Here's a simple demonstration. I've deliberately written it as Playground code (though I actually developed it in an iOS project).

First, here is a general TimeConsumer class that wraps a single time-consuming Task. We can ask for the task to be created and started, but because we retain the task, we can also cancel it midstream. It happens that my task doesn't return a value, but that's neither here nor there; it could if we wanted.

class TimeConsumer {
    var current: Task<(), Error>?
    func consume(seconds: Int) async throws {
        let task = Task {
            try await Task.sleep(for: .seconds(seconds))
        }
        current = task
        _ = await task.result
    }
    func cancel() {
        current?.cancel()
    }
}

Now then. In front of my TimeConsumer I'll put a TaskVendor actor. A TimeConsumer represents just one time-consuming task, but a TaskVendor has the ability to maintain multiple time-consuming tasks, identifying each task with an identifier.

actor TaskVendor {
    private var tasks = [UUID: TimeConsumer?]()
    func giveMeATokenPlease() -> UUID {
        let uuid = UUID()
        tasks[uuid] = nil
        return uuid
    }
    func beginTheTask(uuid: UUID) async throws {
        let consumer = TimeConsumer()
        tasks[uuid] = consumer
        try await consumer.consume(seconds: 10)
        tasks[uuid] = nil
    }
    func cancel(uuid: UUID) {
        tasks[uuid]??.cancel()
        tasks[uuid] = nil
    }
}

That's all there is to it! Observe how TaskVendor is configured. I can do three things: I can ask for a token (really my actual TaskVendor needn't bother doing this, but I wanted to centralize everything for generality); I can start the task with that token; and, optionally, I can cancel the task with that token.

So here's a simple test harness. Here we go!

let vendor = TaskVendor()
func test() async throws {
    let uuid = await vendor.giveMeATokenPlease()
    print("start")
    Task {
        try await Task.sleep(for: .seconds(2))
        print("cancel?")
        // await vendor.cancel(uuid: uuid)
    }
    try await vendor.beginTheTask(uuid: uuid)
    print("finish")
}
Task {
    try await test()
}

What you will see in the console is:

start
[two seconds later] cancel?
[eight seconds after that] finish

We didn't cancel anything; the word "cancel?" signals the place where our test might cancel, but we didn't, because I wanted to prove to you that this is working as we expect: it takes a total of 10 seconds between "start" and "finish", so sure enough, we are consuming the expected time fully.

Now uncomment the await vendor.cancel line. What you will see now is:

start
[two seconds later] cancel?
[immediately!] finish

We did it! We made a cancellable task vendor.

matt
  • 515,959
  • 87
  • 875
  • 1,141
  • Thanks @matt, I did think of having two separate methods, but this seems like a step back, rather than progress. I appreciate the depth of the answer and I see that wrapping with tasks, and then wrapping with objects is possible. I'm not so sure if using async/await for something like this is practical, let alone elegant. Just my personal, evolving opinion. – Matjan Dec 29 '22 at 17:58
  • 1
    Well obviously I disagree. Treating a Task as a cancellable object is not extra legwork or clumsy, it's simple, elegant, and cool. – matt Dec 29 '22 at 18:14
-2

I'm including one possible answer to the question, for the benefit of others. I'll leave the question in place in case someone has another take on it.

The only way that I know of having a 'one-shot' async method that would return a token before returning the async result is by adding an inout argument:

func loadImage(url: URL, token: inout UUID?) async -> Result<UIImage, Error> {
    
    token = UUID()
    
    //...
}

Which we may call like this:

var requestToken: UUID? = nil
let result = await imageLoader.loadImage(url: url, token: &requestToken)

However, this approach and the two-shot solution by @matt both seem fussy, from the api design standpoint. Of course, as he suggests, this leads to a bigger question: How do we implement cancellation with swift concurrency (ideally without too much overhead)? And indeed, using tasks and wrapper objects seems unavoidable, but it certainly seems like a lot of legwork for a fairly simple pattern.

Matjan
  • 3,591
  • 1
  • 33
  • 31
  • What would your ideal API look like? If you just return the Task itself, then I would think you'd have everything you're asking for, but it would help if you showed what a better calling interface would even look like (ignoring whether or not Swift supports it). In your `inout` solution, nothing can ever make use of `requestToken`, because by the time the line after `let result` completes, the token is expired. What "simple pattern" do you have in mind (that isn't just "return the Task")? – Rob Napier Dec 29 '22 at 20:08
  • @RobNapier my ideal interface is the closure based function I showed in the question. But I'm asking the question to explore async/await. If I'm passing tasks (basically a closure wrapper) across boundaries, I opt out of the main benefit of async, which is to write code without closures. I mean, this is new to me so maybe I'm just catching up to it. But it looks like you can have clean, concise 'await' style code, or you can have cancel-ability, but not both. – Matjan Dec 29 '22 at 21:24
  • If the point is to cancel the network call, rather than cancel the enclosing operation (task), then it seems to me that we could retain the benefit of having an await-able function and let it complete regardless. But then we need a way to signal cancel and the actual tracking of tasks can happen internally. What I'm looking for is a clean way to achieve cancellation AND have an async API. – Matjan Dec 29 '22 at 21:35
  • To clarify this approach. Yes the 'requestToken' cannot be used in the same synchronous context, but it can be used by another asynchronous operation. Such as a user scrolling a feed, causing the image to no longer be needed. – Matjan Dec 29 '22 at 23:28