+20

Hiểu về Asynchronous JavaScript

JavaScript là một ngôn ngữ lập trình đơn luồng có nghĩa là chỉ có một điều có thể xảy ra tại một thời điểm. Tức là, JavaScript engine chỉ có thể xử lý một câu lệnh tại một thời điểm trong một luồng duy nhất.

Mặc dù ngôn ngữ đơn luồng đơn giản hóa việc viết code do bạn không cần lo lắng về các vấn đề tương tranh, điều này cũng có nghĩa là bạn không thể thực hiện các tác vụ dài như truy cập mạng mà không chặn luồng chính.

Hãy tưởng tượng bạn request dữ liệu từ một API. Tùy thuộc vào tình hình máy chủ có thể mất một thời gian để xử lý request trong khi chặn luồng chính làm cho trang web không hồi đáp.

Đó là lý do asynchronous JavaScript xuất hiện. Sử dụng asynchronous JavaScript (callbacks, promises, and async/await) bạn có thể thực hiện các request network dài mà không chặn luồng chính.

Synchronous JavaScript hoại động như thế nào?

Trước khi đi sâu vào asynchronous JavaScript, ta cần hiểu cách mà synchronous JavaScript code được thực thi trong JavaScript engine. Ví dụ:

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

Để hiểu đoạn code trên được thực thi như thế nào trong JavaScript engine, chúng ta phải hiểu khái niệm về execution contextcall stack.

Execution Context

Execution Context là một khái niệm trừu tượng về môi trường nơi JavaScript code được đánh giá và thực hiện. Bất cứ khi nào code được thực thi trong JavaScript, nó chạy trong một execution context.

Code cục bộ thực thi trong một execution context cục bộ, và code toàn cục thực hiện trong một execution context toàn cục. Mỗi hàm có một execution context riêng của nó.

Call Stack

call stack như tên gọi của nó là một ngăn xếp với cấu trúc LIFO được sử dụng để lưu trữ tất cả các execution context được tạo ra trong quá trình thực thi code.

JavaScript có một ngăn xếp duy nhất vì nó là một ngôn ngữ lập trình đơn luồng. Call Stack có cấu trúc LIFO nghĩa là các mục chỉ có thể được thêm hoặc xóa khỏi đầu ngăn xếp.

Hãy quay lại đoạn mã trên và tìm hiểu cách code được thực thi bên trong JavaScript engine.

Vậy điều gì đang xảy ra ở đây?

Khi đoạn code này được thực thi, một execution context toàn cục được tạo ra (biểu diễn bằng hàm main()) và được đẩy vào đầu ngăn xếp. Khi hàm first() được gọi, nó được đẩy vào đầu ngăn xếp.

Tiếp theo, console.log('Hi there!') được đẩy vào đầu ngăn xếp, khi kết thúc, nó được lấy ra từ ngăn xếp. Sau đó hàm second() được gọi và nó được đẩy vào đầu của ngăn xếp.

console.log('Hello there!') được đẩy vào đầu ngăn xếp là lấy ra khi nó kết thúc. Hàm second kết thúc, do đó nó được lấy ra khỏi ngăn xếp.

console.log(‘The End’) được đẩy vào đầu của ngăn xếp và xóa đi khi nó kết thúc. Sau đó, hàm first kết thúc và nó được lấy ra khỏi ngăn xếp.

Chương trình thực hiện xong ở đây và execution context toàn cục (main()) được lấy ra khỏi ngăn xếp.

Đến đây ta đã có ý tưởng cơ bản về call stack và JavaScript đồng bộ hoạt động như thế nào, hãy quay trở lại với JavaScript bất đồng bộ.

Blocking là gì?

Giả sử ta đang xử lý hình ảnh hay thực hiện network request theo cách đồng bộ:

const processImage = (image) => {
  /**
  * doing some operations on image
  **/
  console.log('Image processed');
}
const networkRequest = (url) => {
  /**
  * requesting network resource
  **/
  return someData;
}
const greeting = () => {
  console.log('Hello World');
}
processImage(logo.jpg);
networkRequest('www.somerandomurl.com');
greeting();

Việc xử lý hình ảnh và network request cần có thời gian. Vì vậy, khi hàm processImage() được gọi, sẽ mất một chút thời gian tùy thuộc vào kích thước của hình ảnh.

Khi hàm processImage() hoàn thành, nó sẽ bị xóa khỏi ngăn xếp, sau đó hàm networkRequest() được gọi và được đẩy vào ngăn xếp, một lần nữa cũng sẽ mất một thời gian để nó thực thi xong.

Cuối cùng khi hàm networkRequest() hoàn thành, hàm greeting() được gọi và nó chỉ thực thi một câu lệnh console.log và các câu lệnh console.log nói chung là nhanh, do đó hàm greeting() thực hiện xong ngay lập tức và kết thúc.

Như đã thấy, chúng ta phải chờ các hàm processImage()networkRequest() kết thúc. Điều này có nghĩa là các hàm này đang chặn call stack hay luồng chính. Do đó chúng ta không thể thực hiện hoạt động khác khi code trên đang thực hiện.

Vậy giải pháp là gì?

Giải pháp đơn giản nhất là callbacks không đồng bộ. Ta sử dụng callbacks không đồng bộ để làm cho code trở thành non-blocking. Ví dụ:

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

Ở đây, phương thức setTimeout được sử dụng để mô phỏng network request. Cần nhớ rằng setTimeout không phải là một phần của Javascript engine. Nó là một phần của web APIs và C/C++ APIs.

Để hiểu đoạn code này được thực thi như thế nào, chúng ta cần hiểu một vài khái niệm khác như event loopcallback queue (hay message queue)

event loop, web APIs và message queue không phải là một phần của JavaScript engine, nó là một phần của môi trường thực thi Javascript của browser hay môi trường thực thi Nodejs JavaScript (trong trường hợp Nodejs). Trong Nodejs, web APIs được thay thế bởi C/C++ APIs.

Bây giờ hãy trở lại với đoạn code trên và xem xét nó được thực thi như thế nào theo cách bất đồng bộ.

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

Khi đoạn code trên được load vào trình duyệt, console.log(‘Hello World’) được đẩy vào ngăn xếp và lấy ra khi nó kết thúc. Tiếp theo, hàm networkRequest() được gọi và nó được đẩy vào đầu ngăn xếp.

Tiếp theo, hàm setTimeout() được gọi, nó được đẩy vào đầu ngăn xếp. HàmsetTimeout() có hai tham số 1) callback và 2) thời gian với đơn vị mili giây.

Hàm setTimeout() bắt đầu hẹn giờ 2s trong môi trường web APIs. Tại thời điểm này, setTimeout() kết thúc và được lấy ra khỏi ngăn xếp. Sau đó console.log('The End') được đẩy vào ngăn xếp, thực thi và xóa khỏi ngăn xếp sau khi nó hoàn tất.

Trong khi đó, bộ hẹn giờ đến hạn và callback được đẩy vào message queue. Nhưng callback không được thực hiện ngay lập tức và đó là nơi event loop được khởi động.

Event Loop

Công việc của event loop là nhìn vào call stack và xác định xem call stack có trống hay không. Nếu call stack trống, nó sẽ nhìn vào mesage queue để xem có bất kỳ callback nào đang chờ xử lý để thực hiện nó.

Trong trường hợp này, message queue có chứa 1 callback còn call stack thì trống rỗng. Do đó event loop đẩy callback vào đầu ngăn xếp.

console.log(‘Async Code’) được đẩy vào đầu ngăn xếp, thực thi và lấy ra khỏi ngăn xếp. Tại đây, callback kết thúc và được lấy ra khỏi ngăn xếp và chương trình kết thúc.

Message queue cũng chứa các callback từ các sự kiện DOM như sự kiện click và sự kiện keyboard. Ví dụ:

document.querySelector('.btn').addEventListener('click',(event) => {
  console.log('Button Clicked');
});

Trong trường hợp sự kiện DOM, trình lắng nghe sự kiện đặt ở môi trường web APIs chờ một sự kiện cụ thể xảy ra. Và khi nó xảy ra, hàm callback được đặt vào message queue và chờ được thực thi. Một lần nữa event loop sẽ kiểm tra xem call stack có trống không và đẩy callback vào ngăn xếp nếu nó trống và callback được thực thi.

Trì hoãn thực thi hàm

Chúng ta cũng có thể sử dụng setTimeout để trì hoãn việc thực thi hàm cho đến khi ngăn xếp trống. Ví dụ:

const bar = () => {
  console.log('bar');
}
const baz = () => {
  console.log('baz');
}
const foo = () => {
  console.log('foo');
  setTimeout(bar, 0);
  baz();
}
foo();

Kết quả là:

foo
baz
bar

Khi code này chạy, đầu tiên foo () được gọi, bên trong foo chúng ta gọi console.log ('foo'), sau đó setTimeout () được gọi với bar ()callback và bộ đếm thời gian 0 giây.

Bây giờ nếu chúng ta không sử dụng setTimeout, hàm bar () sẽ được thực hiện ngay lập tức, nhưng việc sử dụng setTimeout với bộ đếm thời gian 0 giây giúp trì hoãn việc thực hiện bar() cho đến khi ngăn xếp trống.

Sau 0 giây, callback bar () được đưa vào message queue đang đợi để được thực thi. Nhưng nó sẽ chỉ được thực hiện khi ngăn xếp hoàn toàn trống rỗng sau khi hàm bazfoo kết thúc.

ES6 Job Queue

Chúng ta đã học được cách các callback không đồng bộ và các sự kiện DOM được thực thi, sử dụng message queue để lưu trữ tất cả các callback chờ đợi để được thực thi.

ES6 giới thiệu khái niệm về job queue được sử dụng bởi Promises trong JavaScript. Sự khác biệt giữa message queuejob queuejob queue có mức ưu tiên cao hơn message queue, có nghĩa là các promise job bên trong job queue sẽ được thực thi trước các callback bên trong message queue.

Ví dụ:

const bar = () => {
  console.log('bar');
};
const baz = () => {
  console.log('baz');
};
const foo = () => {
  console.log('foo');
  setTimeout(bar, 0);
  new Promise((resolve, reject) => {
    resolve('Promise resolved');
  }).then(res => console.log(res))
    .catch(err => console.log(err));
  baz();
};
foo();

Kết quả là:

foo
baz
Promised resolved
bar

Chúng ta có thể thấy rằng promise được thực hiện trước setTimeout, bởi vì promise response được lưu trữ bên trong job queue có mức độ ưu tiên cao hơn message queue.

Kết luận

Chúng ta đã học được cách JavaScript không đồng bộ hoạt động và các khái niệm khác như call stack, event loop, message queuejob queue cùng nhau tạo ra môi trường chạy JavaScript. Mặc dù bạn không cần phải tìm hiểu tất cả các khái niệm này để trở thành một JavaScript developer tuyệt vời nhưng sẽ rất hữu ích khi biết các khái niệm này.

Tham khảo

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


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí