Using a single programming language for ages - also known as the easiest way to get ‘programmer tunnel vision’ and think you can code everything in that framework. Maybe you can, but this doesn’t mean it’s the best tool for the job. This is one of the reasons why the last years saw me learning and improving on NodeJs, Elixir and Go.
So I'm at this point where I acquired enough to know that a single programming language is no longer enough, and to talk about how multiple perspectives can make you a better developer and further improve your career goals.
Getting Started: From C/C++ to PHP
The first programming language that made a huge impact on me was one of the first I ever learned: C/C++. I say one of the first because the first one was Pascal (the default back in my high-school days).
C/C++ is like the bread and butter of programming languages. If you learn how to write code in that language you can learn a lot of more high level languages because they all have a very similar core syntax.
PHP helped me a lot to understand how web works and how to build web applications. I learned how HTTP works from request&response and headers to browser caching and status codes, how to work with databases, in memory cache and how to scale an application horizontally. I also learned a lot about OOP and design patterns.
PHP allowed me to move fast or very fast when building backends for web applications. With time I learned to separate the frontend from the API and build multi-tier applications from the start. However, with all its benefits, it did have a few drawbacks:
- Performance - every request to the backend took hundreds of milliseconds if not more.
- Scale - You needed more servers to handle a large number of requests with a limit or a few tens of thousands per node.
- Synchronous - Everything in PHP is synchronous and it has blocks for any external resource you may need.
- Limited Use Cases - PHP is not the best tool to build chat services or applications that are always on.
NodeJs - Server application
For me, coming from LAMP stack(Linux, Apache, MySQL, PHP), NodeJS also allowed me to move past the request=server approach of PHP where you needed to load every resource into memory for every request and not have anything persist after the request is done unless you store them in a DB or cache to instead try to think how I could design the entire server and add everything that could help speed my application before any request comes in.
This, coupled with learning about new projects (like Nginx) allowed me to think beyond the backend-as-a-single-block-of-code that processes requests. I started to perceive the backend as a series of components that can each function on their own and are focused on completing their tasks with as little dependencies as possible.
This might sound similar to how microservices work, however try to think more in terms of how you would build your backend if you didn't use any established framework and you would have the freedom to build the entire stack... which means:
- no Apache to create threads of execution;
- no Symfony to give you a file structure and handle requests;
- no particular relational database that you have to use, etc.
Once you get this mindset, you enter a whole new realm of possibilities and you actually start to think more about the architecture of your application instead of just following a standard chosen for you by your current framework.
For me, this meant that I could build real-time applications using web sockets and stream data between services. This on top of the increased speed of execution of my application.
This did not happen over night and there is always something new that could change my perspective again... and something else in fact did, as you will see as you read on.
One thing that NodeJs lacks is resilience. If you have any unhandled exception and you are not aware of it everywhere in your code or library that you use you may end up crashing the entire process and your application along with it. Since NodeJs is single-threaded, any running code is potentially affected by a crash (whichever area we're taking about).
Another limitation of NodeJs I did not like is that it runs as a single-threaded process and it does not efficiently use all of the available resources. Sure you can start multiple processes - one for each vCPU - but that only works when building applications like a web server or worker when the processes that you start all look exactly the same.
So naturally, I wanted to move past these limitations and I started to look for alternatives. I started to look more closely to Go and Elixir and used both of them on various small personal projects.
Elixir - Resiliency & Immutability
The more I was working with Elixir the more I liked it. Having started with both Go and Elixir at a similar time, I was always comparing my experience between the two, even if not very fairly at times.
The more I was working with Elixir the more I started to see opportunities instead of limitations as compared to NodeJs and Go.
From the ground up, Elixir (or, better said, the Erlang VM or BEAM) is build with support for resiliency in mind and the concept it uses is soo simple that it's made me rethink how I write an application in any other language as well.
Elixir defines the concept of a 'process' that represents a single synchronous execution context.
This process is not a system process or a thread, but a much lighter representation of one that allows the developer to create as many processes as he needs.
Resiliency comes through another concept called 'supervisor' which is as simple as one process that monitors another process. In case a process fails while doing what it was intended, the supervisor will automatically be notified that the process it monitors failed and it will then restart it.
This can be grouped into hierarchies of processes that monitor other processes and in order to crash an Elixir application you would have to crash the root process (Erlang VM) of that hierarchy starting from a leaf and moving up through the parent supervisors. And this is very very hard to do.
In Elixir, everything is immutable and optimised for that immutability. While there is no concept of a reference, everything gets copied when needed - and I stress when needed because the Erlang VM knows that everything is immutable and optimises when a memory space should be reused and when it should not. It also means that you don't have to worry about one process modifying the data of another process because - again - everything is immutable.
In order to ensure this, the VM does two things:
- It limits access to variables to only the process that defined those variables.
- It copies data when they are passed from one process to another.
So the obvious question is: if everything is immutable, how do you manage a state you want to access from multiple processes?
Well, you create a process that manages that state and you send messages to that process to get or update the state/variable.
What this means is that if a process is responsible for providing a simple functionality, only that process has the right to define how it should be handled. Single Responsibility Principle all the way. These processes can be grouped to provide complex functionalities all while ensuring resiliency and immutability throughout your application.
These simple concepts in Elixir also gives you one other built in feature: Scalability.
We said that processes are synchronous and all data is immutable. In order to work with asynchronous tasks you simply send a message to another process and wait for a response. You create and work with processes, but we have not defined where those processes are located.
When you create a process, you get a PID back that you can use to monitor or interact with that processes. That PID has the format "<NODE.ID.SERIAL>" and is split as follows:
- NODE: the id of the node the process is on in the cluster (0 for the current node);
- ID: the id of the process on the node from 0 to MAXPROCS;
- SERIAL: a counter in case the number of processes exceeds MAXPROCS.
What this means if that you interact in exactly the same way with a remote process as you do with a local process. Most of your application need not be aware of such trivial details as network or scale.
Another awesome thing about it is that this simple concept of processes coupled with immutability also abstracts the use of multi-core processors so your application makes use of all the CPU power it can right from the start.
Ok Elixir is awesome.
But what you learn from it is not just how it works, but also how to design a system that doesn't just connect to a database, runs some queries and returns a result, but instead you design your application to be a living thing. Sure you may load some data from the database or persist them when you need to, but the fun part in all this happens in-between.
You can distribute a load to multiple nodes based on a certain pattern, keep an internal state with the most requested data from the database and only persist when needed, handle node failure or restarts in a graceful way by moving the state to another server before shutting down and a lot more possibilities that you would not normally think about in a traditional application.
Elixir is however a high-level language which runs on top of a virtual machine. Its running speed is high but still it can not be compared to the speed of a lower-level language, so when you need to have a lot of computational power you may want to look at some alternatives.
Go: Performance over Scale
A while back, I started to work on a matching engine and I had a goal set to handle 1 million transactions per second in order to demonstrate to a client that it can be done. So naturally, I wanted something capable of performing a lot of raw computations and since I had some recent experience with it, I chose Go.
After using Elixir for a while, I started to miss some of the built-in functionality it gave me. Anyhow, I focused on the task at hand. The first iteration of the code was a somewhat naive approach and was able to process only around 20k orders per second - way too far from my initial goal.
When you first get into using Go you learn a lot of channels, Go procedures and how easy they are to create and use. While Go delivers in these aspects, it also comes with an associated performance cost - same as any bad piece of code you write does.
If you really care about performance in your application, you will quickly realise that you can't truly affect it unless you start measuring every optimisation you want to add along with their impact. So you learn how to use the built-in Go perf tool to measure CPU and memory consumption or go-torch to get a quick overview in which sections you can still make improvements.
In terms of code structure, Go has none and is very similar with NodeJs in this regard. This can be a bit daunting at first, but as you write more and more Go code you are happy that you can make the right decisions for your app and you can easily restructure code as you see fit.
In my case, for the matching engine, I also added unit tests, benchmarks, code coverage and runtime metrics while improving the performance to a little over the 1 million mark.
In some languages we get soo used to just installing and using a third party packages and while you can do that in Go as well, it's not always a good idea if you have specific use cases in mind.
No silver bullets
While not advocating that you use these three programming languages, I would like to encourage you to try to play around with multiple languages, design patterns and software architectures in order to see what new concepts you could learn to make the code you write today a lot better.
Since I like to practice what I'm preaching, I've been trying to cultivate the above mindset at Around 25. Today, my colleagues hold the concept of fullstack developer at a high standard, not because they can work in multiple areas or because it's easier to form teams that deliver, but also because it forces them to think more about the entire user experience not just the small part they develop.
This being said, we're not changing the languages we use every day. Nor do we use multiple languages in the same project for no reason. But we do understand that there is no silver bullet when it comes to choosing the right technology for your project. And I hope more fellow coders understand this as well.