26

Actors are not a good concurrency model (2010)

 5 years ago
source link: https://www.tuicool.com/articles/hit/RjmAvmB
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

Update: Also seefollow up post and discussion on reddit .

Actors are not a good concurrency model, and neither are Erlang processes.

Wait, what? Isn't Erlang the king of high-uptime distributed systems? If Erlang can do all this using the actor model (Erlang processes are identical to actors in the essential ways, discussed below), isn't there something good about it? Isn't there??

Well, yes. What's good about it is it's better than the dinosaur era alternative of shared-state preemptive multithreading. But so what? Just because the actor model is better than dinosaur era technology doesn't mean we should keep using it. Remember the rise of OO? Before OO, the alternative was programming in languages like C, with pretty much zero support for polymorphism. Any approach to polymorphism was better than nothing at all, so OO's concepts of classes and subtyping was a huge improvement (my opinion is that polymorphism was the real unfilled niche that OO filled). Now that we've learned a bit more, OO has started to seem less appealing to some - parametric polymorphism exists independent of OOP, and bounded polymorphism can be implemented with either typeclasses or a combination of first-class and higher-order modules. It's possible there are some other ideas from OO worth salvaging, but more likely I think OO is just an evolutionary dead-end.

And so it might be with actors. Though I don't know exactly what the replacement looks like yet (I'd like to look at some alternatives and explore ideas in a future post), I do know what's wrong with actors, and that's what I'd like to explore here. But going further, I'd like to use actors as an example to show what's also problematic with side effects and impure functions in general . In doing so I'll try to avoid the usual FP evangelizing and make this a more precise, technical argument.

So what's wrong with actors? The problem which dooms the actor model is that actors are fundamentally not composable . I'm going to leave that term undefined for now, except to say vaguely that entities are composable if we can easily and generally combine their behaviors in some way without having to modify the entities being combined . I think of composability as being the key ingredient necessary for acheiving reuse, and for achieving a combinatorial expansion of what is succinctly expressible in a programming model. (Also see this explanation by Tony Morris .) It goes without saying that code reusability is a very desireable property for programs to have, and many of the other virtues of good software engineering end up tying back to reusability - for instance, testability is nothing more than the ability to reuse a component in the context of a test.

So what makes actors not composable? Well, an actor (and an Erlang process) is constructed from a function A => Unit . That this function returns Unit rather than a meaningful type means that it must hardcode some action to take once the result ( whose type is not even shown ) is available . It is this unfortunate property that destroys any chance we have at combining actors in any sort of general way like we would in a typical combinator library.

As a simple example, consider the following two actors: 1) an actor that accepts lists of integers and turns the list into a min-heap. 2) An actor that accepts (heap,k) pairs and extracts the top k elements in order from the given heap. Can we write a function that accepts these two actors and returns an actor which accepts (list,k) pairs and pulls out the k minimum elements in sorted order? And generalizing a bit, can we write the more general combining function, the one that doesn't care whether the types are 'heap', 'int' and 'list' or A , B or C ?

You just can't do this with actors since you can't make any assumptions about what the actor will do with the result - it might forward it to some other actor, write it to a file, extract from it the missile launch codes and launch the missile, etc. In fact, if you actually try to achieve the composability I'm talking about using actors, what you end up doing is having all actors return their result as a response to their sender, essentially recreating pure functions within the actor model, badly, losing type information in the process. What this should tell you is that actors, if worth anything, might be useful more as a tool for building some other higher-level abstraction. But if that's the case, perhaps we shouldn't even expose actors as a programming model and instead just program directly in the higher-level model.

In practice, I suspect actors are basically only used at the top level of a program, where the damage to composability is minimized, and pure functions (which are composable and work fine at any level of the program) are used everywhere else to achieve reuse. The problem with this approach is when you find out a few months later that what you thought was going to be the top level of your program actually is going to become a very small embedded component in some larger system, and this top level now contains a large amount of unreusable complex logic that must be gutted to work as an embedded component.

The problem I am talking about here is not in any way specific to actors. It applies to any functions with side effects. I claim the only way to achieve maximum composability is to program with pure functions, and this applies to concurrent programs or to any other programs you'd like to write. (Sometimes, you decide it's worth taking the composability hit and you write functions with side effects - of course I don't object to this in absolutely all cases - but I don't think the fundamental model underlying concurrent and distributed programming should have to take this hit.)

What makes pure functions composable is they are only the logic of the computation. A pure function makes no decisions about what actions to take with the result of the computation, and also makes no decisions about what actions to take before the computation is executed. By keeping these concerns separate, we can reuse the function elsewhere in places where we need to do something different with the result, or where we need to do something different before the computation is executed (for instance, in testing, to generate an instance of the input type rather than obtaining the input from some impure function).

In fact, you can think of any impure function as having three "steps": 1) An "input" side effect, Unit => B , a pure function (A,B) => C , and an "output" side effect C => Unit . It makes perfect software engineering sense to decouple these components - and that is exactly what is done in purely functional programming.

Let's look at a very simple example. Suppose I write a sort function, which sorts the input list in place. For this function, we have no input side effects. The pure core of this function is (conceptually) a sorting function that returns a new list. What is the output side effect? It is to rebind the name that the input list is bound to in the caller to the sorted version of the list

. So if the caller were something like

def foo(list: ArrayList[Int]): Foo = { 
  ...
  sort(list)
  ...
}

then sort will rebind the name list to the sorted version of that list. Since list is itself a parameter of foo , this rebinding will occur in the caller of foo , and possibly its caller, all the way out to the place where that list was originally declared. The question is, should the sort function really be making the decision about whether to do this? If we let the sort function make this decision, we're allowing it to make an assumption about the caller, namely, that the caller no longer intends to maintain references to the unsorted version of the list (an assumption which, incidentally, is not even tracked by the type system).

Of course, this example of an in-place sort isn't too terrible. The caller just needs to be aware of this side effect and has to adjust its calling convention accordingly. But the lack of uniformity in chaining and combining logic caused by side effects starts to add up very quickly. In practice, what actually occurs in large systems with impure functions sprinkled about is that many functions end up with so many assumptions about their callers (and these assumptions propagate to their callers, as above) that the call graph becomes very static, with functions often having only a single or small number of callers. This makes the system a lot more rigid, more difficult to test without elaborate mocking and dependency injection frameworks (which brings with it its own set of problems), and results in a lot of (often hidden) duplication of logic.

Other simple examples show more obvious destruction of composability. If I write a function that reads a bunch of things from a file and then performs some complex logic, we can't easily reuse that logic elsewhere (unless we want to populate a file first, which may not be what we want). If a function performs some complex logic and then launches the missile, we can't easily reuse that complex logic elsewhere in places where we don't want to launch the missile.

Taking a step back from all this bashing of actors and side effects, I do certainly agree that actors and side effects in general have a certain "intuitive" appeal, a rough analog to how the real world works. But that does not justify their usage as a programming model. The technical challenges of engineering large pieces of software mean that a good programming model may have to tradeoff intuitiveness for attributes like composability. But I suspect even this tradeoff is overplayed - intuitiveness is also often code language for "what system I am familiar with". Once you learn and internalize a different model your ability to reason within that model (and thus, I believe, its intuitiveness) is more a function of the features and formal structure of that model than of some inherent "intuitiveness" metric.


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK