SoFunction
Updated on 2025-03-02

Detailed explanation of the use of multiple tasks in parallel with Swift concurrent system

Preface

One of the benefits of Swift's built-in concurrency system is that it makes it easier to perform multiple asynchronous tasks in parallel, which in turn allows us to significantly speed up operations that can be broken down into separate parts.

In this article, let's look at several different approaches and when each of these techniques is particularly useful.

From asynchronous to concurrent

First, let's say we are developing some form of shopping application to display various products, and we have implemented aProductLoaderAllows us to load applications that use a series of asynchronous APIs to load different product collections, as follows:

class ProductLoader {
    ...
    func loadFeatured() async throws -> [Product] {
        ...
    }
    func loadFavorites() async throws -> [Product] {
        ...
    }
    func loadLatest() async throws -> [Product] {
        ...
    }
}

Although most of the time each of the above methods may be called individually, assuming that in some parts of our application we also want to form aRecommendationsIncludes these threeProductLoaderCombination model of all results of the method:

extension Product {
    struct Recommendations {
        var featured: [Product]
        var favorites: [Product]
        var latest: [Product]
    }
}

One way is to useawaitThe keyword calls each loading method and then uses the results of these calls to create usRecommendationsAn example of the model - as follows:

extension ProductLoader {
    func loadRecommendations() async throws ->  {
        let featured = try await loadFeatured()
let favorites = try await loadFavorites()
let latest = try await loadLatest()
        return (
            featured: featured,
            favorites: favorites,
            latest: latest
        )
    }
}

The above implementation does work – however, even though our three load operations are all completely asynchronous, they are currently being executed sequentially, one after another. So despite our toploadRecommendationsThe method is being executed concurrently relative to other code in our application, but in fact it has not yet utilized concurrency to perform its internal set of operations.

Since our product loading methods do not depend on each other in any way, there is actually no reason to execute them in order, so let's see how we can get them to execute exactly at the same time.

The initial idea on how to do this might be to simplify the above code into a single expression, which will allow us to use a singleawaitKeywords to wait for each of our operations to complete:

extension ProductLoader {
    func loadRecommendations() async throws ->  {
        try await (
            featured: loadFeatured(),
            favorites: loadFavorites(),
            latest: loadLatest()
        )
    }
}

However, even if our code now looks concurrent, it will actually execute exactly in order as before.

Instead, we need to use Swift'sasync letBinding to tell the concurrent system to perform each of our loading operations in parallel. Using this syntax allows us to start an asynchronous operation in the background without us waiting for it to complete immediately.

awaitIf we combine it with a single keyword when we actually use the loaded data (i.e. when forming the model)Recommendations, then we will get all the benefits of performing load operations in parallel without worrying about things like state management or data competition:

extension ProductLoader {
    func loadRecommendations() async throws ->  {
        async let featured = loadFeatured()
async let favorites = loadFavorites()
async let latest = loadLatest()
        return try await (
            featured: featured,
            favorites: favorites,
            latest: latest
        )
    }
}

Very neat! thereforeasync let,When we have a set of known, limited tasks to be executed, it provides a built-in way to run multiple operations simultaneously. But what if that is not the case?

Task Group

Now let's say we're developing aImageLoaderA tool that allows us to load images through the network. To load a single image from the givenURL, we can use the following method:

class ImageLoader {
    ...
    func loadImage(from url: URL) async throws -> UIImage {
        ...
    }
}

To make it simple to load a series of images at a time, we also created a convenient API that takes an array of URLs and asynchronously returns an image dictionary, which is keyed by the URL of the downloaded image:

extension ImageLoader {
    func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
        var images = [URL: UIImage]()
        for url in urls {
            images[url] = try await loadImage(from: url)
        }
        return images
    }
}

Now let's say, just like usProductLoaderThe previous work is the same, we want to make the aboveloadImagesMethods are executed concurrently, rather than downloading each image in order (this is currently the case because weawaitUse directly when callingloadImageOurforring).

However, this time we will not be able to use itasync let, because the number of tasks we need to execute is unknown at compile time. Thankfully, there is also a tool in the Swift concurrency toolbox that allows us to execute dynamic number of tasks in parallel - task groups.

To form a task group, we can callwithTaskGrouporwithThrowingTaskGroup, it depends on whether we want to have the option to throw an error in our task. In this case we will choose the latter because our bottom layerloadImageThe method is to usethrowsKeyword tagged.

Then we will iterate through each URL, just like before, just this time we add each image loading task to our group instead of just waiting for it to complete. Instead, we willawaitGroup the results individually after adding each task, which will allow our image loading operation to be performed completely concurrently:

extension ImageLoader {
    func loadImages(from urls: [URL]) async throws -> [URL: UIImage] {
        try await withThrowingTaskGroup(of: (URL, UIImage).self) { group in
            for url in urls {
                {
    let image = try await (from: url)
    return (url, image)
} 
            }
            var images = [URL: UIImage]()
            for try await (url, image) in group {
    images[url] = image
}
            return images
        }
    }
}

To learn about the abovefor try awaitFor more information about syntax and general asynchronous sequences, please check out"Async sequences, streams, and combinations"

Just like when usingasync let,A huge benefit of writing concurrent code in a way that our operations don't directly change any state is that doing so allows us to completely avoid any type of data race problem, while also not requiring us to introduce any locking or serializing code to mix together.

awaitSo, when possible, letting each of our concurrent operations return a completely independent result and then return these results in turn to form our final dataset is usually a good way to do this.

In future articles, we will look more closely at other ways to avoid data competition (e.g. by using Swift's newactortype).

in conclusion

It is important to remember that simply because a given function is marked asasyncIt doesn't necessarily mean it performs its work at the same time. Instead, if this is what we want to do, we have to deliberately let our tasks run in parallel, which makes sense only if we perform a set of operations that can be run independently.

The above is a detailed explanation of the use of Swift concurrent running multiple tasks in parallel. For more information about Swift concurrent running multiple tasks, please pay attention to my other related articles!