swiftswift-concurrency

What is normal exit for a task group in Swift Concurrency


In WWDC session Explore structured concurrency in Swift. There is a part about the normal exit of task group.

While task groups are a form of structured concurrency, there is a small difference in how the task tree rule is implemented for group tasks versus async-let tasks. Suppose when iterating through the results of this group, I encounter a child task that completed with an error. Because that error is thrown out of the group’s block, all tasks in the group will then be implicitly canceled and then awaited. This works just like async-let. 

The difference comes when your group goes out of scope through a normal exit from the block. Then, cancellation is not implicit. This behavior makes it easier for you to express the fork-join pattern using a task group, because the jobs will only be awaited not canceled. You can also manually cancel all tasks before exiting the block using the group’s cancelAll method. Keep in mind that no matter how you cancel a task, cancellation automatically propagates down the tree.

Let's use an example.

func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
    var thumbnails: [String: UIImage] = [:]
    try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
        for id in ids {
            group.async {
                return (id, try await fetchOneThumbnail(withID: id))
            }
        }
        // Obtain results from the child tasks, sequentially, 
        // in order of completion.
        for try await (id, thumbnail) in group {
            thumbnails[id] = thumbnail
        }
    }
    return thumbnails
}

What is the normal exit in this example? When it reaches return the task group finished all the tasks, there is nothing to cancel. Please help me understand this.


Solution

  • They are just saying that with async let, if you neglect to await that task before it falls out of scope, it will be “implicitly canceled”. But with task group, if you don’t explicitly await the individual group tasks, those will not be implicitly canceled.

    When the video references “normal exit”, they are just talking about the “happy path” where no errors occurred, where nothing was explicitly canceled, and execution just completed as normal, without error. They talk about the explicit cancelation and error handling elsewhere in that video; they just wanted to bring our attention to the subtle difference between how async let behaves if it falls out of scope without being awaited, and the corresponding task group behavior.


    Consider this example in SE-0313:

    func go() async { 
        async let f = fast() // 300ms
        async let s = slow() // 3seconds
        print("nevermind...")
        // implicitly: cancels f
        // implicitly: cancels s
        // implicitly: await f
        // implicitly: await s
    }
    

    That is admittedly a contrived example, not something that you would likely ever do in practice. A more realistic example might be some code in which, after creating the tasks with async let, we might have some logic that employs an early exit, resulting it never reaching the await of one or more of the async let tasks. In that scenario, with async let, any tasks not explicitly awaited will be implicitly canceled.

    Having outlined what “implicit cancel” means, let us now consider your example:

    func fetchThumbnails(for ids: [String]) async throws -> [String: UIImage] {
        try await withThrowingTaskGroup(of: (String, UIImage).self) { group in
            for id in ids {
                group.addTask { [self] in
                    try await (id, fetchOneThumbnail(withID: id))
                }
            }
            
            // Obtain results from the child tasks, sequentially,
            // in order of completion.
    
            var thumbnails: [String: UIImage] = [:]
    
            for try await (id, thumbnail) in group {
                thumbnails[id] = thumbnail
            }
    
            return thumbnails
        }
    }
    

    (I replaced group.async with group.addTask and made a few cosmetic changes, but this is effectively the same as yours.)

    This is really not applicable to the “implicit cancel” discussion because this has a for loop that has an await for each task in the group (as you accumulate the results in a dictionary). So the whole idea of “implicit cancel” does not apply because all of the child tasks are explicitly awaited.

    Instead, let us consider a variation on the theme, perhaps one where fetchOneThumbnail did not actually return anything, but just updated some internal cache. Then it would might be:

    func fetchThumbnails(for ids: [String]) async {
        await withTaskGroup(of: Void.self) { group in
            for id in ids {
                group.addTask { [self] in
                    await fetchOneThumbnail(withID: id)
                }
            }
    
            // NB: no explicit `for await` loop of `group` sequence is needed; unlike 
            // `async let` pattern, these tasks will *not* be implicitly canceled
        }
    }
    

    But in this example, even though we never for await the group sequence at all (thus, none of the child tasks are explicitly awaited, in contrast to the prior example), this will not implicitly cancel the tasks in the group, unlike async let. It will just automatically await all those tasks for us.

    In short, async let will implicitly cancel anything not explicitly awaited, while task group will not implicitly cancel anything (in the “normal exit” scenario, at least), but rather will implicitly await the child tasks.