5 ms·
> Rust chose this approach to get zero-cost FFI to the enormous amounts of existing C and C++ code written using that model, and because the C runtime is the sh
by amluto 3y ago
> Rust chose this approach to get zero-cost FFI to the enormous amounts of existing C and C++ code written using that model, and because the C runtime is the shared minimum of all mainstream platforms. But this runtime model is incompatible with stackful coroutines, so Rust needed to introduce a stackless coroutine mechanism instead.
Can anyone (withoutboats?) elaborate on this? If “stackful” coroutines means functions with an ordinary-ish stack that can yield, then those stacks are just memory, and you can take references to them. Heck, you can do this, slowly and painfully, in C, with setcontext. And you can mostly do FFI with a degree of care about stack sizes. You can even yield across FFI boundaries. In some sense, the coroutine objects implicitly created by Rust and C++ are really just stacks minus the hardware stack pointer part.
Also, I found this amusing as a kernel programmer:
> the implementations of each of these things are completely different. The code that runs when you spawn an async task is nothing like spawning a thread, and the definition and implementation (for example) of an async lock is very different from a blocking lock: usually they will use an atomics-based lock under the hood with the addition of a queue of tasks that are waiting for the lock. Instead of blocking the thread, they put this task into that queue and yield; when the lock is freed, they wake the first task in the queue to allow it to take the lock again.
When blocking a thread, Linux (and most conventional operating systems) put the thread into a queue, mark the thread as non-runnable, and yield, and when the thing they’re waiting for is ready, they mark one or more queued threads runnable so the executor, I mean scheduler, will run them so they can try again :)
Most OSes do this a lot more slowly than can be done in userspace.
- zozbot234 3y ago> Can anyone (withoutboats?) elaborate on this? If “stackful” coroutines means functions with an ordinary-ish stack that can yield, then those stacks are just memory, and you can take references to them. See http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p1364r0.pdf http://www.open-std.org/JTC1/SC22/WG21/docs/papers/2018/p136... for an in-depth analysis. The short version is that stackful fibers turn out to be a terrible choice. The author (Gor Nishanov, for the C++ ISO committee) explicitly says that they should not be used - this is even in spite of the fact that Windows API explicitly supports fibers as part of the programming interface, at a far deeper level than just POSIX setcontext.
- Rusky 3y agoGor Nishanov's analysis is way too narrow to be used as general language design advice. Windows API fibers are also way too specific of an implementation to generalize. Call stacks are just a data structure like any other. That they happen to be managed by the compiler to store things like local variables doesn't change this. Consider that there are algorithmic ways to convert from function calls to this data structure (defunctionalization) and back (refunctionaliztion), and the correspondence between recursive programs and iterative programs using an explicit stack. The question here is, for any given stack, what requirements does it place on the memory layout and allocation for that stack? Gor's analysis covers a few of these, but not everyone has the same requirements and not every implementation style makes the same tradeoffs. And you can mix and match these styles within a single programming language! Rust async functions and C++ coroutines both still store much of their local state on the native C stack- they only use their state machines to store local state (including the state machines of their callees) that lives across suspension points. This is what enables Rust to support APIs like spawn_blocking or block_on- you can run different parts of a program using different styles of stacks.
- zozbot234 3y ago> Call stacks are just a data structure like any other. Call stacks are a part of the system API/ABI; your fancy ersatz stacks not so much. This is kind of a key consideration in the above analysis. The whole point of having Windows API support for fibers was to try and obviate that basic pain point, but ultimately this has been shown not to be very successful. > Rust async functions and C++ coroutines ... only use their state machines to store local state ... that lives across suspension points. Yes and this is briefly mentioned in that analysis as a key advantage of stackless coroutines compared to fibers. If you might call a function that requires 500k of stack, a fiber has to provide for that amount of stack space to be available; a stackless coroutine doesn't need to store the 500k of data, provided that it doesn't live across await points.
- Rusky 3y agoFibers can make that exact same optimization by using a separate stack (as a stackless coroutine uses a separate state machine) alongside the C stack. This can be done either through stack switching, or by treating the fiber stack more like an ordinary object without a special reserved ABI register. The reason Windows fibers failed is not because they were stackful, but because they tried to use only one stack at a time.
- withoutboats3 3y agoThe bigger problem is growable stacks. It’s true you can suspend a stack and then continue as long as your implementation doesn’t ever move it. I didn’t really dwell on this because I wrote more about it in “Why async Rust?” And you’re right that the mutex in the kernel is implemented with a similar algorithm - I just meant that the code in the library you’re using is very different, because it is a relatively thin wrapper around the relevant syscalls whereas the async one is implemented in userspace.