What is the idiomatic way to running two async methods in parallel?

I have two situations that I’m not quite sure how to best write using the new Swift concurrency features. It’s in a Vapor code base, but this isn’t really tied to Vapor.

I’m not quite sure whether a TaskGroup is the right thing to do or async let. Or is there some other way that I’m missing here? What is the best and/or idiomatic way to do this?

The two situations are:

1. An async method that kicks off two other async calls and then returns when both are done

Here’s what I came up with for the two versions:

func runTwoThingsUsingTaskGroup() async throws {
    try await withThrowingTaskGroup(of: Void.self) { group in
        group.addTask { try await self.work1() }
        group.addTask { try await self.work2() }
        try await group.waitForAll() // If any of the two parallel tasks throws an error, the whole method throws
    }
}

func runTwoThingsUsingAsyncLet() async throws {
    async let result1 = self.work1()
    async let result2 = self.work2()
    (_, _) = try await (result1, result2) // If any of the two parallel tasks throws an error, the whole method throws
}

2. An async method that kicks off two other async calls and then returns the result of one of them

Old code

I think thought it’s possible to use a TaskGroup for this, but it’s… not pretty. Or I’m missing something here:

Edit: This doesn’t compile because: Mutation of captured var 'finalResult' in concurrently-executing code

func runTwoThingsUsingTaskGroupWithResult() async throws -> Value {
    return try await withThrowingTaskGroup(of: Void.self, returning: Value.self) { group in
        var finalResult: Value?
        group.addTask { finalResult = try await self.work1() }
        group.addTask { try await self.work2() }
        try await group.waitForAll() // If any of the two parallel tasks throws an error, the whole method throws
        return finalResult!
    }
}

Here’s what I came up with in a async let variant:

func runTwoThingsUsingAsyncLetWithResult() async throws -> Value {
    async let result1 = self.work1()
    async let result2 = self.work2()
    let (finalResult, _) = try await (result1, result2).0 // If any of the two parallel tasks throws an error, the whole method throws
    return finalResult
}

Use async let.

I found @kavon's WWDC session helpful in explaining the difference: basically, async let is for when you have a fixed number of tasks to run concurrently (in this case, 2 tasks).

TaskGroup is for when the number of tasks is only known at runtime (e.g. concurrently processing each element in an Array).

You need to use a TaskGroup, but that's only because we don't expose any real "Future"-like APIs on async let variables. What you want to do is grab the first element from the TaskGroup, and then cancel the other pending tasks (in this case just one other task).

EDIT: Or wait... maybe not. I was under the assumption you want to return the first task which completes without waiting for the other one. If you're happy to wait for both tasks, async let is fine.

3 Likes

Thanks for the answer and the link! One more reason why this solution is more appealing.

Just out of curiosity: Isn’t the “first element of the TaskGroup” more or less random? I understood that when iterating over a TaskGroup, the results are returned in the order they finish. But I want the result of a specific method, the result of the task that returns first.

OK, now we’re on the same page. Thanks for confirming :grinning:

1 Like

On the same note, what will the syntax be for

func runTwoThingsUsingAsyncLet() async throws {
    async let result1 = self.work1()
    async let result2 = self.work2()
    (_, _) = try await (result1, result2) // If any of the two parallel tasks throws an error, the whole method throws
}

When I don't care about the result of either result1 or result2 and I don't want to throw any potential errors. I just want to do work1 and work2 in parallel.

try await (result1, result2) works. There's no special reason to wait for them both at once right now, although perhaps there should be. Currently we await one and then the other, which means the first won't be cancelled immediately if the second throws. You could argue it would be better to recognize that the await covers multiple async lets, wait for them both simultaneously, and then cancel them if either throws. That would potentially introduce non-determinism about which error was thrown, though.

Does this potential change jibe with the rules for argument evaluation order? Would try (await result1, await result2) be the idiomatic way to ensure that they are awaited separately?

Well, it depends on what the exact semantics were, but yes, the simplest implementation could lead to observing a different error being thrown and even skipping over any side-effects between the formal evaluation of the two async let references. It might be a good idea to limit it to some specific form like await (x,y,z).

What will the syntax be if I don't wait to await result1 and result2 in the function ?

I would like to do:

func runTwoThingsInParallelIgnoringResult() async { 
// TODO: concurrently run work1 and work2 without caring about the result 
// The callee of runTwoThingsInParallelIgnoringResult should await the function
}

That is not directly possible; you would need to return a Task that awaits the two results.

So if I do

private func runTwoThingsInParallelIgnoringResult() async {
        Task {
            async let work1Result = work1()
            async let work2Result = work2()
        }
}

Then I get a warning Constant 'work1Result' inferred to have type '()', which may be unexpected . But it works as I am expecting it to.

If I do async let _ = work1() then I get compiler error Expression is 'async' but is not marked with 'await'

private func runTwoThingsInParallelIgnoringResult() async {
    await withTaskGroup { group in
        group.addTask { await work1() }
        group.addTask { await work2() }
        await group.waitForAll()
    }
}

If I understood correct, this will do desired behavior.

The following version have to do the same as I understand async let, but not sure if it will work though:

private func runTwoThingsInParallelIgnoringResult() async {
    async let r1: Void = work1()
    async let r2: Void = work2()
    _ = await [r1, r2]
}
2 Likes

Thanks, I knew that I can do that - I was wondering if I can achieve the same by using the async let syntax.

I’ve added async let version, it currently has a bit odd IMO behavior for functions which return Void, so I favor TaskGroup for such cases. It makes more explicit that result is discarded.

async let is significantly more efficient because the child tasks' async stacks are allocated within the parent task's async stack. TaskGroup heap allocates for every child task; this also leads to more ARC traffic.

I agree that async let tasks which return Void are a bit awkward to use. I worry that the optimizer will remove them because the return value isn't used.

3 Likes

what if work2 requires the result of work1 please???

In that case you can’t run the two pieces of code in parallel. It’s just a function that takes a parameter that happens to be the result of another asynchronous function. That is:

let x = await work1()
work2(x)

Please let me know if I misunderstood your question.

2 Likes

I apologize for a late reply to this, above and beyond your compiler warnings, we should note two problems here:

  1. You have marked runTwoThingsInParallelIgnoringResult as an async method, but it isn’t. It will return immediately because you do not await anything here. The async qualifier of the method is misleading.

  2. The deeper problem is that if you do not await these two async let statements, it will cancel them immediately as soon as work1Result and work2Result fall out of scope. See the Implicit async let awaiting discussion of SE-0317. That section is discusses the “implicit awaiting”, but more importantly, it will also implicitly cancel them.


So, in answer to your question about the warning, you should specify the type as ():

private func runTwoThingsInParallelIgnoringResult() async {
    async let work1Result: () = work1()
    async let work2Result: () = work2()

    _ = await (work1Result, work2Result)
}

The key takeaways are that:

  • The explicit reference to a return type of () will silence the warning about a variable for a function that does not return a result;
  • We remove the unnecessary unstructured concurrency introduced with Task {…}; and
  • We await the two methods (which will run concurrently), avoiding the implicit cancellation of the work.

The other approach is to use the task group. Unlike the OP’s question, since you are not returning values, a discarding task group using withDiscardingTaskGroup might be appropriate:

private func runTwoThingsInParallelIgnoringResult() async {
    await withDiscardingTaskGroup { group in
        group.addTask { await self.work1() }
        group.addTask { await self.work2() }
    }
}
1 Like

@marco.masser Please forgive an answer to an old question, but you posed this as one approach:

If you want to await both, but return the result of work1 while discarding the result of work2, you can

  • async let of work1, and
  • await work2().

E.g.:

func runTwoThingsUsingAsyncLetWithResult() async -> Value {
    async let work1Result = work1() // start work1
    _ = await work2()               // start work2 concurrently and await its result

    return await work1Result        // now await work1
}

Or, if work2 does not return anything, but you just want to make sure you do not return until its asynchronous work is complete, it is even simpler:

func runTwoThingsUsingAsyncLetWithResult() async -> Value {
    async let work1Result = work1() // start work1
    await work2()                   // start work2 concurrently and await its result

    return await work1Result        // now await work1
}

Note, because these are running concurrently, it really does not matter the order you await them (but make sure to await the async let result, as the compiler does not warn you if you neglect to do so).

If I use OSSignposter and Instruments to watch the intervals of work1 and work2, where work1 is slower, you can see them run concurrently:

Or if work2 is slower, you still get similar results:


In answer to your question about what’s “best” or “most idiomatic”, while that’s a matter of opinion, here is the guideline I personally use:

  • I use task group if:

    • If the task results are homogenous (especially if I want to collate the results into some collection that will be returned);
    • I don’t care about the order in which the results complete, but just need to know when they’re all done; and, especially
    • If the number of tasks is variable and not predetermined when writing the code.
  • I use async let if:

    • If the the number of tasks is fixed (and relatively small); or
    • The results of the asynchronous tasks are heterogenous; and especially
    • I am going to pass the results of these individual tasks to something else where I don’t want to re-order the results like I would would a task group.

Neither task group nor async let is right or wrong, per se, but in my experience, the nature of the particular use-case generally screams for one or the other. And in those rare situation where both would work equally well, I use whichever pattern that gets the job done in the most expressive way and/or with the least amount of syntactic noise.

3 Likes

No need to apologize – thank you very much for taking the time to write up such a detailed answer!

Looking at this now, this seems so obviously correct that I’m wondering how anything else even came to my mind. I guess I was hung up on wanting both work1() and work2() to be called in the same way – which doesn’t make sense, given that I wanted to handle their results completely differently (return one, discard the other).

So thank you for solving this 3½ years old riddle not just with simple code, but also with a clear explanation and nice Instruments screenshots to back up what you wrote :100:

3 Likes