Sequential Brain
I’m pretty sure most of you readers have heard someone say (even made the claim yourself), “I’m a multitasker.” The effects of trying to act as a multitasker range from humorous (e.g., the silly patting-head-rubbing-stomach kids’ game) to mundane (chewing gum while walking) to downright dangerous (texting while driving).
But are we multitaskers? Can we really do two conscious, intentional actions at once and think/reason about both of them at exactly the same moment? Does our highest level of brain functionality have parallel multithreading going on?
The answer may surprise you: probably not.
That’s just not really how our brains appear to be set up. We’re much more single taskers than many of us (especially A-type personalities!) would like to admit. We can really only think about one thing at any given instant.
I’m not talking about all our involuntary, subconscious, automatic brain functions, such as heart beating, breathing, and eyelid blinking. Those are all vital tasks to our sustained life, but we don’t intentionally allocate any brain power to them. Thankfully, while we obsess about checking social network feeds for the 15th time in three minutes, our brain carries on in the background (threads!) with all those important tasks.
We’re instead talking about whatever task is at the forefront of our minds at the moment. For me, it’s writing the text in this book right now. Am I doing any other higher level brain function at exactly this same moment? Nope, not really. I get distracted quickly and easily — a few dozen times in these last couple of paragraphs!
When we fake multitasking, such as trying to type something at the same time we’re talking to a friend or family member on the phone, what we’re actually most likely doing is acting as fast context switchers. In other words, we switch back and forth between two or more tasks in rapid succession, simultaneously progressing on each task in tiny, fast little chunks. We do it so fast that to the outside world it appears as if we’re doing these things in parallel.
Does that sound suspiciously like async evented concurrency (like the sort that happens in JS) to you?! If not, go back and read Chapter 1 again!
In fact, one way of simplifying (i.e., abusing) the massively complex world of neurology into something I can remotely hope to discuss here is that our brains work kinda like the event loop queue.
If you think about every single letter (or word) I type as a single async event, in just this sentence alone there are several dozen opportunities for my brain to be interrupted by some other event, such as from my senses, or even just my random thoughts.
I don’t get interrupted and pulled to another “process” at every opportunity that I could be (thankfully — or this book would never be written!). But it happens often enough that I feel my own brain is nearly constantly switching to various different contexts (aka “processes”). And that’s an awful lot like how the JS engine would probably feel.
Doing Versus Planning
OK, so our brains can be thought of as operating in single-threaded event loop queue like ways, as can the JS engine. That sounds like a good match.
But we need to be more nuanced than that in our analysis. There’s a big, observable difference between how we plan various tasks, and how our brains actually operate those tasks.
Again, back to the writing of this text as my metaphor. My rough mental outline plan here is to keep writing and writing, going sequentially through a set of points I have ordered in my thoughts. I don’t plan to have any interruptions or nonlinear activity in this writing. But yet, my brain is nevertheless switching around all the time.
Even though at an operational level our brains are async evented, we seem to plan out tasks in a sequential, synchronous way. “I need to go to the store, then buy some milk, then drop off my dry cleaning.”
You’ll notice that this higher level thinking (planning) doesn’t seem very async evented in its formulation. In fact, it’s kind of rare for us to deliberately think solely in terms of events. Instead, we plan things out carefully, sequentially (A then B then C), and we assume to an extent a sort of temporal blocking that forces B to wait on A, and C to wait on B.
When a developer writes code, they are planning out a set of actions to occur. If they’re any good at being a developer, they’re carefully planning it out. “I need to set z
to the value of x
, and then x
to the value of y
,” and so forth.
When we write out synchronous code, statement by statement, it works a lot like our errands to-do list:
// swap `x` and `y` (via temp variable `z`)
z = x;
x = y;
y = z;
These three assignment statements are synchronous, so x = y
waits for z = x
to finish, and y = z
in turn waits for x = y
to finish. Another way of saying it is that these three statements are temporally bound to execute in a certain order, one right after the other. Thankfully, we don’t need to be bothered with any async evented details here. If we did, the code gets a lot more complex, quickly!
So if synchronous brain planning maps well to synchronous code statements, how well do our brains do at planning out asynchronous code?
It turns out that how we express asynchrony (with callbacks) in our code doesn’t map very well at all to that synchronous brain planning behavior.
Can you actually imagine having a line of thinking that plans out your to-do errands like this?
“I need to go to the store, but on the way I’m sure I’ll get a phone call, so ‘Hi, Mom’, and while she starts talking, I’ll be looking up the store address on GPS, but that’ll take a second to load, so I’ll turn down the radio so I can hear Mom better, then I’ll realize I forgot to put on a jacket and it’s cold outside, but no matter, keep driving and talking to Mom, and then the seatbelt ding reminds me to buckle up, so ‘Yes, Mom, I am wearing my seatbelt, I always do!’. Ah, finally the GPS got the directions, now…”
As ridiculous as that sounds as a formulation for how we plan our day out and think about what to do and in what order, nonetheless it’s exactly how our brains operate at a functional level. Remember, that’s not multitasking, it’s just fast context switching.
The reason it’s difficult for us as developers to write async evented code, especially when all we have is the callback to do it, is that stream of consciousness thinking/planning is unnatural for most of us.
We think in step-by-step terms, but the tools (callbacks) available to us in code are not expressed in a step-by-step fashion once we move from synchronous to asynchronous.
And that is why it’s so hard to accurately author and reason about async JS code with callbacks: because it’s not how our brain planning works.
Note: The only thing worse than not knowing why some code breaks is not knowing why it worked in the first place! It’s the classic “house of cards” mentality: “it works, but not sure why, so nobody touch it!” You may have heard, “Hell is other people” (Sartre), and the programmer meme twist, “Hell is other people’s code.” I believe truly: “Hell is not understanding my own code.” And callbacks are one main culprit.
Nested/Chained Callbacks
Consider:
listen( "click", function handler(evt){
setTimeout( function request(){
ajax( "http://some.url.1", function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
} );
}, 500) ;
} );
There’s a good chance code like that is recognizable to you. We’ve got a chain of three functions nested together, each one representing a step in an asynchronous series (task, “process”).
This kind of code is often called “callback hell,” and sometimes also referred to as the “pyramid of doom” (for its sideways-facing triangular shape due to the nested indentation).
But “callback hell” actually has almost nothing to do with the nesting/indentation. It’s a far deeper problem than that. We’ll see how and why as we continue through the rest of this chapter.
First, we’re waiting for the “click” event, then we’re waiting for the timer to fire, then we’re waiting for the Ajax response to come back, at which point it might do it all again.
At first glance, this code may seem to map its asynchrony naturally to sequential brain planning.
First (now), we:
listen( "..", function handler(..){
// ..
} );
Then later, we:
setTimeout( function request(..){
// ..
}, 500) ;
Then still later, we:
ajax( "..", function response(..){
// ..
} );
And finally (most later), we:
if ( .. ) {
// ..
}
else ..
But there’s several problems with reasoning about this code linearly in such a fashion.
First, it’s an accident of the example that our steps are on subsequent lines (1, 2, 3, and 4…). In real async JS programs, there’s often a lot more noise cluttering things up, noise that we have to deftly maneuver past in our brains as we jump from one function to the next. Understanding the async flow in such callback-laden code is not impossible, but it’s certainly not natural or easy, even with lots of practice.
But also, there’s something deeper wrong, which isn’t evident just in that code example. Let me make up another scenario (pseudocode-ish) to illustrate it:
doA( function(){
doB();
doC( function(){
doD();
} )
doE();
} );
doF();
While the experienced among you will correctly identify the true order of operations here, I’m betting it is more than a little confusing at first glance, and takes some concerted mental cycles to arrive at. The operations will happen in this order:
doA()
doF()
doB()
doC()
doE()
doD()
Did you get that right the very first time you glanced at the code?
OK, some of you are thinking I was unfair in my function naming, to intentionally lead you astray. I swear I was just naming in top-down appearance order. But let me try again:
doA( function(){
doC();
doD( function(){
doF();
} )
doE();
} );
doB();
Now, I’ve named them alphabetically in order of actual execution. But I still bet, even with experience now in this scenario, tracing through the A -> B -> C -> D -> E -> F
order doesn’t come natural to many if any of you readers. Certainly, your eyes do an awful lot of jumping up and down the code snippet, right?
But even if that all comes natural to you, there’s still one more hazard that could wreak havoc. Can you spot what it is?
What if doA(..)
or doD(..)
aren’t actually async, the way we obviously assumed them to be? Uh oh, now the order is different. If they’re both sync (and maybe only sometimes, depending on the conditions of the program at the time), the order is now A -> C -> D -> F -> E -> B
.
That sound you just heard faintly in the background is the sighs of thousands of JS developers who just had a face-in-hands moment.
Is nesting the problem? Is that what makes it so hard to trace the async flow? That’s part of it, certainly.
But let me rewrite the previous nested event/timeout/Ajax example without using nesting:
listen( "click", handler );
function handler() {
setTimeout( request, 500 );
}
function request(){
ajax( "http://some.url.1", response );
}
function response(text){
if (text == "hello") {
handler();
}
else if (text == "world") {
request();
}
}
This formulation of the code is not hardly as recognizable as having the nesting/indentation woes of its previous form, and yet it’s every bit as susceptible to “callback hell.” Why?
As we go to linearly (sequentially) reason about this code, we have to skip from one function, to the next, to the next, and bounce all around the code base to “see” the sequence flow. And remember, this is simplified code in sort of best-case fashion. We all know that real async JS program code bases are often fantastically more jumbled, which makes such reasoning orders of magnitude more difficult.
Another thing to notice: to get steps 2, 3, and 4 linked together so they happen in succession, the only affordance callbacks alone gives us is to hardcode step 2 into step 1, step 3 into step 2, step 4 into step 3, and so on. The hardcoding isn’t necessarily a bad thing, if it really is a fixed condition that step 2 should always lead to step 3.
But the hardcoding definitely makes the code a bit more brittle, as it doesn’t account for anything going wrong that might cause a deviation in the progression of steps. For example, if step 2 fails, step 3 never gets reached, nor does step 2 retry, or move to an alternate error handling flow, and so on.
All of these issues are things you can manually hardcode into each step, but that code is often very repetitive and not reusable in other steps or in other async flows in your program.
Even though our brains might plan out a series of tasks in a sequential type of way (this, then this, then this), the evented nature of our brain operation makes recovery/retry/forking of flow control almost effortless. If you’re out running errands, and you realize you left a shopping list at home, it doesn’t end the day because you didn’t plan that ahead of time. Your brain routes around this hiccup easily: you go home, get the list, then head right back out to the store.
But the brittle nature of manually hardcoded callbacks (even with hardcoded error handling) is often far less graceful. Once you end up specifying (aka pre-planning) all the various eventualities/paths, the code becomes so convoluted that it’s hard to ever maintain or update it.
That is what “callback hell” is all about! The nesting/indentation are basically a side show, a red herring.
And as if all that’s not enough, we haven’t even touched what happens when two or more chains of these callback continuations are happening simultaneously, or when the third step branches out into “parallel” callbacks with gates or latches, or… OMG, my brain hurts, how about yours!?
Are you catching the notion here that our sequential, blocking brain planning behaviors just don’t map well onto callback-oriented async code? That’s the first major deficiency to articulate about callbacks: they express asynchrony in code in ways our brains have to fight just to keep in sync with (pun intended!).