Tìm hiểu về tính đồng bộ và bất đồng bộ trong JavaScript

1. Giới Thiệu

    Chào mọi người, hôm nay chúng ta cùng tìm hiểu về Synchronous (Lập trình đồng bộ) là gì? Asynchronous (Lập trình bất đồng bộ) là gì? Synchronous và Asynchronous hoạt động như thế nào trong Javascript nhé!

2. Tổng quan:

Synchronous

    Vậy Synchronous là gì?

    Hãy tưởng tưởng đến việc một quán ăn nhỏ có 2 bàn và chỉ có 1 người phục vụ bàn. Lúc này có 2 nhóm khách hàng lần lượt vào quán, người phục vụ sẽ thực hiện yêu cầu gọi món của nhóm thứ nhất, tiến hành thông báo cho nhà bếp chế biến món ăn và chờ món ăn sau khi được hoàn thành và dọn ra cho nhóm thứ nhất. Sau khi hoàn thành toàn bộ yêu cầu của nhóm thứ nhất thì người phục vụ mới bắt đầu chuyển sang nhóm thứ hai để lấy yêu cầu của họ và xử lí. Đây chính là xử lí đồng bộ, tức là mọi việc đều được xử lí theo thứ tự từng bước, chỉ khi nào bước đầu tiên xong thì bước thứ hai mới được thực hiện.

    Trong trường hợp này, xử lí đồng bộ thật khoai khi nhóm thứ hai phải chờ nhóm thứ nhất được phục vụ xong mới đến lượt mình?? Tuy nhiên, đó chỉ là ví dụ về một trường hợp bất lợi của Synchronous, do đó, chúng ta hãy tiếp tục bàn luận về ưu điểm và khuyết điểm của nó nhé.

Ưu điểm:

  • Chương trình sẽ chạy theo thứ tự từ trên xuống, có quy tắc rõ ràng nên nếu có lỗi chúng ta có thể dễ dàng tìm ra vị trí lỗi và xử lí nó.
  • Do đó, lập trình đồng bộ dễ dàng kiểm soát quá trình xử lí.

Khuyết điểm:

  • Do các dòng lệnh chạy theo thứ tự nên nó sẽ sinh ra trạng thái chờ, và nếu lệnh trên chạy quá lâu thì làm lãng phí thời gian của cách lệnh dưới
  • Khi nói về UX, nó gây ra cảm giác giật lag cho người dùng vì họ không thể thực hiện thao tác khác nếu thao tác trước đó chưa được xử lí xong.

Asynchronous

    Vậy Asynchronous là gì?

    Hãy cùng tiếp tục với ví dụ ở phần Synchronous nhé. Khi 2 nhóm khách hàng lần lượt vào quán, người phục sẽ lần lượt lấy yêu cầu của từng nhóm rồi đồng thời đưa chúng cho đầu bếp để chế biến, sau khi món ăn của bất kì nhóm nào được chế biến xong thì người phục vụ sẽ mang ra cho nhóm đó. Điều này có nghĩa là cả nhóm 1 và nhóm 2 đều được lấy yêu cầu và xử lí gần như cùng lúc chứ không phải chờ đợi nhau như Synchronous. Trong trường hợp này rõ ràng xử lí bất đồng bộ chiếm ưu thế hơn xử lí đồng bộ, tuy nhiên, nó cũng có ưu và nhược điểm riêng, hãy cùng bàn luận về nó nhé:

Ưu điểm:

  • Xử lí nhiều công việc cùng lúc mà không cần phải chờ đợi theo thứ tự
  • Tối ưu thời gian chạy và xử lí của chương trình
  • Tối ưu sức mạnh của tài nguyên

Khuyết điểm:

  • Do các câu lệnh được thực hiện đồng thời, và kết quả cũng được trả về một cách không theo thứ tự nên rất khó kiểm soát cũng như debug.

3. Synchronous trong Javascript:

    JavaScript mặc định là ngôn ngữ lập trình đồng bộ, blocking và Single-thread (đơn luồng), có nghĩa là một thao tác sẽ được tiến hành tại một thời điểm, trên một luồng chính duy nhất và mọi thứ khác bị chặn cho đến khi thao tác đó hoàn thành.

    Trước khi xem qua ví dụ, bạn cần biết khái niệm Event loop là gì, và cách hoạt động như thế nào. Bạn có thể tham khảo video tại đây để hiểu rõ hơn về cách hoạt động của nó. Mình sẽ tóm gọn lại một số thuật ngữ như sau: image.png

  • Heap: là nơi tất cả việc phân bổ bộ nhớ xảy ra đối với các biến đã xác định trong chương trình của mình.
  • Callstack: là một ngăn xếp với cơ chế LIFO (last in first out), code sẽ được đẩy vào và thực thi sau đó bật ra khi thực hiện xong. Và vì Javascript là ngôn ngữ đơn luồng nên chỉ có duy nhất 1 call stack.
  • Web APIs: Bao gồm DOM Events ( sự kiện onClick, onLoad ...), ajax (XMLHttpRequest), setTimeout, ... Nó giúp đẩy các job ra bên ngoài và chỉ tạo ra các sự kiện kèm theo các handler gắn với các sự kiện.
  • Callback Queue: Hay còn gọi là Task Queue, Message Queue. Đây là nơi mã không đồng bộ của bạn được đẩy đến và chờ thực thi.
  • Event Loop: Luôn luôn theo dõi Callstack và Callback Queue. Nếu Callback Queue có tồn tại element thì nó tiếp tục kiểm tra sang Callstack, nếu Callstack lúc này đang trống thì element trong Callback Queue sẽ được push sang Callstack để thực thi.

    Lưu ý: Event Loop, Web APIs và Callback Queue không thuộc về JavaScript engine, mà chỉ là một phần của Browser’s JavaScript runtime environment trong Browser hoặc Nodejs JavaScript runtime environment trong Nodejs.

    Như đã bàn luận ở trên, xử lí đồng bộ có thể ảnh hưởng đến hiệu xuất của chương trình. Hãy cùng xem ví dụ dưới đây:

console.log("Start")
 
for (let i = 0; i < 20; i++) {
   console.log(i)
}

console.log("End")

    Kết quả in ra như sau:

Start
0
1
2
3
4
5
6
7
8
9
End

    Step 1: câu lệnh console.log("Start") được push vào call stack, và sau đó pop ra ngoài

    Step 2: câu lệnh console.log(i) với giá trị i đầu tiên là 0 được push vào call stack và pop ra ngoài

    Step 3: câu lệnh console.log(i) với giá trị i = 1 được push vào call stack và pop ra ngoài

    ...

    Step 11: câu lệnh console.log(i) với giá trị i = 9 được push vào call stack và pop ra ngoài

    Step 12: câu lệnh console.log("End") được push vào call stack, và sau đó pop ra ngoài

    Như các bạn có thể thấy, dòng lệnh console.log('End') không hề được thực hiện cho đến khi vòng lặp được kết thúc. Giả sử nếu vòng lặp này có số vòng cần lặp là 10,000 thì nó sẽ tạo ra blocking khiến các câu lệnh sau đó như console.log("End") rất lâu sau mới được thực hiện.

    Việc này trở nên rất nghiêm trọng khi chúng ta làm việc trên một ứng dụng lớn với nhiều yêu cầu của máy chủ. Thật may mắn khi Javascript đã cung cấp cho chúng ta một giải pháp đó là Asynchronous.

4. Asynchronous trong Javascript:

    Vậy Asynchronous hoạt động như thế nào trong Javascript? Cùng nhau xem qua đoạn code của quá trình xử lí đồng bộ này nhé:

console.log("Start")
 
setTimeout(()=>{
   console.log("Middle")
}, 1000)

console.log("End")

    Kết quả in ra như sau:

Start
End
Middle

    Step 1: câu lệnh console.log("Start") được push vào call stack, và sau đó pop ra ngoài

    Step 2: câu lệnh setTimeout() được push vào call stack nhưng không được thực thi và lập tức được gửi sang WebAPIs để xử lí, lúc này WebAPIs sẽ chứa 1 timer 1000ms và một callback function (ở ví dụ trên là arrow function) và sau 1000ms, funtion này sẽ được gửi sang Callback Queue để chờ được xử lí

    Step 3: câu lệnh console.log("End") được push vào call stack, và sau đó pop ra ngoài

    Step 4: sau 1000ms, Event Loop kiểm tra Callback Queue và thấy tồn tại callback, lúc này kiểm tra thấy callstack đang trống nên nó push callback vào trong callstack và thực hiện xử lí

    Step 5: câu lệnh console.log("Middle") bên trong callback được push vào call stack, và sau đó pop ra ngoài, đồng thời callback ở trên cũng được pop ra khỏi callstack

    Trong Javascript, bất đồng bộ xảy ra khi chúng ta thực hiện các thao tác bất đồng bộ ví dụ:

  • Call API, setTimeout, setInterval
  • XMLHttpRequest, file reading,
  • RequestAnimationFrame

    Tuy nhiên, như đã nói ở trên, xử lí bật đồng bộ khiến chúng ta khó kiểm soát code, và để làm cho các câu lệnh được thực hiện theo đúng thứ tự của nó, chúng ta có 3 phương án chính để giải quyết vấn đề này:

  • Call Back
  • Promise
  • Async/Await

Callback

    Callback có nghĩ là một function được truyền vào một function khác dưới dạng tham số và được thực thi bên trong function đó.

    Ví dụ:

function asyncFunction(callback) {
   console.log("Start");
   setTimeout(() => {
      callback();
   }, 1000);
   console.log("Middle");
}

let printEnd = function() {
   console.log("End");
}

asyncFunction(printEnd)

    Kết quả in ra như sau:

Start
Middle
End

    => Callback function là một cách thức phổ biến, dễ hiểu, và dễ sử dụng, tuy nhiên nếu sử dụng quá nhiều Callback lồng nhau thì sẽ xảy ra tình trạng Callback Hell (tức là hàm lồng nhau) dẫn đến việc code khó hiểu, khó debug và khó maintain. Ví dụ về Callback Hell image.png

    Do đó, phiên bản JS ES6 đã mang đến cho chúng ta Promise để giải quyết cho nỗi đau Callback Hell

Promise

    Promise nghĩa là "lời hứa" đại diện cho 1 tác vụ nào đó chưa hoàn thành ngay được và ở 1 thời điếm trong tương lai, promise sẽ trả về giá trị khi thành công (resolve) hoặc thất bại (reject).

    Promise nhận vào một hàm callback gồm 2 tham số:

  • resolve: một function sẽ được gọi nếu đoạn code bất đồng bộ trong Promise chạy thành công.
  • reject: một function sẽ được gọi nếu đoạn code bất đồng bộ trong Promise có lỗi xảy ra.

    Promise cũng cung cấp cho chúng ta 2 phương thức để xử lý sau khi được thực hiện:

  • then(): Dùng để xử lý sau khi Promise được thực hiện thành công (khi resolve được gọi).
  • catch(): Dùng để xử lý sau khi Promise có bất kỳ lỗi nào đó (khi reject được gọi).
  • finally(): Dùng để xử lý sau khi Promise được thực hiện thành công hoặc thất bại (resolve hoặc reject được gọi)

    Cấu trúc:

const promise = new Promise((resolve, reject) => {
        // Logic
        // Nếu thành công: resolve()
        // Nếu thất bại: reject()
)

promise
    .then((res) => {
        // nếu resolve => callback trong then được gọi
    })
    .catch((err) => {
        // nếu reject => callback trong catch được gọi
    }
    .finally(() => {
        // nếu resolve hoặc reject => callback trong finally được gọi
    }

    Ví dụ cụ thể:

const randomNumber = new Promise((resolve, reject) => {
   const url = 'https://www.random.org/integers/?num=1&min=1&max=10&col=1&base=10&format=plain&rnd=new';
   let request = new XMLHttpRequest();

   request.open('GET', url);
   request.onload = function() {
      if (request.status == '200') {
         resolve(request.response);
      } else {
         reject(Error(request.statusText)); 
      }
   };

   request.onerror = function() {
      reject(Error('Error fetching data.'));
   };

   request.send();
});

randomNumber
    .then((res) => {
       console.log("Success");
       console.log("Random number: ", res);
    })
    .catch((err) => {
       console.log("Error: ", err.message);
    })

    Ngoài ra, chúng ta có thể nối nhiều Promise với nhau bằng 'then' thông qua tính chất chain của promise:

doSomething()
    .then(result => doSomethingElse(result))
    .then(newResult => doThirdThing(newResult))
    .then(finalResult => {
          console.log(`Got the final result: ${finalResult}`);
     })
    .catch(failureCallback);

    Lưu ý: chỉ cần 1 catch để bắt lỗi trong chuỗi then

    Ngoài ra, việc hiểu rõ tính chất chain của Promise cũng giúp chúng ta tránh khỏi tình trạng Promise hell, tương tự như Callback Hell

fetchData()
    .then((data) => {
        return filterData(data)
            .then((filteredData) => {
                return sortData(filteredData)
                    .then(sortedData) => {
                        return renderData(sortedData)
                    })
                })
            })
        })
    })

    Hãy sử dụng Promise chain để biến đổi đoạn code bị Promise hell bên trên nhé

fetchData()
    .then((data) => {
        return filterData(data)
    })
    .then((filteredData) => {
        return sortData(filteredData)
    })
    .then((sortedData) => {
        return renderData(sortedData)
    })

    Ngoài ra, có rất nhiều cách để xử lí tình huống Promise hell, các bạn xem thêm tại đây nhé

    => Promise giúp tránh được Callback Hell, giúp code rõ ràng, dễ đọc, dễ debug hơn, và hơn hết là nó giải quyết được hầu hết các vấn đề bất đồng bộ.

Async await

    Kể từ ES7, JS cung cấp cho ta một tính năng ngôn ngữ mới đó là Async await, về bản chất nó được xây dựng dựa trên Promise, và tương thích với tất cả các Promise dựa trên API, tuy nhiên nó giúp cho code gọn gàng và dễ hiểu hơn.

    Từ khóa Async: Được đặt trước 1 function để khai báo bất đồng bộ cho cho hàm. (VD: async function myFunc() {...}).

  • Kết quả trả về của async function luôn luôn là 1 Promise
  • Khi gọi tới hàm async nó sẽ xử lý mọi thứ và được trả về kết quả trong hàm của nó.
  • Async cho phép sử dụng Await

    Từ khóa Await: Được sử dụng khi muốn tạm dừng việc thực hiện các hàm async (VD: Var result = await myAsyncCall())

  • Khi được đặt trước một Promise, nó sẽ đợi cho đến khi Promise kết thúc và trả về kết quả.
  • Await chỉ làm việc với Promises, nó không hoạt động với callbacks.
  • Await chỉ có thể được sử dụng bên trong các function async.

    Async/await giúp bạn chạy các promise 1 cách tuần tự:

const myFunc = async () => {  
   var result1 = await promise1();
   var result2 = await promise2(result1);
   var result3 = await promise3(result2);
}

    Async/await có thể sử dụng try catch như xử lí đồng bộ

const makeRequest = async () => {
  try {
    // this parse may fail
    const data = JSON.parse(await getJSON())
    console.log(data)
  } catch (err) {
    console.log(err)
  }
}

    Vậy Async Await có thể thay thế hoàn toàn Promise hay không?

    Như mình đã đề cập ở định nghĩa, sử dụng Async/Await chính là đang sử dụng Promise ngầm, và Async/Await không thể nào thay thế được Promise. Và chúng ta hoàn toàn có thể sử dung cả hai cùng lúc, ví dụ chức năng Promise.all() với nhiệm vụ cho phép request nhiều trong cùng một thời gian. Hãy cùng xem đoạn code dưới đây để hiểu rõ hơn nhé:

async function sequence() {
  await promise1(500); // chờ 500ms
  await promise2(1000); // ...Tiếp tục chờ 1000ms
  await promise3(1500); // ...Tiếp tục chờ 1500ms
  return "done!";
}

    Như vậy, chúng ta phải chờ tổng cộng là 3000ms để thực hiện function sequence, điều này xảy ra do các promise được thực hiện một cách tuần tự: promise1 xong thì đến promise2, và cuối cùng là promise3. Vậy có cách nào để tăng tốc nó không?? Câu trả lời đó là Promise.all()

    Theo MDN: "Phương thức Promise.all(iterable) trả ra một Promise mới và promise mới này chỉ được kết thúc khi tất cả các promise trong iterable kết thúc hoặc có một promise nào đó xử lý thất bại. Kết quả của promise mới này là một mảng chứa kết quả của tất cả các promise theo đúng thứ tự hoặc kết quả lỗi của promise gây lỗi."

    Lúc này hãy kết hợp nó với Promise.all() để thực hiện cả 3 cùng lúc nhé:

async function sequence() {
    await Promise.all([promise1(), promise2(), promise3()]);
    return "done!";
}

    Lúc này tổng thời gian để thực hiện hàm sequence chỉ mất 1500ms, tương đương với thời gian thực hiện lớn nhất của các promise đó là promise3. Điều này chính là nhờ Promise.all() đã giúp cho các request trên được thực hiện cùng lúc và kết thúc function khi request mất nhiều thời gian nhất được thực hiện xong. Thật thú vị phải không nào 😃

5. Kết luận:

    Như vậy, chúng ta đã cùng tìm hiểu về xử lí đồng bộ và bất đồng bộ trong Javascript. Đây chính là những gì mình vừa đúc kết được trong quá trình tìm hiểu được về sync và async trong Javascript, nếu có sai sót hay thiếu sót, mong các anh chị, các bạn chỉ điểm. Xin cám ơn mọi người đã dành thời gian đọc.

    Tài liệu tham khảo:

    https://medium.com/swlh/synchronous-vs-asynchronous-programming-1bfef19f032c

    https://medium.com/@Rahulx1/understanding-event-loop-call-stack-event-job-queue-in-javascript-63dcd2c71ecd

    https://blog.bitsrc.io/understanding-asynchronous-javascript-the-event-loop-74cd408419ff

    https://codelearn.io/sharing/bat-dong-bo-trong-javascript-phan-1

    https://codelearn.io/sharing/asyncawait-trong-javascript

Nguồn: Viblo

Bình luận
Vui lòng đăng nhập để bình luận
Một số bài viết liên quan