Swift 5.5: Asynchronous Looping With Async/Await

In an earlier article, I introduced the Swift 5.5 async/await syntax that lies at the heart of Swift’s new structured concurrency. To do so, I demonstrated how to replace a common use case of Grand Central Dispatch (GCD) — switching from the main thread to a background thread and then back to the main thread — with an async/await implementation.

In this article, I want to dive deeper into structured concurrency. I’ll focus on another common use case: looping asynchronously. This is one of the trickiest things to get right in GCD; you have to use a DispatchGroup, and everything has to be done just perfectly or your code won’t work properly — plus, if you make a mistake, you can end up with a data race that you won’t even know about. In the wonderful new world of async/await, on the other hand, asynchronous looping is easy, safe, and downright fun.

So what do I mean by asynchronous looping? It will be easiest to answer that question by giving an example.

A mock server

To illustrate asynchronous looping, I need an asynchronous API that I can call.

The most common use of DispatchGroup to loop asynchronously probably has to do with talking to an online API; and indeed, the issue that prompted this article really did arise for me when I was wrestling with some GCD code that used successive REST calls to talk to an online API. Unfortunately, though, that API is private, and I couldn’t readily find anything equivalent.

So I’ll start by writing a mock server class, to pose as an endpoint for online communication — without doing any actual online communication.

Here’s the implementation:

class Server {
    private init() {}
    static let shared = Server.init()
    private static let serverQueue = DispatchQueue.global(qos: .background)
    func getCountries(completion: @escaping (Array<String>) -> Void) {
        let countries = ["France", "Germany", "Spain", "Portugal"]
        let delay = Int.random(in: 1..<4)
        Self.serverQueue.asyncAfter(deadline: .now() + .seconds(delay)) {
            completion(countries)
        }
    }
    func getCapital(of country:String, completion: @escaping (String) -> Void) {
        let capitals = ["France":"Paris", "Germany":"Berlin", "Spain":"Madrid", "Portugal":"Lisbon"]
        let delay = Int.random(in: 1..<10)
        Self.serverQueue.asyncAfter(deadline: .now() + .seconds(delay)) {
            completion(capitals[country, default:"N/A"])
        }
    }
}

My Server class is a singleton with a shared factory method and two public methods. Using GCD, I’m causing the two public methods to delay by some some random amount, so that they are asynchronous, just like talking to an online API. The methods then call back into a completion handler, which is also just what would happen when talking to an online API with GCD. The two public methods are:

  • getCountries(completion:) — Returns (in the completion handler) a list of country names.

  • getCapital(of:completion:) — Given a country name, returns (in the completion handler) the name of its capital city.

The idea, then, is to pretend that when we call one of these methods, receiving a result by way of a completion handler, we’re doing some sort of networking call using URLSession. You’ve certainly resumed a URLSession data task and retrieved the result in a completion handler; perhaps you’ve even done a POST method call to an online API. So imagine that that’s the sort of thing we’re doing when we talk to our shared Server object.

Our goal will be:

  1. First, to ask the server for the list of countries.

  2. Then, second, to loop over those country names, asking the server to supply the capital city for each of them.

  3. Finally, let’s specify that the correct output at the very end will be an array of all those capital cities. We’ll demonstrate that output in a simple way: by printing it.

This may seem like an extremely trivial and silly goal — but only with respect to its content. If you think about the form of what we’re doing, this task perfectly reflects the structure of two common problems when talking to an API:

  1. Getting from step 1 to step 2. How do I wait for the result of one asynchronous task to arrive, in order to use that result in a second asynchronous task? (In this case, the first asynchronous task is to obtain the list of country names, and then the second asynchronous task involves looping over that list.)

  2. Getting from step 2 to step 3. How do I wait to perform all the iterations of a loop, where each iteration calls an asynchronous task and gets back a result, in order to return one result consisting of all the iteration results? (In this case, each iteration returns the capital city of one country, but I need to return the array of all the capital cities.)

If you imagine that our ultimate goal is to populate a table view’s data source model and update the table view, you can readily see that this could be a very real-world problem indeed.

Don’t try this at home or anywhere

Before I give any right solutions to those problems, let me give a wrong solution! This will demonstrate what we are struggling against. I’m going to make a mistake that I see naïve beginners make all the time. This is not really the fault of the beginners. Asynchronous code, after all, is difficult to reason about; that’s exactly why we’re here in the first place. And GCD, I would argue, doesn’t make it all that much easier.

Here we go:

Server.shared.getCountries { countries in
    var capitals = [String]()
    for country in countries {
        Server.shared.getCapital(of: country) { capital in
            capitals.append(capital)
        }
    }
    DispatchQueue.main.async {
        print(capitals)
    }
}

In actual fact, our hypothetical naïve programmer has done one thing quite correctly: the loop of calls to getCapital takes place inside the completion handler from the call to getCountries. So the loop through countries does indeed come after the call to getCountries, and uses the result of that call, solving our first problem. That is effectively the same sort of problem that I discussed in the earlier article.

That’s what’s right about the code. But do you see what’s wrong with it? If not, run it and you’ll find out. The array of capitals, when we print it in the last line, is empty! Why? Because we didn’t “wait” for all the calls to getCapital. In fact, we didn’t wait for any of them! The last line, print(capitals), actually executes before any of the capitals.append calls is executed.

So we have solved our first problem, but not our second problem. Let’s proceed to solve the second problem.

DispatchGroup to the rescue, sort of

What we need to do, somehow, is to “wait” for all the iterations of the for country in countries loop to finish before executing the next line after the loop. This sounds vaguely illegal, but in fact it’s fine as long as you’re on a background thread.

The usual approach, with GCD, is to use a DispatchGroup. Unfortunately, DispatchGroup is rather tricky to use correctly. In its most common usage, it comes in four pieces:

  • Before everything, you create a DispatchGroup.

  • Within the loop, each time you are about to do your asynchronous task, you tell the DispatchGroup to enter.

  • Still within the loop, each time the asynchronous task finishes — typically, at the end of its completion handler — you tell the DispatchGroup to leave.

  • After the loop, you tell the DispatchGroup to notify.

That pattern is a bit tricky to wrap your head around, but after years of using it, I can usually get it more or less right — on the second or third try! Let’s give it a go, shall we?

let group = DispatchGroup()
DispatchQueue.global(qos: .default).async {
    Server.shared.getCountries { countries in
        var capitals = [String]()
        print(countries)
        for country in countries {
            group.enter()
            Server.shared.getCapital(of: country) { capital in
                capitals.append(capital)
                group.leave()
            }
        }
        group.notify(queue: .main) {
            print(capitals)
        }
    }
}

By combining enter, leave, and notify in the standard pattern, we can “wait” (in a background queue, of course) before retrieving the fully populated array and printing it.

This definitely works in one sense: our printed capitals array is populated. Here’s the result after a typical run:

["Madrid", "Berlin", "Paris", "Lisbon"]

That is certainly an array of capital cities. However, there are some problems.

One problem that’s quite obvious is that we’d better not care about the order in which the results appear. That order is completely random! Why? Because the calls to getCapital, effectively all starting at the same time, are asynchronous. Each of them finishes and calls us back asynchronously at some future unknown time — and we’re just assembling those results into our capitals array in whatever order the results happen to come back to us from the server.

Even if we retain our original countries array, therefore, there is no correspondence between its order and the order of our final capitals result. This is the countries array:

["France", "Germany", "Spain", "Portugal"]

The order of the capitals has nothing to do with the order of the countries we started with. We’re going to have to think what to do about that.

Race to the bottom

Before we talk about the order, however, I have to pause and point out yet another problem with both of the preceding code examples using GCD. This problem is one that shows just how insidious GCD programming can be. Whether we know it or not, we’ve got a race condition. That means we are mutating our array on different simultaneous threads. Arrays are not thread-safe; what we’re doing is bad.

This issue can be extremely difficult to detect; it might manifest itself if the capitals array happens to come back to us missing one or two entries, but it might not manifest itself at all, and we might ship this code and not discover the problem until our app is out in the field.

Fortunately, Xcode’s Thread Sanitizer can warn us about the problem. Unfortunately, the Thread Sanitizer won’t do anything unless you happen to turn it on! (You do that in the Scheme editor, under Diagnostics.) What’s more, even if you do turn it on, the Thread Sanitizer still might not report any problem. You have to be lucky, meaning unlucky: you need to have two accesses occur close enough in time to one another that the Thread Sanitizer observes the issue. In a world of random delays, that might not happen. So you’re actually quite unlikely to discover the issue in the first place, despite all your testing.

And then, even if you do discover the race condition, it is not at all obvious what to do about it. One possibility is to get onto a dedicated serial queue just for all accesses to the array. With that solution, our code becomes safer — but also becomes even harder to read:

let group = DispatchGroup()
let q = DispatchQueue(label: "race_fixer")
DispatchQueue.global(qos: .default).async {
    Server.shared.getCountries { countries in
        var capitals = [String]()
        print(countries)
        for country in countries {
            group.enter()
            Server.shared.getCapital(of: country) { capital in
                q.async { // *
                    capitals.append(capital)
                    group.leave()
                }
            }
        }
        group.notify(queue: .main) {
            print(capitals)
        }
    }
}

Order in the court

Let’s go back now to the little matter of the order in which the results are returning to us.

One way to solve this problem is to pause the code at the end of each loop iteration, so that the networking for each iteration finishes before the next iteration is even permitted to begin. You can configure that easily enough, with DispatchGroup, by adding a wait statement:

let q = DispatchQueue(label: "race_fixer")
let group = DispatchGroup()
DispatchQueue.global(qos: .default).async {
    Server.shared.getCountries { countries in
        var capitals = [String]()
        print(countries)
        for country in countries {
            group.enter()
            Server.shared.getCapital(of: country) { capital in
                q.async {
                    capitals.append(capital)
                    group.leave()
                }
            }
            group.wait() // *
        }
        group.notify(queue: .main) {
            print(capitals)
        }
    }
}

That certainly works, in the sense that the results do arrive in order, and so they are appended to the capitals array in order, and so our final result is in the correct order:

["Paris", "Berlin", "Madrid", "Lisbon"]

That matches the order of the countries we started with:

["France", "Germany", "Spain", "Portugal"]

However, now there’s a new problem: what we’re doing is incredibly slow! If every call to getCapital takes on average, say, 4 seconds to return a value to us, this whole routine is going to take 16 seconds to complete. And the time would increase linearly with the number of countries in the list. That’s ridiculous. If this were truly an asynchronous call that does networking, we would not be getting all the simultaneous “networking” that we’re entitled to.

In effect, we have thrown away the concurrency of our loop entirely. This is now a serial loop. That’s not what we wanted.

Order in the court, part two

What we need is a better solution to the ordering problem. Let’s think about this a little differently.

The problem isn’t really so much the order of the capitals in our capitals array; it’s that they have lost all association with the countries whose capitals they are. Let’s fix the problem of order by accepting the order we get, but keeping the association between each country and its capital. That way, whoever calls us can use the information intelligently in any desired manner.

So, instead of storing just the capitals as the results in the capitals array, we’ll store pairs: each original country, along with the corresponding capital. Like this:

    let q = DispatchQueue(label: "race_fixer")
    let group = DispatchGroup()
    Server.shared.getCountries { countries in
        var capitals = [(String,String)]() // *
        for country in countries {
            group.enter()
            Server.shared.getCapital(of: country) { capital in
                q.async {
                    capitals.append((country,capital)) // *
                    group.leave()
                }
            }
        }
        group.notify(queue: .main) {
            print(capitals)
        }
    }

In that implementation, I keep each original country value associated with its corresponding capital result that I get back from calling getCapital. At the end, I am left with pairs, and I can hand these to our caller.

I can think of various other ways to tweak these results. For example, I could co-sort the array of pairs to match the order of the original countries. Or, going just the other way, instead of returning pairs, I could return a dictionary keyed by country names, with the capital cities as values.

Interlude: Then came Combine

So much for GCD. In a moment, I’m going to rewrite all of that using async/await. But before I do, I’ll just quickly tell you what I did in the interim, while waiting for async/await to be implemented. I used the Combine framework.

This entire story, as I think I’ve mentioned, is actually based on real-life events. I was handed a big code base full of REST calls to an online database API, where each call would lead immediately to several more calls, based on the results of the first call. The code used GCD, naturally enough, and was deeply indented rightwards by nests within nests. It was chock full of DispatchGroups, and was utterly incomprehensible. I wanted to rewrite it to use Combine.

Discovering how to do that was a major breakthrough for me, and the core of that breakthrough was learning how to do in Combine what DispatchGroup does using a loop. You can actually see me puzzling about this out loud in a Stack Overflow question, and getting a very helpful response. As a consequence, I was able to get rid of my use of DispatchGroup entirely. Instead, I ended up just flowing the values beautifully down the page with Combine.

To demonstrate, I’ll convert the previous example from GCD to Combine. I’ll start by supplying myself with two utility methods that generate Futures wrapped around calls to our two Server methods:

func makeGetCountriesFuture() -> AnyPublisher<Array<String>,Never> {
    Deferred {
        Future<Array<String>,Never> { promise in
            Server.shared.getCountries {
                promise(.success($0))
            }
        }
    }.eraseToAnyPublisher()
}
func makeGetCapitalFuture(of country:String) -> AnyPublisher<String,Never> {
    Deferred {
        Future<String,Never> { promise in
            Server.shared.getCapital(of: country) {
                promise(.success($0))
            }
        }
    }.eraseToAnyPublisher()
}

Here, then, is the Combine equivalent of our GCD implementation:

makeGetCountriesFuture().eraseToAnyPublisher()
    .receive(on: DispatchQueue.global(qos: .background))
    .flatMap { (countries:Array<String>) -> AnyPublisher<String,Never> in
        Publishers.Sequence(sequence: countries).eraseToAnyPublisher()
    }
    .flatMap { (country:String) -> AnyPublisher<(String,String),Never> in
        makeGetCapitalFuture(of:country)
            .map { capital in (country,capital)}.eraseToAnyPublisher()
    }
    .collect()
    .receive(on: DispatchQueue.main)
    .sink { print($0) }
    .store(in: &storage)

The great thing about looping in Combine, it turns out, is that we don’t loop! The pipeline starts with the Future that calls getCountries. Now I have my list of countries, so I turn that list into a Sequence publisher. The original values in the list thus come down the pipeline individually, and we pass them individually into our Future that calls getCapital. Then we turn all those results back into an array with the .collect operator! What I’m actually collecting are pairs, so I end up with just the same answer as the previous example with GCD (remembering, of course, that the pairs can come in any order).

You’ll observe that, as opposed to GCD where local variables just magically “fall through” to nested completion handlers at a lower level of scope, every step in a Combine pipeline must explicitly pass down all information that may be needed by a later step. This can result in some rather ungainly values working their way down the pipeline, often in the form of a tuple, as I’ve illustrated here.

But I don’t regard that as a problem. On the contrary, being explicit about what’s passing down the pipeline seems to me to be a gain in clarity. I like to write out the input and output types for .map and .flatMap (and similar functions), not just for my own benefit, but also to help the compiler, which can get a bit lost if it’s asked to do too much implicit type inference. Also, as you can see, I like to move things off into ancillary utility functions, such as my Future generators, to keep each piece of the pipeline simple and clear.

The resulting code, in my opinion, is eminently readable, which is essential if you want anyone (including yourself) to be able to reason about and maintain that code. And I should not fail to mention, there is no race condition, because there are no shared mutable values; everything just passes down the pipeline in good order.

But you didn’t come here to hear about Combine, I know. You want to know about async/await! Still, I think it’s right for me to be telling you about the Combine approach. Combine goes back to iOS 13; async/await is good for only iOS 15 and later. So if you need to improve your GCD code in code that must run before iOS 15, Combine could be a good solution.

Wrapping a completion handler

Okay, so now at long last let’s start over and rewrite all of our GCD code using structured concurrency.

Our first problem has to do with the Server class. Look at these method declarations:

func getCountries(completion: @escaping (Array<String>) -> Void) {
    // ...
}
func getCapital(of country:String, completion: @escaping (String) -> Void) {
    // ...
}

Those methods are using completion handlers. For async/await, that’s just wrong. We want these to be async methods that simply return a value, directly. That’s the whole point of async/await! Therefore, I’ll start by stubbing an extension on the Server class that declares new versions of the methods:

extension Server {
    func getCountries() async -> Array<String> {
        // ...
    }
    func getCapital(of country:String) async -> String {
        // ...
    }
}

But what should these methods do? This raises an interesting procedural challenge. In real life, we might not have any actual choice about our original Server methods. They might not belong to us, or there might be older code that needs to go on using them. In general, async/await provides a brilliant solution to this kind of problem. You use the async version of a method to wrap the completion handler version. The link between them is provided by a global generic function called withUnsafeContinuation, which takes a closure.

What’s a continuation, you ask? Well, do you remember how await has the magical ability to suspend our code while permitting the thread that it was using to go on its merry way? Continuations are the mechanism that makes that possible. A continuation is like a little functional bookmark that gets stored for you when an await starts waiting for a function call to complete. When the function call does complete, the continuation is called, and our code resumes.

Well, in this situation, the waiting starts when we call our method that has a completion handler, and the continuation should be called when that completion handler is called. The difference here is that we call the continuation! It is handed to us as the parameter of the closure that we write; it is a generic UnsafeContinuation object. It has various resume methods we can call; we can resume with no parameter, resume with a success parameter, resume with a failure parameter, or resume with a Result parameter. But the rule is that we must call exactly one of those methods on every exit path from the closure.

In this instance, success is our only option, and there is only one exit path. So here’s the implementation of our Server extension:

extension Server {
    func getCountries() async -> Array<String> {
        return await withUnsafeContinuation { continuation in
            self.getCountries() {
                continuation.resume(returning: $0)
            }
        }
    }
    func getCapital(of country:String) async -> String {
        return await withUnsafeContinuation { continuation in
            self.getCapital(of:country) {
                continuation.resume(returning: $0)
            }
        }
    }
}

That’s it! There was no need to resolve the generic explicitly, because we are returning the result of the withUnsafeContinuation method as the result of our async method, whose return type thus resolves the generic implicitly. We have no error call, so the error type is Never; our two UnsafeContinuation objects are automatically resolved as UnsafeContinuation<Array<String>, Never> and UnsafeContinuation<String, Never>, respectively.

The beauty of this architecture is that the async versions of these methods not only call the completion handler versions, they coexist with them. They are completely distinct methods; the compiler has no difficulty knowing which one we are calling. So we can now go right ahead and call the async versions of the Server methods.

Naïve looping with async/await

With that preparation made, we are ready to write an initial async/await version of our looping code. I am assuming, of course, that we are in an async context (such as a method marked async). Here we go:

let countries = await Server.shared.getCountries()
print(countries)
var capitals = [String]()
for country in countries {
    let capital = await Server.shared.getCapital(of:country)
    capitals.append(capital)
}
print(capitals)

Wow, was that easy or what? Our Server calls return results directly to us; we just await them and go on our merry way.

However, there’s one slight hitch. This code works perfectly, and our results come back in order; but the whole thing takes a long time. Why? Because we’ve gone back to looping serially through the getCapital calls; every call must wait until it completes before we can loop round and go on to the next call. This is like what happened when we inserted a wait() call into our loop with DispatchGroup in GCD.

(On the bright side, there’s no race condition, exactly because everything is happening serially in the same async context.)

So now we come to the question of how to tell the structured concurrency system to perform calls concurrently. That is an extremely interesting topic, and is in fact why we are actually here.

Looping concurrently with async/await

In order to explain how to tell the structured concurrency system that you want calls to be performed concurrently using a loop, I have to introduce a completely new pattern of usage.

The only pattern of usage for calling an asynchronous method with async/await that I’ve discussed up to now, in this article and the earlier one, is to say await as you call the asynchronous method. This does exactly what you would expect: it waits for the asynchronous method to finish.

This means that I have explained how to do only one thing at a time with async/await. It is concurrency in the sense that we are asynchronously running the code that we are calling; but we are not directly asking to call multiple asynchronous methods simultaneously — indeed, the power of await is that we do not do that. Now, however, I do want to do that.

The solution involves a task group.

A task group is a scope — a closure — in which you can spawn multiple asynchronous tasks to be run at the same time as one another. To create a task group, you call a generic global function, withTaskGroup (or, if there can be a thrown error, withThrowingTaskGroup). This takes a closure that gives you a parameter which represents the group. (I like to name that parameter group.) You then call that group’s addTask method, as many times as needed, once for each of the things you’d like done simultaneously.

It’s simple, but not quite as simple as I’ve just said. I’m going to start by giving some incorrect code; see if you can spot what’s wrong with it:

let countries = await Server.shared.getCountries()
var capitals = [String]()
await withTaskGroup(of: Void.self) { group in
    for country in countries {
        group.addTask {
            let capital = await Server.shared.getCapital(of: country)
            capitals.append(capital) // wrong
        }
    }
}
print(capitals)

That looks reasonable, but there’s a problem: we’ve got a data race when we call capitals.append. This is exactly comparable to the mistake we were making earlier with GCD. But with async/await, there’s a big difference: the code I’ve just shown you doesn’t compile!

Yes, that’s right. Unlike with GCD, the compiler is able to reason about the data race with async/await, and stops you in your tracks. This is one of the most delightful things about structured concurrency; it is a Swift language feature, and the compiler is able to help you make your code correct.

Okay, so how are we going to populate capitals safely? The answer may seem quite surprising when I show it to you; yet I assure you that after you’ve written code like this once or twice, you will just incorporate the entire pattern into your mental lexicon and it will become completely natural after that.

It turns out that the group parameter inside the task group closure is an AsyncSequence. Not only that; whatever is returned from the group’s addTask method closure is appended to that AsyncSequence. That, it turns out, is why you have to give an of: parameter in your withTaskGroup call; you are stating the type of element that is going to be returned into the sequence from the addTask closure.

So here’s what you do. Inside your group’s addTask closure, you return each value that you want to be emitted as part of the sequence. After you’ve given those instructions for assembling the sequence, you give instructions to read that sequence asynchronously, using for await — like this:

    let countries = await Server.shared.getCountries()
    print(countries)
    var capitals = [String]()
    await withTaskGroup(of: String.self) { group in
        for country in countries {
            group.addTask {
                let capital = await Server.shared.getCapital(of:country)
                return capital
            }
        }
        for await capital in group {
            capitals.append(capital)
        }
    }
    print(capitals)

That is thread-safe, because the calls to capitals.append all happen sequentially in the same context. The compiler is happy, the programmer is happy, and the program works! We are calling getCapital multiple times concurrently; the results are being gathered up by the for await loop as they arrive. As soon as all the results have arrived and the last one has been gathered up, the task group finishes, the await in await withTaskGroup stops waiting, our code resumes, and we print the resulting array.

Order in the court, once again

The only problem with the preceding code is that the resulting array presents the results of our getCapital calls in random order — the order in which the results happened to come back from the server.

Okay, but we know how to fix that! Make this an array of tuples so that we can sort back into the original order based on the original values. Let’s make a few minor adjustments, and we’re all done:

    let countries = await Server.shared.getCountries()
    print(countries)
    var capitals = [(country:String, capital:String)]()
    await withTaskGroup(of: (String,String).self) { group in
        for country in countries {
            group.addTask {
                let capital = await Server.shared.getCapital(of:country)
                return (country,capital)
            }
        }
        for await pair in group {
            capitals.append(pair)
        }
    }
    print(capitals)

That’s it. We have converted the final version of our GCD code to use async/await instead. Mission accomplished!

A simpler way

One final note on this topic: In the limited case where you have a known finite set of asynchronous calls that you want to make simultaneously, there’s a simpler way.

To illustrate, let’s suppose that you already have all the country names, and you just want to fetch the capitals of two specific countries. Watch this little move:

async let france = Server.shared.getCapital(of: "France")
async let germany = Server.shared.getCapital(of: "Germany")
// ... could do other stuff here
let capitals = [("France", await france), ("Germany", await germany)]

Here, we are using an async let declaration, twice. We are allowed to make as many async let declarations as we like; the initializer for these constants needs to be an asynchronous call. We do not say await when we make the asynchronous call. That’s because we don’t wait; the code just goes right on, with the asynchronous calls happening concurrently, and at the same time as our code proceeds.

But later, when we want the actual values that are supposed to be returned from those asynchronous calls, that is when we must say await, in order to make sure that we do not proceed until the request made in the async let initializer has actually been fulfilled. That’s what happens in the last line of our code. We are not allowed to refer to the constants that we initialized with async let without saying await.

Don’t worry, the compiler will make you say the right thing! async let is easy to use; that’s the whole point.

Conclusion

Swift 5.5 structured concurrency is a big subject. Nevertheless, as I’ve tried to show in this article and the previous one, getting started is fun and easy. Your code will be much simpler to write than when you were trying to do this sort of thing with GCD. And it will be more reliably correct as well.

There’s still more to know, of course. Our call to withTaskGroup introduces the idea of a Task object; a handle to the Task object is actually returned when you say withTaskGroup, but I didn’t use that returned value in my examples, because I didn’t need it for anything. Using the returned value, we can cancel tasks. Task groups can be nested, and when they are, cancellation works coherently. So does throwing from within the task (in fact, throwing and cancellation are closely related; often, the way to respond to being cancelled is to throw). When you learn about all that, you’ll be able to accomplish things that were dauntingly difficult or even impossible with GCD.

So I hope you’re looking forward to diving even deeper into structured concurrency. Meanwhile, start using it! I think you’ll be a happier programmer when you do. I certainly am.

You Might Also Like…

Swift 5.5: Replacing GCD With Async/Await

Multithreading! The mere word sends shivers up one’s spine. And if it doesn’t, it should. Main thread and background threads. Code that runs asynchronously. Code that can run simultaneously with other code. Code that can run simultaneously with itself. Code that can share data across threads — possibly with disastrous consequences. Concurrency. Multithreaded code is …

Swift 5.5: Replacing GCD With Async/Await Read More »

    Sign Up

    Get more articles and exclusive content that takes your iOS skills to the next level.