Why stackless (async/await) for Swift?

I believe there's still plenty of room to do optimizations like merging "stack" allocations so as to make fewer requests to the task allocator, and "shrink wrapping" async stack frame setup so that early outs can avoid it without having to make them inlinable (though this is tricky because it's not obvious to the compiler what the difference between "a nil check I'd like to quickly run in the caller's context before suspending" and "100ms of work that I wanted to run asynchronously in the callee's context" is).

6 Likes

what the heck was that for.

there needs to be more appreciation to other people's opinions even when it comes to a question why async/await on a swift forum. people at Go and others who didn't end up with C# inspired async/await are not idiots who ran out of pepto and haven't read enough. perhaps they just think different. have different perspectives, considering different set of constraints and priorities. there are certain strong points about fibers (no need for compiler support, no dynamic memory allocation on switch whatsoever, no significant issues with scaling up to 100K fibers when running on a modern laptop, perhaps up to a million when running on a high end machine). i'd say there still is a different kind of "fiber poisoning" compared to "the color of your function" (i.e. if i were put on a new project which uses fibers i'd be very cautious initially compare to working on a normal app) but still that's a trade-of worth considering.

10 Likes

I think you misunderstood my tone. I am not calling anybody an idiot and have no particular skin in the structured multiprogramming vs green threads debate. I am commenting on two things:

  1. It is poor form to plug your other threads in unrelated discussions
  2. async/await is not useless syntax

I fully appreciate other opinions and I hope you appreciate mine. In this case, there's a point at which a discussion, even if it appears well-meaning, isn't about "curious discussion" anymore and instead becomes about pushing some agenda. I personally 100% enjoyed this thread and all the valid discussion of the tradeoffs between different approaches and hearing from different people until dabrahams called async/await syntax useless and plugged their entirely unrelated try/catch thread (as if it's some trophy) where they argue for ages ad nauseam with half the swift team about a design decision they just simply don't agree with all the while trying to play aloof and make it look like honest confusion. I find it hard to take dabrahams position here in good faith after such a comment and really am not interested in another thread the likes of the one linked to ensue here so I decided to say something.

I try very very hard to never assume mal intent and always give people the benefit of the doubt. But, frankly, what happened in the linked thread is called sealioning and we do not need to entertain it here. Let's keep this about stackless cooperative concurrency and not devolve into some syntax war under the pretext of honest simple questions about "why language feature x".

Hello, blog post author here! (Kind of you to call my blog post an essay, by the way.)

Regardless of your feelings about Dave's intent, I want to stress that as the author of that post I believe Dave is entirely aware of the argument I was trying to make. More importantly, I also think that a careful read of Dave's argument regarding async/await makes it very clear that he and I are in agreement.

Specifically, Dave's original post about async/await (On the proliferation of try (and, soon, await)) explicitly calls out:

We can apply the same line of inquiry to async . First, why should we care that a call is async ? The motivation can't be about warning the programmer that the call will take a long time to complete or that the call would otherwise block in synchronous code, because we'll write async even on calls from an actor into itself. No, AFAICT the fundamental correctness issue that argues for marking is almost the same as it is for error-handling: an async call can allow shared data to be observed partway through a mutation, while invariants are temporarily broken.

So where is that actually an issue? Notice that it only applies to shared data: except for globals (which everybody knows are bad for concurrency, right?), types with value semantics are immune. Furthermore, the whole purpose of actors appears to be to regulate sharing of reference types. Especially as long as actors are re-entrant by default, I can see an argument for awaiting calls from within actors, but otherwise, I wonder what await is actually buying the programmer. It might be the case that something like async_anywhere is called for, but being much less experienced with async / await than with error handling I'd like to hear what others have to say about the benefits of await .

This argument is distinctly in line with the argument I make in the post. Specifically, I quote Glyph's Unyielding:

When you’re looking at a routine that manipulates some state, in a single-tasking, nonconcurrent system, you only have to imagine the state at the beginning of the routine, and the state at the end of the routine. To imagine the different states, you need only to read the routine and imagine executing its instructions in order from top to bottom. This means that the number of instructions you must consider is n , where n is the number of instructions in the routine. By contrast, in a system with arbitrary concurrent execution – one where multiple threads might concurrently execute this routine with the same state – you have to read the method in every possible order, making the complexity nn .

Notice that what Glyph is talking about (and what my post targets) is "shared mutable state pre-emptive concurrency". This is exactly what Dave is talking about: he points out that value semantics prevent shared mutable state, and actors exist to regulate the remaining state. In this world, where all state is either value semantic or an actor, it seems entirely reasonable to me to ask whether the await and async keywords buy you very much at all.

This is not what I was talking about in my original post. I was discussing languages that pervasively use shared mutable state: Python, Go, Node. I think that Dave and I are much more in agreement than we are in divergence, and I like to think that Dave would largely agree with me that a) my post is well-reasoned for languages with pervasive shared mutable state, and that b) in languages that disallow shared mutable state it is reasonable to ask whether we need async/await at all. As a fun example of the latter, consider Rust's "fearless concurrency" pattern, which I think is an excellent example of how to rein in the scariness of threading without needing to add annotations to the code for async/await.

10 Likes

Maybe you should write another post to clear things up and perhaps give an update on your current thinking, then. You explicitly argue that you don’t mind async/await because they’re tools that help people write correct code and that you don’t think the syntax is a burden if you minimize async use. In the linked thread all of this is already covered and the argument you quoted has been responded to multiple times. I would encourage people to go read it there rather than rehash it here. I’m not sure about your Rust point, async/await are basically syntax sugar. In Rust you can’t .await in a non async context. You can take the future and do stuff with it so that’s nice. Idk we are probably not that far off in our thinking either, personally I like Rust’s approach too and prefer expressing things as types and providing syntax sugar rather than too much bespoke magic. But again it’s clear the argument has been heard and discussed in this community and despite that the language has explicit keywords. People need to learn when a ship has sailed. You put it best:

Code colour is a convenience , not a requirement. And we shouldn’t get too worked up about it, or worry that we’re segmenting our code. Instead, we should focus on using code colour sparingly, because while it adds clarity it also hurts reusability. Making that trade off well is one of the skills of being a programmer, and we should inform our community about that trade off, and then trust them to make the right call.

ok, i wasn't aware of that apparently heated and very long other thread. will read through it as time allows. let's not bring the heat from there to here.

Lukasa, this is actually very interesting and relevant link, thanks! it shows why explicit markers in the code are actually a very good thing (and this is exactly where fibers are wrong).

func bar() {
    bobBalance -= amount
    log("something")
    // or just foo()
    // on even some benign looking getter "let x = object.property"
    aliceBalance += amount
}

without markers we don't know if "log" (or "foo" or "object.property") yields or not. and even if we audit the code today and ensure it doesn't - tomorrow a fellow developer introduces a change that breaks our today's reasoning... quite fragile proposition.

with explicit marker (or their explicit absence):

func transfer(amount: Int) { *** <should be marker here>
    bobBalance -= amount
    log("something") *** <should be marker here>
    aliceBalance += amount
}

there is always a guarantee one way or another; obviously this marker needs to propagate to the "bar" signature in order to work (and "infect" the callers). the particular form of this marker varies across languages (can be "async foo" or "foo.async()" at the call side and "async func" vs a function that returns promise, etc in the function definition).

this is probably the most compelling reason against fibers. other mentioned reasons just add a bit. cc @dabrahams

ps. fibers can be saved if the team using them agrees to mark yielding functions in a particular way, e.g. "log_yielding". i.e. introduce the manual coloring via some coding guidelines. it's somewhat fragile but can work in practice.

pps. you may even enforce the color, albeit in a bit awkward and manual way:

func log(yields: Yields, String) {
    yields.yield() // the only way to yield is via "yields"
}

func normalFunction() {
    bobBalance -= amount
    log(<oops, don't have yields here, can't call>, "something")
    aliceBalance += amount
}

func yieldingFunction(yields: Yields) {
    bobBalance -= amount
    log(yields, "something") // we see "yields" here and are alerted
    aliceBalance += amount
}

ppps. this manual color enforcement is still fragile, e.g. "yields" will be undesirably available in escaping closures or nested functions used as escaping closures, or it can be undesirably passed over via a global or type variable.

pppps. it'd be not unimaginable to create a language variant of existing language that introduces a color for the sole purpose of labelling the "yield" function (and its direct and indirect callers). in all other aspects this language can be unmodified and it can use a sackful approach to concurrency (perhaps done without any language support or with a minimal language support). so it would use fibers / green threads (and be a subject to some limitations incurred by those, and take certain advantages of those) but it would be a coloured language with all yielding points explicitly marked! how about that as a compromise :slight_smile:

2 Likes

Perhaps not, but you did use my name as a negative epithet. That was at best extremely rude and unwarranted attack.

  1. It is poor form to plug your other threads in unrelated discussions
  2. async/await is not useless syntax

It’s not at all unrelated; as I’ve discovered by discussing it here, stackful is actually two things: an implementation model, and a programming model that doesn’t require marking. And I didn’t claim that async await is always useless; just in scenarios when no invariants are broken… which is a lot of the time, as with try.

I find it hard to take dabrahams position here in good faith after such a comment and really am not interested in another thread the likes of the one linked to ensue here so I decided to say something.

You could have expressed that cordially; I might even have thought “he has a point.”

I try very very hard to never assume mal intent and always give people the benefit of the doubt. But, frankly, what happened in the linked thread is called sealioning and we do not need to entertain it here.

I have a hard time keeping up with all the conversational gambits that the kids today consider to be bad form, but I can try to keep that one in mind.

4 Likes

I need to respond to this thread as a moderator, but unfortunately I also have to step away from my computer, so I’m going to close it until I get back.

3 Likes

Okay. I think overall this has been a very interesting and productive thread, so I'm going to open it back up again.

These forums are governed by the Swift community's code of conduct. That code of conduct requires us to treat each other with respect. @dcowuno's reply above is clearly well short of that standard, and I'll take that up with him privately. I don't want to see anything more about this in this thread; please bring it up with me immediately, by flag or PM, if there's any further problem.

18 Likes

I don’t think this is news to me; yielding is still only a hazard when invariants of shared mutable state are broken. That may be quite common it a large class of apps, e.g. with a shared UI state that must be accessed on the main thread, and that may even be enough to justify requiring it everywhere in Swift, but it’s clearly not an issue for all possible Swift programs. That’s why, for example, Haskell programs can be automatically parallelized safely (not necessarily effectively): no mutation => no shared mutable state => no race conditions or reentrancy problems.

2 Likes

well, then you've half-answered the thread subject already: swift allows mutable state and decided to make all points of (potential) modifications explicit to prevent a footgun. although as i mentioned above explicit marking (colouring) doesn't necessarily rule out the stackful approach, the precedent of using green threads along with colouring is yet to be seen.

ps. need to go through the other thread to see your points about invariants. quickly scrolled though it and noticed "crusty" in there. is he real? thought he's your alter ego.

2 Likes

I just came across Async Ruby via HackerNews:

This is all really, really nice. Not only we don't have to put up with threads and their complexities, but we can also use Ruby's standard URI.open to run requests, both outside and inside an Async block. This can certainly make for some convenient code reuse.

Here is the HackerNews discussion:

Async Ruby is colorless!

It's obvious from the examples provided in the post. For example, you can use the method URI.open both synchronously and asynchronously.

Just FYI, and for what it's worth.

1 Like

That just reminded me of this post, which may (or may not!) also be of interest:

Here's the core idea: every time our control splits into multiple concurrent paths, we want to make sure that they join up again.

It's a very different take on how to solve the problem. (Here is the implementation.)

1 Like

Sure, Trio has been an influential inspiration for Swift’s structured concurrency :wink: Task groups are similar to nurseries (as trio calls them), and async let is a language embedded form of structured concurrency as well.

8 Likes

David, do you know how well Swift performs in this mentioned micro-benchmark?

1 Like

Anyone wants to try the microbenchmark below? Or even better, any suggestions on how to improve its performance?

I can't properly test it on my (quite old) machine now, as I lost the knowledge of running async await code on Xcode 13.2 (sic) and can't upgrade to a newer version of Xcode at the moment.

When running it on swift-fiddle I am getting a reasonable performance if I change actor to class:

Swift version 5.8-dev (LLVM b2416e1165ab97c, Swift 965a54f037cfa76)
Target: x86_64-unknown-linux-gnu
elapsed: 157ms, result: 499999500000

However with actor swift-fiddle kills the test (presumably the test takes too much memory when using actors). Note that it runs fine with actors if I change the number of levels from 6 to 5 (thus creating 100K actors).

cc @dabrahams

Swift skynet micro-benchmark
import Foundation

/// https://github.com/atemerev/skynet
/// Creates an actor (goroutine, whatever), which spawns 10 new actors, each of them spawns 10 more actors, etc. until one million actors are created on the final level. Then, each of them returns back its ordinal number (from 0 to 999999), which are summed on the previous level and sent back upstream, until reaching the root actor. (The answer should be 499999500000).


let maxLevel = 6
let maxPeers = 10

actor A { // or class
    static var id: Int = 0

    static var nextId: Int {
        id += 1
        return id - 1
    }
    
    var id: Int = 0
    var child: A?
    var peer: A?
    
    init(levels: Int, peers: Int) {
        if levels > 1 {
            child = A(levels: levels - 1, peers: maxPeers)
        } else {
            id = Self.nextId
        }
        if peers > 1 {
            peer = A(levels: levels, peers: peers - 1)
        }
    }

    func run() async -> Int {
        await id + (child?.run() ?? 0) + (peer?.run() ?? 0)
    }
}

var done = false
print("start")

func test() async {
    let start = clock()
    let a = A(levels: maxLevel, peers: maxPeers)
    let result = await a.run()
    let elapsed = clock() - start
    let ms = Int((Double(elapsed) / Double(CLOCKS_PER_SEC)) * 1000)
    print("elapsed: \(ms)ms, result: \(result)")
    precondition(result == 499999500000)
    done = true
}

Task {
    await test()
}

while !done {
    sleep(1)
}

print("done")

Getting pretty consistent timing on my M1 Ultra:

499999500000
0.1378699541091919s

Whether this implementation has the proper semantics I don't know, but this seems like a good result if it does.

A more modern implementation.
import Foundation

@main
enum SkynetBenchmark {
    static func main() async {
        let start = Date()
        let a = A(levels: maxLevel, peers: maxPeers)
        let output = await a.run()
        print(output)
        print(Date().timeIntervalSince(start))
    }
}

let maxLevel = 6
let maxPeers = 10

actor A { // or class
    static var id: Int = 0

    static var nextId: Int {
        id += 1
        return id - 1
    }
    
    var id: Int = 0
    var child: A?
    var peer: A?
    
    init(levels: Int, peers: Int) {
        if levels > 1 {
            child = A(levels: levels - 1, peers: maxPeers)
        } else {
            id = Self.nextId
        }
        if peers > 1 {
            peer = A(levels: levels, peers: peers - 1)
        }
    }

    func run() async -> Int {
        await id + (child?.run() ?? 0) + (peer?.run() ?? 0)
    }
}
1 Like

Nice, thank you.

Note that this 138 ms can't be honestly compared to the numbers obtained on a much older (7 years old?) hardware.

BTW, does this swift implementation use all cores? (shows how little I know about actors and async/await ;-)