blob: a91614543af4adf51b7b8ec08085d746b60fbe96 (
plain) (
tree)
|
|
+++
title = "xpost: Cooperative-multitasking studies (matcha-threads)"
[taxonomies]
tags = ["threading", "linux", "x86", "arm", "riscv"]
+++
This is a cross post to a **cooperative-multitasking implementation** that I
did in the past and hosted on github [>> matcha-threads <<][matcha].
Cooperative-multitasking allows to perform the thread scheduling in user space.
Executing threads need to give the control back to the `scheduler` such that
other threads can run. Since control is returned explicitly to the scheduler,
threads need to **"cooperate"**.
The following code snippet shows an example of two such threads:
```cpp
#include "lib/executor.h"
#include "lib/thread.h"
#include <cstdio>
void like_tea(nMatcha::Yielder& y) {
std::puts("like");
y.yield();
std::puts("tea");
}
int main() {
nMatcha::Executor e;
e.spawn(nMatcha::FnThread::make(like_tea));
e.spawn(nMatcha::FnThread::make([](nMatcha::Yielder& y) {
std::puts("I");
y.yield();
std::puts("green");
}));
e.run();
return 0;
}
```
Which gives the following output when being run:
```txt
I
like
green
tea
```
The main focus of that project was to understand the fundamental mechanism
underlying cooperative-multitasking and implement such a `yield()` function as
shown in the example above.
Looking at the final implementation, the yield function does the following:
1. push callee-saved regs to current stack
2. swap stack pointers (current - new)
3. pop callee-saved regs from new stack
<img src="yield.svg">
Implementations for different ISAs are available here:
- [x86_64][yield-x86]
- [arm64][yield-arm64]
- [armv7a][yield-arm]
- [riscv64][yield-rv64]
<div style="overflow: auto;">
<img src="init-stack.svg" style="float: right; width: 20%; padding-left: 2ch;">
Since a thread returns into the last stack-frame of the new thread after
switching the stack pointers in the yield function, special care must be taken
when a new stack is created.
The _initial stack_ is setup such that, when yield-ing into the new stack for
the first time, the stack contains the initial values for the callee-saved
registers, which yield will restore and the return frame contains an address
which should be returned to when returning from yield.
An example of setting up the initial stack can be seen in [init_stack
(x86)][init-stack]. From this it can also be seen that the first time the
thread will return to [thread_create][thread-create], which just calls into a
function passed to [init_stack][init-stack].
</div>
## Appendix: os-level vs user-level threading
The figure below depicts *os-level* threading (left) vs *user-level* threading
(right).
The main difference is that in the case of user-level threading, the operating
system (os) does not now anything about the user threads. In the concrete
example, only a **single** user thread can run at any given time, whereas in
the case of os-level threading, all threads can run truly parallel.
When a user-level thread is scheduled, the *stack-pointer* (sp) of the os
thread is adjusted to the user threads' stack. For the example below, if the
user thread **A** is scheduled (yielded to), the stack-pointer for the os
thread **S** is switched to the **stack A**. Once the user thread yields back
to the scheduler, the stack-pointer is switched back to **stack S**.
<img src="os-vs-user-threads.svg">
[matcha]: https://github.com/johannst/matcha-threads
[yield-x86]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/x86_64/yield.s
[yield-arm]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/arm/yield.s
[yield-arm64]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/arm64/yield.s
[yield-rv64]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/riscv64/yield.s
[init-stack]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/x86_64/init_stack.cc
[thread-create]: https://github.com/johannst/matcha-threads/blob/master/lib/arch/x86_64/thread_create.s
|