premise
One program, four times, on purpose
141OS is a resource simulation: some number of users competing for a fixed pool of disks and printers, where every request is either granted immediately or queued until something frees up.
I wrote it in Gleam. Then I wrote it again in Rust, in Go, and in C++, because I wanted to know what the same concurrency problem felt like in languages built around different ideas of what concurrency is.
That repetition is the only reason any of this is worth writing down. The other projects here taught me things too, but Redis in Elixir and Kafka in Rust are different systems in different languages, so when something came out easy I could never fully separate this system is simple from this language made it simple. 141OS holds everything constant except the language. Same spec, same fixtures, same grader.
the spread
Four answers to the same question
In Gleam the resource manager is an actor. It owns its state, takes Request
and Release messages, and returns a new state on the way out.
pub fn new(num_items: Int) -> Result(ResourceManager, actor.StartError) {
actor.start(State(list.repeat(True, num_items), queue.new()), handle_message)
}
There is no lock anywhere in that file, because there is nothing to lock. No other process can reach the state.
Rust has no actor, so I built one: a channel for the mailbox, a thread for the process, and shared ownership of the state so the thread could hold it.
let (sender, receiver) = mpsc::channel::<Message>();
let _state = Arc::new(Mutex::new(State { /* ... */ }));
let state_clone = Arc::clone(&_state);
thread::spawn(move || { ResourceManager::run(receiver, state_clone); });
Same protocol, same state, same logic, assembled by hand out of parts.
C++ goes one level lower again. There is no mailbox to build the manager out of, so the blocking queue itself gets written from a mutex and a condition variable:
std::mutex mutex;
std::condition_variable notEmpty;
// ...
std::unique_lock<std::mutex> lock(mutex);
notEmpty.wait(lock, [this]() { /* ... */ });
Which puts three of the four on a spectrum of how much you assemble yourself. Then Go refused the question.
var freeDisks chan *Disk
freeDisks = make(chan *Disk, *numDisks)
freeDisk := <-freeDisks // blocks here until one is free
freeDisks <- freeDisk // and this hands it back
There is no resource manager in the Go version. There is no free list, no waiting queue, no manager type, no state. A buffered channel already is all of those things: take from it and you block until something is available, send to it and you release. The component I had written twice turned out to be a restatement of a primitive.
That is the one that actually changed how I think. Gleam and Rust and C++ were four different answers about which tool to reach for. Go's was that the whole structure I had assumed was necessary was a thing I had been carrying around.
the mutex
What the language talked me into
Going back through the Rust version later, I noticed the Arc<Mutex<State>>
isn't protecting anything.
Only the run loop ever locks it. The handle stored on the struct is never read
again, which is why it ended up named _state. And the channel already delivers
one message at a time, so there is no second party that could be locked out.
The lock guards against contention that cannot happen.
What makes it worth admitting is that a few hundred lines away in the same
program, Arc<Mutex<Disk>> and Arc<Mutex<Printer>> get locked from several
worker threads at once, and there they are doing real work. So it was never
confusion about what a mutex is for. Arc<Mutex<T>> is just the shape Rust
hands you for state a thread is going to touch, and I reached for it in the
one place the design had already made it unnecessary.
In Gleam that mistake isn't available. The actor owns its state, so the question of who else might be touching it never comes up.
elsewhere
The same lesson without the controls
The rest of these are single implementations, so they are weaker evidence, but they kept producing the same shape.
Writing a Redis server in Elixir, the parts that are genuinely hard in most languages, many concurrent clients and supervising a process that dies and a replica that reconnects without taking the primary down with it, are the parts OTP already has answers for. I still hit a race condition and had to rework command handling so it ran sequentially through one message handler. The runtime had the answer; I had to learn to reach for it.
Writing a Kafka broker in Rust, reading the spec tells you what the protocol does, and implementing it tells you which decisions were load-bearing and which were arbitrary. The fields that exist because of a failure mode someone hit, versus the fields that exist because of a version negotiation nobody wanted. Kafka's request headers carry a lot of the former. I had also assumed that pattern matching on bytes, the way Gleam and Elixir do it, was simply the idiomatic way to read a stream. Rust's cursor was unexpectedly pleasant, and the reason is that the two languages are good at different halves: the pattern match describes the shape of what you are reading, while the cursor remembers where you are so you don't have to thread the position through every call yourself.
Writing Lox in Go, I used errors as values throughout rather than threading exceptions the way the book does, which collapses static and runtime error handling into the same shape. More typing at every call site and considerably less thinking at the boundaries, which turned out to be the trade I wanted. Then I worked through the book a second time in OCaml and got a different lesson entirely, mostly about being wrong: I rebuilt the parser and interpreter as first-class modules because it seemed more elegant, and it held up fine until functions, where the environment and the interpreter and the evaluation result all turn out to be mutually recursive. Nystrom sidesteps it with a type cast in Java. I never found the OCaml equivalent.
what stuck
Instincts, and which ones were borrowed
I went in thinking I was learning systems, and I did learn systems. But the part I use most is the other thing, which is a much better sense of when a strong instinct about how code should look is actually an instinct about one language.
Almost every time I was confidently wrong here, it took the same form. Pattern matching on bytes is the idiomatic way to parse a stream, except a cursor was simpler. First-class modules are the elegant way to build an interpreter, except they collapse at functions. Variants are more idiomatic than option fields, except option fields turned out easier to reason about. Shared state needs a lock, except when the channel in front of it already serialized everything.
None of those are things I could have read. They needed the second implementation, which is the entire argument for doing this at all.