Serialization Is the Secret

2024-09-2913:26278100www.zachdaniel.dev

If a value mutates in the forest with no one to see it, does it really mutate?

One of the major elements that sets Elixir apart from most other programming languages is immutability. But what does that actually mean? What does it do for us?

The word immutable is defined by Merriam-Webster as “not capable of or susceptible to change”. If nothing ever observes a change, it may as well have not happened anyway, so we will be discussing mutation as two discrete concepts: mutation, and the observation of mutation.

Here is a simple example of a mutable program in Javascript.

Here is a simple example of an immutable program in Elixir.

They look very similar. And in fact, they both have consistent behavior if we next line of the program was to print out the value of counter, we are guaranteed in both cases to see the value 1 printed. It would appear that the observation of variables behaves the same.

Before I explain the benefits of immutability, it’s important to disambiguate something that folks often initially believe is mutation.

In the Javascript example, our reference to the counter is mutated. However, in the Elixir example, what we’ve done is rebound the variable. No pointers or values have changed. In Javascript, primitive values are immutable, but a variable’s reference is not. In later examples we’ll see how Javascript allows mutating other values i.e objects, called reference values.

The first is an actual mutation of memory. The second is an allocation of new memory. The old memory is unaffected. One could argue that this is a form of mutation. This mutation, however, is syntactic.

This is a convenience that the language provides. When you rebind a variable, it is equivalent to creating a new variable, and altering references to that variable in the same scope to use that new name instead. Let’s look at what this means in practice.

In Javascript, we mutate the actual underlying memory. What this looks like in practice:

Notice how the value for counter is now 1. In Elixir, however, this behaves differently.

How can this be? We’ve clearly reassigned counter. The reason for this is that rebinding is equivalent to creating a new variable. Just like in Javascript, a variable is not available outside of the scope it was defined in. The Elixir code is effectively rewritten to this:

In this way, no reference to a given variable can ever truly be mutated in Elixir. You only ever create new bindings with new values. There is just a language level construct that allows you to reuse the same variable name.

Now that you understand that rebinding isn’t mutation, you can see the fact that the value of a given variable will never change. This lends itself to well to understandable code. For example, in Javascript, objects are mutable, and are passed to functions by reference. Which means that calling functions can do “surprising stuff”:

After executing this code, profile has mutated. Before calling newScore, profile.score was 3. Afterwards, it is 7. This would be a pretty beginner mistake to make, but it is still something that you have to contend with. You will always have to consider using things like Object.freeze or Object.seal, or just trusting that your other code makes no mutations to the parameters that you pass in.

In Elixir, it is just not possible for that to happen. If I call a function, it can do all kinds of side effects, but it will never be able to affect the values of the variables that are currently in scope for me.

This is where observation of mutation comes into play. In the Javascript example, mutation can be observed effectively at any time. If I started an async function, for example, and passed in a mutable variable, it could change at any time.

And I don’t mean dealing with time zones. Although maybe that actually is the most frustrating part of programming.

What I mean is handling the passage of time. Let’s go back to our Javascript example, and introduce some concurrency. When concurrency comes into play, we can no longer think in terms of a discrete, causal series of events. Instead, we have to think of “state over time”.

The value of profile.score at the end of this program is entirely dependent on how long it takes to doSomethingElse(). That means that sequential runs of this program can produce different results. There are absolutely steps to mitigate this. For instance, you can await newScore. You can make doSomethingElse() an async function, and await them both using Promise.all().

But imagine that you had passed `profile` into both of those now-async functions? And each one modified it at a different time? Handling race conditions like this can be very difficult, and oftentimes immutable data structures are the solution. You can use things like locks and mutexes, but that is just as easy to get wrong as any other thing.

So, to summarize, mutation can occur at any time and can also be observed at any time in Javascript.

In Elixir, everything runs in a process. It is a garbage collected language. Each process has its own stack, and its own heap. This garbage collection means that the memory location of a variable can technically change at any time. So from a pedantic standpoint, you could have two variables whose memory location swaps at some point while running your program. This is transparent to the programmer (which is good).

With this in mind, an argument could be made that anything stored in a process’s heap is mutable state. For example, let’s take a simple, GenServer-backed counter in Elixir.

When I increment this counter, new memory is allocated for the new number to live in. When I access this counter, I’m reading from that memory location. At some point, a garbage collection comes through, cleaning up the references to the old variables, and potentially moving the location of the new variable in memory.

So this process, while indirect, actually has all of the hallmarks of mutation! The same variable changing its location in memory in a way that is completely out of the developer’s control. Other variables being removed from memory or moved around at any time. SpoOoOoky!

So Elixir is mutable then? By some definitions, sure. In Elixir, values are immutable, but ultimately there is state, somewhere in our system. And it even changes over time! So what gives?

There are two secret sauces in the Elixir restaurant. They are:

  • Function calling as the only way to observe mutating state

  • Serialized access to mutating state

Something we have available to us in Elixir is the “process dictionary”. By many definitions, the process dictionary is mutable state. I would argue that, from the perspective of our program, it is not more or less mutable than any other thing. The reason for this, is that in Elixir, all mutating state requires calling a function to observe it.

Want to know the current time? You must call DateTime.now(). You can change the process dictionary with Process.put. You can even call a function that makes this change. Lets look at an example based on the Javascript “mutating profile” example from before:

Here we are “mutating” the process dictionary state. It was one thing, and now it is something else. However, we cannot observe this mutation without calling Process.get. The profile variable will always, unambiguously, equal %{score: 3}. The only way that could change is by reassigning the variable, and calling a function. For example:

Our variable values are always safe in Elixir 🥳

This is the most important part of the whole equation. We’ve shown that we can accomplish mutating state in Elixir, albeit in a different way than you might have seen elsewhere. But what about the race condition example from before? We can’t really write an equivalent example to that in Elixir. Our concurrency primitive is to start another process, which has its own isolated process dictionary. There is no way to modify another process’s state.

So we’ll base this on our counter example, which is how you’d actually do This Kind Of Thing™ in Elixir. Each process handles messages serially, one-at-a-time. This means that, from the perspective of the counter, there is no such thing as a race condition! And callers, in order to interact with the counter, must call functions to send it a message, or to observe its changing state. Nothing the counter does can modify the state of the callers.

By forcing the mutation of state to be serialized through a process’s mailbox, and limiting the observation of mutating state to calling functions, our programs are more understandable, and less surprising.

There is a lot more to say on this topic, and I fully intend to do so! Understandability and predictability are not the only benefits of the concepts described above. Many of the benefits only even come into the picture when it’s time to scale, or to model graceful failure of our application components, and its those properties that I’ll be writing about next.


Read the original article

Comments

  • By akoboldfrying 2024-10-0212:191 reply

    In most other languages, your newScore() example would indeed race as you claim, but in JS it actually won't. JS uses a single-threaded event loop, meaning asynchronous things like timers going off and keys being pressed pile up in a queue until the currently executing call stack finishes, and are only processed then.

    In your example, this means profile.score will remain at 3 every time. Interestingly this would still be the case even if setTimeout() was replaced with Promise.resolve(), since the sole "await" desugars to ".then(rest-of-the-function)", and handlers passed to .then() are always added to the job queue, even if the promise they are called on is already settled [0].

    To fix this (i.e., introduce an actual race), it would be enough to add a single "await" sometime after the call to newScore(), e.g., "await doSomethingElse()" (assuming doSomethingElse() is async). That would cause the final "profile.score" line to appear in the job queue at some indeterminate time in the future, instead of executing immediately.

    [0]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Refe...

    • By borromakot 2024-10-0213:09

      Gross :)

      But I will update my example in that case. Someone else mentioned this but I was waiting to hear back. I will alter the example to `await doSomethingElse`.

  • By LegionMammal978 2024-10-0213:192 reply

    I'd note that 'immutability everywhere' isn't the only way to solve the issue of uncontrolled observation of mutations, despite that issue often being cited as a justification. You can also design a language to just directly enforce static restrictions on who may mutate a referenced value and when. Rust with its aliasing rules is easily the most famous implementation of this, but other languages have continued to experiment with this idea.

    The big benefit is that you can still have all the usual optimizations and mental simplicity that depend on non-observability, while also not having to contort the program into using immutable data structures for everything, alongside the necessary control flow to pass them around. (That isn't to say that they don't have their use cases in a mutable language, especially around cross-thread data structures, but that they aren't needed nearly as frequently in ordinary code.)

    • By packetlost 2024-10-0213:511 reply

      I think having some sort of structured mutability is a very, very good idea. Look at Clojure's atoms and transient data-structures for some other ways that this has been done. There's probably others, I'd love to see more examples!

      • By jerf 2024-10-0214:323 reply

        The historically-ironic thing to me is that Erlang/BEAM brushed up against the idea and just didn't quite get it. What's important to the properties that Erlang maintains is that actors can't reach out and directly modify other actor's values. You have to send messages. It is sufficient to maintain this properly that you can't send references in messages, and it is sufficient to maintain that property to simply not have references, which Erlang and BEAM do not. Full immutability is sufficient but not necessary.

        Erlang was a hair's breadth away from having mutation contained within the actor's variable space, with no external mutation, which for the time would have been quite revolutionary. Certainly Rust's mutation control is much richer, but Rust came a lot later, and at least based on its current compile performance, wasn't even on the table in the late 1990s.

        But the sort of understanding of mutability explained in the original post was not generally understood. Immutability was not a brand new concept chronologically, but if you define the newness of a computer science concept as the integration of its usage over time, it was still pretty new by that metric; it had been bouncing around the literature for a long time but there weren't very many programming languages that used it at the time. (And especially if you prorate "languages" by "how easy it is to write practical programs".)

        Elixir does a reasonable job of recovering it from the programmer's perspective, but I think an Erlang/BEAM that just embraced mutability within an actor probably would have done incrementally better in the programming language market.

        • By toast0 2024-10-0216:262 reply

          I think you're right that "interior immutability" of actors isn't really necessary to the programming model that you get from requiring message passing between actors.

          However, interior immutability is not without its benefits. It enables a very simple GC. GC is easily done per-actor because each actor has independent, exclusive, access to its own memory. But the per-actor GC is very simple because all references are necessarily backwards in time, because there's no way to update a reference. With this, it's very simple to make a copying GC that copies any active references in order; there's no need for loop checking, because loops are structurally impossible.

          I don't know that this was the intent of requiring immutability, but it's a nice result that pops out. Today, maybe you could pull in an advanced GC from somewhere else that already successfully manages mutable data, but these were not always available.

          Of course, it should be noted that BEAM isn't entirely immutable. Sometimes it mutates things when it knows it can get away with it; I believe tuples can be updated in some circumstances when it's clear the old tuple would not be used after the new one is created. The process dictionary is direct mutable data. And BIFs, NIFs, and drivers aren't held to strict immutability rules either, ets has interior mutability, for example.

          • By jerf 2024-10-0217:09

            "Of course, it should be noted that BEAM isn't entirely immutable."

            Mutability is relative to the layer you're looking at. BEAM is, of course, completely mutable from top to bottom because it is constantly mutating RAM, except, of course, that's not really a helpful way of looking at it, because at the layer of abstraction you program at values are immutable. Mutatable programs can be written in terms of immutable abstractions with a well-known at-most O(n log n) penalty, and immutable programs can be written on a mutable substrate by being very careful never to visibly violate the abstraction of immutability, which is good since there is (effectively for the purposes of this conversation) no such thing as "immutable RAM". (That is, yes, I'm aware of WORM as a category of storage, but it's not what this conversation is about.)

          • By hinkley 2024-10-031:44

            It took me a long time when implementing Norvig's Sudoku solver to realize those one-way pointers were going to force me to do something very different to implement this code with immutability.

            Norvig's secret sauce is having 3 different projections of the same data, and that involves making updates in one part of a graph that are visible from three entry points.

            I'm sure there are other solutions but mine didn't settle down until I started treating the three views as 3 sets of cell coordinates instead of lists of cells.

        • By LegionMammal978 2024-10-0214:551 reply

          IIRC, Rust's idea of controlled mutability originally came directly from the Erlang idea of immutable messages between tasks. Certainly, in the classic "Project Servo" presentation [0], we can see that "no shared mutable state" refers specifically to sharing between different tasks. I think it was pretty early on in the project that the idea evolved into the fine-grained aliasing rules. Meanwhile, the lightweight tasks stuck around until soon before 1.0 [1], when they were abandoned in the standard library, to be later reintroduced by the async runtimes.

          [0] http://venge.net/graydon/talks/intro-talk-2.pdf

          [1] https://rust-lang.github.io/rfcs/0230-remove-runtime.html

          • By dartos 2024-10-0215:25

            I have mixed feelings about rust’s async story, but it is really nice having good historical documentation like this.

            Thanks for the links!

        • By mrkeen 2024-10-0214:541 reply

          > What's important to the properties that Erlang maintains is that actors can't reach out and directly modify other actor's values. You have to send messages.

          I just cannot make this mental leap for whatever reason.

          How does 'directly modify' relate to immutability? (I was sold the lie about using setters in OO a while back, which is also a way to prevent direct modification.)

          • By jerf 2024-10-0215:322 reply

            So, this is something I think we've learned since the 1990s as a community, and, well, it's still not widely understood but: The core reason mutability is bad is not the mutation, it is "unexpected" mutation. I scare quote that, because that word is doing a lot of heavy lifting, and I will not exactly 100% nail down what that means in this post, but bear with me and give me some grace.

            From a the perspective of "mutability", how dangerous is this Python code?

                x = 1
                x = 2
                print(x)
            
            Normally little snippets like this should be understood as distilled examples of a general trend, but in this case I mean literally three lines. And the answer is, obviously, not at all. At least from the perspective of understanding what is going on. A later programmer reading this probably has questions about why the code is written that way, but the what is well in hand.

            As the distance between the two assignments scales up, it becomes progressively more difficult to understand the what. Probably everyone who has been in the field for a few years has at some point encountered the Big Ball Of Mud function, that just goes on and on, assigning to this and assigning to that and rewriting variables with wild abandon. Mutability makes the "what" of such functions harder.

            Progressing up, consider:

                x = [1]
                someFunction(x)
                print(x)
            
            In Python, the list is mutable; if someFunction appends to it, it will be mutated. Now to understand the "what" of this code you have to follow in to someFunction. In an immutable language you don't. You still need to know what is coming out of it, of course, but you can look at that code and know it prints "[1]".

            However, this is still at least all in one process. As code scales up, mutation does make things harder to understand, and it can become hard enough to render the entire code base pathologically difficult to understand, but at least it's not as bad as this next thing.

            Concurrency is when mutation just blows up and becomes impossible for humans to deal with. Consider:

               x = [1]
               print(x)
            
            In a concurrent environment where another thread may be mutating x, the answer to the question "what does the print actually print?" is "Well, anything, really." If another thread can reach in and "directly" mutate x, at nondeterministic points in your code's execution, well, my personal assertion is nobody can work that way in practice. How do you work with a programming language where the previous code example could do anything, and it will do it nondeterministically? You can't. You need to do something to contain the mutability.

            The Erlang solution is, there is literally no way to express one actor reaching in to another actor's space and changing something. In Python, the x was a mutable reference that could be passed around to multiple threads, and they all could take a crack at mutating it, and they'd all see each other's mutations. In languages with pointers, you can do that by sharing pointers; every thread with a pointer has the ability to write through the pointer and the result is visible to all users. There's no way to do that in Erlang. You can't express "here's the address of this integer" or "here's a reference to this integer" or anything like that. You can only send concrete terms between actors.

            Erlang pairs this with all values being immutable. (Elixir, sitting on top of BEAM, also has immutable values, they just allow rebinding variables to soften the inconvenience, but under the hood, everything's still immutable.) But this is overkill. It would be fine for an Erlang actor to be able to do the equivalent of the first example I wrote, as long as nobody else could come in and change the variable unexpectedly before the print runs. Erlang actors tend to end up being relatively small, too, so it isn't even all that hard to avoid having thousands of variables in a single context. A lot of Erlang actors have a dozen or two variables tops, being modified in very stereotypical manners through the gen_* interfaces, so having in-actor truly mutable variables would probably have made the language generally easier to understand and code in.

            In the case of OO, the "direct mutation" problem is related to the fact that you don't have these actor barriers within the system, so as a system scales up, this thing "way over there" can end up modifying an object's value, and it becomes very difficult over time to deal with the fact that when you operate that way, the responsibility for maintaining the properties of an object is distributed over the entire program. Technically, though, I wouldn't necessarily chalk this up to "mutability"; even in an immutable environment distributing responsibility for maintaining an object's properties over the entire program is both possible and a bad idea. You can well-encapsulated mutation-based objects and poorly-encapsulated immutable values. I'd concede the latter is harder than the former, as the affordances of an imperative system seems to beg you to make that mistake, but it's certainly possible to accidentally distribute responsibilities incorrectly in an immutable system; immutability is certainly not a superset of encapsulation or anything like that. So I'd class that as part of what I mentioned in this post before I mentioned concurrency. The sheer size of a complex mutation-based program can make it too hard to track what is happening where and why.

            Once you get used to writing idiomatic Erlang programs, you contain that complexity by writing focused actors. This is more feasible than anyone who hasn't tried thinks, and is one of the big lessons of Erlang that anyone could stand to learn. It is then also relatively easy to take this lesson back to your other programming languages and start writing more self-contained things, either actors running in their own thread, or even "actors" that don't get their own thread but still are much more isolated and don't run on the assumption that they can reach out and directly mutate other things willy-nilly. It can be learned as a lesson on its own, but I think one of the reasons that learning a number of languages to some fluency is helpful is that these sorts of lessons can be learned much more quickly when you work in a language that forces you to work in some way you're not used to.

            • By AnimalMuppet 2024-10-0218:112 reply

              I've run into something like this when working on embedded systems using OO. You have persistent mutable data, you have an object that encapsulates some data, you have multiple sources of control that each have their own threads that can modify that data, and you have consistency relationships that have to be maintained within that data.

              The way you deal with that is, you have the object defend the consistency of the data that it controls. You have some kind of a mutex so that, when some thread is messing with certain data, no other thread can execute functions that mess with that data. They have to wait until the first thread is done, and then they can proceed to do their own operations on that data.

              This has the advantage that it puts the data and the protection for the data in the same place. Something "way over there" can still call the function, but it will block until it's safe for it to modify the data.

              (You don't put semaphores around all data. You think carefully about which data can be changed by multiple threads, and what consistency relationships that could violate, and you put them where you need to.)

              Is that better or worse than Erlang's approach? Both, probably, depending on the details of what you're doing.

              • By jerf 2024-10-0218:40

                That's possibly what I meant by the "actors that don't get their own thread" at the very end. I've switched away from Erlang and write most of my stuff in Go now, and while I use quite a few legit "actors" in Go, that have their own goroutine, I also have an awful lot of things that are basically "actors" in that they have what is effectively the same isolation, the same responsibilities, the same essential design, except they don't actually need their own control thread. In Erlang you often just give them one anyhow because it's the way the entire language, library, and architecture is set up anyhow, but in Go I don't have to and I don't. They architecturally have "one big lock around this entire functional module" and sort of "borrow" the running thread of whoever is calling them, while attaining the vast majority of benefits of an actor in their design and use.

                If you have an "actor", that never does anything on its own due to a timer or some other external action, that you never have to have a conversation with but are interacting with strictly with request-response and aren't making the mistake someone discussed here [1], then you can pretty much just do a One Big Lock and call it a day.

                I do strictly follow the rule that no bit of code ever has more than one lock taken at a time. The easiest way to deal with the dangers of taking multiple locks is to not. Fortunately I do not deal in a performance space where I have no choice but to take multiple locks for some reason. Though you can get a long way on this rule, and building in more communication rather than locking.

                [1]: https://news.ycombinator.com/item?id=41722440

              • By mrkeen 2024-10-0219:54

                > You don't put semaphores around all data

                You're talking about putting semaphores around code.

                Locking data, not code, is a great way to do things. It composes and you don't run into too-many-locks, too-few-locks, forgetting-to-take-a-lock, or deadlocking problems.

                https://www.adit.io/posts/2013-05-15-Locks,-Actors,-And-STM-...

            • By mrkeen 2024-10-0219:501 reply

              > In the case of OO, the "direct mutation" problem is related to the fact that you don't have these actor barriers

              Right, the OO guys said to use "encapsulation" rather than direct mutation.

              > so as a system scales up, this thing "way over there" can end up modifying an object's value

              Can you not send a message way over there?

              • By asa400 2024-10-0221:43

                You're correct that any process can more or less send a message to any other process, but the difference is what guarantees the Erlang runtime provides around that idea.

                For example, in Erlang, if I have processes A, B, and C, and B and C both send messages to A at the same time, the runtime guarantees that A processes the messages one at a time, in order, before moving on to the next message (there is some more detail here but it is not important to the point).

                The runtime guarantees that from A's perspective, the messages from B and C cannot arrive "simultaneously" and trample on each other. The runtime also guarantees that A cannot process both messages at the same time. It processes the messages one at a time. All code in A is run linearly, single-threaded. The VM takes care of scheduling all of these single-threaded processes to run on the same hardware in parallel.

                As other posters have pointed out, the runtime also guarantees that B and C cannot reach in and observe A's raw memory in an uncontrolled fashion (like you could in C, Java, etc.), so B and C cannot observe any intermediate states of A. The only way for B and C to get any information out of A is to send a message to A and then A can send a reply, if it wants. These replies are just normal messages, so they also obey all of the guarantees I've already described, so A will send the replies one at time, and they will end up in the mailboxes of B and C for their own processing.

                Given all this (and more which I haven't gone into), Erlang doesn't have the concept of a data race where 2 or more threads are concurrently accessing the same memory region, as you might have in say, the C language (note that this is different than a logical race condition, which Erlang of course still can have).

                I hope this is useful, you're asking good questions.

    • By p1necone 2024-10-0223:55

      Yeah rust totally changed my opinion on mutability. When I first started using it I thought that the lack of explicitly immutable struct members was a glaring flaw in the language, but it turns out the ability to choose between ownership, reference and mutable reference at function/method callsites is just as useful for ensuring correctness.

  • By gr4vityWall 2024-10-0215:385 reply

    This article is exceptionally well written. The author did a good job at making the subject approachable.

    I disagree with this phrase:

    > By forcing the mutation of state to be serialized through a process’s mailbox, and limiting the observation of mutating state to calling functions, our programs are more understandable

    My experience is quite the opposite - that's a mental model for programs that goes against how most people I know reason about code.

    The examples in Elixir all looked more complicated for me to understand, generally, although I understand the value brought by that model. The cognitive load seemed fairly higher as well.

    • By toast0 2024-10-0216:452 reply

      I think understandable can mean different things here. If we're looking at a state dump and your data is in a weird place, I'd want to understand how the data got into that weird place.

      In a model with state owned by a process, and changes coming in through the mailbox, I know that all of the state changes happened through processing of incoming messages, one at a time. If I'm lucky, I might have the list of messages that came in, and be able to run them one a time, but if not, I'll just have to kind of guess how it happened, but there's probably only a limited number of message shapes that are processed, so it's not too hard. There's a further question of how those messages came to be in the process's mailbox, of course.

      In a model with shared memory mutability, it can be difficult to understand how an object was mutated across the whole program. Especially if you have errors in concurrency handling and updates were only partially applied.

      There's certainly a learning curve, but I've found that once you've passed the learning curve, the serialized process mailbox model makes a lot of understanding simpler. Individual actors are often quite straight forward (if the application domain allows!), and then the search for understanding focuses on emergent behavior. There's also a natural push towards organizing state into sensible processes; if you can organize state so there is clear and independent ownership of something by a single actor, it becomes obvious to do so; it's hard to put this into words, but the goal is to have an actor that can process messages on that piece of state without needing to send sub-requests to other actors; that's not always possible, sometimes you really do need sub-requests and the complexity that comes with it.

      • By Nevermark 2024-10-0221:35

        Well said.

        The essence: understanding scales better with mutation serialization.

        Any little bumps of complexity at the beginning, or in local code, pays off for simpler interactions to understand across longer run histories, increased numbers of processes, over larger code bases.

      • By gr4vityWall 2024-10-0217:291 reply

        That's an interesting point of view, thanks for taking the time to write that.

        I wonder how much that learning curve is worth it. It reminds me of the Effect library for TypeScript, in that regard, but Elixir looks more readable to me in comparison.

        • By toast0 2024-10-0217:59

          > I wonder how much that learning curve is worth it.

          It really depends on how well your application fits the model. If it's a stretch to apply the model and you're already comfortable with something else, it might not be worth it. But if you're building a chat server, or something similar, I think it's very worthwhile. I think it can be pretty useful if you have a lot of complex state per user, in general, as your frontend process could send process inbound requests into messages into a mailbox where each user would only be processed by a single actor (each user doesn't need their own actor, you could hash users in some way to determine which actor handles a given user). Then you have a overall concurrent system, but each user's complex state is managed in a single threaded manner.

          I think process/actor per connection is a superior mental model to explicit event loops for something like a large HTTP server, but unless the server is performing complex work within it, I would assume without testing that the explicit event loop would win on performance. I think it would be more fun to build a large HTTP server in Erlang than with c and kqueue, but nginx and lighttpd are already written, and quite fast. When I needed to do a TCP proxy for million + connections, I took HAProxy and optimized it for my environment, rather than writing a new one in Erlang; that was still fun, but maybe different fun; and being able to handle tens of thousands of connections with minutes of configuration work was a better organization choice than having fun building a proxy from scratch. :)

    • By azeirah 2024-10-0215:462 reply

      > My experience is quite the opposite - that's a mental model for programs that goes against how most people I know reason about code.

      > The examples in Elixir all looked more complicated for me to understand, generally, although I understand the value brought that model. The cognitive load seemed fairly higher as well.

      Of course it's not as understandable to someone who's not used to it.

      When I read articles about a different paradigm, I assume that "better" means "with equal experience as you have in your own paradigm"

      So if someone says "this convoluted mess of a haskell program is better than js", I will say "yes, if you spent 100 hours in Haskell prior to reading this".

      • By gr4vityWall 2024-10-0217:32

        > Of course it's not as understandable to someone who's not used to it.

        Sorry, I didn't mean to imply otherwise. Perhaps the original quote should make what you said ("with equal experience as you have in your own paradigm") explicit.

        I do believe that the paradigm proposed in the article has a much higher learning curve, and expect it to not be adopted often.

      • By cpill 2024-10-0219:01

        Hey, and hence the rampant adoption of Haskell :P

    • By rkangel 2024-10-0216:491 reply

      > The examples in Elixir all looked more complicated for me to understand, generally, although I understand the value brought that model. The cognitive load seemed fairly higher as well.

      I can see that might be the case with simple examples, but with more complex systems I find the cognitive load to be much lower.

      The Elixir/Erlang approach naturally results in code where you can do local reasoning - you can understand each bit of the code independently because they run in a decoupled way. You don't need to read 3 modules and synthesise them together in your head. Similarly the behaviour of one bit of code is much less likely to affect the behaviour of other code unexpectedly.

      • By gr4vityWall 2024-10-0217:341 reply

        Question: do you know any of such more complex systems which are Free Software, so that I could take a look?

        Sounds like it would be a fun learning experience.

        • By rkangel 2024-10-0219:071 reply

          I'd love to point you at some code from work but it's all closed source unfortunately.

          One example that gets used is https://github.com/hexpm/hex which is the code behind the Elixir package management website. It's more a good example of a Phoenix app than it is general OTP stuff, but there are some GenServers in there.

    • By jimbokun 2024-10-0217:291 reply

      > that's a mental model for programs that goes against how most people I know reason about code.

      But the mental model most of us have for reasoning about code in environments with concurrent execution is simply wrong.

      So the Elixir model is more understandable, if you want a correct understanding of what your code will do when you run it.

      • By gr4vityWall 2024-10-0219:091 reply

        > the mental model most of us have for reasoning about code in environments with concurrent execution is simply wrong.

        Could you elaborate on that?

        • By jimbokun 2024-10-0221:091 reply

          It is very difficult to write thread based code with no bugs.

          • By necovek 2024-10-034:01

            I don't think that's true: it's pretty easy, really (basically, adopt the message passing/directed-acyclic-graph structure in your threads).

            What is true is that it's too easy to write threads-based code with bugs, and therein lies the problem.

    • By hinkley 2024-10-031:50

      People seem to do okay figuring out concurrency in programming languages with global interpreter locks. At some point you know actions are happening serially, so you have to worry less about having to update three pieces of state to finish a task because unless you start a side quest in the middle of the calculation, any other code will either see the Before or After state, not any of the 2 intermediate states.

HackerNews