khoa-pham-async-await

Xử lý bất đồng bộ trong JavaScript


I. Đồng bộ và bất đồng bộ

1. Khái niệm

2. So sánh ưu nhược điểm của lập trình đồng bộ và bất đồng bộ

Ở hình sau, đường màu đỏ thể hiện thời gian chờ của một câu lệnh, đường màu xanh dày thể hiện thời gian thực hiện bình thường của lệnh đó.

Sync

setTimeout(() => console.log('Line 1'), 1000);
console.log('Line 2');

// Line 2
// Line 1

Câu lệnh thứ hai trả về kết quả trước câu lệnh thứ nhất

II. Bất đồng bộ trong JavaScript

Javascript là ngôn ngữ lập trình bất đồng bộ và chỉ chạy trên một luồng. Sự bất đồng bộ trong javascript xuất hiện khi nó thao tác với các WebAPI (ajax, setTimeout(), … ). Khi một câu lệnh thao tác với WebAPI, nó sẽ mất một khoảng thời gian để chờ các dữ liệu trả về từ WebAPI, do đó ở trong luồng chính của javascript, nó sẽ ở trong trạng thái chờ. Tuy nhiên chương trình sẽ không bỏ trống khoảng thời gian chờ đó, chương trình sẽ tiếp tục thực hiện các câu lệnh tiếp theo. Đó là lý do Javascript là ngôn ngữ bất đồng bộ. Sau đây chúng ta sẽ tìm hiểu kĩ hơn về cách javascript hoạt động với các trường hợp bất đồng bộ.

Sync

Một câu lệnh trong javascript khi được thực hiện nó phải trải qua sự kiểm soát các các đối tượng: Timer, Message Queue, Event Loop, CallStack. Đầu tiên, nếu một câu lệnh được gọi thao tác với WebAPI, nó sẽ được chuyển đến hàng đợi Timer. Sau khi hết thời gian chờ nó sẽ được chuyển đến Message Queue. Call Stack là ngăn xếp rất quen thuộc trong lập trình. Khi một hàm được gọi, hàm đó được thêm vào ngăn xếp và hàm đó sẽ được lấy ra khỏi ngăn xếp khi hàm đó thực thiện xong. Event Loop sẽ kiểm tra khi nào trong Call Stack trống thì sẽ chuyển câu lệnh trong Message Queue vào trong Call Stack.

III. Xử lý bất đồng bộ trong JavaScript

Để làm cho các câu lệnh thực hiện theo đúng thứ tự của nó, chúng ta có 3 phương án giải quyết phổ biến : Call Back, Promise, Asyn/Await

Tham khảo thêm: Xử lý Bất đồng bộ bằng Callback, Promise, Async Await hay Observable?

1. Call Back

Call Back là một hàm được truyền vào một hàm khác với tư cách như một tham số của hàm đó. Ví dụ như

function nauGa(callback)
    nauNuocSoi();
    vatLongGa();
    callback();
}
function luocGa(){
    //
}

function nuongGa(){
//
}
nauGa(luocGa);
nauGa(nuongGa);

Ở đoạn mã trên, chúng ta thấy rằng, hàm luocGa và nuongGa được dùng như tham số trong hàm nauGa Với Javascript, một ngôn ngữ hướng sự kiện, call back được sử dụng rất nhiều khi xử lý các sự kiện, ví dụ như

$('#button').click(function () {
  alert('Hello!');
});

Chúng ta có thể áp dụng call back để đồng bộ hóa các đoạn mã không đồng bộ. Ví dụ như ở đoạn mã trên. Nấu nước sôi cần một khoảng thời gian chờ nước sôi, chúng ta không phải làm gì. Ta có thể biểu diễn thời gian chờ này bằng hàm setTimeout() trong javascript.

function soCheGa() {
  nauNuocSoi();
  vatLongGa();
}
function nauNuocSoi() {
  setTimeout(function () {
    console.log('nau nuoc soi');
  }, 1000);
}
function vatLongGa() {
  console.log('vat long ga');
}
soCheGa();

Và nếu như theo đúng cách chạy của Javascript thì hành động vặt lông gà sẽ được thực hiện trước hành động nấu nước sôi. (à, quên mất, nấu nước sôi là để nhúng gà vào vặt lông chứ không phải để luộc gà đâu nhé ) .Mà nếu chúng ta vặt lông gà luôn mà không cần nhúng nước nóng thì tội cho bác gà quá. Vì thế để cho gà có thể ra đi thanh thản chúng ta cần đồng bộ hóa lại quy trình bằng callback như sau

function soCheGa(callback) {
  nauNuocSoi(vatLongGa);
}
function nauNuocSoi(callback) {
  setTimeout(function () {
    console.log('nau nuoc soi');
    callback();
  }, 1000);
}

Tuy nhiên, Callback cũng có nhược điểm. Đó là khi chúng ta muốn nhiều hành động bất đồng bộ thực hiện theo đúng thứ tự liên tiếp nhau, chúng ta phải gọi nhiều hàm callback lồng vào nhau nhiều lần, gây ra đoạn code rất khó kiểm soát và không tối ưu. Đây gọi là tình trạng Callback Hell. Ví dụ như muốn in các số từ 1 đến 10, mà mỗi hành động in đều là một hàm bất đồng bộ

function printNumber(number, callback) {
  setTimeout(() => {
    console.log(number);
    callback();
  }, Math.floor(Math.random() * 100) + 1);
}

function printAll() {
  printNumber(1, function () {
    printNumber(2, function () {
      printNumber(3, function () {
        printNumber(4, function () {
          printNumber(5, function () {
            printNumber(6, function () {
              printNumber(7, function () {
                printNumber(8, function () {
                  printNumber(9, function () {
                    printNumber(10, function () {});
                  });
                });
              });
            });
          });
        });
      });
    });
  });
}

2. Promise

Promise là một đối tượng bao hàm một hàm chứa các đoạn code không đồng bộ. Hàm này chứa 2 tham số là hai hàm callback để giải quyết sau khi mã đồng bộ thực hiện thành công hay thất bại. Promise cung cấp cho ta hai phương thức xử lý sau khi đoạn mã bất đồng bộ thực hiện thành công hoặc thất bại. Hàm then() dùng để xử lý sau khi mã bất đồng bộ được thực hiện thành công và hàm catch() dùng để xử lý sau khi mã bất đồng bộ thực hiện thất bại

function printNumber(number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (number == 0) {
        reject();
      } else {
        console.log(number);
        resolve();
      }
    }, 1000);
  });
}
printNumber(1)
  .then(() => printNumber(2))
  .reject(() => console.log('number == 0'));

Phương thức then có thể thực thi một hàm, một Promise hay một đối tượng. Nếu chúng ta dùng then để trả về một Promise thì ta có thể tận dụng để xử lý tình trạng Callback Hell

printNumber(1)
  .then(() => printNumber(2))
  .then(() => printNumber(3))
  .then(() => printNumber(4))
  .then(() => printNumber(5))
  .then(() => printNumber(6))
  .then(() => printNumber(7))
  .then(() => printNumber(8))
  .reject(() => console.log('number = 0'));

Tuy nhiên, dù Promise đã giải quyết được vấn đề Callback Hell, nhưng chúng ta có thể thấy, đoạn mã vẫn chưa thực sự rõ ràng và dễ hiểu. Trong phương thức chúng ta vẫn phải truyền vào một hàm, mà hàm đó trả về một hàm khác có giá trị trả về là 1 Promise. Chúng ta tạm gọi đây là tình trạng Promise Hell.

3. Async / Await

Async / Await là một tính năng ngôn ngữ là một phần của tiêu chuẩn ES8. Từ khóa Async để khai báo rằng hàm này sẽ xử lý các hàm bất đồng bộ, nó sẽ chờ kết quả của các hàm bất đồng bộ được trả về sau đó mới thực hiện tiếp. Hàm bất đồng bộ đó phải trả về một Promise và được khai báo với từ khóa Await

function printNumber(number) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      console.log(number);
      resolve();
    }, Math.floor(Math.random() * 100) + 1);
  });
}

async function printAll() {
  await printNumber(1);
  await printNumber(2);
  await printNumber(4);
  await printNumber(5);
  await printNumber(6);
}
printAll();

Đến đây thì chúng ta đã thấy Async/Await đã giải quyết triệt để được tình trạng Callback Hell cũng như Promise Hell.

IV . Tổng kết

Qua một hồi luyên thuyên thì chúng ta rút ra kết luận là: Lập trình bất đồng bộ có hiệu suất tốt hơn lập trình đồng bộ. Tuy nhiên, trong nhiều trường hợp chúng ta vẫn phải thực thi các đoạn mã một cách đồng bộ. Trong JavaScript, chúng ta có ba kĩ thuật là Callback, Promise, Asyn/Await. Callback phù hợp trong các trường hợp xử lý đơn giản hơn (như đồng bộ 2, 3 hàm bất đồng bộ) vì nó dễ hiểu. Async/Await phù hợp cho các trường hợp phức tạp như cần đồng bộ quá nhiều hàm bất đồng bộ.