Search posts, tags, users, and pages
Hi Vitaly, great article - I did learn a bit today :)
One part could use a bit of clarification/correction though:
You can easily see this when you add a try? await Task.sleep(nanoseconds: 8_000_000_000) to your code before the end. Now slow() won't get cancelled anymore.
Besides that, great article. Thanks a lot for sharing your work. Swift Concurrency looks easy on the surface but is quite complicated for edge cases.
Kind regards, Heiko
Hi Heiko,
Thanks for the comment! Greatly appreciate your feedback!
I assume you are referencing to error propagation rule example with async lets. Let's try to clarify it.
"""slow() is not cancelled because fast() did throw an error
slow() is cancelled, because your main program "wants to quit" (leave your local scope)"""
After fast throws TestError1, execution leaves the local scope because error is propagated outside of it, and because of that slow is implicitly cancelled and implicitly awaited. You are correct, and it is what I meant there.
"""You can easily see this when you add a try? await Task.sleep(nanoseconds: 8_000_000_000) to your code before the end. Now slow() won't get cancelled anymore."""
If I add "try? await Task.sleep(nanoseconds: 8_000_000_000)" right after "try await (f, s)", it will change nothing because when TestError1 is propagated outside local scope, everything after "try await (f, s)" will be abandoned and not be executed. slow() will be cancelled for the same reason as it cancelled in original example. If I got your idea in wrong way, please let me know.
"""try? await (f, s) will try to wait for all async let, but will exit early on the first error thrown by any of the async let statements it awaits for"""
That is also not correct if I got your thought correctly. "try await (f, s)" and "try? await (f, s)" will work differently and I used first one, but you used second in your comment. Just want to make sure we don't miss that.
Both of them will not try to await all of async lets, at least at the same time. They will await "f" and "s" sequentially, one after another. And propagation logic can be affected by the order of awaiting. For example, even if "s" will throw TestError2 before "f" is completed or throw TestError1, error from "s" will not be propagated, because "f" was awaited first.
Difference for them is:
I have an article about error propagation edge cases of async lets here, logic is a bit tricky I would say. vbat.dev/async-let-vs-task-group
Hope my explanation was useful! Let me know if I need to provide more clarifications!
Hi Vitaly,
thank you for your fast and elaborate reply! I guess my initial post was a bit in a hurry, so I'll give it a second try and more time :)
"""I assume you are referencing to error propagation rule example with async lets"""
=> Yes exactly, that was were my comment was aimed at
"""If I add "try? await Task.sleep(nanoseconds: 8_000_000_000)" right after "try await (f, s)", it will change nothing because when TestError1 is propagated outside local scope, everything after "try await (f, s)" will be abandoned and not be executed. slow() will be cancelled for the same reason as it cancelled in original example. If I got your idea in wrong way, please let me know."""
=> I'm sorry, I wasn't very clear with thoughts. So first of all I was considering the """async let f = fast(); async let s = slow(); try await (f, s)""" not to be a function but being top-level code. I also changed "try" to "try?" so I wouldn't get a fatalError due to being top-level code. In that scenario that top level scope would not result in a fatalError / exception and therefore the slow() task wouldn't necessarily get cancelled - it only gets cancelled when the top level code ends.
The point I was trying to make is that it's not the fast() task throwing an error that is cancelling the slow() task, but leaving the scope, because the current level of scope is left, either (a) because an error or propagated and current level of scope its exited with an error or (b) due to exiting the current level of scope in a "normal" way.
I apologize that I misunderstood your idea in the first place. To help others in the same mind-space as myself, maybe you could extend your example with try await (f, s) being inside a function?
Thank you again for taking your time and deep dive into my comment! Really appreciate that.
Kind regards, Heiko
Here are some output snippets, that might help to elaborate the point I was trying to make - but I guess it's based on the misinterpretation of top-level code vs. code inside a function:
=> Case 1: top level code + try (just added Timestamps) Output: 2025-04-17 19:09:15 +0000 slow started 2025-04-17 19:09:15 +0000 fast started 2025-04-17 19:09:20 +0000 fast ended 2025-04-17 19:09:20 +0000 slow cancelled CancellationError() 2025-04-17 19:09:20 +0000 slow ended Swift/ErrorType.swift:200: Fatal error: Error raised at top level: async_let_test.TestError1()
=> Case 2: top level code + try? Output: 2025-04-17 19:10:24 +0000 fast started 2025-04-17 19:10:24 +0000 slow started 2025-04-17 19:10:30 +0000 fast ended leaving local scope 2025-04-17 19:10:30 +0000 slow cancelled CancellationError() 2025-04-17 19:10:30 +0000 slow ended Program ended with exit code: 0
=> Case 3: top level code + do { try await (f, s) } catch { print(Date.now, "caught", error) } 2025-04-17 19:12:24 +0000 fast started 2025-04-17 19:12:24 +0000 slow started 2025-04-17 19:12:29 +0000 fast ended 2025-04-17 19:12:29 +0000 caught TestError1() leaving local scope // last statement. Top level code want to quit, but due to structured concurrency it cancels all child tasks and awaits for them 2025-04-17 19:12:29 +0000 slow cancelled CancellationError() 2025-04-17 19:12:29 +0000 slow ended Program ended with exit code: 0
=> Case 4: top level code + do { try await (f, s) } catch { print(Date.now, "caught", error) } + try? await Task.sleep(nanoseconds: 6_000_000_000) 2025-04-17 19:17:51 +0000 slow started 2025-04-17 19:17:51 +0000 fast started 2025-04-17 19:17:56 +0000 fast ended 2025-04-17 19:17:56 +0000 caught TestError1() 2025-04-17 19:18:02 +0000 slow ended // due to the extra wait before leaving local scope (here: top level code): no cancellation necessary leaving local scope Program ended with exit code: 0
=> Case 5: using an asynchronous throwing function and call it with a do try catch block from top level code (and no additional Task.sleep() ): func test() async throws { async let f = fast() async let s = slow() try await (f, s) print("leaving local scope") }
do { try await test() } catch { print(Date.now, "external catch", error) }
2025-04-17 19:19:35 +0000 fast started 2025-04-17 19:19:35 +0000 slow started 2025-04-17 19:19:41 +0000 fast ended 2025-04-17 19:19:41 +0000 slow cancelled CancellationError() // local scope of try { } block wants to quit and continue with catch { } block 2025-04-17 19:19:41 +0000 slow ended 2025-04-17 19:19:41 +0000 external catch TestError1() Program ended with exit code: 0
Thanks for providing more details! Makes total sense. I will wrap it into function to avoid ambiguity.