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
}
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.
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.
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
}
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.