Why is Programming in Node So Different?
I have a series of screencasts on Node.js that I've been doing now for quite some time through Nettuts+, and I recently received a question in reaction to some of the code in my latest episode. The code in question is pretty typical for anyone familiar with Node, but can cause some serious looks of consternation from anyone new to Node, especially if they have any experience with more traditional web frameworks and languages like Rails, Django, or PHP. The code that caused this question, and is thus the catalyst for this blog post, can be seen below:
function parseBody (req, callback) {
var body = '';
req.on('data', function(chunk) {
body += chunk;
});
req.on('end', function() {
callback(qs.parse(body));
})
}
The code above shows the standard way to get form data from a POST request. The code registers two callback functions: one with the data
event and the other with the end
event. The data
event is fired every time a new chunk of data associated with that particular request is received from the network. The callback, in this case, simply appends the new chunk to a buffer variable. The end
event fires once all chunks for a request have been received, and at this point, our callback simply calls another callback function that was passed into our function, and it passes to it the full request body. Now all this probably sounds pretty complex, so it's no wonder that people new to Node find it somewhat odd and disconcerting. The reason it tends to cause so much confusion amongst those new to Node is that it seems to be an unnecessary over-complication of what is a relatively simple task--getting the data from a simple HTTP POST request. The problem with that statement is that there really is no such thing as a "simple" HTTP POST/GET operation in Node.
If you've used other, more traditional languages and frameworks, like I mentioned above, you're probably expecting to be able to do something like request.POST['name']
to get at the data from a POST request. Well, in those frameworks you can do that due to the fact that they're using a thread to handle each HTTP request. The reason that each request gets its very own thread is that HTTP is inherently asynchronous.
HTTP sits atop a packet-based communication protocol stack that, in turn, sits atop a dynamic network. This means that whenever you hit the Submit in a form in your browser, the data in that form is collected and divvied up into chunks of data (packets) and sent across the network piecemeal. Due to the dynamic nature of the network, these packets, at the lower levels, can be lost, sent in duplicate, or out of order. The higher level protocols take on the task of making sure all of the packets for a request are eventually received and stitched back together in the correct order. However, due to the non-deterministic manner in which these packets can arrive, it is impossible to know when, in what order, or even if, all of the packets that make up a single request will be delivered to the server. As a result, the server must be able to just wait around for all of the packets to come in, and there are two methods that we have devised for doing just that.
The first is to assign a thread to each HTTP connection, and this is what the traditional frameworks like Django and Rails do. Doing so allows a single connection to "wait" around for the remaining packets without blocking the server application from taking on new connections. The nice part about this is that it allows you to program as you would normally, i.e., with calls that block, ordered one after the other. The problem with this method, is that too many threads can quickly bog down your server and exhaust all of its available resources and essentially cause your application to become very slow and possibly even crash.
The second solution is to use an event-oriented mechanism and this is what Node is doing. In this scenario, there is only one thread (this is vastly oversimplified, but for our purposes this definition will suffice) that runs in a loop and calls functions--callbacks that we register with specific events--whenever an event occurs. Doing this allows all of the connections to be interlaced (i.e., multiplexed) onto a single thread. Each connection registers a callback for an event with the main loop and then exits--it's essentially done executing until the event it registered its callback with occurs. The beauty of this is that it doesn't block the main loop, instead it can continue to look for new events, call callbacks, and take new callback registrations. The advantage of this method is that you don't have a ton of threads bogging down the server, just a small set of threads in a thread pool for handling everything. In other words, it's extremely efficient. The downside of this approach though is that, the way you program it is a little unintuitive, as you've seen with the example code above. Also, the developer needs to be more aware of computationally expensive code since it could bring the main loop to a complete stop, blocking all other requests.
I want to delve into that last statement a bit further. By computationally expensive, I mean something that will keep the CPU engaged. While reading a large file from a disk can take a while, it's not computationally expensive since the CPU basically does nothing while a file is being read. The task of reading a file belongs to the disk drive hardware and the CPU only needs to do something once the drive completes its task and notifies the CPU of the completion. In the meantime, it could be off doing something else. An example of a computationally expensive task would be something more like performing a Fibonacci calculation, as this can be a very long task and it takes place solely in the CPU. The CPU must loop (or recurse), constantly calculating this number. So, imagine you receive an HTTP request that asks for the 1000th Fibonacci number (contrived, I know, but bear with me). This is going to take quite a while to complete. In the traditional example that's fine, as all the computation will take place on a separate thread that was created specifically for handling that HTTP request. In the meantime, the system will continue to accept and process new connections on a completely different thread.
In the event-oriented scenario though, this same request would bring the main loop to a screeching halt. In this scenario, the request would come into the main loop and it would call the callback function associated with receiving new requests. If that callback function was just reading a huge file, everything would be just fine since it could just register another callback function with the event for the "end of the file read" event and then exit returning control back to the main loop and allowing the system to continue processing more requests. However, in the case of our Fibonacci example, the callback function would begin calculating the 1000th Fibonacci number and the main loop would be stuck since the callback, rather than exiting immediately, would be crunching through a long Fibonacci calculation. What that would mean is that all incoming requests would also be stuck waiting for the main loop to handle them which, in turn, is stuck waiting for the Fibonacci request to finish its calculation.
Hopefully from this explanation it's now easy (well, at least, hopefully possible) to see that, when dealing with Node, you simply cannot create functions that do any sort of task that can take a long, or indeterminate amount of time. If you are going to do so, then you have to write it in such a way that the calling function can simply register a callback with it and exit, returning control back to its calling function and eventually back to the main loop. What this means with respect to our example code is that we cannot simply call a function or property on our request object and expect to receive an object back representing the data for that request. Doing so would mean blocking our main loop while we wait for the entire request body to be received, and thereby, losing the ability to handle new requests. So, instead, we're stuck with what has been (affectionately?) called "callback hell". While certainly not the most intuitive way to write code, it does offer us the benefit of being able to write, relatively, simple servers that can handle extreme amounts of traffic with little or no performance degradation compared to the more traditional methods.
I hope that helps anyone having trouble understanding why Node seems to do things so weird compared to other technologies. It may be ugly and hard to understand at times, but once you understand the reason for it, you come to see that there is definitely a method to its madness.