Posts‎ > ‎

Fearless concurrency: how Clojure, Rust, Pony, Erlang and Dart let you achieve that.

posted Feb 24, 2019, 9:13 AM by Renato Athaydes   [ updated Feb 24, 2019, 1:42 PM ]
Anyone who has written any concurrent code (i.e. code where more than one Thread of execution is present) involving more than a couple of mutable variables knows that it's really hard to get it right using the low-level tools like locks and semaphores.

Testing this kind of code is even harder: you're forced to write probabilistic tests (i.e. tests that can prove the presence of a bug, but never its absence with 100% certainty even after a large number of runs) or use tools specifically designed to test concurrency (which can be pretty complex themselves).

Most reasonable developers tend to use higher level concurrency primitives (thread-local, concurrent collections etc) to write concurrent code if at all possible. The problem is, that sometimes, unfortunately, these tools are just not sufficient, it's still easy to shoot your own foot and get lost in a sea of complexity.

For these reasons, several models that make it easier to reason about concurrent programs have been envisioned over time.

In this article, we'll have a quick look at a few of them, from new to not-so-new languages. I don't intend to give an extensive analysis of each solution, or make a formal comparison between them. My intention is to simply explain the basics of each solution and how they can be used in practice (with code samples that show off what the result of using the models might look like), so that other developers may have an easier time understanding them and deciding which solution, or language, might be better applicable to their particular problems.


Concurrency in this article is meant to refer to the multi-threaded execution of tasks over possibly multiple CPUs, or even machines.
This implies at least the possibility of parallelism. See Concurrency VS Parallelism for the subtleties of the difference.





Alternative concurrency models


Immutability, Functional Programming (Clojure)


A popular alternative to make concurrent code safe and easier to understand is to just remove mutable variables entirely from the equation.

After all, when all things are immutable, concurrency becomes much more manageable, as functional programming languages have shown for a long time. Shared state is not a problem unless it's mutable state.

It may appear to be impossible to write an application using only immutable data structures in case you're not familiar with functional programming, but that's definitely not the case. To understand how, let's have a look at a very simple example.

The following example shows how Clojure allows replacing some element of an immutable list (or vector):



 (def my-vec [0 1 2 3 4 5])

; replace element at index 3 with x
(def new-vec (assoc my-vec 3 :x))

(println my-vec)
(println new-vec)



Result:


[0 1 2 3 4 5]

[0 1 2 :x 4 5]


The original vector, my-vec, is not modified. It's immutable, after all... but we can create another vector, new-vec, which contains the contents of the original vector, but with one or more elements replaced, as in this example where the 4th element is replaced with :x.

Programs that are written using only immutable data structure always have to work this way: instead of changing something, you apply a function to it to get a new reference to the "modified" something. For other parts of the code to "see" changes, they must explicitly ask for the new reference, as the copy they might have had will never change.

Such programs can be trivially parallelized because there's never any need to synchronize access to immutable state (and there's no mutable state!).

The example below shows how we can transform each element of a vector by running some expensive calculation on a different Thread for each item (explanation below):




(defn random-long-between
[min max]
(def max-rand (- max min))
(long (+ min (rand max-rand))))

(defn expensive-operation
[n]
(let [wait-millis (random-long-between 250 750)]
(Thread/sleep wait-millis)
{:n n :even? (even? n) :delay wait-millis}))

(defn eager-map-async
"Applies fn fun on each element of the given sequence on a separate Thread,
returning a vector with the results."
[fun sequence]
; future applies the given function asynchronously in another Thread
(let [async-seq (map (fn [n] (future (fun n))) sequence)]
; we then use '@' to de-reference each future, getting the new elements into a vector
(vec (map (fn [fut] @fut) async-seq))))

 (def my-vec [0 1 2 3 4 5])
; Apply and time the eager-map-async function on my-vec
(def new-vec (time (eager-map-async expensive-operation my-vec)))

(doall (map println new-vec))



Result:



"Elapsed time: 622.28397 msecs"

{:n 0, :even? true, :delay 616}

{:n 1, :even? false, :delay 557}

{:n 2, :even? true, :delay 254}

{:n 3, :even? false, :delay 611}

{:n 4, :even? true, :delay 584}

{:n 5, :even? false, :delay 257}




The eager-map-async function in the code above does the main job of calling the provided function, expensive-operation in this case, in a different Thread by using the future function, part of the Clojure SDK. It then blocks waiting for all elements to be realized by future (using the @ symbol to de-reference the future), before returning a vector containing all the results.

You can see that the full vector transformation took 622 ms to complete, which is expected if all operations ran in parallel, as the longest delay was 616 ms (presumably, the other 6 ms were spent doing actual work).



This example only touches upon Clojure's concurrency facilities! You can learn more about concurrency in Clojure on the Brave Clojure online book.



It may sound very wasteful to make copies all the time, but it's possible to avoid fully copying data structures on each modification via structural sharing and other techniques. Clojure's data structures are very smart in that regard! This article on hypirion.com explains in detail how they work. If you just want to learn to use them, purelyfunctional.tv has an ultimate guide on Clojure collections.

But immutability and functional programming are not the only game in town. There are other ways to write concurrent applications with guarantees about correctness (even though it might still be hard to write them!).

The next section briefly explains the Rust solution to the problem.


Ownership System (Rust)



One of the most intriguing solutions to the concurrency problem is the system used by Rust: the ownership system, supported by the (in)famous Rust borrow checker. With this system, you can write concurrent code as you always did in languages like Java or C++, with the difference that any mistake you make will be caught by the borrow checker. If there's a possibility that you passed a mutable variable to a function, and that variable may be accessed simultaneously from another Thread, the Rust borrow checker will yell at you and your code won't even compile, let alone run. It's great, but the cost is high: programs that can be easily written in most languages can be taxing to write in Rust, even when they would be safe to run without all the protections that Rust demands.


The ownership system was devised primarily as a solution to memory management without a garbage-collector. However, the system also solves concurrency issues, with a little help of the type system, because memory management and concurrency are actually intrinsically linked.


The following example demonstrates how Rust handles mutable variables (even in the absence of concurrency, as in Rust, anything is visible from all Threads, so the possibility of concurrent access is always there):



use std::collections::LinkedList;

fn main() {
let mut list: LinkedList<u64> = LinkedList::new();
list.push_back(1);
list.push_back(2);
list.push_back(3);
println!("{:?}", &list);
}



Result:

[1, 2, 3]


Notice that the declaration of list includes the mutable keyword, mut. Failing to include that keyword would make the list immutable, so the push_back method could not be used in that case:


error[E0596]: cannot borrow `list` as mutable, as it is not declared as mutable
 --> src/main.rs:9:5
  |
8 |     let list: LinkedList<u64> = LinkedList::new();
  |         ---- help: consider changing this to be mutable: `mut list`
9 |     list.push_back(1);
  |     ^^^^ cannot borrow as mutable


This shows the borrow checker in action. When you call a method in Rust, the receiver, list in this case, needs to be borrowed by the method implementation itself! When you borrow something that you might want to modify, you must borrow it as mutable.

This is what the push_back implementation does:



pub
fn push_back(&mut self, elt: T) { ... }



This allows Rust to know when a value may be changed by a block of code, which helps it reason about what's safe to allow.

Let's say we wanted to add the elements to the list in a separate function:



use std::collections::LinkedList;

fn main() {
let mut list: LinkedList<u64> = LinkedList::new();
add_elements_to(&mut list);
println!("{:?}", &list);
}

fn add_elements_to(list: &mut LinkedList<u64>) {
list.push_back(1);
list.push_back(2);
list.push_back(3);
}



Result:

[1, 2, 3]


That still works and is safe, so Rust allows it. But suppose we wanted to do this in a separate thread... now, that's no longer safe, so Rust won't let you do that:



use std::collections::LinkedList;

fn main() {
let mut list: LinkedList<u64> = LinkedList::new();
thread::spawn(move || {
add_elements_to(&mut list);
}).join().unwrap();
println!("{:?}", &list); // ERROR!!!!
}

fn add_elements_to(list: &mut LinkedList<u64>) {
list.push_back(1);
list.push_back(2);
list.push_back(3);
}



Error:


  --> src/main.rs:12:22
   |
9  |     thread::spawn(move || {
   |                   ------- value moved into closure here
10 |         add_elements_to(&mut list);
   |                              ---- variable moved due to use in closure
11 |     }).join().unwrap();
12 |     println!("{:?}", &list);
   |                      ^^^^^ value borrowed here after move
   |
   = note: move occurs because `list` has type `std::collections::LinkedList<u64>`, which does not implement the `Copy` trait


Error messages in Rust are extremely nice in most cases. You can clearly see the Rust developers put a lot of effort into making things clear for users when errors occur.

In our case above, it clearly informs us that we can't use list after we moved it (notice the move keyword when we declared the Thread's closure, which implicitly moves any variables the closure uses into the new scope) into the closure's scope as it does not implement the Copy trait (which presumably would have allowed that). And we had to move it because it's not thead-safe to allow two different threads to have access to a mutable variable.

Rust has a nice solution to this problem: channels.

Below, we use a channel to be able to get back our linked-list after giving it to another thread:



use
std::thread;
use std::collections::LinkedList;
use std::sync::mpsc;

fn main() {
let (tx, rx) = mpsc::channel();
let mut list: LinkedList<u64> = LinkedList::new();
thread::spawn(move || {
add_elements_to(&mut list);
tx.send(list).unwrap();
}).join().unwrap();

let list = rx.recv().unwrap();
println!("{:?}", &list);
}

fn add_elements_to(list: &mut LinkedList<u64>) {
list.push_back(1);
list.push_back(2);
list.push_back(3);
}



Result:

[1, 2, 3]


This style of handling concurrency safely without locks is called message passing, and is one of the core concepts of the Actor model, which we'll encounter in the next sections. But Rust also lets you use lower-level concurrency tools.

Let's look at the example the Rust Book gives on how to use a Mutex to handle shared mutable state (in this case, a counter):



use
std::sync::{Mutex, Arc};
use std::thread;

fn main() {
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];

for _ in 0..10 {
let counter = Arc::clone(&counter);
let handle = thread::spawn(move || {
let mut num = counter.lock().unwrap();

*num += 1;
});
handles.push(handle);

}
for handle in handles {
handle.join().unwrap();
}

println!("Result: {}", *counter.lock().unwrap());
}



Result:

Result: 10


This example starts up 10 Threads at the same time (approximately) and then waits for all of them to run. Each Thread accesses and changes the counter, which at the end is printed by the main function. The reason Rust allows that is that before each Thread accesses the counter, they obtain a lock that protects it from concurrent access (the lock is automatically released when the Arc it's in goes out of scope).


Arc stands for atomic reference count and is the secret for sharing state between multiple threads in Rust.


This is good old-fashioned concurrency with locks, but with a little help from a smarter compiler!

Next, we'll look at another system language, but one that guarantees the absence of deadlocks and race conditions.

Reference Capabilities (Pony)



Another interesting alternative is being popularized by the Pony programming language: it uses a capabilities system to indicate which variables are safe to share between threads (or Actors, which are similar to green threads in Pony). For example, if a variable is immutable (a val in Pony) then Pony will let you share it with other Actors without any limitations. If the variable is iso (for isolated), meaning there's only one reference to it at any given time (which the compiler verifies), then it's also safe to share it even if it's mutable (because no two Actors can write to it at the same time). And there are a few other capabilities that give you fine control to define how your variables should be used, with the compiler ensuring it's safe to use them as such.

To get a taste of what that looks like, let's write a small Pony program that passes a mutable variable, say, an Array[U64], between two Actors. The first actor, Main, creates an array, sends it to the Adder actor which knows how to add a number n to each element of the array, then sends the result back to Main, which prints it.




actor Main
let _env: Env
let _adder: Adder

new create(env: Env) =>
_env = env
_adder = Adder(this)
start()

be start() =>
let my_array: Array[U64] iso = [1; 2; 3; 4]
_adder.addN(consume my_array, 4)

be showResult(result: Array[U64] iso) =>
let a: Array[U64] box = consume result
let len = a.size() - 1
let str = recover String end
str.append("[")
for (i, item) in a.pairs() do
str.append(item.string())
if i < len then
str.append(", ")
end
end
str.append("]")
_env.out.print(consume str)

actor Adder
let _runner: Main

new create(runner: Main) =>
_runner = runner

be addN(array: Array[U64] iso, n: U64) =>
let result = recover iso
let a: Array[U64] ref = consume array
for (i, item) in a.pairs() do
try a.update(i, item + n)? end
end
a
end
_runner.showResult(consume result)



Result:


[5, 6, 7, 8]


Notice that the array in question is always the same array, no copy is made of the original. But as we're using Pony Actors, the array is definitely being passed between threads (assuming the two actors are run in different threads, which may not be always the case), but unlike in most languages, this is completely safe (memory- and concurrency-safe) in Pony.

To understand why, a more detailed explanation is due.

First of all, notice that the methods of the actors in the example above are declared with the be keyword. That means they are not actually methods in the usual sense of the word (which Pony does have, they are declared with fun), they are behaviours. Behaviours are executed asynchronously (and concurrently with other behaviours!). For that reason, the arguments they take must be sendable, and they never return any value directly.

As we saw earlier, it's possible to send a mutable variable to another Actor if and only if it's an iso variable (it's also possible to send vals as they are immutable, and tags which are basically other Actors). That's why we declare addN (which wants to mutate the array it's given) like this:




be addN(array: Array[U64] iso, n: U64) => ...



As you can see, the capability is declared after the type. Array[U64] iso means an array of unsigned-64-bit integers with the iso capability. When calling this behaviour, the owner of the array must give it up, which is done with consume:



_adder.
addN(consume my_array, 4)




The n argument is a Primitive, so it's a val and can always be passed safely to other Actors. By consuming the my_array variable, we ensure that there's still only one reference to it, and that reference now belongs to the body of the addN behaviour.


Pony Primitives, interestingly, are different from other languages in that they can be user-defined. Combined with union types, they allow for very efficient and concise code.


Looking at the implementation of the addN behaviour, notice the following line:



let a: Array[U64] ref = consume array



This may look pointless, but it's necessary because array here has the iso capability, but to modify the array's internals we need the ref capability (see update method). The ref capability allows us to read and write to a variable. So, the above line basically casts an iso to a ref. This is safe as long as we don't pass the ref around, which we are not allowed to do by the compiler.

Finally, it's interesting to notice that by the end of the addN behaviour, we'll need to pass the array back to the Main actor, but we can't do that yet as our array now is a ref (and we consumed the original array reference, so it can't be used anymore). In order to recover the iso capability that will let us do that, we wrap the whole part of the code where we mutate the array inside a recover block.



let result = recover iso
let a: Array[U64] ref = consume array
for (i, item) in a.pairs() do
try a.update(i, item + n)? end
end
a
end
_runner.showResult(consume result)



The Pony docs explain this in more detail, but the important thing to understand is that within a recover block, only sendable values from the enclosing lexical scope can be accessed, hence the result of the block is also sendable. So we end up with a sendable result!


It's absolutely brilliant, but unfortunately, as far as I know, Pony has not been gaining much popularity in the several years it's already been around... I've been following its progress for a while as a curious bystander, and it's a bit sad to see that, even though Pony brings something quite innovative to the table, it looks like that's just not enough to attract many people to it. That may be due to the tough competition, with Rust taking most of the interest from people working on high performance, highly concurrent systems. But I suspect it's due to the lack of tooling (I even wrote a Gradle plugin for it and tried to contribute to the VS Code plugin at some point but was met with total lack of interest from the people running the project, so gave up) and the lack of a larger community around it, which is always a problem for any new language.

But I still have some hope for Pony, as I think people may come to realize that provably safe concurrency (no deadlocks, no race conditions) and high performance (advanced, per-actor garbage-collector with predictable pauses) at the same time is kind of a big deal.

The Actor Model (Erlang, Dart)



The last alternative solution to writing concurrent applications safely that I am aware of is the Actor Model. It had already been invented by the 1970's, as most of the greatest ideas in computer science, but it's still slowly making its way into mainstream.

Some languages that are based on the Actor Model include Erlang and its sister language, Elixir. Other languages have libraries to support the Actor Model, for example, Akka for Java/Scala, Riker for Rust, CAF for C++. There's even a cross-platform library called Proto.Actor which enables Go, .Net and Java/Kotlin actors to communicate with each other.


Pony is also part of this category, but due to the quite different way messages work in Pony (for example, they can be mutable) and its unique capabilities system, I decided to keep it in its own special category.


In the Actor Model, each Actor has its own state, which no other Actor can interact with except via message passing. Hence, concurrency is achieved transparently by the runtime, with the actual application code never having to worry about that.

Let's look at the basic ping-pong example from the Erlang documentation:



-
module(tut15).

-export([start/0, ping/2, pong/0]).

ping(0, Pong_PID) ->
Pong_PID ! finished,
io:format("ping finished~n", []);

ping(N, Pong_PID) ->
Pong_PID ! {ping, self()},
receive
pong ->
io:format("Ping received pong~n", [])
end,
ping(N - 1, Pong_PID).

pong() ->
receive
finished ->
io:format("Pong finished~n", []);
{ping, Ping_PID} ->
io:format("Pong received ping~n", []),
Ping_PID ! pong,
pong()
end.

start() ->
Pong_PID = spawn(tut15, pong, []),
spawn(tut15, ping, [3, Pong_PID]).



Result:


1> c(tut15).
{ok,tut15}
2> tut15: start().
<0.36.0>
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
ping finished
Pong finished


Erlang is a traditional functional language, so as you can see, actors are really just functions that receive/send messages to each other. The syntax to send a message to an actor, which Erlang calls process (not an OS process, Erlang processes are much cheaper to create and many thousands of them can exist at any given time) is:


process_pid ! message.


A process PID can be obtained by spawning a new actor, which is done by calling spawn with the module and function to run, as well as the initial message to give it:



Pong_PID = spawn(tut15, pong, [])



The ping function then can send a message to the process with this PID:



Pong_PID ! {ping, self()}



On the receiver side, pattern-matching is used to handle the message (as Erlang is dynamically typed, patterns match on contents rather than types, unlike with Pony and, as we'll see, Dart) within a receive block:



pong
() ->
receive
finished ->
io:format("Pong finished~n", []);
{ping, Ping_PID} ->
io:format("Pong received ping~n", []),
Ping_PID ! pong,
pong()
end.




Very neat.

This is quite similar to how Dart Isolates work!

Here's the roughly equivalent ping-pong example in Dart:



import
'dart:io' show exit;
import 'dart:isolate';

class PingStartMessage {
final int n;
final SendPort pong;

PingStartMessage(this.n, this.pong);
}

ping(PingStartMessage startMessage) {
var n = startMessage.n;
final pong = startMessage.pong;
final pingPort = ReceivePort();
pingPort.listen((message) {
if (message == 'pong') {
print("Ping received pong");
if (n > 0) {
pong.send(pingPort.sendPort);
n--;
} else {
pong.send('finished');
}
}
});
pong.send(pingPort.sendPort);
}

pong(SendPort starterPort) {
final pongPort = ReceivePort();
pongPort.listen((message) {
if (message == 'finished') {
print("Pong finished");
starterPort.send(message);
} else if (message is SendPort) {
print("Pong received ping");
message.send('pong');
}
});
starterPort.send(pongPort.sendPort);
}

main() async {
final starterPort = ReceivePort();
await Isolate.spawn(pong, starterPort.sendPort);
starterPort.listen((message) {
if (message is SendPort) {
// got pong's port, give it to ping
Isolate.spawn(ping, PingStartMessage(3, message));
} else if (message == 'finished') {
exit(0);
}
});
}



Result:


Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong received ping
Ping received pong
Pong finished


It's a little bit more verbose than Erlang as we need to do some more maintenance to start the Isolates and manage their ports ourselves (see the isolate library, which helps with the boilerplate). But the essence is the same.

Without Isolates, Dart is actually a single-threaded language, even considering its async/await feature. Asynchronous functions are simply run in what Dart calls microtasks, which is its event loop, similarly to JavaScript.


Notice that all Dart code runs in an Isolate, including main. But if the application code never starts any other Isolates, the whole application runs in a single Thread, on a single CPU.
As of Dart 2, Isolates are not supported in web applications - they need to use Web Workers instead. However, they work fine in Flutter.


Isolates, however, run in parallel with other Isolates. They do not share any memory with other Isolates, hence are very similar to Erlang processes.

In the above example, we can see that several different types of messages were being exchanged. For example, when the ping isolate was started, we gave it a PingStartMessage right away:



Isolate.spawn(ping, PingStartMessage(3, message));



The pong Isolate responds to a SendPort message (the type of the object used to send messages to other Isolates) with the pong string:



if (message is SendPort) {
print("Pong received ping");
message.send('pong');
}



When the ping Isolate receives that, it might send another ping or a finished message:



if (message == 'pong') {
print("Ping received pong");
if (n > 0) {
pong.send(pingPort.sendPort);
n--;
} else {
pong.send('finished');
}
}



All messages being sent in this example are immutable, but Dart does not actually require that, which can be confusing.

This example demonstrates that:



import
'dart:isolate';
import 'dart:io' show exit;

class Message {
final List<int> list;
final SendPort sender;

Message(this.list, this.sender);
}

go(Message msg) {
msg.list.add(10);
print("Added 10 to the list: ${msg.list}");
msg.sender.send('ok');
}

main() async {
final port = ReceivePort();
final list = [1, 2, 3];

await Isolate.spawn(go, Message(list, port.sendPort));

port.listen((msg) {
if (msg == 'ok') {
print("List after receiving message: $list");
exit(0);
}
});
}



Result:

Added 10 to the list: [1, 2, 3, 10]
List after receiving message: [1, 2, 3]


The reason is simple: again, no memory is shared between Dart Isolates. The List the Isolate in the example above modifies is NOT the same list the main function created. Messages are copied when they cross the Isolate boundary. For that reason, for something like the above to work, it's necessary that the Isolate send back the result of its computation. Otherwise, there's no way for any Isolates to observe the changes made by it.

Given this knowledge, we can rewrite the example above correctly now:



import
'dart:isolate';
import 'dart:io' show exit;

class Message {
final List<int> list;
final SendPort sender;

Message(this.list, this.sender);
}

go(Message msg) {
msg.list.add(10);
print("Added 10 to the list: ${msg.list}");
msg.sender.send(msg.list);
}

main() async {
final port = ReceivePort();
final list = [1, 2, 3];

await Isolate.spawn(go, Message(list, port.sendPort));

port.listen((msg) {
if (msg is List<int>) {
print("List after receiving message: $msg");
exit(0);
}
});
}



Result:

Added 10 to the list: [1, 2, 3, 10]
List after receiving message: [1, 2, 3, 10]



Honourable mentions


Notice that this article does not include Go, a language that admittedly has an elegant concurrency solution as well (Go channels) because that solution is not actually thread-safe - it's not very hard to have race conditions in Go or corrupt state because Go does not enforce a separation of shareable and not-shareable mutable state.

For the same reason, I decided to not include Kotlin co-routines either. They suffer from essentially the same problem.

It should be clear to the reader how the solutions presented here differ from that.

I need to also mention Haskell would probably be a better example than Clojure of a functional language that suppoorts easy concurrency because of the central roles immutable data plays in it. I only decided to use Clojure because of my familiarity with it and the fact that it seems quite a lot easier to manage concurrency in Clojure from my biased point-of-view. Hope the Haskell fans out there will forgive me :).


Conclusion


This was a long article, but I hope that you've learned something new about concurrency that will be useful to you regardless of which language you use.

The different solutions discussed in this article do not form clean, separate categories, but I believe the grouping made sense. The important for me was to try to capture the essence of each model, and show how they help  make sure concurrency is done safely.

In Clojure, even though mutability is actually supported via some lower-level primitives and the JVM itself (all Clojure code can call any Java code), as long as users stick to the immutable collections in concurrent code (which is very easy to do, and even idiomatic in Clojure) they will be completely safe from concurrency issues.

It is true that it's possible for any language to use this strategy (including Java and C++), but functional programming languages in general tend to be much better in dealing with immutable data - it's their sweet spot.

Rust, on the other hand, at the cost of requiring a detailed description by the programmer of what can be changed, borrowed, sent to other threads etc. even in code that is not intended to be used concurrently, allows the programmer to achieve a high level of confidence in the thread-safety of their programs, and with very little runtime costs.

Pony is mid-way between Rust and Erlang. It has extremely high performance, but generally looks and feels like a much higher level language. It has such a sexy syntax (looks like Python, but no, it doesn't use syntactically meaningful whitespace!) that it makes it hard for me to not want to write all my code in it! But the lack of libraries and community, as well as tooling, really hurts its adoption.

Erlang, the oldest and most battle-tested language in the list, offers a superbly reliable VM and it (or Elixir) should definitely be considered by anyone wishing to write a highly available, distributed system that doesn't necessarily require very high bare metal performance. Its use of dynamic typing makes me a little bit hesitant to use it, though, as I really love the help provided by static typing.

Dart has a very approachable concurrent system that anyone familiar with Java, for example, can learn in a matter of minutes. The async/await support is amazing, and Isolates are fairly easy to code against.
It is important to understand, though, that Dart may let you write code that doesn't behave correctly without warning if you don't understand the limitations of Isolates (complete isolation of memory). This limitation is not something that may be lifted in the future with more work from the Dart team: it's the fundamental feature of Dart that allows it, as Erlang, to support real parallelism without any concurrency primitive at all.

Finally, I'd like to mention that there's no best system, in my opinion. For each occasion, any of the systems above could be the best solution. They are all very good, but have different strengths and weaknesses which I hope the reader will be better equipped to discern after reading this article and doing a little bit further research based on the information provided.



Comments