#6 Understanding Promises
In modern programming there are two major concepts that every programmer needs to understand when thinking about how CPU’s compute their…
Relax, I’m not going to wax philosophical about how no one can keep a promise these days (although now that you mention it...). I'm talking about the promise programming design pattern in Javascript.
In modern programming there are two major concepts that every programmer needs to understand when thinking about how CPU’s compute their code: asynchronous(“Async”) vs synchronous (“Sync”).
Synchronous processes can be thought of as doing things in a strict and explicit order: Something1, then Something2, then Something3, etc.
Asynchronous can be thought of as doing Something1 and not waiting for Something1 to finish before you tackle Something2.
The problem with Sync only code is that it can be unnecessarily slow and cause the CPU to wait when it could be using cycles to do other things. Whereas the problem with Async code is that sometimes you need the results from Something1 before you tackle Something2 (i.e. you need to control the flow of results).
You can think of writing code as giving someone very explicit cooking instructions. Take for example making some eggs and toast:
Step 1: Get two eggs from fridge Step 2: Take out pan, place on stove top, turn on stove to medium Step 3: Take eggs from Step 1, crack them in the pan from Step 2. Step 4: Cook eggs for 3 min, turn off the stove Step 5: Get two slices of bread from the pantry Step 6: Take out slice of bread and place in toaster, toast Step 7: Get a plate out of the cupboard Step 8: Put toast on plate and eggs on plate
Eggs and toast in 8 steps, not bad. That said, from a compute time perspective, we were a bit inefficient. Note that on Step 4 we waited for 3 minutes (!) and did nothing during that time besides watch the eggs cook. You would never do that!
Introducing the Promise:
When we write this program using promises it’s a bit different:
function makeEggs(rawEggs){ let cookingPromise = new Promise( (resolve, reject ) => { let cookedEggs; // When eggs are done, send it out for consumption: resolve( cookedEggs ) } return cookingPromise; } function makeToast(untoastedBread){ let cookingPromise = new Promise ( (resolve, reject) => { // When toast is done, send it out for plating: resolve( toastedBread ) } return cookingPromise; }
Now we have two functions that: (1) makeEggs; and (2) makeToast, both of which return cooking promises. The way to think about this is like you sent those two tasks to separate cooks who PROMISED (see what I did there?) to cook you the food and will let you know once they’ve RESOLVED (this one was less clever) their task. Once they are done, they will have “resolved” the promise, thereby producing the results of the process that you desired. (Note: from a performance perspective, the correct way of thinking about it is, while one cook waits for the eggs to cook, the same cook runs and goes to toast the bread, constantly looking back at the eggs to see when they are done. For now, however, the two cooks analogy helps people visualize it better.)
With the result of a promise out of the way, here’s where the magic happens:
// The makeEggs and makeToast functions were defined above let eggsPromise = makeEggs(newEggs); let toastPromise = makeToast(newSliceOfBread); Promises.all( [eggsPromise, toastPromise] ) // Once the promises have resolved, do the following: .then( resultsOfCooking => { // Plate the eggs and toast plateIngredients( resultsOfCooking ) })
What promises let us do is process both the eggs and toast separately, and then when both processes have completed you have the benefit of receiving the results at the same time. As a result, you don’t have to write your code in a way that says: Step 1, then Step 2, then Step 3.
I want to make something clear, this is not to be confused with multi-threading a process (which we will tackle in another post). The promise-based design pattern allows you to run commands asynchronously, but does not provide the performance benefits of multi-threading. Typically these types of patterns are used when the timing of results from a function is dependent on some other event. For example, when Server_A is making a call to Server_B and does not know when it will receive a reply. Instead of hanging there and wasting compute cycles, it continues to process commands on the same thread.
But Tony, what about async / await keywords?
Technically you can write something like this:
async function makeBreakfast(rawEggs, untoastedBread) { let cookedEggs = await makeEggs(rawEggs); let toastedBread = await makeEggs(rawEggs); plateIngredients ( cookedEggs, toastedBread ) }
I think of the await keyword as trapping the processing until that line resolves. This prevents a racing situation where we use the results of a function before we know whether it has produced a result. Just because you can, doesn’t mean you should, which is why I avoid async / await keywords unless absolutely necessary. The problem I find is that it disrupts the flow of the code and you start to get into a maze of “crap, what else do I have to prefix as async.” At the end of the day, it’s just syntactic sugar for dealing with promises to make it feel and read more like Sync code. If it’s Sync, then write it in a Sync way. If it’s Async, then write it in an Async way. More junior coders (including myself back in the day) pepper the keywords all over the place as a result. The pattern that I find easiest for folks to understand (and therefore maintain) is properly using the .then and .catch methods. We’ll tackle those methods and concepts around chaining and error handling in a future post.