func foo2() async -> Int {
await bar2()
}
func bar2() async -> Int {
42
}
print(await foo2())
yet the first one uses one thread... and the second one – two.
Interestingly you said that, as for me the situation is quite the opposite – the infrastructure saved me from the obvious low-level data races, but that's that.. I wish we'll have something much better eventually that actually "forces" me to write correct code (making an incorrect code a compilation error or at least a runtime error). We are very far from that with the current async/await/actors state of affairs, I am afraid.
Or two tasks to be more precise, which doesn't necessarily mean two different physical threads, but might be.
The benefit of async/await is that you may have a lot going on in your app: user interaction, animations, video or audio rendering, and so by executing things in tasks you give the scheduler a chance to "spread" and parallelize the execution of your snippets.
I find the result amazing because if you look at how something similar is achieved on the server side, say SwiftNIO (which is lagging behind in this regard), i.e. how server apps try to use all available cores and what it means for your code, Swift's structured concurrency is a huge, huge relief and a productivity boost. (And I wish SwiftNIO caught up with async/await but it's not going to be trivial I presume).
YMMV of course but as you specifically mentioned "a productivity boost" in "my app" – it's definitely the opposite.. I admit the previous members didn't pay much attention to these issues, but the mere ease of getting the wrong behaviour throwing a few actors and awaits in – that's what amuses me. By a way of analogy I feel very much back into the manual reference counting wild west world before ARC was invented.
Well not exactly, the mental load of reasoning about asynchronous code, reentrancy, etc... is much greater than what it is for synchronous code. Which increases programmer errors.
I wasn’t comparing it to synchronous code, but GCD to Swift Concurrency. I find the new model easier to reason about in general. There are some caveats, that need addressing, of course. But for many things that gives you better mental model to think about. The same task on serialized execution wouldn’t be so much easier in overall in GCD world (only more familiar since it’s been around for a long time), as it will give simplification in one aspect and increased complexity in another.
Tbh in this particular example second task looks more like an observer? Something somewhere waits for results of another? Potentially this could be achieve with continuations and/or streams, I guess.
Well known quote, but will repeat:
OOP to me means only messaging, local retention and protection and hiding of state-process, and extreme late-binding of all things. It can be done in Smalltalk and in LISP. There are possibly other systems in which this is possible, but I'm not aware of them. Alan Kay, 2003
And considering async/await roots don't think it's a coincidence, that functional patterns work better.
I wish we could just do the following and Swift would automatically ensure that all the @nonReentrant functions complete before it allows reëntry. I know it can create situations where dead lock occurs, but this exact problem is making me have to debug so many situations. Usually when we are waiting for a resource to load.
I have a clarification question about your helpful first example: The withCheckedContinuation inside foo is just so that foo does not return before nonReentrantFoo has run/returned, correct?
I am asking because I used a slightly similar solution in a scenario where reentrancy issues were completely internal to my actor, i.e. the "triggering" function could immediately return as long as the (long-ish) list of steps that was triggered would not be triggered again before it had completed.
In this case I basically had this and want to confirm that it's okay to do so:
actor MyActor {
private let queueContinuation: AsyncStream<Void>.Continuation
private let queueTask: Task<Void, Never>
init() {
// Prepare the queue of foo() invocations
let (stream, queueContinuation) = AsyncStream<Void>.makeStream()
self.queueContinuation = queueContinuation
self.queueTask = Task {
for await request in stream {
await Self.stepOne()
await Self.stepTwo()
}
}
}
deinit {
queueTask.cancel()
}
func triggerOneRun() {
queueContinuation.yield()
}
private static func stepOne() async {
print("doing step one")
try? await Task.sleep(for: .second(1)) // example, some long running stuff with several suspension points
print("finished step one")
}
private static func stepTwo() async {
print("doing step two")
try? await Task.sleep(for: .second(1)) // example, some long running stuff with several suspension points
print("finished step two")
}
triggerOneRun isn't even async here, but of course it has to be awaited from outside the actor. In the example I chose just static methods to stand in for my long running stuff, of course you have to watch for retain cycles if you're using self inside the queueTask, but I liked this approach very much as it modeled the business logic very well (in my case: make several HTTP-API calls in the correct order and don't have them overlap, but still do one complete set of calls for each time the app demands it).
I have heard that server devs say “well of course actors have to be re-entrant!” but device devs say “well of course actors have to be non-re-entrant!”
I am a server side developer, and I can attest that there are many cases that protecting against reentry can become an absolute headache in the current concurrency design, which is why I am very much an advocate of having a @nonReentrant decoration on functions that pause reentry until that function is complete.
FWIW, I'm slowly myself arriving at a similar conclusion (albeit, in my case, I'm converging to the procedural style more).
I think that a big part of the problem is that there's not enough teaching on how to use actors "properly" (whether actors as implemented in Swift or actors as a broader concept); I observe that there's either the high-level documentation that "actors protect shared mutable state", or the low-level, but very abstract advice to think about transactionality, which comes up whenever people discover the reentrant behaviour.
But oftentimes people (myself included) just don't understand what to do with this, as Swift Concurrency is still so fresh that there simply aren't good examples of its usage in typical apps, and my suspicion that people just assume it's enough to change class into actor and keep the OOP implementation of the former class, where I would argue that actors should not be understood as objects, but rather data structures.
Indeed, when working with a typical out-in-the-wild struct (say Dictionary<Int, String>, or a wrapper around it for upholding some additional invariant, let's assume), we do not expect to be able to shove tons of functionality into it in the "classic" OOP manner and make it a service/provider/manager/factory; as it is understood that it's just a data container, and all the high-level business logic goes elsewhere.
I surmise that the same exact thinking should be applied to actors: they work much better as (possibly generic) data structure-y types that do not contain much business logic.
In this case, transactionality requirements are as self-evident as that of a dictionary inserting both the key and the value in one atomic operation, and the reason why this way of thinking is perhaps not as immediate is that actors being reference types sways people into incorrectly assuming that actors "are just thread-safe classes" too easily.
In any case, I've recently been experimenting with refactoring a project this way, and found much, much more success with actors while following this approach.
AFAIK procedural style involves shared state (in its definition), so would suggest to lean towards functional more.
Not sure it's quite true about broader concept, there are Erlang, Akka, Orleans and etc. Not so widespread, though, unfortunately. Those are nice to check to understand actors more, but tbh think Swift one of the first to bring concept everywhere, not only to servers (maybe also Elixir?).
In Erlang process (actor) is basically a recursive function, so yeah, implementation could differ and it's not just classes.
But, returning to the topic, AFAIK only Swift (and Pony?) have reentrant actors, so tbh even making all things functional and structured—sometimes it doesn't help. So far solution for me is to use Task or CheckedContinuation, which been brought up here on forum several times.
I don't disagree that being a macro would be awesome, but I REALLY don't want to use Macros with all the slowing down in compilation. I have been avoiding them. But if someone implements it and it's well tested, I might consider the tradeoff.