JavaScript Visualized: Event Loop, Web APIs, (Micro)task Queue

JavaScript Visualized: Event Loop, Web APIs, (Micro)task Queue

Event Loop là… một chủ đề nổi tiếng và phức tạp. Tuy nhiên, khi nhìn tổng thể, nó chỉ là một thành phần nhỏ trong toàn bộ JavaScript runtime!

Event Loop đóng vai trò quan trọng trong việc xử lý các tác vụ bất đồng bộ! Điều này rất quan trọng vì JavaScript là đơn luồng - chúng ta chỉ làm việc với một Call Stack duy nhất

🥇 Call Stack

Call Stack quản lý quá trình thực thi của chương trình. Khi chúng ta gọi một hàm, một ngữ cảnh thực thi mới sẽ được tạo và đẩy vào Call Stack. Hàm nằm trên cùng của call stack sẽ được đánh giá, và nó có thể gọi thêm một hàm khác, cứ thế tiếp diễn.

Một ngữ cảnh thực thi sẽ được loại bỏ khỏi Call Stack khi hàm hoàn thành việc thực thi của nó.

JavaScript chỉ có thể xử lý một tác vụ tại một thời điểm; nếu một tác vụ mất quá nhiều thời gian để được loại bỏ khỏi Call Stack, thì không có tác vụ nào khác có thể được xử lý.

Điều này có nghĩa là các tác vụ chạy lâu có thể chặn bất kỳ tác vụ nào khác thực thi, dẫn đến việc chương trình của chúng ta bị "đóng băng"!

Trong trường hợp này, importantTask phải chờ cho đến khi longRunningTask được loại bỏ khỏi Call Stack, điều này mất một khoảng thời gian do các phép tính phức tạp trong thân hàm của nó.

Nhưng khoan đã… Để xây dựng một ứng dụng thực tế, chúng ta thường cần thực hiện một số tác vụ chạy lâu hơn. Những tác vụ này có thể là các yêu cầu mạng, bộ đếm thời gian, hoặc bất cứ thứ gì dựa trên đầu vào của người dùng.

Điều này có nghĩa là toàn bộ ứng dụng của chúng ta sẽ bị đóng băng khi sử dụng các tác vụ chạy lâu như vậy sao?

fetch("https://website.com/api/posts") 
// We don't know when the data gets returned from the server...
// Would this be on the call stack until the data returns?

May mắn thay, không phải vậy! Những chức năng như thế thực ra không phải là một phần của JavaScript; chúng được cung cấp cho chúng ta thông qua Web APIs.

🥇 Web APIs

Web APIs cung cấp một bộ giao diện để tương tác với các tính năng mà trình duyệt sử dụng. Điều này bao gồm các chức năng mà chúng ta thường xuyên sử dụng khi phát triển với JavaScript, chẳng hạn như Document Object Model, fetch, setTimeout, và nhiều hơn nữa.

Trình duyệt là một nền tảng mạnh mẽ với rất nhiều tính năng. Một số trong số đó là cần thiết để chúng ta xây dựng các ứng dụng chức năng, chẳng hạn như Rendering Engine để hiển thị nội dung hoặc Networking Stack cho các yêu cầu mạng.

Chúng ta còn có thể truy cập vào một số tính năng cấp thấp hơn, như cảm biến của thiết bị, camera, vị trí địa lý, và nhiều thứ khác.

Web APIs thực chất đóng vai trò như một cầu nối giữa JavaScript runtime và các tính năng của trình duyệt, cho phép chúng ta truy cập thông tin và sử dụng các tính năng vượt ra ngoài khả năng của JavaScript.

Ổn rồi, nhưng điều này liên quan gì đến các tác vụ bất đồng bộ?

Một số Web APIs cho phép chúng ta khởi tạo các tác vụ bất đồng bộ, và giao các tác vụ chạy lâu hơn cho trình duyệt!

Việc gọi một phương thức được cung cấp bởi các API như vậy thực chất là để giao tác vụ chạy lâu hơn cho môi trường trình duyệt, và thiết lập các trình xử lý để xử lý khi tác vụ này hoàn thành.

Sau khi khởi tạo tác vụ bất đồng bộ (mà không chờ đợi kết quả), ngữ cảnh thực thi có thể nhanh chóng bị loại bỏ khỏi Call Stack; nó không chặn!

Các Web APIs cung cấp khả năng bất đồng bộ thường sử dụng phương pháp dựa trên callback hoặc promise.

Đầu tiên, hãy cùng nói về phương pháp sử dụng callback.

🥈 Callback-based APIs

Hãy lấy Geolocation API làm ví dụ. Trên website của chúng ta, chúng ta muốn truy cập vị trí của người dùng.

Để làm điều này, chúng ta có thể sử dụng phương thức getCurrentPosition, phương thức này nhận hai callback: **successCallback** sẽ được sử dụng khi chúng ta nhận được vị trí của người dùng một cách thành công, và **errorCallback** là tùy chọn, sẽ được gọi trong trường hợp có sự cố xảy ra.

Việc gọi hàm này sẽ đẩy ngữ cảnh thực thi mới được tạo vào Call Stack. Thực ra, đây chỉ là để "đăng ký" các callback của nó với Web API, sau đó Web API sẽ giao tác vụ cho trình duyệt.

Hàm sau đó sẽ bị loại bỏ khỏi Call Stack; lúc này, trách nhiệm xử lý là của trình duyệt.

Trong hậu trường, trình duyệt sẽ yêu cầu người dùng cấp quyền truy cập vị trí cho website.

Chúng ta thực sự không biết khi nào người dùng sẽ tương tác với thông báo yêu cầu của chúng ta, có thể họ sẽ bị phân tâm hoặc đơn giản là không nhìn thấy cửa sổ pop-up xuất hiện.

Nhưng đó không phải là vấn đề! Vì tất cả những điều này đang diễn ra trong nền, Call Stack vẫn có sẵn để nhận và thực thi các tác vụ khác. Website của chúng ta vẫn giữ được tính phản hồi và có thể tương tác.

Cuối cùng, người dùng đã cho phép website của chúng ta truy cập vào vị trí của họ. API lúc này nhận được dữ liệu từ trình duyệt và sử dụng successCallback để xử lý kết quả.

Tuy nhiên, successCallback không thể đơn giản bị đẩy vào Call Stack, vì làm như vậy có thể làm gián đoạn một tác vụ đang thực thi, điều này có thể dẫn đến hành vi không thể dự đoán và các xung đột tiềm ẩn.

Máy ảo JavaScript chỉ có thể xử lý các tác vụ một lần một, điều này đảm bảo một môi trường thực thi có thể dự đoán và tổ chức tốt.

🥇 Task Queue

Thay vào đó, successCallback được thêm vào Task Queue (cũng được gọi là Callback Queue vì lý do này). Task Queue giữ các callback của Web API và các trình xử lý sự kiện đang chờ được thực thi vào một thời điểm nào đó trong tương lai.

Được rồi, vậy thì bây giờ successCallback đang ở trong task queue... Nhưng khi nào nó sẽ được thực thi?

🥇 Event Loop

Cuối cùng, chúng ta đến với Event Loop! Event Loop có trách nhiệm liên tục kiểm tra xem Call Stack có rỗng không.

Mỗi khi Call Stack rỗng — có nghĩa là không có tác vụ nào đang chạy — nó sẽ lấy tác vụ đầu tiên có sẵn từ Task Queue và đưa nó vào Call Stack, nơi callback sẽ được thực thi.

Event Loop liên tục kiểm tra xem Call Stack có rỗng không, và nếu rỗng, nó sẽ kiểm tra tác vụ đầu tiên có sẵn trong Task Queue và chuyển nó vào Call Stack để thực thi.

Một Web API phổ biến khác sử dụng callback là setTimeout. Mỗi khi chúng ta gọi setTimeout, lời gọi hàm sẽ được đẩy vào Call Stack, và nó chỉ có trách nhiệm khởi tạo một bộ đếm thời gian với độ trễ đã chỉ định. Trong nền, trình duyệt sẽ theo dõi các bộ đếm thời gian này.

Khi bộ đếm thời gian hết hạn, callback của bộ đếm thời gian sẽ được đưa vào Task Queue! Điều quan trọng cần nhớ là độ trễ chỉ định thời gian sau khi callback được đưa vào Task Queue, chứ không phải vào Call Stack.

Điều này có nghĩa là độ trễ thực tế để thực thi có thể lâu hơn so với độ trễ đã chỉ định khi gọi setTimeout! Nếu Call Stack vẫn đang bận xử lý các tác vụ khác, callback sẽ phải chờ trong Task Queue.

Cho đến nay, chúng ta đã thấy cách các API sử dụng callback được xử lý. Tuy nhiên, hầu hết các Web API hiện đại sử dụng phương pháp promise-based, và như bạn có thể đã đoán, những API này được xử lý khác đi.

Tôi sẽ giả định bạn có một chút kiến thức cơ bản về Promises từ đây trở đi. Video của tôi về Promises có thể giúp bạn nếu cần làm quen lại!

🥇 Microtask Queue

Hầu hết các Web APIs (hiện đại) trả về một promise, cho phép chúng ta xử lý dữ liệu trả về thông qua việc nối các handler của promise (hoặc sử dụng await) thay vì sử dụng callbacks.

fetch("...")
  .then(res => ...)
  .catch(err => (...))

Vì chúng ta đang xử lý dữ liệu trong một promise handler, chúng ta đang sử dụng Microtask Queue!

Microtask Queue là một queue khác trong runtime với độ ưu tiên cao hơn so với Task Queue. Queue này dành riêng cho:

  • Các callback của promise handler (then(_callback_), catch(_callback_), và finally(_callback_))

  • Việc thực thi các thân hàm async sau khi gặp await

  • Các callback của MutationObserver

  • Các callback của queueMicrotask

Khi Call Stack rỗng, Event Loop sẽ xử lý tất cả các microtask từ Microtask Queue trước khi chuyển sang Task Queue.

Sau khi hoàn thành một tác vụ từ Task Queue, và Call Stack rỗng, Event Loop sẽ "bắt đầu lại" bằng cách xử lý tất cả các microtask trong Microtask Queue trước khi tiếp tục chuyển sang tác vụ tiếp theo.

Điều này đảm bảo rằng các microtask liên quan đến tác vụ vừa hoàn thành được xử lý ngay lập tức, duy trì tính phản hồi và nhất quán của chương trình.

Microtasks cũng có thể lên lịch các microtasks khác! Điều này có thể tạo ra một kịch bản nơi chúng ta tạo ra một vòng lặp microtask vô hạn, làm trì hoãn Task Queue vô thời hạn và khiến phần còn lại của chương trình bị đóng băng. Vì vậy, hãy cẩn thận!

Một kịch bản như vậy không thể (!) xảy ra với Task Queue. Event Loop xử lý các tác vụ trong Task Queue một cách lần lượt, sau đó "bắt đầu lại" bằng cách kiểm tra Microtask Queue.

Một API phổ biến dựa trên promise là fetch. Khi chúng ta gọi fetch, execution context của nó được thêm vào Call Stack.

Gọi fetch tạo ra một Promise Object trong bộ nhớ, và mặc định nó có trạng thái là "pending". Sau khi khởi tạo yêu cầu mạng, lời gọi fetch sẽ bị pop khỏi Call Stack.

Lúc này, engine gặp phải handler then đã được nối, điều này tạo ra một bản ghi PromiseReaction được lưu trữ trong PromiseFulfillReactions.

Tiếp theo, console.log được đẩy vào Call Stack, và in ra End of script lên console. Trong trường hợp này, yêu cầu mạng vẫn đang chờ xử lý.

Khi máy chủ cuối cùng trả về dữ liệu, [[PromiseStatus]] được thiết lập thành "fulfilled", và [[PromiseResult]] được thiết lập thành đối tượng Response. Khi promise được giải quyết, PromiseReaction được đẩy vào Microtask Queue.

Khi Call Stack trống, Event Loop di chuyển callback của handler từ Microtask Queue vào Call Stack, nơi nó được thực thi, ghi đối tượng Response vào console, và cuối cùng bị pop khỏi call stack.

Có phải tất cả các Web APIs đều được xử lý bất đồng bộ?

Không, chỉ những API khởi tạo các hoạt động bất đồng bộ mới được xử lý như vậy. Các phương thức khác, ví dụ như document.getElementById() hoặc localStorage.setItem(), được xử lý đồng bộ.

🥇 Tổng kết

Hãy cùng tóm tắt lại những gì chúng ta đã tìm hiểu cho đến nay:

  • JavaScript chỉ có thể xử lý một tác vụ tại một thời điểm, vì nó chạy trên một luồng đơn.

  • Web APIs được sử dụng để tương tác với các tính năng mà trình duyệt sử dụng. Một số API này cho phép chúng ta khởi tạo các tác vụ bất đồng bộ trong nền.

  • Lệnh gọi hàm khởi tạo tác vụ bất đồng bộ sẽ được thêm vào Call Stack, nhưng chỉ để chuyển giao cho trình duyệt. Tác vụ bất đồng bộ thực sự được xử lý trong nền và không còn ở lại trên Call Stack.

  • Task Queue được sử dụng bởi các API dựa trên callback để thêm các callback vào hàng đợi khi tác vụ bất đồng bộ hoàn thành.

  • Microtask Queue được sử dụng bởi các trình xử lý Promise, các hàm async sau await, các callback của MutationObserver và callback của queueMicrotask. Hàng đợi này có độ ưu tiên cao hơn Task Queue.

  • Khi Call Stack rỗng, Event Loop sẽ di chuyển các tác vụ từ Microtask Queue cho đến khi hàng đợi này hoàn toàn trống. Sau đó, nó sẽ chuyển sang Task Queue, nơi nó chuyển tác vụ đầu tiên có sẵn lên Call Stack. Sau khi xử lý tác vụ đầu tiên, nó sẽ "bắt đầu lại" bằng cách kiểm tra lại Microtask Queue.

🥇 Chuyển đổi các API sử dụng callback thành Promise

Để cải thiện khả năng đọc và quản lý luồng các thao tác bất đồng bộ trong các API Web sử dụng callback, chúng ta có thể bọc chúng trong một Promise.

Ví dụ, chúng ta có thể bọc phương thức getCurrentPosition của API Geolocation sử dụng callback trong một constructor Promise.

function getCurrentPosition() {
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(resolve, reject);
  });
}

Điều này tận dụng đầy đủ sức mạnh của Promise, chẳng hạn như khả năng đọc dễ dàng hơn và sử dụng cú pháp async/await.

async function fetchAndLogCurrentPosition() {
  try {
    const position = await getCurrentPosition();
    console.log(position);
  } catch (error) {
    console.log(error);
  }
}
 
// function fetchAndLogCurrentPosition() {
//   getCurrentPosition()
//     .then((position) => console.log(position))
//     .catch((error) => console.error(error));
// }

Chúng ta thậm chí có thể tạo một bộ đếm thời gian dựa trên Promise bằng cách sử dụng setTimeout để hoãn việc thực thi một đoạn mã cho đến khi bộ đếm thời gian hết giờ:

function delay(ms) {
  return new Promise(resolve => setTimeout(resolve, ms))
}
 
async function doStuff() {
  // Perform tasks...
  await delay(5000);
  // Perform the rest after 5 seconds
}

🥇 Kết luận

Hiểu cách mà Event Loop, Task Queue, và Microtask Queue hoạt động cùng nhau là rất quan trọng để thành thạo JavaScript bất đồng bộ, không chặn.

Event Loop điều phối việc thực thi các tác vụ, ưu tiên Microtask Queue để đảm bảo các promise và các thao tác liên quan được giải quyết nhanh chóng trước khi chuyển sang các tác vụ trong Task Queue.

Điều này giúp JavaScript xử lý hành vi bất đồng bộ phức tạp trong một môi trường đơn luồng.

Bài viết được tham khảo từ: JavaScript Visualized: Event Loop, Web APIs, (Micro)task Queue

Phạm Hồng Đức
Phạm Hồng Đức
Một developer thích nghiên cứu và chia sẻ kiến thức lập trình, phần mềm mã nguồn mở (open-source), và cuộc sống.