[HN Gopher] Serialization Is the Secret
___________________________________________________________________
Serialization Is the Secret
Author : borromakot
Score : 250 points
Date : 2024-09-29 13:26 UTC (3 days ago)
(HTM) web link (www.zachdaniel.dev)
(TXT) w3m dump (www.zachdaniel.dev)
| fracus wrote:
| This was very enlightening for me on the subject of immutability.
| mrkeen wrote:
| > 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.
|
| Are you never not inside a called function?
|
| This just sounds like pervasive mutability with more steps.
| colonwqbang wrote:
| It sounds like all old bindings to the value stay the same. So
| you have a "cell" inside which a reference is stored. You can
| replace the reference but not mutate the values being referred
| to.
|
| If so, this sounds a lot like IORef in Haskell.
| finder83 wrote:
| The functions don't return a mutable version of a variable or
| anything. You still get an immutable copy (it may not be an
| actual copy, I don't know the internals) of the state, and the
| state he's referencing in a Genserver is the current state of a
| running process that runs in a loop handling messages. For
| example in liveview, each connection (to an end-user) is a
| process that keeps state as part of the socket. And the editing
| is handled through events and lifecycle functions, not through
| directly mutating the state, so things tend to be more
| predictable in my experience. It's kind of like mutation by
| contract. In reality, it's more like for each mailbox message,
| you have another loop iteration, and that loop iteration can
| return the same value or a new value. The new values are always
| immutable. So it's like going from generations of variables,
| abandoning the old references, and using the new one for each
| iteration of the loop. In practice though, it's just message
| handling and internal state, which is what he means by "from
| the perspective of our program".
|
| You typically wouldn't just write a Genserver to hold state
| just to make it mutable (though I've seen them used that way),
| unless it's shared state across multiple processes. They're not
| used as pervasively as say classes in OOP. Genservers usually
| have a purpose, like tracking users in a waiting room, chat
| messages, etc. Each message handler is also serial in that you
| handle one mailbox message at a time (which can spawn a new
| process, but then that new process state is also immutable), so
| the internal state of a Genserver is largely predictable and
| trackable. So the only way to mutate state is to send a
| message, and the only way to get the new state is to ask for
| it.
|
| There's a lot of benefits of that model, like knowing that two
| pieces of code will never hit a race condition to edit the same
| area of memory at the same time because memory is never shared.
| Along with the preemptive scheduler, micro-threads, and process
| supervisors, it makes for a really nice scalable (if well-
| designed) asynchronous solution.
|
| I'm not sure I 100% agree that watching mutating state requires
| a function to observe it. After all, a genserver can send a
| message to other processes to let them know that the state's
| changed along with the new state. Like in a pub-sub system. But
| maybe he's presenting an over-simplification trying to explain
| the means of mutability in Elixir.
| borromakot wrote:
| `send` is a function. `receive` is a special form but in this
| context it counts as a function
| bux93 wrote:
| I think the author means "I said everything is immutable, and
| rebinding is obviously changing something, but the thing it
| changes doesn't count!". The idea being, if you read a bunch of
| code, none of the variables in that piece of code can have the
| value of it changed unless there is some explicit line of code.
| borromakot wrote:
| I didn't mean you "must be inside of a function".
|
| If you call `Process.put(:something, 10)`, any references you
| have to whatever was already in the process dictionary will not
| have changed, and the only way to "observe" that there was some
| mutating state is that now subsequent calls to
| `Process.get(:something)` return a different value than it
| would have before.
|
| So with immutable variables, there is a strict contract for
| observing mutation.
| sailorganymede wrote:
| I really enjoyed reading this because it explained the topic
| quite simply. It was well written !
| tromp wrote:
| > One of the major elements that sets Elixir apart from most
| other programming languages is immutability.
|
| It's interesting to compare Elixir to that other immutable
| programming language: Haskell.
|
| In Elixir, a binding counter = counter + 1
|
| binds counter to the _old_ value of counter, plus 1. In Haskell
| it instead binds counter to the _new_ value plus 1.
|
| Of course that doesn't make sense, and indeed this causes an
| infinite loop when Haskell tries to evaluate counter.
|
| BUT it does make sense for certain recursive data structures,
| like an infinite list of 1s: ones = 1 : ones
|
| We can check this by taking some finite prefix:
| ghci> take 5 ones [1,1,1,1,1]
|
| Another example is making a list of all primes, where you don't
| need to decide in advance how many elements to limit yourself to.
|
| Can you define such lazy infinite data structures in Elixir?
| chucky_z wrote:
| https://hexdocs.pm/elixir/Stream.html
|
| Although I don't think it'll be quite as elegant as the Haskell
| code.
| finder83 wrote:
| Infinite, yes, but I would say it's not quite as core to the
| language as it is in Haskell where everything's lazy. Infinite
| streams are quite simple though:
| Stream.iterate(1, fn(x) -> x end) |> Enum.take(5)
| [1, 1, 1, 1, 1]
| tromp wrote:
| How do you use that for lists that do not simply iterate,
| like ghci> fibs = 0 : 1 : zipWith (+) fibs
| (drop 1 fibs) ghci> take 10 fibs
| [0,1,1,2,3,5,8,13,21,34] ?
| darcien wrote:
| You can use Stream.unfold/2:
| Stream.unfold({0,1}, fn {a,b} -> {a,{b,a+b}} end) |>
| Enum.take(10) [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
|
| https://rosettacode.org/wiki/Fibonacci_sequence#Elixir
| torginus wrote:
| I'm a bit confused - isn't this how all Static Single
| Assignment representations in compilers work? And those are
| used in things like LLVM IR to represent C and C++ code. Is C++
| immutable now?
| kqr wrote:
| The difference at the high level is that assigning a variable
| creates a new scope. E.g. in C I would expect to be able to
| int i = 0; while (i < 5) { i = i+1;
| printf("i: %d\n", i); }
|
| whereas in Haskell I could hypothetically something like
| let i = 0 in whileM (pure (i < 5)) $
| let i = i + 1 in printf "i: %d\n" i
|
| but the inner assignment would not have any effect on the
| variable referenced by the condition in the while loop - it
| would only affect what's inside the block it opens.
|
| (And as GP points out, i=i+1 is an infinite loop in Haskell.
| But even if it was used to build a lazy structure, it would
| just keep running the same iteration over and over because
| when the block is entered, i still has the value that was set
| outside.)
| eru wrote:
| Btw, Haskell also supports mutable re-assignment of
| variables. But it's not something that's built into the
| language, you get mutable variables via a library. Just
| like you can get loops in Haskell via a library.
| kqr wrote:
| Oh, yeah, it's right there in the standard library. But
| one has to be a little more explicit about the fact that
| one is accessing truly mutable variables. For example,
| given the helper utilities checkIORef r
| p = fmap p (readIORef r) usingIORef r a =
| readIORef r >>= a
|
| the example can be written as main = do
| i <- newIORef (0 :: Int) whileM_ (checkIORef i
| (< 5)) $ do modifyIORef i (+1)
| usingIORef i (printf "i: %d\n")
|
| That said, even if one actually needs to mix mutable
| variables with I/O actions like printing, I'm not sure I
| would recommend using IORefs for it. But opening the can
| of MonadIO m => StateT Int m () is for another day.
| eru wrote:
| You can also use the State monad, instead of IORef.
| kqr wrote:
| Not while printfing. That is what takes a MonadIO m =>
| StateT s m () transformer, or at least a stack with a
| Writer in it.
| eru wrote:
| Yes, that's true.
| cies wrote:
| I'm much stricter when it comes to what means immutable.
| counter = counter + 1
|
| vs counter += 1
|
| Are exactly the same to me. In both cases you bind a new value to
| counter: I don't care much if the value gets updated or new
| memory is allocated. (sure I want my programs to run fast, but I
| dont want to be too much worried about it, the
| compiler/interpreter/runtime should do that "good enough" most of
| the times)
|
| In the absence of type safety immutability --IMHO-- becomes a bit
| of a moot point. This is valid Elixir: x = 10
| y = 25 z = x + y y = "yeahoo" IO.puts
| "Sum of #{x} and #{y} is #{z}"
|
| Trying to add another line "z = x + y" to the end, and you have a
| runtime error.
|
| The "feature" of Elixir that allows scoped rebinding to not
| affect the outer scope, looks frightening to me. Most of the IDEs
| I've worked with in the past 15 years warn me of overshadowing,
| because that easily leads to bugs.
|
| Haskell was already mentioned. There we can see real
| immutability. Once you say a = 3, you cannot change that later
| on. Sure sometimes (in many programs this can be limited) you
| need it, and in those cases there's Haskell's do-notation, which
| is basically syntactic sugar for overshadowing.
| nuancebydefault wrote:
| >Most of the IDEs I've worked with in the past 15 years warn me
| of overshadowing
|
| Most IDEs adapt their rules for warnings to the file type.
|
| As i understand it, Elixir leans more to the functional
| paradigm, so the rules are different. This different paradigm
| has the pros described in the article. Of course it also has
| cons.
|
| If shadowing is a feature of the language, a feature that is
| often used, the programmer, who has shifted their thinking to
| that paradigm, knows that, and a warning is not needed.
| cies wrote:
| I'd say that rebinding a value to a variable is similar to
| shadowing IN THE SAME SCOPE. Pretty much what Haskell does
| with the do-notation.
| cies wrote:
| I find FP and shadowing have nothing to do with eachother
| (maybe except though Haskell's do-notation)
| nuancebydefault wrote:
| You have a point. Maybe the shadowing is a paradigm on
| itself, supporting FP. If you know it happens all the time,
| and you know every var lives in the current scope, all you
| have to care about is the current scope. Just like in C
| function-local-vars (which might or not shadow globals) ,
| but then applied to every code block.
| mrkeen wrote:
| > Maybe the shadowing is a paradigm on itself, supporting
| FP.
|
| Not really. Shadowing is compiler-warning in Haskell.
| borromakot wrote:
| The point wasn't to encourage shadowing bindings from a parent
| scope, only to illustrate that it is not the same thing as
| mutable references. Whether to, and when to, use rebinding is a
| different conversation, but in some cases it can lead to subtle
| bugs just like you described.
| cies wrote:
| I still think the difference to me as a programmer is largely
| semantic (except when the performance has to be considered):
| "rebinding" or "in-place mutation", in both cases it is a
| mutable variable to me.
| borromakot wrote:
| There is a guarantee that you have when rebinding is the
| only option.
|
| with rebinding: def thing() do
| x = 10 do_something(x) # x is 10, non
| negotiably, it is never not 10 end
|
| with mutability def thing() do
| x = 10 do_something(x) # who knows what
| x is? Could be anything. end
|
| Additionally, because rebinding is a syntactic construct,
| you can use a linter to detect it: https://hexdocs.pm/credo
| /Credo.Check.Refactor.VariableRebind...
| cies wrote:
| I find this a much better example than the example shown
| in the article. I even find it quite useful when
| explained like this.
|
| Thanks!
| mrkeen wrote:
| > Haskell's do-notation, which is basically syntactic sugar for
| overshadowing.
|
| Do-notation does not relate to variable shadowing.
|
| It's syntactic sugar over excessive flatmapping / pyramid of
| doom.
| nmadden wrote:
| Rebinding is nicer than mutation, but neither are referentially
| transparent.
| xavxav wrote:
| What do you mean? let-bindings don't interfere with referential
| transparency. `let x = 1 in let x = 2 in foo` is referentially
| transparent.
| Izkata wrote:
| I think you're thinking of shadowing, not re-binding.
| kreetx wrote:
| Yup, as a Haskeller, it's important to remember that
| rebinding means something else in other languages.
| nmadden wrote:
| The example given in the article is:
| counter = 0 counter = counter + 1
|
| This is very different to shadowing where there is a clear
| scope to the rebinding. In this case, I cannot employ
| equational reasoning within a scope but must instead trace
| back through every intervening statement in the scope to
| check whether the variable is rebound.
| tonyg wrote:
| It's a straightforward syntactic transformation. The two
| are equivalent. The scope of the rebound variable begins at
| the rebinding and ends when the surrounding scope ends.
| Perfectly clear - the only difference is a "let" keyword.
| counter = 0 ... counter = counter + 1 ...
|
| vs let counter = 0 in ... let
| counter = counter + 1 in ...
| nmadden wrote:
| The ellipses in your straightforward transformation are
| doing some heavy lifting there. Typically the let...in
| construct has some way to indicate where the scope of the
| "in" part ends: indentation (Haskell), explicit "end"
| marker (SML) etc. Even with that, shadowing does make
| equational reasoning harder (you have to look at more
| surrounding context) and should generally be avoided.
| jerf wrote:
| Referential transparency is not a property of Elixir or Erlang
| anyhow. In Haskell terms, everything is always in IO. So this
| doesn't seem particularly relevant.
| akoboldfrying wrote:
| 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...
| borromakot wrote:
| 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`.
| Closi wrote:
| So VB6 had it right all along?
| vanderZwan wrote:
| In more ways than one, I've been told.
| iamwil wrote:
| What are the other ways?
| avaldez_ wrote:
| On error resume next /jk
| snapcaster wrote:
| i loved VB6 (our school intro programming class used it) and i
| sometimes wonder how much influence that had on my future
| career choices. It was a programming environment that felt fun,
| powerful and not scary at all
| LegionMammal978 wrote:
| 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.)
| packetlost wrote:
| 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!
| jerf wrote:
| 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.
| mrkeen wrote:
| > 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.)
| jerf wrote:
| 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.
| AnimalMuppet wrote:
| 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.
| jerf wrote:
| 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
| mrkeen wrote:
| > 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-...
| mrkeen wrote:
| > 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?
| asa400 wrote:
| 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.
| LegionMammal978 wrote:
| 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
| dartos wrote:
| I have mixed feelings about rust's async story, but it is
| really nice having good historical documentation like
| this.
|
| Thanks for the links!
| toast0 wrote:
| 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.
| jerf wrote:
| "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.)
| gr4vityWall wrote:
| 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.
| azeirah wrote:
| > 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".
| gr4vityWall wrote:
| > 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.
| cpill wrote:
| Hey, and hence the rampant adoption of Haskell :P
| toast0 wrote:
| 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.
| gr4vityWall wrote:
| 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.
| toast0 wrote:
| > 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. :)
| Nevermark wrote:
| 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.
| rkangel wrote:
| > 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.
| gr4vityWall wrote:
| 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.
| rkangel wrote:
| 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.
| jimbokun wrote:
| > 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.
| gr4vityWall wrote:
| > the mental model most of us have for reasoning about code
| in environments with concurrent execution is simply wrong.
|
| Could you elaborate on that?
| jimbokun wrote:
| It is very difficult to write thread based code with no
| bugs.
| prerok wrote:
| What I don't understand about immutability is performance. How do
| these languages achieve small memory footprints and avoiding
| continuous allocations of new object versions, because a single
| property changed?
|
| I mean, all I see are small scale examples where there are only a
| few properties. The production Rust code I did see, is passing
| copies of objects left and right. This makes me cringe at the
| inefficacy of such an approach.
|
| Disclaimer: I have 0 experience in immutable languages, hence the
| question :)
| greener_grass wrote:
| 1. Structural sharing - most data doesn't change so you create
| a copy that reuses much of the original structure
|
| 2. Garbage collection and lexical scopes - you clean up memory
| quickly and in batch
|
| 3. Compiler optimizations - you turn functional constructs into
| imperative ones that reuse and mutate memory at compile-time,
| where it is provably safe to do so
|
| Roc Lang might interest you:
| https://www.youtube.com/watch?v=vzfy4EKwG_Y
| whateveracct wrote:
| "Purely Functional Data Structure" by Chris Okasaki is a
| classic and imo a must-read data structures book.
|
| https://www.cs.cmu.edu/~rwh/students/okasaki.pdf
| heeton wrote:
| Small example to show that performance can be great: Phoenix
| (the Rails-comparable web framework for Elixir) defaults to
| listing microseconds instead of milliseconds for response
| times.
| prerok wrote:
| Hmm, is rails really comparable to anything that's
| precompiled (genuine question, I don't mean this
| dismissively).
|
| What I really meant was, as a backend engineer, I frequently
| deal with optimizations on too many object allocations and
| long running/too frequent GC cycles even without immutability
| built into the language.
|
| On the Rust front, the problem is in small memory
| allocations, fragmented memory and then more calls to kernel
| to alloc.
| samatman wrote:
| Ruby/Rails and Elixir/Phoenix both run on a garbage-
| collected virtual machine. I think that makes the
| comparison fair.
| munificent wrote:
| _> The production Rust code I did see, is passing copies of
| objects left and right._
|
| You might be surprised how fast memcpy() is in practice on
| modern hardware. It's worth sitting down and writing a little C
| program that moves memory around and does some other stuff to
| get a feel for what the real world performance is like.
| sroussey wrote:
| I too come from a time of worrying about memory access
| patterns and usage (pre virtual memory in hardware), so my
| initial reaction is like the parent comment, at least
| instinctively.
|
| And yes, memcpy is fast, but I would not use a little program
| to convince myself. You will end up with stuff in CPU caches,
| etc, which will give you a very incorrect intuition.
|
| Better to take a large program where there is a base factory
| and make some copies there or something and see how it
| affects things.
|
| That said... for most businesses these days, developer time
| is more expensive than compute time, so if you're not
| shipping an operating system or similar, it simply doesn't
| matter.
|
| And an optimizing compiler could do something like copy on
| write, and make much of the issue moot.
|
| I had a brief period of time designing a simple CPU and it's
| made everything since turn my stomach a little bit.
| steveklabnik wrote:
| Just because something is semantically a copy doesn't mean that
| they will not be optimized out. Take a look at this example:
| https://godbolt.org/z/roxn43eMc
|
| While create() semantically copies the struct Foo out to its
| caller, with optimizations on, the function isn't even invoked,
| let alone a copy be made.
|
| That said, of course sometimes this optimization is missed, or
| it can't happen for some reason. But my point is just that some
| Rust code may look copy heavy but is not actually copy heavy.
| prerok wrote:
| Thank you!
|
| Yeah, I will have to take a closer look at just why the copy
| elision isn't happening in the cases I looked at...
| skybrian wrote:
| I can see this being useful for the same reason that it's useful
| that non-async functions in JavaScript can't be interrupted -
| it's as if you held a lock for the entire function call.
|
| Between any two event handlers, anything could change. Similarly
| for await calls in JavaScript. And to get true parallelism, you
| need to start a separate worker. Concurrency issues can still
| happen, but not in low-level operations that don't do I/O.
|
| I don't see anything wrong with mutating a local variable in
| cases when it's purely a local effect. It's sometimes cleaner,
| since you can't accidentally refer to an obsolete version of a
| variable after mutating it.
___________________________________________________________________
(page generated 2024-10-02 23:00 UTC)