8 ms·
Writing an OS in Rust: Async/Await
- throwlaplace 6y agogreat project for this lull. i've been going through http://craftinginterpreters.com/ http://craftinginterpreters.com/ in rust (in order to learn rust) and it's a fantastic learning experience. the language is straightforward after you understand the basics (smart pointers and generics) and if you have a good ide with completion (CLion). lifetimes/borrow checker aren't as painful as people would have you believe if you use heap allocation (i.e. Box, Rc<RefCell>). now obviously heap allocation isn't optimal but for getting started it enables a smooth progression.
- steveklabnik 6y agoThis is also just a fantastic introduction to async/await in Rust, regardless of the OS bits. Another amazing article by Phil. > The only requirement is that we use at least nightly 2020-03-25 of Rust because async/await was not no_std compatible before. There's some fun stuff here that's omitted (which makes sense, of course). It was always a design constraint of async/await in Rust that you could use it without the standard library. However, the initial implementation required thread local storage. The future trait's poll method took a &mut Context, and generators didn't support passing context in to them when resuming. This meant that the context would be placed in TLS, and would pull it back out when needed. Generators are unstable, partially for this reason. However, this was fixed, and that means TLS is no longer a requirement here! See https://github.com/rust-lang/rust/pull/68524 https://github.com/rust-lang/rust/pull/68524 for more details.
- amelius 6y agoAn OS with async/await sounds awfully similar to Windows 3.1 with its cooperative multitasking model. What's old is new again ...
- steveklabnik 6y agoWell, there's a big difference: > Using async/wait, we now have basic support for cooperative multitasking in our kernel. While cooperative multitasking is very efficient, it leads to latency problems when individual tasks keep running for too long and thus prevent other tasks to run. For this reason, it makes sense to also add support for preemptive multitasking to our kernel. > In the next post, we will introduce threads as the most common form of preemptive multitasking. In addition to resolving the problem of long running tasks, threads will also prepare us for utilizing multiple CPU cores and running untrusted user programs in the future. It seems the plan here is to use this for internal work, and still give a preemptive multitasking API to userspace.
- jklehm 6y agoIsn't that driving against the lesson learned from the past though? 3rd party drivers can not be trusted to behave and hang the kernel when doing so with cooperative multitasking.
- steveklabnik 6y agoI am not sure what Phil's overall kernel design is; you're assuming a monolithic kernel, but not every kernel has drivers in kernel space.
- jklehm 6y agoFair point, looking forward to following along :)
- eximius 6y agoToy OS, so he has less to worry about. But also maybe just be more careful around kernel modules, if that eventually exists?
- mtnygard 6y agoPhil's series is meant for learning rather than production use. So sometimes he takes a "shortcut" in one post then removes the restrictions or workarounds later.
- Koshkin 6y agoGP is not wrong, then: with this implementation, async/await still amounts to the good old cooperative multitasking and does not (yet?) take advantage of threads. (In C#, for example, async/await is indeed different from this, being based on TAP - Task-based Asynchronous Pattern, where tasks are executed "asynchronously on a thread pool thread rather than synchronously on the main application thread.")
- steveklabnik 6y agoAsync/await lets you build tasks, but is completely orthogonal to threads. You could set up tasks with a 1:1 or N:M or even single threaded model. And yes, the GP is not wrong that if this were the only mechanism provided, it would be similar. It does not seem like the plan is to expose this externally whatsoever, though.
- AnIdiotOnTheNet 6y agoWell, I'm not well versed in the details, but I do wonder if cooperative multitasking isn't a better idea nowadays in the age of ubiquitous multicore.
- bluejekyll 6y agoAs Steve’s sibling comment points out, this is only for the kernel. The article mentions preemptive threading for the user space as still being desirable.
- AnIdiotOnTheNet 6y agoI'm not convinced that's actually true though, is what I mean. The big drawback of cooperative is just that a single process can monopolize resources, which in the singlecore days mean the system became unresponsive and you had no way to recover from it. That isn't really true now with multicore. As long as the OS has a core to work on it can terminate or otherwise deal with misbehaving processes. Again, this is based on my admittedly limited understanding, but I haven't yet seen good reasoning why pre-emptive is still preferred.
- steveklabnik 6y agoMy understanding is that you've now moved the monopolized case from 1 to N, where N is the number of cores. N is usually pretty small. The laptop I'm writing this on has four cores, and the Chrome instance has 17 threads running...
- Bigpet 6y ago> Error: you cannot open another Chrome tab because all your cores are already used up by Slack and VSCode You would probably still want to preempt, because you're not going to rewrite all the widely used software to actually yield. Because that's the thing about cooperative multithreading, the participating parties need to cooperate. And if you look at the amount of threads that some software open it's just crazy. Looking on my machine the top5 are: 1. 260 threads System (ok, that's basically the OS that could be changed). 2. 145 threads Dropbox.exe (that's enough to lock all cores on any single consumer CPU). 3. 87 threads SearchIndexer.exe (also OS, could be rectified). 4. 74 threads EpicGamesLauncher (looks like an i9 is no longer enough) 5. 70 threads MemoryCompression (also OS) I know not all of those threads are active at all time an I guess all the blocking threads could be said to be cooperating but even Dropbox alone regularly has 3-5 threads running. There's still so many 2 and 4 core consumer machines out there that cooperative multi-tasking with the current software ecosystem would be a disaster. Not only would applications keep each other from making regular steady progress but even single applications would regularly hang themselves from all the threads they themselves created if there was no pre-emptive yielding beyond using any OS-call as a yield-point
- hope-striker 6y agoDidn't most old operating systems use cooperative multitasking? I remember, at least, that classic Mac OS (i.e. pre-OSX) didn't use preemptive multitasking, either. Anyway, this SO answer[0] explains why early Linux, much like the hobby kernel in this article, used cooperative scheduling inside the kernel, and only preempted user-space stuff. [0]: https://stackoverflow.com/a/16766562 https://stackoverflow.com/a/16766562
- bestouff 6y agoMacOS may have been a little late. Windows NT, OS/2, Linux did preemptive multitasking since beginning of 90s, even AmigaOS had it in the 80s.
- saagarjha 6y agoMac OS badly needed preemptive multitasking (and memory protection!), and got it when the NeXT bits were transformed into Mac OS X.
- renox 6y agoThanks for the link! I don't understand why this would be different for a Rust based OS..
- fortran77 6y agoOr MacOS up to and including OS9
- dfox 6y agoThere is one significant difference: Windows did context-switches in the blocking calls and did not rely on the program code having "the right structure" needed for straight co-routines to work. The difference between preemptive and cooperative multitasking is not whether you do full context switches, but whether there is a way to do context switch at a point where the process does not expect it (ie. by handling some kind of timer interrupt as scheduling opportunity).
- amelius 6y agoDoes Rust allow a computational for-loop to be interrupted somehow? Computation can also be viewed as a blocking operation.
- steveklabnik 6y agoThe only yield points are .await points. If there's an .await in a loop, then sure, but otherwise no.
- amelius 6y agoWhat is the cost of an .await point? For this to work, perhaps Rust should cooperate too, inserting .await points at strategic places in the code, to keep cost low but still guaranteeing a certain responsiveness of the overall system.
- jfkebwjsbx 6y agoThat would explode the state machine size and make its performance characterization very hard. And you would need to avoid having any kind of call to external code (or yield before and after every such call, and pray for the best).
- deleted 6y ago[deleted]
- mwcampbell 6y ago
- imtringued 6y agoJust because a kernel is using something internally doesn't mean it is exposing it to user space applications.
- m0th87 6y agoIs it possible to context switch between userspace processes directly, without going through the kernel, i.e. a kind of fast, inter-process cooperative multitasking? I know earlier operating systems used inter-process cooperative multitasking, but I'm guessing they still went through the scheduler? I was trying to figure out if QNX does this with `MsgSend`, as QNX is renowned for being a fast microkernel, but it wasn't clear to me. According to Animats, "There's no scheduling delay; the control transfer happens immediately, almost like a coroutine. There's no CPU switch, so the data that's being sent is in the cache the service process will need." [1] According to wikipedia, "If the receiving process is waiting for the message, control of the CPU is transferred at the same time, without a pass through the CPU scheduler." [2] It seems like `MsgSend` circumvents the scheduler, but does it circumvent context switching to the kernel entirely too? 1: https://news.ycombinator.com/item?id=9872640 https://news.ycombinator.com/item?id=9872640 2: https://en.wikipedia.org/wiki/QNX#Technology https://en.wikipedia.org/wiki/QNX#Technology
- cwzwarich 6y agoNo, it doesn't circumvent context switching to the kernel. With memory protection and separate address spaces, you generally need privileged execution to change page tables. However, the raw CPU cost of a syscall exception to the kernel followed by an exception return to userspace isn't actually all that high compared to all of the work that an OS usually does on top of it.
- retrac 6y agoIt's not possible without specific hardware support. Context-switching on most current platforms with virtual memory requires fiddling with things like page mappings, and that simply must be done from supervisor mode. The ability to switch tasks in hardware has existed on some historical machines. For example the GEC 4000 series basically implemented a microkernel, including the scheduler, in hardware. Switching to another process was done with a single hardware instruction. I don't think anything current has such features besides the long-obsolete and unused taskswitching feature on x86 processors.
- bregma 6y agoMsgSend() in QNX Neutrino does not circumvent the kernel, just the scheduler. If you're clever and lucky you can get an entire round trip to and from the service in a single timeslice. On the other hand, MsgSend() always blocks, so the call could be preempted depending on service priority or if there is an I/O wait or whatever, and that would mean the scheduler comes to play.
- zackmorris 6y agoI've shied away from async/await because I haven't seen a good writeup on how to make it deterministic. Come to think of it, all of the times I've encountered it, there was no way to analyze the codepaths and prove that every exceptional situation and failure mode was handled. Maybe I missed something along the way? So my feeling about it is that it may turn out to be an evolutionary dead end, or anti-pattern at the least. My gut feeling is that async/await is functionally equivalent to the the synchronous/blocking/coroutine/channel system of other languages like Go. Could we write a thin wrapper that converts async/await to that or vice versa? This is the primary reason why I've stuck to synchronous/blocking PHP with all of its flaws instead of Node.js. I think this is a fundamental thing that shouldn't be glossed over and accepted so readily into other languages.
- steveklabnik 6y agoWhat do you mean by "deterministic" here? > Come to think of it, all of the times I've encountered it, there was no way to analyze the codepaths and prove that every exceptional situation and failure mode was handled. There is nothing special about async/await with regards to this in Rust, at least if I'm understanding you correctly. Async functions can return Results like any other function for recoverable errors, and can panic like any other function for un-recoverable errors. > My gut feeling is that async/await is functionally equivalent to the the synchronous/blocking/coroutine/channel system of other languages like Go. It depends on what level of abstraction you're talking about. For Rust, which cares a lot about details, they're not. I gave a talk comparing and contrasting all this stuff here: https://www.infoq.com/presentations/rust-2019/ https://www.infoq.com/presentations/rust-2019/
- zackmorris 6y agoWell, I'm coming from the Javascript side, where people use promises a lot for async, but it's almost impossible to trace execution in the debugger. It's usually immediately obvious to me that many exceptions and failure modes have been missed, but I find it difficult to reason about large promise chains and I usually have no idea where I would add more error handling. It tends towards a big ball of spaghetti. Then if something does fail (wifi blips out without a retry, who knows) the page just hangs with no indication of what went wrong. Contrast this with Unix-style synchronous blocking code piping data between threads with no shared state. Since every step of execution happens in a single thread, blocking until a pipe returns data, it's trivial to step through the code. Async/await really becomes a problem in Javascript though because they made it a language keyword in Node.js. So you never know if you are dealing with a sync function or async function. Eventually the entire program has async in front of everything and ends up being written just exactly like sync blocking (but with superfluous syntactic sugar everywhere). So I question what was really gained there. IMHO it just doubles the workload in the mind since now we have to juggle both types of functions and explore those permutations. That's the last thing we need when we're trying to brainstorm a solution from a large possible search space. I'm also looking at this from a one-shot functional programming perspective. I realize that sync blocking code blocks the main thread, which is usually the GUI rendering loop. There are ways around that though. The best solution I've seen so far is Apple's Grand Central Dispatch: https://en.wikipedia.org/wiki/Grand_Central_Dispatch https://en.wikipedia.org/wiki/Grand_Central_Dispatch https://www.raywenderlich.com/5370-grand-central-dispatch-tutorial-for-swift-4-part-1-2 https://www.raywenderlich.com/5370-grand-central-dispatch-tu... https://www.swiftbysundell.com/articles/a-deep-dive-into-grand-central-dispatch-in-swift/ https://www.swiftbysundell.com/articles/a-deep-dive-into-gra... Basically it works by being sync blocking and providing simple ways to run closures on other threads. I find it much easier to debug than async/await though. Rust probably already has all of that functionality, so I don't understand the advantage of copying Javascript model with all of its caveats. I think what's happening is that people just want to run with the context they're already familiar with.
- jerf 6y agoIs async/await a good idea for an OS kernel, even a toy one? Cooperative multitasking tends to break down at scale, because the probability that all the "threads" you're cooperating with are playing nice goes to zero as the number of threads increases. An OS will tend to have a concentrated number of the pathological cases in it as it deals with hardware and all the other hardest timing & concurrency problems. It's a viable option for user-space programs because you can far more tightly characterize them and program them from top to bottom to work with that paradigm nice. Embedding islands of cooperative multitasking in a sea of pre-emptive multitasking seems to make a lot more sense than the other way around. However, this post is a question, not a statement. If, for example, a Linux kernel developer posts "nah, it's no biggie, the Linux kernel is effectively structured the same", for instance, I would not quibble.
- deleted 6y ago[deleted]
- briangold 6y agoTrue for a general-purpose OS kernel. But in an embedded system or unikernel it could make a lot of sense.
- afiori 6y agoAlso in a microkernel architecture possibly; if the scheduling were cooperative within each process and preemptive between processes that would look like it could make everyone here happy.
- ww520 6y agoAn OS has many aspects to it. Scheduling tasks is only one of the aspects. Async/await is just the interface/mechanism in dealing with the tasks, in this case cooperative tasks. Interacting with hardware, dealing with interrupts, memory mapping/managing, task isolation, etc are all other aspects of an OS that are apart from task scheduling but still needed. Cooperative multitasking works fine as long as the users/developers understand the limitation.
- fbhdev 6y agoAside from the cooperative multitasking discussion, I thoroughly enjoyed how you discussed the rust async/await concepts in detail and learned a lot from it. I find your article providing a lot more value than the rust documentation, which is unfortunate since that makes the learning curve somewhat difficult.
- phil-opp 6y agoGreat to hear that! I also found the official documentation a bit short on background information, so I decided to write my own explanation rather than link to something existing. Given that the async/await implementation is still quite young, I'm sure that the official docs will improve over time.