Mastering JavaScript Threads: How It Handles Multiple Operations & Users

Mastering JavaScript Threads: How It Handles Multiple Operations & Users

JavaScript is known for being single-threaded, but what does that mean? And how can it manage multiple tasks and serve multiple users at once? To understand this, let's start with the basics.


What is a Thread?

A thread is like a line of workers. In computing, a thread is the smallest sequence of programmed instructions that can be managed by the operating system. When we say JavaScript is single-threaded, it means there’s only one worker handling all tasks, one at a time.

This may seem limiting, especially when you consider how a modern app often needs to handle multiple tasks at once, like loading images, handling user input, and fetching data from a server. But JavaScript has ways to handle this using asynchronous programming!


Single-Threaded: The Basics

JavaScript is single-threaded by nature. This means it can only do one thing at a time. Imagine having a worker in a factory who can only work on one task before moving on to the next. This is how JavaScript operates — it has one thread that processes code, called the main thread.

Call Stack:

The call stack is a data structure that keeps track of where the program is in its execution. When you call a function, it gets pushed onto the stack, and when the function returns a result, it's popped off the stack. Here's an example:

function first() {
  console.log('First function');
}
function second() {
  console.log('Second function');
}
first();
second();        

In this code, the first() function is added to the stack, executed, then removed, followed by the second() function.


Synchronous vs. Asynchronous

By default, JavaScript executes code synchronously, meaning one thing after another. But what happens when a task takes time, like fetching data from a server? We don’t want to wait and block other tasks, so JavaScript uses asynchronous programming.

Synchronous:

In synchronous programming, tasks are executed one after the other, waiting for each task to complete before moving on to the next. This approach works fine for simple tasks, but it becomes inefficient for time-consuming operations, like reading from a file or fetching data from a server.

console.log('Task 1');
console.log('Task 2');        

Executes only after Task 1 finishes

Let's see another example:

console.log('Start');
for (let i = 0; i < 1000000; i++) { /* long loop */}
console.log('End');        

In the example above, the code runs line by line, blocking further execution until the current task (the loop) is completed. This means users would have to wait for the loop to finish before anything else can happen.


Asynchronous:

In asynchronous programming, tasks are started but can continue running in the background. This way, JavaScript can keep working on other tasks while waiting for the asynchronous operation to complete. When the background task is done, JavaScript picks up where it left off.

Example:

console.log('Start');
setTimeout(() => {
  console.log('Task Complete');
}, 2000);
console.log('End');
        

Output is in this manner :

Start
End
Task Complete        

Now, the question is, why is 'End' from the console executed before the 'Task Complete'?

Here, JavaScript does not block the entire program while waiting for the setTimeout function to finish. It moves on and finishes the other tasks (console.log('End')), and once the timer is done, it logs the message "Task Complete". This is the key advantage of asynchronous programming — tasks can run in the background without halting the flow of the main thread.


JavaScript uses callbacks, promises, and async/await to manage asynchronous tasks.

Callback:

One of the earliest techniques JavaScript used for handling asynchronous tasks was callbacks. A callback is simply a function passed into another function as an argument, to be executed later when the time is right.

function fetchData(callback) {
  setTimeout(() => {
    callback('Data fetched!');
  }, 2000);
}

fetchData((message) => {
  console.log(message); // This will log "Data fetched!" after 2 seconds
});        
This will log "Data fetched!" after 2 seconds        

Here, fetchData uses a setTimeout to simulate a long task. The callback function (in this case, a function that logs the message) is passed in and gets called after 2 seconds when the task is done.

However, callbacks have a problem: callback hell.

Callback Hell:

When callbacks are nested inside other callbacks, the code can become hard to read and maintain:

doTask1(() => {
  doTask2(() => {
    doTask3(() => {
      doTask4(() => {
        console.log('All tasks completed');
      });
    });
  });
});        

This is often referred to as "callback hell" because it creates deep nesting and makes the code messy and difficult to follow. Thankfully, JavaScript introduced a better way to handle asynchronous code: promises.

Promise:

A more modern way to handle asynchronous tasks. Promises allow better error handling and chaining. A promise is an object that represents the eventual completion (or failure) of an asynchronous operation and its resulting value. Promises allow us to write asynchronous code more cleanly, without the need for deeply nested callbacks.

let fetchData = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('Data fetched successfully!');
  }, 2000);
});

fetchData
  .then((message) => {
    console.log(message); // Logs "Data fetched successfully!" after 2 seconds
  })
  .catch((error) => {
    console.error(error);
  });        

Promise States:

  • Pending: The operation is still in progress.
  • Fulfilled (Resolved): The operation completed successfully.
  • Rejected: The operation failed.


Now, Let's see how we can write the 'promise' in a cleaner way :

Async/Await: The Modern Solution

To make promises even easier to work with, JavaScript introduced async and await. This allows us to write asynchronous code that looks and behaves like synchronous code, without the hassle of chaining .then() calls.

async function fetchData() {
  try {
    let result = await new Promise((resolve) => {
      setTimeout(() => resolve('Data fetched successfully!'), 2000);
    });
    console.log(result); // Logs "Data fetched successfully!" after 2 seconds
  } catch (error) {
    console.error(error);
  }
}

fetchData();        

By using await, the function pauses execution until the promise resolves, making the code easier to read and maintain, as if it were synchronous.

Now, we know about the call stack and callback. It's time to learn about Event Loop!


Event Loop: How JavaScript Manages Async Code

The event loop allows JavaScript to execute synchronous code, manage asynchronous operations, and continue processing without blocking the main thread.The call stack executes all synchronous code.

Here’s how it works:

  1. Call Stack: JavaScript runs all functions and statements in the call stack.
  2. Web APIs: If an asynchronous operation is encountered (like setTimeout or fetch), it’s sent to the Web APIs (managed by the browser or Node.js environment).
  3. Callback Queue: Once the async operation is done, the callback is sent to the callback queue.
  4. Event Loop: The event loop continuously checks if the call stack is empty. If it is, it moves the task from the callback queue to the call stack for execution.

This makes JavaScript non-blocking and allows it to handle multiple operations efficiently, even though it’s single-threaded.


How Does JavaScript Handle Multiple Requests?

When JavaScript is used on the server (e.g., Node.js), you might wonder how it handles requests from multiple users if it’s single-threaded.

Node.js uses an event-driven, non-blocking I/O model. When a request comes in, it’s handled asynchronously. The server doesn’t need to wait for the database to respond or the file system to finish reading — it moves on to handle the next request.

Here’s how it works:

  1. A request is made to the server.
  2. The server processes the request asynchronously (e.g., fetching from a database).
  3. Once the processing is complete, the server sends back the response to the correct user.

This allows Node.js to handle thousands of requests simultaneously, even though it operates on a single thread.

Example:

const http = require('http');

const server = http.createServer((req, res) => {
  setTimeout(() => {
    res.end('Request handled');
  }, 1000); // Simulate asynchronous operation
});

server.listen(3000, () => {
  console.log('Server is running on port 3000');
});        

In this example, Node.js processes requests asynchronously. Even if one user’s request takes a while, the server can handle other users’ requests without being blocked.

Tanvir Ahmed Khan

Associate Software Engineer@Kaz Software | Ex-Rokomari SWE | Beta MLSA | ICPC Regionalist

5 个月

Very informative Keep sharing bro

要查看或添加评论,请登录

Rakibul Hasan Dihan的更多文章

社区洞察

其他会员也浏览了