+3

Event Loop và Web APIs: Phép thuật đằng sau sự "Bất đồng bộ"

Chào anh em,

Nếu anh em xuất thân từ các ngôn ngữ backend tuần tự (như PHP xử lý request theo kiểu đồng bộ), chắc chắn khi mới nhảy sang nghịch JavaScript (Node.js hoặc Frontend) sẽ bị "khớp". Code viết rõ ràng ở dòng số 2, nhưng dòng số 3 lại chạy xong xừ nó rồi.

Đi phỏng vấn dạo, 99% anh em sẽ gặp câu hỏi mốc mồm này: "JavaScript là ngôn ngữ đơn luồng (single-threaded). Vậy tại sao nó lại có thể gọi API, set timeout, đọc ghi file cùng lúc mà không bị treo luôn cái luồng đó?"

Câu trả lời thường thấy là: "Nhờ cơ chế Non-blocking I/O và Event Loop". Nghe rất nguy hiểm đúng không? Hôm nay chúng ta sẽ lột mặt nạ cái gọi là Event Loop và Web APIs bằng ngôn ngữ "bình dân học vụ" nhất nhé.

1. V8 Engine thực ra rất "phế" (ở khía cạnh đa nhiệm)

Khi nói về JavaScript, người ta hay nhắc đến engine V8 của Google. Thằng V8 này có một cái gọi là Call Stack (Ngăn xếp lệnh).

Đúng như tên gọi của nó là "đơn luồng", Call Stack của V8 giống như một con đường chỉ có duy nhất một làn xe. Nó chỉ có thể thực hiện một việc duy nhất tại một thời điểm.

Nếu anh em táng một vòng lặp for chạy 1 tỷ lần vào đây, Call Stack sẽ bị kẹt cứng (blocking). Trên trình duyệt, hệ quả là giao diện web của anh em bị đơ, không bấm được nút nào cả. Vậy tại sao JS lại xử lý bất đồng bộ mượt mà đến thế?

Bí mật nằm ở chỗ: JavaScript Engine (V8) không hoạt động một mình!

2. Web APIs: Thế lực "đa luồng" đứng sau cánh gà

Trình duyệt (như Chrome) không chỉ có mỗi V8 Engine. Nó được viết bằng C++ và cung cấp cho chúng ta một đống các công cụ xịn sò, gọi chung là Web APIs.

Web APIs bao gồm những thứ mà bản thân ngôn ngữ JS không hề có, ví dụ:

  • setTimeout, setInterval (Xử lý thời gian)
  • fetch, XMLHttpRequest (Gọi mạng, AJAX)
  • DOM Manipulation (Thao tác HTML)

Cơ chế chuyển rác (Offloading):

Khi Call Stack đang chạy mà đụng phải lệnh setTimeout(callback, 2000), V8 Engine kiểu:

"Ây da, cái này của Web APIs, tao không biết làm, mày cầm lấy mà tự đếm giờ đi, tao chạy lệnh tiếp theo đây".

Thế là cái bộ đếm giờ 2 giây đó được ném sang cho Web APIs (chạy ở một luồng khác của trình duyệt) xử lý. Call Stack của V8 lại rảnh rang để chạy tiếp những dòng code bên dưới. Đường lại thông, hè lại thoáng!

3. Callback Queue: Phòng chờ của những kẻ xong việc

Vậy sau khi Web APIs đếm xong 2 giây, hoặc gọi xong cái API mạng, nó sẽ trả kết quả (chính là cái function callback anh em truyền vào) đi đâu?

Nó không được phép ném thẳng vào mặt Call Stack, vì lỡ lúc đó Call Stack đang bận chạy một cái vòng lặp nào đó thì sao? Xảy ra đụng độ (race condition) ngay!

Thay vào đó, Web APIs đẩy cái callback đó vào một cái hàng đợi gọi là Callback Queue (hay Task Queue). Thằng nào làm xong trước thì xếp hàng trước, tuân thủ nguyên tắc FIFO (First In, First Out).

4. Sự xuất hiện của Event Loop: Gã bảo vệ mẫn cán

Đến đây, chúng ta có một ông V8 (Call Stack) đang mải mê chạy code, và một phòng chờ (Callback Queue) đang chứa mấy cái hàm đã chạy xong chờ được gọi. Làm sao để hai bên giao tiếp với nhau?

Xin giới thiệu trùm cuối: Event Loop.

Công việc của Event Loop thực ra cực kỳ nhàm chán và lặp đi lặp lại. Giống như một anh bảo vệ đứng giữa Call Stack và Callback Queue, quy trình của nó chỉ có 2 bước:

  1. Nhìn sang Call Stack: Có đang trống không? (Tức là toàn bộ code đồng bộ đã chạy xong chưa?)
  2. Nhìn sang Callback Queue: Nếu Call Stack đã trống, mày xem trong hàng đợi có thằng nào đang chờ không? Nếu có, bốc thằng đầu tiên ném vào Call Stack cho nó chạy đi.

Quá trình này lặp đi lặp lại liên tục (loop), tạo nên cái gọi là Event Loop.

5. Bài test nhân phẩm: Code này in ra cái gì?

Để xem anh em đã ngấm chưa, thử đoán xem đoạn code kinh điển này in ra kết quả thứ tự thế nào nhé:

console.log('1');

setTimeout(() => {
    console.log('2');
}, 0);

console.log('3');

Nếu anh em chưa biết về Event Loop, có thể anh em sẽ đoán là 1 -> 2 -> 3 (vì thời gian chờ là 0 giây cơ mà). Nhưng thực tế kết quả là 1 -> 3 -> 2.

Giải thích luồng chạy theo phong cách Event Loop:

  1. console.log('1') vào Call Stack -> In ra 1 -> Rời Call Stack.
  2. Gặp setTimeout. V8 ném việc này cho Web APIs. Dù là 0 giây, Web APIs lập tức đẩy cái callback () => console.log('2') vào phòng chờ Callback Queue.
  3. console.log('3') vào Call Stack -> In ra 3 -> Rời Call Stack.
  4. Lúc này, toàn bộ code đã chạy hết. Call Stack đã trống.
  5. Event Loop làm nhiệm vụ: À, Call Stack trống rồi, trong Queue đang có thằng chờ. Nó bốc cái callback ở bước 2 ném vào Call Stack.
  6. console.log('2') chạy -> In ra 2.

Đó, dù cho thời gian chờ có là 0 giây, thì nó vẫn phải đi một vòng rào qua Web APIs, xếp hàng ở Queue và chờ thằng Event Loop "phát lệnh" thì mới được chạy.

Chốt hạ

Tóm lại, bản thân anh chàng JavaScript thì đơn luồng và rất chân phương, nhưng nhờ ôm cái đùi to là Web APIs (của trình duyệt hoặc C++ lib trong Node.js) và bộ não điều phối Event Loop, nó mới có thể "múa rồng múa rắn" xử lý hàng nghìn request I/O cùng lúc mà không lo chết tắc.

Hy vọng bài viết này giúp anh em clear được cái concept ảo ma này. Lần tới đi phỏng vấn bị hỏi, cứ tự tin mà "chém" nhé! Nếu thấy bài viết dễ hiểu, anh em cho mình xin một upvote để lấy động lực lên bài tiếp nha. Code vui vẻ!


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í