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?
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.
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ặpawait
-
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ênCall 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àmasync
sauawait
, các callback củaMutationObserver
và callback củaqueueMicrotask
. Hàng đợi này có độ ưu tiên cao hơnTask 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 sangTask Queue
, nơi nó chuyển tác vụ đầu tiên có sẵn lênCall 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ạiMicrotask 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
.
Đ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
.
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ờ:
🥇 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