Nuno Gonçalves
Nuno Gonçalves

Reputation: 6805

Swift's "async let" error thrown depends on the order the tasks are made

I'm trying to understand async let error handling and it's not making a lot of sense in my head. It seems that if I have two parallel requests, the first one throwing an exception doesn't cancel the other request. In fact it just depends on the order in which they are made.

My testing setup:

struct Person {}
struct Animal {}
enum ApiError: Error { case person, animal }

class Requester {

    init() {}

    func getPeople(waitingFor waitTime: UInt64, throwError: Bool) async throws -> [Person] {
        try await waitFor(waitTime)
        if throwError { throw ApiError.person }
        return []
    }

    func getAnimals(waitingFor waitTime: UInt64, throwError: Bool) async throws -> [Animal] {
        try await waitFor(waitTime)
        if throwError { throw ApiError.animal }
        return []
    }

    func waitFor(_ seconds: UInt64) async throws {
        do {
            try await Task.sleep(nanoseconds: NSEC_PER_SEC * seconds)
        } catch {
            print("Error waiting", error)
            throw error
        }
    }
}

The exercise.


class ViewController: UIViewController {

    let requester = Requester()

    override func viewDidLoad() {
        super.viewDidLoad()
        Task {
            async let animals = self.requester.getAnimals(waitingFor: 1, throwError: true)
            async let people = self.requester.getPeople(waitingFor: 2, throwError: true)

            let start = Date()
            do {
//                let (_, _) = try await (people, animals)
                let (_, _) = try await (animals, people)
                print("No error")
            } catch {
                print("error: ", error)
            }
            print(Date().timeIntervalSince(start))
        }
    }
}

For simplicity, from now on I'll just past the relevant lines of code and output.

Scenario 1:

async let animals = self.requester.getAnimals(waitingFor: 1, throwError: true)
async let people = self.requester.getPeople(waitingFor: 2, throwError: true)
let (_, _) = try await (animals, people)

Results in:

error: animal 1.103397011756897 Error waiting CancellationError()

This one works as expected. The slower request, takes 2 seconds, but was cancelled after 1 second (when the fastest one throws)

Scenario 2:

async let animals = self.requester.getAnimals(waitingFor: 2, throwError: true)
async let people = self.requester.getPeople(waitingFor: 1, throwError: true)
let (_, _) = try await (animals, people)

Results in:

error: animal 2.2001450061798096

Now this one is not expected for me. The people request takes 1 second to throw an error and we still wait 2 seconds and the error is animal. My expectation is that this should have been 1 second and people error.

Scenario 3:

async let animals = self.requester.getAnimals(waitingFor: 2, throwError: true)
async let people = self.requester.getPeople(waitingFor: 1, throwError: true)
let (_, _) = try await (people, animals)

Results in:

error: person 1.0017549991607666 Error waiting CancellationError()

Now this is expected. The difference here is that I swapped the order of the requests but changing to try await (people, animals).

It doesn't matter which method throws first, we always get the first error, and the time spent also depends on that order.

Is this behaviour expected/normal? Am I seeing anything wrong, or am I testing this wrong?

I'm surprised this isn't something people are not talking about more. I only found another question like this in developer forums.

Please help. :)

Upvotes: 7

Views: 2183

Answers (2)

Rob
Rob

Reputation: 437552

Cora hit the nail on the head (+1). The async let of a tuple will just await them in order. Instead, consider a task group.

But you do not need to cancel the other items in the group. See “Task Group Cancellation” discussion in the withThrowingTaskGroup(of:returning:body:) documentation:

Throwing an error in one of the tasks of a task group doesn’t immediately cancel the other tasks in that group. However, if you call next() in the task group and propagate its error, all other tasks are canceled. For example, in the code below, nothing is canceled and the group doesn’t throw an error:

withThrowingTaskGroup { group in
    group.addTask { throw SomeError() }
}

In contrast, this example throws SomeError and cancels all of the tasks in the group:

withThrowingTaskGroup { group in
    group.addTask { throw SomeError() }
    try group.next() 
}

An individual task throws its error in the corresponding call to Group.next(), which gives you a chance to handle the individual error or to let the group rethrow the error.

Or you can waitForAll, which will cancel the other tasks:

let start = Date()
do {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask { let _ = try await self.requester.getAnimals(waitingFor: 1, throwError: true) }
        group.addTask { let _ = try await self.requester.getPeople(waitingFor: 2, throwError: true) }
        try await group.waitForAll()
    }
} catch {
    print("error: ", error)
}
print(Date().timeIntervalSince(start))

Bottom line, task groups do not dictate the order in which the tasks are awaited. (They also do not dictate the order in which they complete, either, so you often have to collating task group results into an order-independent structure or re-order the results.)


You asked how you would go about collecting the results. There are a few options:

  1. You can define group tasks such that they do not “return” anything (i.e. child of Void.self), but update an actor (Creatures, below) in the addTask calls and then extract your tuple from that:

    class ViewModel1 {
        let requester = Requester()
    
        func fetch() async throws -> ([Animal], [Person]) {
            let results = Creatures()
            try await withThrowingTaskGroup(of: Void.self) { group in
                group.addTask { try await results.update(with: self.requester.getAnimals(waitingFor: animalsDuration, throwError: shouldThrowError)) }
                group.addTask { try await results.update(with: self.requester.getPeople(waitingFor: peopleDuration, throwError: shouldThrowError)) }
    
                try await group.waitForAll()
            }
            return await (results.animals, results.people)
        }
    }
    
    private extension ViewModel1 {
        /// Creatures
        ///
        /// A private actor used for gathering results
    
        actor Creatures {
            var animals: [Animal] = []
            var people: [Person] = []
    
            func update(with animals: [Animal]) {
                self.animals = animals
            }
    
            func update(with people: [Person]) {
                self.people = people
            }
        }
    }
    
  2. You can define group tasks that return enumeration case with associated value, and then extracts the results when done:

    class ViewModel2 {
        let requester = Requester()
    
        func fetch() async throws -> ([Animal], [Person]) {
            try await withThrowingTaskGroup(of: Creatures.self) { group in
                group.addTask { try await .animals(self.requester.getAnimals(waitingFor: animalsDuration, throwError: shouldThrowError)) }
                group.addTask { try await .people(self.requester.getPeople(waitingFor: peopleDuration, throwError: shouldThrowError)) }
    
                return try await group.reduce(into: ([], [])) { previousResult, creatures in
                    switch creatures {
                    case .animals(let values): previousResult.0 = values
                    case .people(let values): previousResult.1 = values
                    }
                }
            }
        }
    }
    
    private extension ViewModel2 {
        /// Creatures
        ///
        /// A private enumeration with associated types for the types of results
    
        enum Creatures {
            case animals([Animal])
            case people([Person])
        }
    }
    
  3. For the sake of completeness, you don't have to use task group if you do not want. E.g., you can manually cancel earlier task if prior one canceled.

    class ViewModel3 {
        let requester = Requester()
    
        func fetch() async throws -> ([Animal], [Person]) {
            let animalsTask = Task {
                try await self.requester.getAnimals(waitingFor: animalsDuration, throwError: shouldThrowError)
            }
    
            let peopleTask = Task {
                do {
                    return try await self.requester.getPeople(waitingFor: peopleDuration, throwError: shouldThrowError)
                } catch {
                    animalsTask.cancel()
                    throw error
                }
            }
    
            return try await (animalsTask.value, peopleTask.value)
        }
    }
    

    This is not a terribly scalable pattern, which is why task groups might be a more attractive option, as they handle the cancelation of pending tasks for you (assuming you iterate through the group as you build the results).

FWIW, there are other task group alternatives, too, but there is not enough in your question to get too specific in this regard. For example, I can imagine some protocol-as-type implementations if all of the tasks returned an array of objects that conformed to a Creature protocol.

But hopefully the above illustrate a few patterns for using task groups to enjoy the cancelation capabilities while still collating the results.

Upvotes: 3

cora
cora

Reputation: 2102

From https://github.com/apple/swift-evolution/blob/main/proposals/0317-async-let.md

async let (l, r) = {
  return await (left(), right())
  // -> 
  // return (await left(), await right())
}

meaning that the entire initializer of the async let is a single task, and if multiple asynchronous function calls are made inside it, they are performed one-by one.

Here is a more structured approach with behavior that makes sense.

struct ContentView: View {
    var body: some View {
        Text("Hello, world!")
            .padding()
            .task {
                let requester = Requester()
                let start = Date()
                
                await withThrowingTaskGroup(of: Void.self) { group in
                    let animalTask = Task {
                        try await requester.getAnimals(waitingFor: 1, throwError: true)
                    }
                    group.addTask { animalTask }
                    group.addTask {
                        try await requester.getPeople(waitingFor: 2, throwError: true)
                    }
                    
                    do {
                        for try await _ in group {
                            
                        }
                        group.cancelAll()
                    } catch ApiError.animal {
                        group.cancelAll()
                        print("animal threw")
                    } catch ApiError.person {
                        group.cancelAll()
                        print("person threw")
                    } catch {
                        print("someone else")
                    }
                }
                
                print(Date().timeIntervalSince(start))
            }
    }
}

The idea is to add each task to a throwing group and then loop through each task.

Upvotes: 4

Related Questions