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?

Hôm nay qua một số google search và đặc biệt xem được bài thuyết trình này mình thấy Event Loop chính là thứ nguồn gốc, hay ho nhất và muốn chia sẻ, thảo luận cùng mọi người. Đấy là những gì mình hiểu ra chứ chưa chắc đã là chuẩn xác. Anh em có gì góp ý mình cực kỳ hoan nghênh và tiếp thu.

Tất cả các ngôn ngữ lập trình đều được sinh ra để làm thứ ngôn ngữ giao tiếp giữa người và máy. Dù là ngôn ngữ gì đi chăng nữa thì cuối cùng vẫn phải dịch ra mã máy, được load lên memory, chạy từng dòng lệnh, ghi các dữ liệu tạm thời ra bộ nhớ, ổ đĩa rồi giao tiếp các thiết bị ngoại vi… Thế nên để cho tiện mình xin nhắc lại một số khái niệm cơ bản sau.

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.

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

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?.

Có một sự thật là trên web browser thì trong khi get data từ các url thì người dùng vẫn có thể thực hiện các thao tác khác như click button và gõ vào các ô textbox. Tất cả là nhờ có các web apis và cơ chế hoạt động của Event Loop. Tuy Js Runtime chỉ có một thread duy nhất nhưng các web apis giúp nó giao tiếp với thế giới multi thread bên ngoài, tận dụng các con chip đa nhân vốn rất phổ biến hiện nay. Web apis giúp đẩy các job ra bên ngoài và chỉ tạo ra các sự kiện kèm theo các handler gắn với các sự kiện. Kể cả đối với NodeJs khi không có web apis thì nó vẫn có các cơ chế tương đương khác giúp đẩy job ra bên ngoài và chỉ quản lý các đầu việc. Web Apis hoạt động như vậy thì Event Loop sẽ thế nào ?

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();
}

 

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

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).

Như các bạn thấy trên hình thì JS Runtime còn thao tác với một callback queue hay event queue ngoài stack ra. Event queue này khác với stack ở chỗ nó là queue kiểu FIFO (First In First Out). Mỗi khi có một Event được tạo ra, ví dụ user click vào một Button thì một Event sẽ được đẩy vào Event queue cùng với một handler (event listener) gắn với nó. Nếu một Event không có listener thì nó sẽ bị mất và không được đẩy vào Event queue. Để cho dễ hình dung cách thức hoạt động của Event Loop ta lấy một ví dụ như sau :

const fs = require('fs');

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

const timeoutScheduled = Date.now();

setTimeout(function logInfo() => {
  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
someAsyncOperation(function readFileAsync() => {
  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
 --------------------              -------------------
|                    |     ------ | readFileAsync     | 
 --------------------     |         -------------------
|                    |    |       | 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           | 
 --------------------     |        -------------------
|                    |    |       |                   |
 --------------------     |        -------------------
| 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 :

$('#button_1').click(function yield() {
  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         | <--        |                   |
 --------------------              -------------------

 

đặt tên hàm là yield chỉ nhằm mục đích dễ theo dõi, ta hoàn toàn có thể bỏ tên hàm đi trong trường hợp này. Khi Bar và Foo return và được lấy ra khỏi Stack thì yield sẽ được đẩy vào Stack với tham số là DOM Element xảy ra sự kiện click.

Cơ chế run to completion của Javascript có một điểm bất lợi đó là nếu một hàm chạy quá lâu hoặc bị vòng lặp vô tận thì sẽ không có hàm nào được chạy nữa, kết quả là Browser sẽ bị đơ, không phản ứng với các sự kiện như click chuột … Ví dụ :

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.

Hầu hết các thao tác trong Javascript đều là bất đồng bộ nhưng có một số ngoại lệ thú vị như hàm alert (hàm này là của Browser API, không có trong NodeJs). Khi hàm này được chạy thì bạn không thể thực hiện một thao tác nào khác ngoài click OK.

Đế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

Tuyển lập trình viên Javascript lương cao tại đây