JavaScript không phải là một ngôn ngữ quá khó để nắm bắt. Tuy nhiên, giống với các ngôn ngữ khác, JavaScript cũng có những khái niệm, chức năng, luồng xử lý mà ta phải hiểu rõ, thông qua đó giúp cho chúng ta xử lý mọi thứ nhanh và tốt hơn. Trong bài viết này, chúng ta sẽ tìm hiểu về bất đồng bộ, hay còn gọi là “Asynchronous”, một trong những khái niệm không thể thiếu khi làm việc với JavaScript.

1. Synchronous trong JavaScript

Chúng ta xem xét đoạn code sau:

const second = function() {
    console.log('Hello there!');
}

const first = function() {
    console.log('Hi there!');
    second();
    console.log('The End');
}

first();

// Kết quả:
// Hi there!
// Hello there!
// The End

Trong đoạn mã trên, chúng ta hoàn toàn dễ dàng đoán được kết quả của nó. Ở đây chúng ta cùng tìm hiểu về khái niệm call stack.

Ngăn xếp (call stack): Với ngăn xếp các lệnh được thực thi theo nguyên tắc LIFO (Last In First Out - Vào sau ra trước). Bản thân JavaScript là ngôn ngữ đơn luồng, nên nó chỉ thực hiện một ngăn xếp duy nhất để quản lý việc thực thi lệnh. Ta có thể thấy thông qua sơ đồ sau:

Asynchronous-trong-JavaScript

Thông qua sơ đồ trên ta thấy rằng, các hàm và lệnh được đẩy vào ngăn xếp và được thực thi theo nguyên tắc LIFO. Khi chúng được hoàn thành thì chúng sẽ bị loại bỏ khỏi ngăn xếp. Việc này giúp duy trì sự tuần hoàn cũng như việc kiểm soát quá trình thực thi mã trong JavaScript.

>>> Xem thêm bài viết về ngôn ngữ lập trình PHP:

2. Asynchronous trong JavaScript

Trong khi làm việc với web, nếu chúng ta muốn lấy ra dữ liệu để có thể hiển thị cho người dùng thì chúng ta sẽ phải làm việc với API. Các API luôn luôn có một độ trễ trong thời gian xử lý yêu cầu, sau đó nó sẽ trả về kết quả chúng ta cần hoặc trong trường hợp xấu có thể nó sẽ không trả về kết quả gì. Trong những trường hợp cần chờ hệ thống xử lý như thế này, nếu chúng ta cố gắng đợi cho việc xử lý hoàn thành nó có thể khiến trang web không phản hồi lại cho người dùng. Đây sẽ là trải nghiệm không tốt đối với người dùng.

Đây là lúc các khái niệm “Asynchronous” được đề cập đến, nó cho phép các tác vụ được thực hiện mà không cần phải chờ đợi các tác vụ trước đó hoàn thành. Việc các tác vụ được xử lý mà không cần chờ các tác vụ khác cho phép ứng dụng có thể tiếp tục hoạt động mà không cần chờ đợi các tác vụ xử lý xong. Điều này sẽ càng được thể hiện rõ ràng hơn trong các tác vụ mất nhiều thời gian xử lý hay giao tiếp nhiều với cơ sở dữ liệu.

Chúng ta hãy cùng xem xét ví dụ sau:

const networkRequest = () => {
  setTimeout(() => {
    console.log('Async Code');
  }, 2000);
};

console.log('Hello World');
networkRequest();
console.log('The End');

Ở đây, chúng ta sử dụng hàm setTimeout để giả sử cho việc gửi request đến Server. Để tiếp tục, các bạn hãy thử đoán xem kết quả sau khi chạy sẽ in ra như thế nào.

Bây giờ tôi sẽ cung cấp cho bạn một vài khái niệm cần biết để có thể hiểu được cách đoạn mã trên hoạt động:

Loại

Mô tả

Event LoopEvent Loop sẽ theo dõi call stack, khi call stack trống nó sẽ tiếp tục kiểm tra message queue. Lúc này, nếu phát hiện thấy có bất kỳ callback nào đang chờ đợi để được thực thi nó sẽ đẩy callback từ message queue vào call stack để nó được thực thi.
Message Queue, Task Queue hay Callback QueueCác tác vụ khi sử dụng Web APIs sẽ được lưu trữ trong Message Queue và được lấy ra theo nguyên tắc FIFO (First In First Out - Vào trước ra trước).
Web APIsBao gồm nhiều thành phần như: setTimeout, DOM Events (ví dụ: sự kiện click nút),... Đây là các phần mở rộng của trình duyệt, cho phép thực hiện các tác vụ không đồng bộ như đợi một khoảng thời gian hoặc xử lý sự kiện người dùng. Các tác vụ này sẽ tạo ra các callback được đưa vào Message Queue để sau đó được thực thi bởi Event Loop.

Bây giờ, bạn đã có đầy đủ các khái niệm để có thể theo dõi và hiểu được đoạn mã ví dụ phía trên. Chúng ta hãy cùng nhau tìm hiểu cách mà nó hoạt động.

  • Đầu tiên, lệnh console.log('Hello World') được gọi lên và được đưa vào call stack. Sau khi thực thi xong lệnh trên sẽ được xóa bỏ khỏi call stack, chúng ta có thể quan sát dòng Hello World được in ra ở console
  • Tiếp theo, hàm networkRequest() cho thấy rằng setTimeout cũng được gọi lên, lúc này cả networkRequest()setTimeout đồng thời được đưa vào stack. Tếp theo ta thấy rằng setTimeout được đặt để chạy sau 2 giây, cho nên chúng được chuyển từ stack sang message queue (các tác vụ chờ để được thực hiện).
  • Câu lệnh cuối cùng là console.log('The End'), tương tự như console.log('Hello World') lệnh này cũng được đưa vào stack và được thực thi.
  • Và như đã nói ở trên, lúc này Event Loop sẽ kiểm tra xem stack có trống hay không. Lúc này vì stack đã trống, nó tiếp tục tìm đến message queue, như đã nói ở trên, networkRequest()setTimeout sẽ được đưa trở lại stack để thực thi. Bây giờ ta mới thấy dòng Async Code được in ra ở màn hình console. Nó sẽ bị xóa khỏi stack khi đã được thực thi.

Có thể bạn vẫn sẽ thấy khó hiểu đúng không? Chúng ta cùng quan sát sơ đồ dưới đây để có một cái nhìn trực quan hơn nhé: 

Asynchronous-trong-JavaScript-working

3. ES6 Job Queue/ Micro-Task queue

Không kém phần quan trọng, trong JavaScript ES6, chúng ta có khái niệm Job Queue/ Micro-Task queue, đây là hai khái niệm mới được giới thiệu trong JavaScript ES6. Ở đây, nếu có cả job queuemessage queue thì job queue sẽ được thực thi trước (job queue có độ ưu tiên cao hơn message queue).

Ví dụ:

const messageQueue = () => {
    console.log("Hàm message queue đã được thực thi.");
}

const startExecute = () => {
    console.log("Bắt đầu thực thi");
    
    setTimeout(messageQueue, 0);
    
    new Promise((resolve, reject) => {
        resolve('Job queue đã được thực thi.');
    })
    .then(res => console.log(res))
    .catch(err => console.log(err));
    
    console.log("Kết thúc!");
}

startExecute();

Bạn đoán kết quá ở ví dụ trên sẽ là như thế nào?

// Kết quả:
Bắt đầu thực thi
Kết thúc!
Job queue đã được thực thi.
Hàm message queue đã được thực thi.

Bạn có thể thấy rằng, tuy hàm Promise() được gọi thực thi sau, nhưng độ ưu tiên cao hơn nên nó sẽ được thực thi trước.

4. Xử lý Asynchronous trong JavaScript với Callbacks, Promises, async/await

4.1. Callbacks

Đây sẽ là cách xử lý Asynchronous trong JavaScript mà chúng ta thường gặp nhất khi chúng ta bắt đầu tiếp xúc với JavaScript. Callbacks là một cách rất đơn giản khi mà chúng ta chỉ cần truyền nó một hàm khác như một đối số và nó sẽ được gọi sau khi tác vụ bất đồng bộ hoàn thành. Ta cùng xem ví dụ dưới đây:

function readFile(filename, callback) {
    // Mô phỏng việc đọc tập tin bất đồng bộ
    setTimeout(() => {
        const data = "Nội dung tập tin đã được đọc";
        callback(data);
    }, 1000);
}

function processFile(data) {
    console.log("Dữ liệu từ tập tin: " + data);
}

readFile("file.txt", processFile);

4.2. Promises

Khi làm việc với callbacks ta sẽ bắt gặp khái niệm callback hell. Nó là khái niệm được sử dụng trong lập trình khi mà việc sử dụng callbacks được kiểm soát không tốt, dẫn đến việc các hàm callbacks lồng vào nhau làm cho mã khó đọc, khó hiểu và khó bảo trì. Dưới đây là một ví dụ về callback hell:

function step1(callback) {
  setTimeout(function() {
    console.log("Step 1");
    callback();
  }, 1000);
}

function step2(callback) {
  setTimeout(function() {
    console.log("Step 2");
    callback();
  }, 1000);
}

function step3(callback) {
  setTimeout(function() {
    console.log("Step 3");
    callback();
  }, 1000);
}

step1(function() {
  step2(function() {
    step3(function() {
      console.log("Done");
    });
  });
});

Vậy, làm sao để không gặp phải callback hell trong lập trình bất đồng bộ. Lúc này, ta có khái niệm Promise. Promise cho phép chúng ta thực hiện các tác vụ bất đồng bộ theo một cách dễ đọc hơn. Dưới đây ta xem thử một ví dụ của Promise:

function readFile(filename) {
  return new Promise((resolve, reject) => {
    // Mô phỏng việc đọc tập tin bất đồng bộ
    setTimeout(() => {
      const data = "Nội dung tập tin đã được đọc";
      resolve(data);
    }, 1000);
  });
}

readFile("file.txt")
  .then((data) => {
    console.log("Dữ liệu từ tập tin: " + data);
  })
  .catch((error) => {
    console.error("Lỗi: " + error);
  });

4.3. async/await

Với Promise, tuy mã đã dễ đọc hơn, nhưng để tiếp tục cải thiện sự thân thiện với người dùng, JavaScript còn hỗ trợ async/await. Lúc này, mã chúng ta sẽ được viết giống như mã đồng bộ thông thường, làm cho mã dễ học, dễ hiểu và dễ bảo trì. Ví dụ về async/await:

async function readFile(filename) {
  return new Promise((resolve, reject) => {
    // Mô phỏng việc đọc tập tin bất đồng bộ
    setTimeout(() => {
      const data = "Nội dung tập tin đã được đọc";
      resolve(data);
    }, 1000);
  });
}

async function processFile() {
  try {
    const data = await readFile("file.txt");
    console.log("Dữ liệu từ tập tin: " + data);
  } catch (error) {
    console.error("Lỗi: " + error);
  }
}

processFile();

Tổng kết

Asynchronous là một khía cạnh quan trọng của JavaScript, cho phép chúng ta xử lý các tác vụ không đồng bộ một cách hiệu quả. Callbacks, Promises và async/await là các công cụ mạnh mẽ giúp chúng ta thực hiện các tác vụ này một cách dễ dàng và hiệu quả hơn. Hiểu và sử dụng chúng là điều quan trọng để xây dựng các ứng dụng web phức tạp và có hiệu suất cao.


Stringee Communication APIs là giải pháp cung cấp các tính năng giao tiếp như gọi thoại, gọi video, tin nhắn chat, SMS hay tổng đài CSKH cho phép tích hợp trực tiếp vào ứng dụng/website của doanh nghiệp nhanh chóng. Nhờ đó giúp tiết kiệm đến 80% thời gian và chi phí cho doanh nghiệp bởi thông thường nếu tự phát triển các tính năng này có thể mất từ 1 - 3 năm.

Bộ API giao tiếp của Stringee hiện đang được tin dùng bởi các doanh nghiệp ở mọi quy mô, lĩnh vực ngành nghề như TPBank, VOVBacsi24, VNDirect, Shinhan Finance, Ahamove, Logivan, Homedy,  Adavigo, bTaskee…

Quý bạn đọc quan tâm xin mời đăng ký NHẬN TƯ VẤN TẠI ĐÂY: