tl;dr
In short, when using Swift concurrency, you should not dwell on direct analogs of GCD patterns. And you shouldn’t generally worry about threads at all.
I might refer you to WWDC 2021 video Swift concurrency: Behind the scenes. It won’t answer many of your specific questions (because this is a new paradigm; you simply will not always find direct analogs to our old, familiar GCD patterns), but it will give you insights regarding the underlying threading model.
You asked:
- Regarding
async
functions: If we wanted to run the same async function sometimes on the main thread and at other times on other threads, you cannot do so like you would with DispatchQueue
s …
Correct. Or to rephrase it (avoiding “thread” terminology): A function is either isolated to a particular actor (e.g., whether the main actor or another actor) or it isn’t. Actors are primarily to provide thread-safe access to some mutable state. If it is actor-isolated (e.g., because it needs to mutate some of the actor properties), it is isolated to that particular actor only. The compiler takes care of all of the threading details for us and validates our code for thread-safety at compile-time.
In light of this, it doesn’t make too much sense to have some method isolated to two different actors. The content/functionality of the method dictates to which actor it is isolated. And if it is not interacting with actor-isolated content, we would probably make it nonisolated
. So, yes, the “main actor” happens to use the main thread for this actor-isolation, but beyond that, we do not worry about on which thread particular code runs, but rather which actor it is isolated to (if isolated at all). Swift concurrency handles all of the thread-related logic for us.
The net effect is that with Swift concurrency, you enjoy compile-time validation of your code, ensuring that code always runs on the correct actor. This gets us away from the fundamental problem in GCD, where one had to hope that the caller dispatched a function to the correct queue and deal with unpredictable runtime behaviors if you happened to fail to do so. We had to use TSAN, dispatch predicates, or “main thread checker” to hopefully catch threading errors at runtime. With Swift concurrency, the compiler largely ensures the correctness of our code.
- … You would have to declare two separate async functions and have one annotated
@MainActor
, showing at the code level where things will be run.
Technically correct, but in practice, if you find yourself inclined to write two separate functions, there probably is a deeper design problem. One is generally either doing something that requires the main actor (e.g., updating the UI; interacting with actor-isolated content; etc.) or not.
We really cannot answer this question practically without seeing a real-world example of a function that must be actor isolated, but sometimes isolated to the main actor and sometimes to some other actor. But that is probably best posed as a separate question on Stack Overflow.
- Regarding
Tasks
: If Task
s are not run on the main actor, there is no way to schedule separate Task
s to run in a serial manner …
If you are calling async
methods that have await
suspension points, not even the main actor will ensure serial execution. Yes, it will make sure that code will not run in parallel at the same time, but it is free to interleave between various tasks isolated to that particular actor.
Swift concurrency is “reentrant” … as soon as you hit an await
suspension point (regardless of what actor you are running on … whether the main actor or some other actor), that particular task is suspended, and the actor is free to run other tasks that are awaiting it. For more information, see SE-0306 - Actors - Actor reentrancy. FWIW, that proposal contemplates eventually introducing non-reentrancy, but not as of now.
(Personally, I think non-reentrancy, or, better, some constrained concurrency, would be greatly welcomed. The constrained concurrency pattern suggested in WWDC 2023’s Beyond the basics of structured concurrency is, IMHO, just embarrassing. How can they think that is an acceptable pattern? It works, but it is ugly.)
- … instead, you would have to make each task an
async
function respectively and await one after another.
Yep, you can await the prior Task
. Or sometimes we reach for AsyncSequence
types, such as AsyncStream
or AsyncChannel
.
So, if your question is, fundamentally, “Swift concurrency seems very different than GCD,” then the answer is that, yes, it is.
I would suggest that you do not worry about the theoretical differences, and instead focus on actual practical problems you are seeing as you refactor your code to adopt Swift concurrency. The answers to the questions you pose here will undoubtedly feel vaguely (extremely?) dissatisfying in the abstract. But if we had concrete, real-world examples, we can probably point you in the right direction.
So, I would suggest that you don’t worry about how different Swift concurrency is from GCD, but rather focus on practical, real-world problems you encounter as you refactor your code. (And, no offense, but make sure to research that on Stack Overflow, because many of these real-world problems have been asked and answered many times already.) But if you have a MRE that is presenting a specific challenge that is not already answered on Stack Overflow, then please post a new question with that specific example.
As a final closing point, I think WWDC 2021 Swift concurrency: Update a sample app, if you have not seen it already, is a great, practical example of how we refactor legacy code to adopt Swift concurrency.