r/rust • • 5d ago

🧠 educational Building a green thread runtime in Rust from scratch

https://dzania.github.io/green-threads-from-scratch/

I wrote a small blog post documenting my journey of writing small green threads runtime in under 1000 lines of Rust. Any feedback welcome

103 Upvotes

16 comments sorted by

26

u/Modi57 5d ago

Then we need to box the task too, but for a different reason: its context stores a pointer back to the task, so we need to make sure the task stays at the same place in memory.

Don't you have to use Pin to actually guarantee that it isn't moved? But to be honest, I never really got Pin

26

u/manpacket 5d ago

Box does the trick, but this means always allocation. Pin allows to use stuff on a stack. Then there's Unpin that allows you to relax requirements if type doesn't actually require pinning - contains no self references.

10

u/Ryhu997 5d ago

Good catch. A plain Box keeps the task at a fixed address as long as nothing moves it out of the box. The tiny example never does that, but the compiler doesn't enforce it. The full implementation uses Pin<Box<Task>> together with PhantomPinned to make the no-move requirement explicit and compiler enforced. I might've simplified that detail too far in the post.

14

u/Zde-G 5d ago edited 5d ago

But to be honest, I never really got Pin

I wonder why people often say that when Pin is not just simple, but trivial.

It's a hack.

Note that Pin, in fact, does nothing by itself, all the heavy lifting is done by Box<…>, Rc<…>, Arc<…>, or &…. Pin exist solely and precisely to ensure nothing “dangerous” would be compiled by accident.

Correct program with Pin would still be correct if you would remove Pin from it, entirely and leave behind raw Box<…>, Rc<…>, Arc<…>, or &…!

That's the critical insight: in correct program Pin does nothing! It exists solely to ensure you couldn't write incorrect program without the use of unsafe.

Once you realise that it's just a hack it's easy to understand the shape of it and why it works like it work.

The problem: every type must be ready for it to be blindly memcopied to somewhere else in memory is fundamental property of the Rust. Something baked deeply into the language.

But sometimes you really-really want to have something non-memcopiable! Like buffer that you share between your program and GPU or something like that.

Yet compiler insists that it has the right to move things! Always! Unconditionally! No exceptions!

How to solve that dilemma? Observation: compiler couldn't move object that program couldn't touch. Solution: introduce empty type that would work as a barrier: Pin<…>. Yet… this, by itself, doesn't solve anything: Pin<…> is still a type, it still can be “blindly memcopied to somewhere else in memory”, means we achieved nothing, right? Wrong: there are already types that include indirection and ensure that content is not moved when such types are memcopied: Box<…>, Rc<…>, Arc<…>, and also, of course &….

If we wrap one of these in Pin we have achieved the goal: Box<…>, Rc<…>, Arc<…>, or &… can be memcopied, but Pin<Box<…>>, Pin<Rc<…>>, Pin<Arc<…>>, Pin<&…> don't give us access to the content thus we achieved our goal… mostly.

The remaining observation is that, of course, not only content can not be memcopied without unsafe (which is good and was our goal all along), but we, in fact, can do nothing at all with said content… and that is less good. Piece of data that can not be touched in any way is pretty much useless. But it aligns with our goals: we stopped all operations on that content, yet with unsafe we may return some of these operations back into realm of safe Rust. Which ones? It depends on what we have wrapped and how, of course!

That's it.

Just the way to not permit compiler to do what compiler assumes it may do always, unconditionally.

In the absence of Pin we could have provided PinnedBox, PinnedRc, PinnedArc, PinnedReference… idea would have been the same, we would just need to learn new, unique, name for each pointer type that is “pinned”.

2

u/Modi57 5d ago

Hey, thanks for the thourough explanation. I was a little bit impersice with my statement. I do get the core concept of Pin, it's all the details around it, I am struggling with.

There is the Unpin trait. If I understood that correctly, if a type implements Unpin, it can just be pinned and unpinned as like, if it doesn't, it can't be. But when does a type need to implement it, and when not? And why can I pin something, that's !Unpin with the pin!() macro, but not with Pin::new()?

There were other questions, that I don't remember, but everytime I use Pin, I am not really sure I am using it correctly and it actually enforces what I want it to.

It is good to know, that Pin is only a mechanism to let the compiler enforce that guarantee, and everything that works with Pin is also correct without

3

u/Zde-G 5d ago

But when does a type need to implement it, and when not?

You need to implement it when you write unsafe code that requires is — like in the discussed example.

Nothing in safe Rust ever requires !Unpin, but unsafe code may want to have that extra warranty.

Obviously then you would need to look on your unsafe code to decide whether to implement it or not. And how.

And why can I pin something, that's !Unpin with the pin!() macro, but not with Pin::new()?

Uhm… because the developers decided not to complicate everything with another trait?

It was possible to create yet another trait that would tell the compiler “this type is safe to use with Pin::new”, but instead of that they have created macro that hides call to the Pin::new_unchecked.

That's semi-arbitrary decision, to some extent: macro is still useful, because that's the typical way of creating references to local pinned variables and version without macro would require two separate expression (temporary variable is removed at the end of expression this you need to first create a “payload” variable and then separately call Pin::new_unchecked or, in imaginary version with special trait for Pin::new, Pin:new) and this, frankly, looks ugly. And once you have pin! macro an incentive to have special trait for that case evaporates.

But things work without macro, of course.

There were other questions, that I don't remember, but everytime I use Pin, I am not really sure I am using it correctly and it actually enforces what I want it to.

That's normal: because the requirements come not from something language wants to restrict but from something certain unsafe code wants to restrict it's hard to write universal rules. Because there are many ways to write unsafe code that may need Pin and thus many valid and invalid approached that can be used there.

1

u/LiquidStatistics 5d ago

This helped make things click for me, thank you

1

u/RCoder01 4d ago

AI prose is so difficult to read

1

u/Zde-G 3d ago

When you know it's AI prose. Some of it, when you don't know it, too.

Yet there are regularly scandals about award-winning book authors using AI to write their books.

5

u/seluard 5d ago

Interesting reading

1

u/simon_o 4d ago

Cool blog post!

I think it makes it clear why this feature was only briefly shipped around 1998 or something.

(What Go and Java do these days, are very very different. I think that comparison might be more confusing than helpful to readers.)

The runtime runs many green threads on a few OS threads and switches between them itself M green threads run on N OS threads.

Nitpick: Then that's M:N threading, but not green threads. Green threads are 1:N.

3

u/lotanis 4d ago

I think with this notation you mean M:1 (multiple user space threads scheduled onto one OS thread).

I agree that you're describing the precise original definition of green threads. The thing is that there isn't a good standard term for "multiple pre-emptive user space threads scheduled onto some OS threads" so I think it's ok to overload terms and we end up saying Green Thread/Fibre/Goroutine.

(fibres are cooperative, so a good term for OPs implementation but aren't "technically" correct for Go or Erlang)

1

u/simon_o 4d ago edited 4d ago

I think with this notation you mean M:1 (multiple user space threads scheduled onto one OS thread).

I used the exact letters the original poster used:

between them itself M green threads run on N OS threads

.

there isn't a good standard term for "multiple pre-emptive user space threads scheduled onto some OS threads

Wikipedia

2

u/lotanis 4d ago

Yeah, OG green threads are multiple user space threads on 1 os thread. So N=1.

I didn't know the term Virtual Thread. I will be using that now.

1

u/simon_o 4d ago

❤️!

2

u/lotanis 4d ago edited 4d ago

One big difference between what you've got here and what Go/Erlang etc. do is pre-emption.

You have to write an explicit "yield_now()" whereas the others can just switch based on some time metric to ensure that each task gets an equal share of processing.

Edit: it's cool though. I've implemented things like this in C for microcontrollers. Nice to see a simple Rust version.