Question regarding concurrency

Hello, so I am just starting to learn about concurrency in Swift and while reading the official documentation regarding Swift Concurrency I got confused about something. In the chapter "Calling Asynchronous Functions in Parallel" there is a following statement:

"Calling an asynchronous function with await runs only one piece of code at a time. While the asynchronous code is running, the caller waits for that code to finish before moving on to run the next line of code."

Followed by this code:

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

I got confused because I thought that while the asynchronous function is suspended other asynchronous functions can run on the same thread meaning that here the second downloadPhoto() function would start its work when the first photo's download is suspended (while waiting for the network response for example) instead of waiting for the first photo to get fully download. So, can somebody please explain what is happening here?

So my understanding of this is only basic so far (still have a lot to learn), but the way I understand it is that even though all of those calls are running asynchronously, the await keyword forces them into a synchronous sequence in the flow of the method itself. So what ends up happening is that the first one gets called and the function waits for that to finish before calling the second one. And so forth. So yes these functions all could run concurrently or in parallel, but using await makes them run serially.

The way to get around that behavior (which is likely not what you want) Swift has this thing called structured concurrency. With this, you can start what is known as a task group that will run fully concurrently, then await all the values as they come in. They may not come back in the order you expect, since that is the nature of parallelism, but the tasks will run at the same time instead of in sequence.

Check out this video from WWDC for more info: Explore structured concurrency in Swift - WWDC21 - Videos - Apple Developer

You can also initiate the three downloads simultaneously and wait for them to complete later.

async let firstPhoto  = downloadPhoto(named: photoNames[0])
async let secondPhoto = downloadPhoto(named: photoNames[1])
async let thirdPhoto  = downloadPhoto(named: photoNames[2])

// Do other work
...

// Need the photos to continue
let photos = await (firstPhoto, secondPhoto, thirdPhoto)

// Process the photos
...

This is also explained in TSPL book, here. :slight_smile:

4 Likes

Ah yeah so this example that you are citing is what the documentation is showing as “not parallel”. The next example with async let “is parallel”, so hopefully that makes more sense. You can also use a task group but it’s not needed for something this simple.

Thanks for explanation, but my question was a little bit different. I am fully aware of async let but what I am more interested in is why the code I posted works the ways it works, I just don't understand the reason why asynchronous functions end up running similarly to synchronous function

Oops. I apologise for misunderstanding your question.

Here is the difference.

A synchronous function does not suspend its task but it blocks the thread executing it, whereas an async function suspends its task and relinquishes the thread so that it becomes available for running other tasks.

Equivalent of this code with completions would be:

downloadPhoto(named: photoNames[0]) {
    downloadPhoto(named: photoNames[1]) {
        downloadPhoto(named: photoNames[2]) {
        }
    }
}

Because async functions suspend current function execution to allow other code somewhere in the app execute – the same way as completion-based functions free current thread.

Imagine if they behave like you have assumed, then this code would make no sense:

let photo = await download(named: name)
let croppedPhoto = await crop(photo)
let localPath = await save(croppedPhoto)

If all three functions would start in parallel here, using photo and croppedPhoto in consequent calls wouldn’t make any sense as they more likely hasn’t been initialized yet.

They don't run the same as synchronous. They look like you can call them similarly to synchronous, allowing to chain the work in the same way as if you'd do with synchrohous code.

To run in parallel you need to use either async let or TaskGroup:

async let downloads = [
    downloadPhoto(named: photoNames[0]),
    downloadPhoto(named: photoNames[1]),
    downloadPhoto(named: photoNames[2])
]
let photos = await downloads

or

let photos = await withTaskGroup(of: Photo.self) { group in 
    for name in photoNames {
        group.addTask { await downloadPhoto(named: name) }
    }
    var result: [Photo] = []
    for await photo in group {
        result.append(photo)
    }
    return result
}
6 Likes
let photo = await download(named: name)
let croppedPhoto = await crop(photo)
let localPath = await save(croppedPhoto)

Thanks for your explanation. I understand now why this code wouldn't make sense and therefore these functions running concurrently wouldn't make sense either:

let firstPhoto = await downloadPhoto(named: photoNames[0])
let secondPhoto = await downloadPhoto(named: photoNames[1])
let thirdPhoto = await downloadPhoto(named: photoNames[2])

I just want to clarify one more thing here. So, let's say the first downloadPhoto() function starts running and at some point it suspends (while waiting for network response for example) what happens on that thread then? We clarified that the second and third downloads won't start but something should be happening on the thread that first downloadPhoto() was running on otherwise the fact that the thread is unblocked is "useless"(as if there is no work being done there then it might well be blocked).

P.S. I am sorry if I don't convey my intent clearly enough I just have a really hard time understanding concurrency.

I answered to @vns post below. If you have any thoughts about it, I would like to hear them. Thanks!

I am not expert but this is what should happen (@John_McCall, please feel free to correct me.)
That thread becomes available to execute some other scheduled work if there is any. If there is no work to do, the thread goes back to the thread pool.

My answer would be the same as above.

1 Like

Literally any other task (or nothing).

At this level you now operate at the task level, so we put this inside some task, say just create unstructured one:

func threePhotosDownload() async {
    let firstPhoto = await downloadPhoto(named: photoNames[0])
    let secondPhoto = await downloadPhoto(named: photoNames[1])
    let thirdPhoto = await downloadPhoto(named: photoNames[2])
}

Task.detached {
    await threePhotosDownload()
}

// other work here

That's one example, but ultimately any async operation will run as a part of some task (structured or unstructured). So now some of the threads from the pool used by Swift Concurrency is busy with this task, and the thread that started it is free to continue with other work. At any point of your program you may have any number of tasks being run, and internally Swift schedules their execution according to priority and available resources.

If you start this task from the main actor (thread), then you will ultimately have tons of UI-related stuff to be running, while your downloads are happening. If that's all your app does, then it will just be idle while download happens.


There are a lot of WWDC sessions from this and several previous years that greatly cover Swift Concurrency and give great mental model of what's happening. I highly recommend watching them.

3 Likes

For background threads in e.g. iOS or Mac apps it usually wouldn't really matter if the thread blocks or not (aside from performance differences in some cases*), but if you would start downloadPhoto() from the main thread, and the thread would block, then the UI wouldn't be able to receive input events or update the screen, e.g. scrolling and button clicks wouldn't work anymore until the download finishes.

(*) and possibly semantic differences regarding reentrancy when running on an actor.

2 Likes

Got it! Thanks for clarifying. Will definitely check out some dub dub session to learn more. Cheers