Hiểu về bất đồng bộ trong JavaScript

               
Vũ Ngọc Hải

Javascript là ngôn ngữ đơn luồng (single-threaded), điều này có nghĩa là nó chỉ có thể xử lý một câu lệnh tại một thời điểm.

Single-threaded language đơn giản hoá việc viết code, khi bạn không cần phải quan tâm đến concurrency, tuy nhiên khi bạn thực hiện những tác vụ tiêu tốn thời gian dài để hoàn thành (chẳng hạn như network access) thì luồng chính (main thread) sẽ bị block.

Hãy thử tưởng tượng bạn truy cập 1 trang web và nó request đến 1 API và API đó tiêu tốn 2 phút để phản hồi, và khi ấy bạn sẽ nhìn thấy thứ mà chắc hẳn mỗi người trong chúng ta đều thấy ít nhất 1 lần, đó là vào 1 ngày đẹp trời máy tính chạy hệ điều hành windows của bạn dở chứng và biểu tượng con chuột bắt đầu quay vòng vòng một cách đầy khiêu khích, bạn không thể làm gì ngoài việc nhìn nó và …đập máy, đùa thôi, nhấn nút nguồn mà lòng đau như cắt.

Ơn trời, Javascript không ngu ngốc như vậy, nó có 1 thứ cực kì hay ho mà chúng ta gọi là cơ chế bất đồng bộ (asynchronous)

Javascript Đồng Bộ Hoạt Động Như Thế Nào?

Trước tiên chúng ta cần hiểu về cơ chế đồng bộ (Synchronous) trước khi đi vào cơ chế bất đồng bộ (Asynchronous). Như ví dụ dưới đây:

Để hiểu được đoạn code trên được thực thi như thế nào bên trong Javascript Engine, chúng ta cần hiểu những khái niệm về execution context và call stack (execution stack)

Execution Context

Execution context là môi trường, nơi JavaScript code được thực thi. Function code được thực thi bên trong function execution context, global code được thực thi bên trong global execution context. Mỗi function code được thực thi bên trong function execution context của riêng nó.

Call Stack

Call stack là một stack với cơ chế LIFO (last in first out), được dùng để lưu trữ tất cả execution context được tạo ra trong suốt quá trình thực thi code.

Và dĩ nhiên Javascript chỉ có 1 call stack vì nó là single-threaded language. Đồng nghĩa với việc item chỉ có thể được thêm vào hoặc lấy ra từ top của stack.

Đọạn code phía trên sẽ được JavaScript engine thực thi như minh hoạ dưới đây:

Call Stack for the above code

Việc thực thi đoạn code trên được giải thích như sau:

Đầu tiên thì global execution context sẽ được tạo ra (đại diện bởi main()) và được đẩy vào top của call stack.

Tiếp đến nó thực thi đến lời gọi hàm first(), thì function execution context được tạo khi thực thi hàm first() sẽ được đẩy tiếp vào top của call stack.

kế tiếp console.log(‘Hi there!’) được push vào top của call stack, sau khi nó được thực thi xong, nó được lấy ra khỏi call stack. Khi Javascript engine thực thi lời gọi hàm second() nó push function execution context của second() lên top của call stack.

console.log(‘Hello there!’) được push vào top của call stack và được lấy ra khỏi call stack khi nó finish, hàm second() finish và nó được lấy ra khỏi call stack.

Sau đó console.log(‘The End’) được push vào top của call stack và nó được lấy ra sau khi finish, hàm first() finish và được lấy ra khỏi call stack.

Chương trình finish và main() được lấy ra khỏi call stack.

JavaScript Bất Đồng Bộ Hoạt Động Như Thế Nào?

Trên đây, chúng ta đã có những hiểu biết nhất định về đồng bộ trong JavaScript, tiếp theo hãy quay lại vấn đề chính.

Blocking Là Gì?

Giả sử chúng ta thực hiện những tác vụ như image processing hoặc network request trong cơ chế đồng bộ sẽ như sau:

Việc thực hiện những tác vụ như xử lý hình ảnh thường rất tốn thời gian để hoàn thành. Vì vậy khi hàm processImage() được gọi, nó cần khá lâu để hoàn thành, tuỳ vào kích cỡ của file ảnh đang được xử lý. Sau khi processImage() kết thúc, nó được lấy ra khỏi call stack.

Tương tự như vậy networkRequest() cũng tiêu tốn một khoảng thời gian kha khá để hoàn thành và sau đó mới được lấy ra khỏi call stack. Sau cùng greeting() mới được gọi, vì chỉ việc in ra màn hình nên greeting() hoàn thành ngay lập tức.

Như vậy chúng ta đã để ý rằng, để hàm greeting() được gọi và in ra màn hình câu “Hello World" chúng ta phải chờ đến đến khi 2 hàm phía trên hoàn thành, việc chờ (blocking main thread) như vậy rõ ràng là chưa tối ưu.

Vậy Giải Pháp Là Gì?

Cách đơn giản nhất có lẽ là sử dụng callback như ví dụ dưới đây:

Ví dụ trên, chúng ta sử dụng setTimeout() để mô phỏng network request. Chú ý rằng, setTimeout() không phải của JavaScript engine, nó thuộc về web APIs (browsers) hay c/c++ APIs (nodejs).

Để hiểu được đoạn code trên hoạt động như thế nào, chúng ta cần tìm hiểu qua một vài khái niệm về event loop và callback queue (hay còn được biết đến với cái tên task queue hay message queue).

An Overview of JavaScript Runtime Environment

Event loopweb APIs và Message queue không thuộc về JavaScript engine, nó là một phần của Browser’s JavaScript runtime environment (Browser) hay Nodejs JavaScript runtime environment (Nodejs). Trong Nodejs, web APIs được thay thế bằng C/C++ APIs.

hãy xem đoạn code trên được thực hiện như thế nào trong cơ chế bất đồng bộ:

Khi đoạn code phía trên đuợc load vào browser, console.log(‘Hello World’) sẽ được push vào top của stack và nó được lấy ra khỏi stack khi hoàn thành. Kế tiếp networkRequest() được thực thi, nó được đẩy lên top của call stack.

Kế tiếp setTimeout() được gọi, do đó nó được đẩy lên top của call stack. setTimeout bắt đầu một bộ đếm thời gian 2s trong môi trường web APIs. Ngay lúc đó setTimeout() kết thúc và được lấy ra khỏi call stack. Tiếp sau đó console.log(‘The End’) được push vào stack, được thực thi, kết thúc và được lấy ra khỏi stack.

Sau 2s, timer kết thúc, bây giờ callback được push đến message queue. Nhưng callback không được thực thi ngay lập tức. Và đó là lúc chúng ta cần biết đến Event loop.

The Event Loop

Nhiệm vụ của event loop là nó sẽ quan sát call stack để xác định xem call stack hiện có đang empty hay không, nếu call stack đang empty thì nó sẽ quan sát message queue để xem hiện có callback nào đang chờ được thực thi không.

Trong trường hợp này, message queue đang có một callback chờ thực thi và call stack đang empty, do đó event loop push callback lên top của call stack.

Sau đó console.log(‘Async Code’) được push lên top của call stack, được thực thi và lấy ra khỏi call stack. Callback kết thúc và được lấy ra khỏi call stack. Chương trình kết thúc.

DOM Events

Message queue cũng có thể bao gồm callback từ DOM events như click event và keyboard event. Xét ví dụ sau:

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

Trong trường hợp DOM event, event listener bên trong web APIs sẽ lắng nghe những event cụ thể (trong trường hợp này là click event). Khi event này xảy ra, callback sẽ được đặt vào message queue chờ thực thi.

Lặp lại quá trình, event loop nhìn vào call stack xem có empty hay không? nếu call stack empty, event loop bốc callback từ message queue cho vào top của call stack và thực thi.

chúng ta đã học được cách thức asynchronous callback và DOM events được thực thi sử dụng message queue.

ES6 Job Queue/ Micro-Task Queue

ES6 giới thiệu một khái niệm mới đó là Job Queue/Micro-Task Queue, được sử dụng bởi promise. Khác biệt chính giữa job queue và message queue đó là job queue có độ ưu tiên cao hơn message queue.

Điều này có nghĩa rằng promise job bên trong job queue sẽ được thực thi trước callback bên trong message queue. Xét ví dụ dưới đây:

Output:

Script start
Script End
Promise resolved
setTimeout

Chúng ta thấy rằng promise được thực hiện trước setTimeout bởi vì promise response nằm trong job queue còn callback của setTimeout nằm trong message queue.

Xét một ví dụ khác:

Output:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
setTimeout 1
setTimeout 2

Một ví dụ nữa:

Output:

Script start
Script End
Promise 1 resolved
Promise 2 resolved
Promise 3 resolved
setTimeout

Kết Luận

Trên đây chúng ta đã học qua cách asynchronous JavaScript hoạt động, cũng như các khái niệm về call stack, message queue, job queue. Tất cả tạo nên thứ mà chúng ta gọi là JavaScript runtime environment.

Tham Khảo:

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