Most active commenters
  • sunshowers(3)
  • fpoling(3)

←back to thread

Futurelock: A subtle risk in async Rust

(rfd.shared.oxide.computer)
421 points bcantrill | 25 comments | | HN request time: 1.652s | source | bottom

This RFD describes our distillation of a really gnarly issue that we hit in the Oxide control plane.[0] Not unlike our discovery of the async cancellation issue[1][2][3], this is larger than the issue itself -- and worse, the program that hits futurelock is correct from the programmer's point of view. Fortunately, the surface area here is smaller than that of async cancellation and the conditions required to hit it can be relatively easily mitigated. Still, this is a pretty deep issue -- and something that took some very seasoned Rust hands quite a while to find.

[0] https://github.com/oxidecomputer/omicron/issues/9259

[1] https://rfd.shared.oxide.computer/rfd/397

[2] https://rfd.shared.oxide.computer/rfd/400

[3] https://www.youtube.com/watch?v=zrv5Cy1R7r4

Show context
jacquesm ◴[] No.45776483[source]
If any rust designers are lurking about here: what made you decide to go for the async design pattern instead of the actor pattern, which - to me at least - seems so much cleaner and so much harder to get wrong?

Ever since I started using Erlang it felt like I finally found 'the right way' when before then I did a lot of work with sockets and asynchronous worker threads. But even though it usually worked as advertised it had a large number of really nasty pitfalls which the actor model seemed to - effortlessy - step aside.

So I'm seriously wondering what the motivation was. I get why JS uses async, there isn't any other way there, by the time they added async it was too late to change the fundamentals of the language to such a degree. But rust was a clean slate.

replies(5): >>45776498 #>>45776569 #>>45776637 #>>45776798 #>>45777596 #
1. sunshowers ◴[] No.45776498[source]
Not a Rust designer, but a big motivation for Rust's async design was wanting it to work on embedded, meaning no malloc and no threads. This unfortunately precludes the vast majority of the design space here, from active futures as seen in JS/C#/Go to the actor model.

You can write code using the actor model with Tokio. But it's not natural to do so.

replies(5): >>45776630 #>>45776683 #>>45777051 #>>45777075 #>>45778490 #
2. lll-o-lll ◴[] No.45776630[source]
As a curious bystander, it will be interesting to see how the Zig async implementation pans out. They have the advantage of getting to see the pitfalls of those that have come before.

Getting back to Rust, even if not natural, I agree with the parent that the actor model is simply the better paradigm. Zero runtime allocation should still be possible, you just have to accept some constraints.

I think async looks simple because it looks like writing imperative code; unfortunately it is just obfuscating the complex reality underlying. The actor model makes things easier to reason about, even if it looks more complicated initially.

replies(3): >>45776690 #>>45778325 #>>45780493 #
3. shepmaster ◴[] No.45776683[source]
> But it's not natural to do so.

I tend to write most of my async Rust following the actor model and I find it natural. Alice Rhyl, a prominent Tokio contributor, has written about the specific patterns:

https://ryhl.io/blog/actors-with-tokio/

replies(2): >>45776703 #>>45779939 #
4. sunshowers ◴[] No.45776690[source]
I think you can do a static list of actors or tasks in embedded, but it's hard to dynamically spin up new ones. That's where intra-task concurrency is helpful.
5. sunshowers ◴[] No.45776703[source]
Oh I do too, and that's one of the recommendations in RFD 400 as well as in my talk. cargo-nextest's runner loop [1] is also structured as two main actors + one for each test. But you have to write it all out and it can get pretty verbose.

[1] https://nexte.st/docs/design/architecture/runner-loop/

6. oconnor663 ◴[] No.45777051[source]
Kind of a tangent, but I think "systems programming" tends to bounce back and forth between three(?) different concerns that turn out to be closely related:

1. embedded hardware, like you mentioned

2. high-performance stuff

3. "embedding" in the cross-language sense, with foreign function calls

Of course the "don't use a lot of resources" thing that makes Rust/C/C++ good for tiny hardware also tends to be helpful for performance on bigger iron. Similarly, the "don't assume much about your runtime" thing that's necessary for bare metal programming also helps a lot with interfacing with other languages. And "run on a GPU" is kind of all three of those things at once.

So yeah, which of those concerns was async Rust really designed around? All of them I guess? It's kind of like, once you put on the systems programming goggles for long enough, all of those things kind of blend together?

replies(1): >>45778676 #
7. fpoling ◴[] No.45777075[source]
Rust async still uses a native stack which just a form of memory allocator that uses LIFO order. And controlling stack usage in the embedding world is just as important as not relying on the system allocator.

So its a pity that Rust async design tried so hard to avoid any explicit allocations rather than using an explicit allocator that embedding can use to preallocate and reuse objects.

replies(2): >>45777326 #>>45777385 #
8. zbentley ◴[] No.45777326[source]
> a native stack [is] just a form of memory allocator

There is a lot riding on that “just”. Hardware stacks are very, very unlike heap memory allocators in pretty much every possible way other than “both systems provide access to memory.”

Tons and tons of embedded code assumes the stack is, indeed, a hardware stack. It’s far from trivial to make that code “just use a dummy/static allocator with the same api as a heap”; that code may not be in Rust, and it’s ubiquitous for embedded code to not be written with abstractions in front of its allocator—why would it do otherwise, given that tons of embedded code was written for a specific compiler+hardware combination with a specific (and often automatic or compiler-assisted) stack memory management scheme? That’s a bit like complaining that a specific device driver doesn’t use a device-agnostic abstraction.

replies(1): >>45779645 #
9. tony69 ◴[] No.45777385[source]
Stack allocation/deallocation does not fragment memory, that’s a yuge difference for embedded systems and the main reason to avoid the heap
replies(1): >>45779568 #
10. throwawaymaths ◴[] No.45778325[source]
iiuc zig has thought about this specifically and there is a safe async-cancel in the new design that wasn't there in the old one.
11. RossBencina ◴[] No.45778490[source]
Why would the actor model require malloc and/or threads?
replies(1): >>45780711 #
12. kibwen ◴[] No.45778676[source]
> So yeah, which of those concerns was async Rust really designed around? All of them I guess?

Yes, all of them. Futures needed to work on embedded platforms (so no allocation), needed to be highly optimizable (so no virtual dispatch), and need to act reasonably in the presence of code that crosses FFI boundaries (so no stack shenanigans). Once you come to terms with these constraints--and then add on Rust's other principles regarding guaranteed memory safety, references, and ownership--there's very little wiggle room for any alternative designs other than what Rust came up with. True linear types could still improve the situation, though.

replies(2): >>45778767 #>>45778958 #
13. mjevans ◴[] No.45778767{3}[source]
In my view, the major design sin was not _forcing_ failure into the outcome list.

.await(DEADLINE) (where deadline is any non 0 unit, and 0 is 'reference defined' but a real number) should have been the easy interface. Either it yields a value or it doesn't, then the programmer has to expressly handle failure.

Deadline would only be the minimum duration after which the language, when evaluating the future / task, would return the empty set/result.

replies(2): >>45779231 #>>45781407 #
14. oconnor663 ◴[] No.45778958{3}[source]
> so no virtual dispatch

Speaking of which, I'm kind of surprised we landed on a Waker design that requires/hand-rolls virtual dispatch. Was there an alternate universe where every `poll()` function was generic on its Waker?

15. danielheath ◴[] No.45779231{4}[source]
Does that imply a lot of syscalls to get the monotonic clock value? Or is there another way to do that?
replies(2): >>45779274 #>>45780705 #
16. mjevans ◴[] No.45779274{5}[source]
If the scheduler is doing _any_ sort of accounting at all to figure out any remote sort of fairness balancing at all, then whatever resolution that is probably works.

At least for Linux, offhand, popular task scheduler frequencies used to be 100 and 1000hz.

Looks like the Kernel's tracking that for tasks:

https://www.kernel.org/doc/html/latest/scheduler/sched-desig...

"In CFS the virtual runtime is expressed and tracked via the per-task p->se.vruntime (nanosec-unit) value."

I imagine the .vruntime struct field is still maintained with the newer "EEVDF Scheduler".

...

A Userspace task scheduler could similarly compare the DEADLINE against that runtime value. It would still reach that deadline after the minimum wait has passed, and thus be 'background GCed' at a time of the language's choice.

replies(1): >>45779597 #
17. fpoling ◴[] No.45779568{3}[source]
Even with the stack the memory can fragment. Just consider one created 10 features on the stack and the last completed last. Then memory for the first 9 will not be released until the last completes.

This problem does not happen with a custom allocator where things to allocate are of roughly the same size and allocator uses same-sized cells to allocate.

replies(1): >>45779721 #
18. jitl ◴[] No.45779597{6}[source]
The issue is that no scheduler manages futures. The scheduler sees tasks, futures are just a struct. See discussion of embedded above: there is no “kernel esque” parallel thread
19. fpoling ◴[] No.45779645{3}[source]
During the design phase of Rust async there were no async embedded code written to be inspired. For systems with tight memory budget it is common to pre-allocate everything often using custom bump allocation or split memory into few regions for fixed-sized things and allocate from those.

And then the need to poll features by the runtime means that async in Rust requires non-trivial runtime going against the desire to avoid abstractions in the embedded.

Async without polling while stack-unfriendly requires less runtime. And if Rust supported type-safe region-based allocators when a bunch of things are allocated one by one and then released at once it could be a better fit for the embedded world.

20. jacquesm ◴[] No.45779721{4}[source]
Indeed, arena allocators are quite fast and allow you to really lock down the amount of memory that is in use for a particular kind of data. My own approach in the embedded world has always been to simply pre-allocate all of my data structures. If it boots it will run. Dynamic allocation of any kind is always going to have edge cases that will cause run-time issues. Much better to know that you have a deterministic system.
21. baq ◴[] No.45779939[source]
The ‘Beware of cycles’ section at the end has some striking similarities with futurelock avoidance recommendations from the original article… not sure what to make of this except to say that this stuff is hard?
22. sgt ◴[] No.45780493[source]
I was wondering when someone would bring up Zig. I think it's fascinating how far it has come in the last couple of years and now the new IO interface/async implementation.

Question is - when will Zig become mature enough to become a legit choice next to say, Go or Rust?

I mean for a regular dev team, not necessarily someone who works deeply along with Andrew Kelley etc like Tigerbeetle.

23. muvlon ◴[] No.45780705{5}[source]
On Linux there is the VDSO, which on all mainstream architectures allows you to do `clock_gettime` without going through a syscall. It should take on the order of (double digit) nanoseconds.
24. gf000 ◴[] No.45780711[source]
You basically have a concurrency-safe message queue. It would be pretty limited without malloc (fixed max queue size).
25. kibwen ◴[] No.45781407{4}[source]
> Deadline would only be the minimum duration after which the language, when evaluating the future / task, would return the empty set/result.

This appears to be misunderstanding how futures work in Rust. The language doesn't evaluate futures or tasks. A future is just a struct with a poll method, sort of like how a closure in Rust is just a struct with a call method. The await keyword just inserts yield points into the state machine that the language generates for you. If you want to actually run a future, you need an executor. The executor could implement timeouts, but it's not something that the language could possibly have any way to enforce or require.