Hiểu về JavaScript bất đồng bộ – Event Loop

Tác giả: Giang Coffee 

Event Loop là gì và hoạt động thế nào?

Trước đây thi thoảng có làm Javascript và cũng có nghe nói qua về một số khái niệm cơ bản và hay ho của Javascript như nhân V8 của Google (quá oách), Event-Driven, Non-blocking I/O, Event Loop… những khái niệm giúp JS tận dụng sức mạnh của phần cứng và hàng chục lợi ích khác. Dạo gần đây có làm nhiều về JS, gặp nhiều lỗi quái đản mình mới tự đặt ra câu hỏi là rốt cục tất cả những thứ trên là cái gì?, hoạt động thế nào? và tại sao nó mang lại lợi ích?

1. Một số khái niệm cơ bản

1.1 Stack

Stack là một vùng nhớ đặc biệt trên con chip máy tính phục vụ cho quá trình thực thi các dòng lệnh mà cụ thể là các hàm. Hàm chẳng qua là một nhóm các lệnh và chương trình thì gồm một nhóm các hàm phối hợp với nhau. Mỗi khi một hàm được triệu gọi thì nó sẽ được đẩy vào một hàng đợi đặc biệt có tên là stack. Stack là một hàng đợi kiểu LIFO (Last In First Out) nghĩa là vào đầu tiên thì ra sau cùng. Một hàm chỉ được lấy ra khỏi stack khi nó hoàn thành và return.

Nếu trong một hàm (Foo) có triệu gọi một hàm khác (Bar) thì trạng thái hiện tại của hàm Foo được cất giữ trong stack và hàm Bar sẽ được chèn vào stack. Vì đây là hàng đợi LIFO nên Bar sẽ được xử lý trước Foo. Khi Bar xong và return thì mới đến lượt Foo được xử lý. Khi Foo được xử lý xong và return thì Stack rỗng và sẽ đợi các hàm tiếp theo được đẩy vào.

        Stack    
|                    |
|      Bar           | <--
|      Foo           |

1.2. Heap

Heap là vùng nhớ được dùng để chưa kết quả tạm phục vụ cho việc thực thi các hàm trong stack. Heap càng lớn thì khả năng tính toán càng cao. Heap có thể được cấp phát tĩnh hoặc cấp phát động bằng mấy lệnh kiểu alloc với malloc (đấy là những gì còn nhớ về C++).

2. Event Loop là gì

Event Loop là cơ chế giúp Javascript có thể thực hiện nhiều thao tác cùng một lúc (concurrent model), trước giờ vẫn nghe nói NodeJs có thể xử lý cả hàng ngàn request cùng một lúc mặc dù nó chỉ dùng một thread duy nhất (Single Threaded). Nếu như ở PHP hay Java thì với mỗi một request sẽ sinh ra một thread để xử lý request đó, các thread hoạt động độc lập, được cấp bộ nhớ, giao tiếp ngoại vi và trả về kết quả. Vậy làm thế nào để NodeJs có thể xử lý cả ngàn request một lúc với chỉ một thread duy nhất?.

3. Event Loop hoạt động như thế nào ?

Event Loop có tên như vậy bởi vì có một vòng lặp vô tận trong Javascript Runtime (V8 trong Google Chrome) dùng để lắng nghe các Event.

while (queue.waitForMessage()) {
  queue.processNextMessage();
}

Nhiệm vụ của Event Loop rất đơn giản đó là đọc Stack và Event Queue. Nếu nhận thấy Stack rỗng nó sẽ nhặt Event đầu tiên trong Event Queue và handler (callback hoặc listener) gắn với Event đó và đẩy vào Stack. Đặc điểm của việc thực thi hàm trong JS là sẽ chỉ dừng lại khi hàm return hoặc throw exception. Có nghĩa là trong khi hàm đang chạy thì sẽ không có một hàm khác được chạy, dữ liệu tạm của hàm cũng sẽ không bị thay đổi bởi một hàm khác hay cũng không bị dừng lại cho đến khi hoàn thành (ngoại trừ yield trong ES6).

const fs = require('fs');

function someAsyncOperation(callback) {
  // giả sử đọc file hết 95ms
  fs.readFile('/path/to/file', callback);
}

const timeoutScheduled = Date.now();

  const delay = Date.now() - timeoutScheduled;
  console.log(`${delay}ms have passed since I was scheduled`);
}, 100);


// đọc file xong sẽ tiếp tục chờ thêm 10ms
  const startCallback = Date.now();

  // chờ 10ms
  while (Date.now() - startCallback < 10) {
    // do nothing
  }
});

đầu tiên phần khai báo biến và hàm sẽ được chạy nhưng không được đẩy vào stack. Tiếp setTimeout() sẽ được đẩy vào stack và thực hiện. Hàm này không có trong Javascript Runtime mà là hàm tiện ích của Browser, nó sẽ khởi tạo một bộ đếm và sau đúng 100ms thì nó sẽ đẩy tham số đầu tiên logInfo (là một callback hoặc có thể gọi là một event listener cũng được) vào Event Queue. Kế đến sẽ chạy hàm someAsyncOperation và đẩy vào stack, vì hàm này async và có callback readFileAsync nên readFileAsync được đẩy luôn vào Event Queue mà không phải chờ như setTimeout để hứng sự kiện đọc xong file (sau 95ms).

         Stack                        Event Queue
|                    |            | readFileAsync     |  <--
|                    |            |                   |
| someAsyncOperation | <--        |                   |

Để ý là Stack LIFO nên someAsyncOperation sẽ nằm dưới cùng còn Event Queue FIFO nên readFileAsync sẽ nằm trên cùng. Sau khi readFileAsyncđược đẩy vào Event Queue thì someAsyncOperation return và được lấy ra khỏi Stack. Lúc này Stack không có gì nên Event Queue sẽ được đọc, nên nhớ là Event Queue chỉ được đọc khi Stack trống rỗng. readFileAsync sẽ được đẩy vào Event Queue trước vì nó chỉ mất có 95ms trong khi logInfo thì phải chờ 100ms. readFileAsync này sẽ được lấy khỏi Event Queue và đẩy vào stack để chạy.

       Stack                           Event Queue
|                    |    |       | logInfo           | <--
| readFileAsync      | <--        |                   |

readFileAsync sẽ gặp vòng while và dừng ở đó 10ms. Vậy tổng cộng hàm đọc file sẽ mất 105ms để hoàn thành. Nhưng ở giây thứ 100 thì logInfođược đẩy vào Event Queue (lúc này đã rỗng) trong khi readFileAsync thì còn phải mất thêm 5ms nữa mới hoàn thành. Vì cơ chế của Javascript là chạy đến khi hoàn thành mới thôi nên logInfo không có cách nào để dừng readFileAsync lại để chiếm quyền điều khiển, trừ khi trong readFileAsynccó lệnh yield. Sau 105ms thì readFileAsync return và được lấy ra khỏi Stack.

       Stack                           Event Queue
|                    |    |       |                   |
| logInfo            | <--        |                   |

Một lần nữa Stack lại trống và logInfo được đẩy vào Stack. Như vậy logInfo sẽ phải đợi tổng cộng 105ms để được chạy, chứ không phải 100ms như dự tính. Do đó tham số thứ 2 của setTimeout là thời gian tối thiểu để một Event được đẩy vào Stack và chạy chứ không phải là thời gian chính xác nó sẽ được chạy.

Giả sử bạn có một đoạn code jQuery như sau :

console.log('Ouch!'); });

thì một hoặc vài event sẽ được đẩy vào Event Queue như sau:

Stack                        Event Queue
|                    |            | yield(Event)      |  <--
|        Bar         |            |                   |
|        Foo         | <--        |                   |

function foo() {
   console.log('i am foo!');
   foo();
}

foo();

hàm đệ quy không điểm dừng sẽ liên tục đẩy foo vào Stack cho đến khi đầy, và bạn đoán xem lúc này chúng ta sẽ có cái mà hàng ngày các develop đều tìm kiếm Stack Overflow

      Stack                           Event Queue
| foo | | Event 1 |
| foo | | Event 2 |
| foo | | Event 3 |

Để tránh tình trạng Browser bị treo vì lỗi lập trình thì các Browser sẽ throw exception trong trường hợp này :

MAXIMUM CALL STACK SIZE EXCEEDED.

Đến đây ta có thể thấy cơ chế quản lý theo đầu việc là bí kíp giúp JS Runtime có thể xử lý hàng ngàn tác vụ cùng một lúc. Giống như bạn được giao một đống việc, bạn chia nhỏ từng việc và giao cho đám đệ tử của mình.

Bài viết gốc được đăng tải tại Giang Coffee