Search code examples
iosswiftasynchronous

Make multiple asynchronous requests but wait for only one


I have a question concerning asynchronous requests. I want to request data from different sources on the web. Each source might have the data I want but I do not know that beforehand. Because I only want that information once, I don't care about the other sources as soon as one source has given me the data I need. How would I go about doing that? I thought about doing it with a didSet and only setting it once, something like this:


var dogPicture : DogPicture? = nil {
  didSet {
    // Do something with the picture
  }
}

func findPictureOfDog(_ sources) -> DogPicture? {
  for source in sources {
    let task = URL.Session.shared.dataTask(with: source) { (data, response, error) in
      // error handling ...
      if data.isWhatIWanted() && dogPicture == nil {
        dogPicture = data.getPicture()
      }
    }
    task.resume()
  }
}

sources = ["yahoo.com", "google.com", "pinterest.com"] 

findPictureOfDog(sources)

However it would be very helpful, if I could just wait until findPictureOfDog() is finished, because depending on if I find something or not, I have to ask the user for more input.

I don't know how I could do it in the above way, because if I don't find anything the didSet will never be called, but I should ask the user for a picture then.

A plus: isWhatIWanted() is rather expensive, so If there was a way to abort the execution of the handler once I found a DogPicture would be great.

I hope I made myself clear and hope someone can help me out with this!

Best regards and thank you for your time


Solution

  • My original answer, below, predates async-await patterns of Swift concurrency. But I am going to insert this Swift concurrency rendition here, as that is ubiquitous nowadays, and the old completion-handler-based answer is a bit out of date.

    But here is the Swift concurrency rendition:

    func findPictureOfDog(_ sources: [String]) async -> DogPicture? {
        …
    }
    

    In this case, where we would await asynchronous functions inside findPictureOfDog, we can make this an async function, and simply await its result.

    For example, if you wanted to check all of these sources in parallel and show the first one that returned a result, it might look like:

    func findPictureOfDog(_ sources: [String]) async -> DogPicture? {
        await withTaskGroup(of: DogPicture?.self) { group in
            for source in sources {
                group.addTask { await pictureOfDog(for: source) }
            }
            
            for await result in group {
                if let result {
                    group.cancelAll()
                    return result
                } 
            }
            
            return nil
        }
    }
    
    func pictureOfDog(for source: String) async -> DogPicture? {
        guard 
            let url = url(for: source),
            let (data, response) = try? await URLSession.shared.data(from: url),
            let httpResponse = response as? HTTPURLResponse,
            (200 ..< 300) ~= httpResponse.statusCode
        else { return nil }
    
        // perhaps additional error checking?
        
        return await data.getPicture()
    }
    

    The details here (on a question over four years old) are less material than the general observation that with async-await, you can return a result from an async method. But if working in an old code base without Swift concurrency, but rather completion handlers, then refer to my original answer, below.


    A couple of things:

    1. First, we’re dealing with legacy asynchronous processes (predating Swift concurrency), one wouldn’t return the DogPicture, but rather use completion-handler pattern. E.g. rather than:

       func findPictureOfDog(_ sources: [String]) -> DogPicture? {
           ...
           return dogPicture
       }
      

      You instead would probably do something like:

       func findPictureOfDog(_ sources: [String], completion: @escaping (Result<DogPicture, Error>) -> Void) {
           ...
           completion(.success(dogPicture))
       }
      

      And you’d call it like:

       findPictureOfDog(sources: [String]) { result in
           switch result {
           case .success(let dogPicture): ...
           case .failure(let error): ...
           }
       }
      
       // but don’t try to access the DogPicture or Error here
      
    2. While the above was addressing the “you can’t just return value from asynchronous process”, the related observations is that you don’t want to rely on a property as the trigger to signal when the process is done. All of the “when first process finishes” logic should be in the findPictureOfDog routine, and call the completion handler when it’s done.

      I would advise against using properties and their observers for this process, because it begs questions about how one synchronizes access to ensure thread-safety, etc. Completion handlers are unambiguous and avoid these secondary issues.

    3. You mention that isWhatIWanted is computationally expensive. That has two implications:

      • If it is computationally expensive, then you likely don’t want to call that synchronously inside the dataTask(with:completionHandler:) completion handler, because that is a serial queue. Whenever dealing with serial queues (whether main queue, network session serial queue, or any custom serial queue), you often want to get in and out as quickly as possible (so the queue is free to continue processing other tasks).

        E.g. Let’s imagine that the Google request came in first, but, unbeknownst to you at this point, it doesn’t contain what you wanted, and the isWhatIWanted is now slowly checking the result. And let’s imagine that in this intervening time, the Yahoo request that came in. If you call isWhatIWanted synchronously, the result of the Yahoo request won’t be able to start checking its result until the Google request has failed because you’re doing synchronous calls on this serial queue.

        I would suggest that you probably want to start checking results as they came in, not waiting for the others. To do this, you want a rendition of isWhatIWanted the runs asynchronously with respect to the network serial queue.

      • Is the isWhatIWanted a cancelable process? Ideally it would be, so if the Yahoo image succeeded, it could cancel the now-unnecessary Pinterest isWhatIWanted. Canceling the network requests is easy enough, but more than likely, what we really want to cancel is this expensive isWhatIWanted process. But we can’t comment on that without seeing what you’re doing there.

        But, let’s imagine that you’re performing the object classification via VNCoreMLRequest objects. You might therefore cancel any pending requests as soon as you find your first match.

    4. In your example, you list three sources. How many sources might there be? When dealing with problems like this, you often want to constrain the degree of concurrency. E.g. let’s say that in the production environment, you’d be querying a hundred different sources, you’d probably want to ensure that no more than, say, a half dozen running at any given time, because of the memory and CPU constraints.

    All of this having been said, all of these considerations (asynchronous, cancelable, constrained concurrency) seem to be begging for an Operation based solution.

    So, in answer to your main question, the idea would be to write a routine that iterates through the sources, and calling the main completion handler upon the first success and make sure you prevent any subsequent/concurrent requests from calling the completion handler, too:

    • You could save a local reference to the completion handler.
    • As soon as you successfully find a suitable image, you can:
    • call that saved completion handler;
    • nil your saved reference (so in case you have other requests that have completed at roughly the same time, that they can’t call the completion handler again, eliminating any race conditions); and
    • cancel any pending operations so that any requests that have not finished will stop (or have not even started yet, prevent them from starting at all).
       

    Note, you’ll want to synchronize the the above logic, so you don’t have any races in this process of calling and resetting the completion handler.

    • Make sure to have a completion handler that you call after all the requests are done processing, in case you didn’t end up finding any dogs at all.

    Thus, that might look like:

    func findPictureOfDog(_ sources: [String], completion: @escaping DogPictureCompletion) {
        var firstCompletion: DogPictureCompletion? = completion
        let synchronizationQueue: DispatchQueue = .main         // note, we could have used any *serial* queue for this, but main queue is convenient
    
        let completionOperation = BlockOperation {
            synchronizationQueue.async {
                // if firstCompletion not nil by the time we get here, that means none of them matched
                firstCompletion?(.failure(DogPictureError.noneFound))
            }
            print("done")
        }
    
        for source in sources {
            let url = URL(string: source)!
            let operation = DogPictureOperation(url: url) { result in
                if case .success(_) = result {
                    synchronizationQueue.async {
                        firstCompletion?(result)
                        firstCompletion = nil
                        Queues.shared.cancelAllOperations()
                    }
                }
            }
            completionOperation.addDependency(operation)
            Queues.shared.processingQueue.addOperation(operation)
        }
    
        OperationQueue.main.addOperation(completionOperation)
    }
    

    So what might that DogPictureOperation might look like? I might create an asynchronous custom Operation subclass (I just subclass a general purpose AsynchronousOperation subclass, like the one here) that will initiate network request and then run an inference on the resulting image upon completion. And if canceled, it would cancel the network request and/or any pending inferences (pursuant to point 3, above).