Lecture 17 Asynchronous Programming

Motivation

JavaScript Is Single-Threaded

Diagram showing that normal JavaScript code runs on a single thread.

Normal JavaScript code only executes on a single thread. That means the lines of code execute one at a time.

But We Have Lots of Long-Running and Sporadic Tasks!

Diagram of the JavaScript runtime showing main JavaScript code and asynchronous systems like timers, DOM events, web fetches, and database or file access.
  • but we frequently need to make calls to external, long-running tasks or tasks that fire unpredictably in response to user inputs like timers, DOM events, web fetches (e.g., loading images, calling LLMs), and database and file accesses. If we did all of that work synchronously, i.e., in the main flow of the JS program, our code would halt waiting for the other tasks to resolve.
  • all these tasks can be run asynchronously, which means they can run independently from the rest of the JavaScript code. In the rest of this lecture we'll look at programming design patterns and abstractions for handling asynchronous code

Asynchrony Is a Double-Edged Sword

Benefits

  • Exploit available parallelism
  • Do 10 things are once, finish 10 times faster
  • Continue to be responsive while waiting for something slow

Costs

  • Have to present pending status to the user
  • Have to remember what we were waiting for
  • Asynchrony is confusing b/c it's not sequential
    • can't trace code sequentially,
      many possible sequences!
    • code is complex, hard to read
    • source of many bugs
    • hard to debug

We'll see in an upcoming lecture how reactive programming can help mitigate some of these downsides.

<script defer src="s3.js"></script> <!--page fetch continues while s3.js is fetched--> <!--s3.js executes after page is loaded--> <!--fetch in parallel, execute in sequence--> <script type="module" src="s3.js"></script> <!--implicitly deferred--> <script async src="s2.js"></script> <!--page fetch/exec continues while s2.js is fetched and executed--> <!--fetch/execute both in parallel—more parallel than defer--> -->

Polling

Example

We want to

Polling

			
			  let img=document.createElement('img');

			  //setting src triggers load
			  img.src='https://pictures.com/mine.jpg'
			  while (!img.complete) {
			  // twiddle my thumbs
			  }

			  init();
			
		  
  • this blocks forever 😢

The Main Thread Gets Stuck

Diagram showing the JavaScript runtime with the main JavaScript code stuck in a while loop.

The main thread never finishes executing that while loop, so the asynchronous image load stays blocked too.

Polling with setInterval()

			
			  let img = document.createElement('img');

			  img.src = 'https://pictures.com/mine.jpg';

			  let pollId = setInterval(() => {
			    if (img.complete) {
			      init();
			    }
			  }, 250);

			  doOtherStuff();
			
		  
  • Poll periodically instead of blocking in a while loop
  • Browser can keep doing other work between polls
  • But this still keeps checking over and over
  • And once image loads, it will keep calling init()

Stop Polling with clearInterval()

			
			  let img = document.createElement('img');

			  img.src = 'https://pictures.com/mine.jpg';

			  let pollId = setInterval(() => {
			    if (img.complete) {
			      clearInterval(pollId);
			      init();
			    }
			  }, 250);

			  doOtherStuff();
			
		  
  • clearInterval() stops future polling once condition is met
  • Now init() only runs once
  • Still more cumbersome and wasteful than being notified directly
  • This motivates event listeners and other async abstractions

Polling

Sometimes Necessary

  • “uncooperative” monitoring
  • e.g., keep track of whether a page on another web site has changed
  • it won't tell you, so you have to keep asking

Drawbacks

  • polling is coarse-grained. We don't know something's updated until we check
  • wasting time and energy when nothing changes

Events

Events

Example

We want to

Load Event Listener

			
			  let img=document.createElement('img');

			  img.addEventListener('load',init);
			  img.src='https://pictures.com/mine.jpg'

			  doOtherStuff(); //runs during img load
			
		  
  • load event is triggered when item finishes loading
  • event listener is a callback
  • system invokes callback when event occurs
  • Drawback: code does not execute in order you see
  • many opportunities to get confused

What's Wrong?

			
			  let img=document.createElement('img');

			  img.src='https://pictures.com/mine.jpg'
			  img.addEventListener('load',init);

			  doOtherStuff(); //runs during img load
			
		  

Race Conditions

  • Triggered load before adding listener
    • may finish before listening starts
    • won't ever hear event
  • Common problem in async programming
    • Things happen in an unexpected order
  • Very hard to debug
    • When rerun, different order happens

Callbacks

Callbacks

  • the callback pattern uses functions to hand execution control to the async task.
  • We've actually seen this callback pattern twice already!
  • what happens when we need to chain multiple async tasks?
setInterval(() => {
  button.textContent = ++i;
}, 500);

img.addEventListener('load', () => {
  init();
});

Chaining Async Callbacks

Let's say I'm building a social media app, and I have a database of users. I want to check whether my best friend's best friend is me or not!

Looking up a user in the database is an async API query.

function getUser(userId, callback) { ... }

And to do this lookup we'll need to make several async requests.

getUser("jopo", user => {
  getUser(user.bestFriendId, friend => {
    getUser(friend.bestFriendId, friendOfFriend => {
      console.log(friendOfFriend.name);
    });
  });
});
🤮 Callback Hell!

Promises

Can we denest our callbacks?

getUser("jopo", user => {
  getUser(user.bestFriendId, friend => {
    getUser(friend.bestFriendId, friendOfFriend => {
      console.log(friendOfFriend.name);
    });
  });
});

getUser("jopo", user => { getUser(user.bestFriendId, friend => { getUser(friend.bestFriendId, friendOfFriend => { console.log(friendOfFriend.name); }); }); });

If we reformat our code slightly differently, we can begin to see a linear chaining pattern that could maybe replace the nesting structure.

Promise

Goal

  • Define code syntax that looks how we think
  • look up me then look up my best friend then look up their best friend
  • Represent order of actions and intents to wait

Example

			
			  getUser("jopo") //returns a promise
			  .then(user => getUser(user.bestFriendId))
			  .then(friend => getUser(friend.bestFriendId))
			  .then(friendOfFriend => {
		    console.log(friendOfFriend.name);
			  });
			
		  

Using Promises

Promise

Turning a callback into a promise

  • Common use of Promise constructor is to wrap callback-based APIs
  • Make them simpler to use
			
			  function getUserPromise (userId) {
			      return new Promise(fulfiller => {
			          getUser(userId, fulfiller);
			      });
			  }

			  getUserPromise("jopo")
			  .then(user => console.log(user.name));
			
		  

Passing Results

  • Promise is “thenable”
  • then(f) says f is a callback to invoke when promise is fulfilled
    • f receives value of fulfilled promise
    • add any number of callbacks any time
  • No race condition:
    • Callback provided before fulfillment is invoked upon fulfillment
    • Callback provided after fulfillment is invoked immediately
			
			  p = delayed("hi",1000);
			  // promise resolves to "hi"
			  // after 1000ms

			  p.then(res => console.log(res));
			  // console outputs 1000ms later
			  ...lots of code
			  p.then(res => console.log("bye"));
			  // if p already fulfilled,
			  // callback executes immediately
			
		  

Chaining


			wait(1000)	//promise fulfilled after 1000ms
			.then(() => console.log(1))
			.then(() => console.log(2))
			.then(() => console.log(3))
		  
		
What gets output?
  1. delay, 1,2,3
  2. delay, 3,2,1
  3. 2, delay, 1, 3
  4. 3, 2, 1, delay

Chaining


			wait(1000)	//promise fulfilled after 1000ms
			.then(() => console.log(1))
			.then(console.log(2))
			.then(() => console.log(3))
		  
		
What gets output?
  1. delay, 1,2,3
  2. delay, 3,2,1
  3. 2, delay, 1, 3
  4. 3, 2, 1, delay

Chaining Results

  • then() callback can return a value (or a promise of one)
  • passed to the next then() callback in chain when available
  • more precisely, then() returns a new promise p providing that value
  • so next then() is invoked on p
  • p.then() passes on the value of p
			
			  //traditional evaluation style
			  y = f(g(h(x)));

			  //promise-based
			  Promise.resolve(x)
			  // promise with fulfilled value x
			  .then(h) //h receives x
			  .then(g) //g receives h(x)
			  .then(f) //f receives g(h(x))
			  .then(res => y=res);
			
		  

Chaining Results

Parallel Promises

  • Promise.all() tracks multiple promises running in parallel
  • Returns one promise
    • resolved when all its subpromises are
    • value is array of individual promises' values
    • rejects if any of its individuals do
  • Much clearer about intent than nested event callbacks
    • so much easier to debug
			
			  let p1=fetch(url1);
			  let p2=fetch(url2);
			  let p3=fetch(url3);
			  Promise.all([p1,p2,p3])
			  .then([c1,c2,c3] =>
			  {//process all 3 pieces},
			  err => {handle the error})
			
		  

Error Handling

  • Sometimes, an asynchronous computation wants to report failure or errors
    • Could just pass the error to then() callback
    • But the pattern is so common we design for it
  • Promise can be rejected instead of fulfilled
  • then() can take a second callback
    • invoked when promise rejected
    • receives value of rejected promise---usually error message
    • like first callback, can return a value or promise
  • whichever callback is invoked, returns promise to invokes next then()
			
			  fetch(url)
			  //returns a promise to provide
			  //   content found at that url
			  .then(content => {
			    //executes if fetch is fulfilled
			    console.log("received" + content);
			    return 0},
			  error => {
			    //executes if fetch is rejected
			    console.log("error: " + error)
			    return -1}
			  })
			  .then(res => console.log(res))
			  //logs 0 if fetch succeeded,
			  //     1 if it failed
			
		  

Summary

Some Other Methods

Async and Await

Async/Await

  • async declares an asynchronous function
    • Any async function returns a Promise that will be resolved when the function finishes and returns its “actual” value
    • without hassle of Promise constructor
  • async function can await any promise
    • await appears synchronous, but it makes that async function pause and wait for the promise to resolve
    • when the promise resolves, execution continues from the pause point

async function doMyWork() {
  console.log('beginning')
  let response =
    await fetch("countries.json");
  let data = await response.json()
  console.log(data);
  for (let i=1; i<=5; i++) {
    let result =
	  await delayed('answer',5000);
    console.log(i + result);
    }
  return data;
  }
			
		  

Async/Await is Syntactic Sugar for Promises

Async

			
async function f () {
  x();
  a = await(y());
  return z();
  }
			
		  

Promise

			
function f () {
  x();
  p = y()
  .then((res) => {a = res;})
  .then(z)
  return p
  }
			
		  

Async Always Returns a Promise

Compare/Contrast