Cách thức hoạt động của Javascript Phần 2: Cốt lõi từ V8 Engine và mẹo để viết code tối ưu

MVT
Đang cập nhật

Rất vui khi quay trở lại cùng các bạn với chủ để "Cách thức hoạt động của Javascrip". Trong phần trước, chúng ta đã biết được khái quát về JS Engine, thời gian thực thi (runtime) và callstack. Bài viết ngày hôm nay sẽ cung cấp cho bạn một cái nhìn sâu sắc về V8 Engine được phát triển bởi Goolge. Ngoài ra, bạn cũng sẽ được cung cấp thêm một vài thủ thuật để viết code tốt hơn.

Tổng quan

Một Javascript Engine là một chương trình hay nói khác đi nó là một trình thông dịch (interpreter) để thực thi các mã code JS. Một JS engine có thể được thực thi như một trình thông dịch chuẩn, hoặc một trình biên dịch (compiler) kịp thời để biên dịch JS thành mã bytecode ở một số dạng khác.

Đây là danh sách các dự án phổ biến đang triển khai JavaScript engine:

  • V8 : Mã nguồn mở, được phát triển bởi Google, viết bằng C++.
  • Rhino : Mã nguồn mở, Được quản lý bởi tập đoàn Mozzila, toàn bộ được phát triển bằng Java.
  • Spider Monkey : Engine đầu tiền của JS, ngày trước hỗ trợ cho Netscape Navigator và ngày nay cung cấp cho Firefox.
  • Javascript Code : Mã nguồn mở, được tài trợ bởi Nitro và được Apple phát triển cho Saffari.
  • KJS : Công cụ của KDE ban đầu được phát triển bởi Harri Porten cho trình duyệt web Konqueror của dự án KDE.
  • Chackra (JScript9) : Internet Explorer.
  • Checkra (Javascript) : Microsoft Edge.
  • Nashorn : mã nguồn mở như một phần của OpenJDK, được viết bởi Oracle Java Languages và Tool Group
  • Jerry Script : Là một engine khá gọn và nhẹ, phục vụ cho Internet of Things (IOT).

Tại sao V8 engine lại ra đời?

V8 Engine là một mã nguồn mở, viết bằng ngôn ngữ C++ bởi Google Team. Engine này được sử dụng trong trình duyệt Google Chrome. Khác với các Engine còn lại, V8 Engine cũng được sử dụng rất phổ biến bên ngoài trình duyệt, nó được dùng trong môi trường Node.js runtime.

V8 lần đầu tiên được thiết kế với mục tiêu để tăng hiệu suất thực thi JavaScript bên trong trình duyệt web. Để đạt được tốc độ cao như mong muốn, V8 phải biên dịch tất cả các mã Javascript thành mã máy sẽ đạt hiệu quả cao hơn thay vì sử dụng trình thông dịch. Nó biên dịch các mã Javascript thành mã máy khi chương trình bắt đầu được thực thi bằng cách khởi chạy một JIT (Just-in-time) compiler (Trình biên dịch đúng thời điểm) giống như nhiều JS engines khác như MonkeySpider hoặc Rhino (Mozilla). Điều khác biết chính ở đây là V8 không tạo ra các bytecode hoặc bất kỳ đoạn code trung gian nào. Vì thế, hiệu suất thực thi code JS nhanh hơn đáng kể.

V8 đã từng có 2 trình biên dịch

Trước khi phiên bản 5.9 của V8 ra mắt, Engine đã sử dụng hai trình biên dịch:

  • full-codegen : một trình biên dịch đơn giản và rất nhanh tạo ra mã máy đơn giản và tương đối chậm.
  • Crankshaft : Một trình biên dịch tối ưu hóa (Just-in-time) có phần phức tạp hơn để tạo ra mã được tối ưu cao hơn.

The V8 Engine cũng sử dụng một số luồng bên trong:

  • Luồng chính đảm nhận những công việc mà bạn kỳ vọng : fetch code, biên dịch code và sau đó thực thi nó.
  • Cũng có một luồng riêng để biên dịch, để luồng chính có thể tiếp tục thực thi trong khi luồng chính trước đó đang tối ưu hóa mã.
  • Một luồng Profiler sẽ cho biết thời gian chạy về những phương pháp nào chúng ta bỏ ra nhiều thời gian để Crankshaft có thể tối ưu chúng.
  • Một vài luồng để xử lý công cụ thu gom, quét rác.

Khi thực thi lần đầu tiền đoạn code Javascript, V8 tận dụng full-codegen để dịch trực tiếp JavaScript đã phân tích cú pháp thành mã máy mà không cần bất kỳ chuyển đổi nào. Điều này cho phép nó bắt đầu thực thi mã máy rất nhanh. Chú ý rằng V8 không sử dụng đại diện bytecode trung gian, cách thức này bỏ đi sự cần thiết của trình thông dịch.

Khi code của bạn đã thực thi khoảng một thời gian, luồng profiler đã thu thập đủ dữ liệu để nói với Engine rằng phương thức nào cần được tối ưu.

Tiếp theo, sự tối ứu Crankshaft khởi đầu trong một luồng khác. Nó dịch cây cú pháp trừu tượng của JS thành một đại diện có chức năng phân công tĩnh ở bậc cao (high-level) được gọi là Hydrogen và cố gắng tối ưu đồ thị Hydrogen này. Hầu hết các tối ưu hóa được thực hiện ở cấp độ này.

Nội tuyến (Inlining)

Việc tối ưu hóa đầu tiên là nội tuyến càng nhiều code càng tốt. Nội tuyến là một quá trình thay thế nơi gọi [call site] (dòng code nơi hàm được gọi) với phần bên trong của hàm được gọi đó. Bước đơn giản này cho phép các tối ưu hóa sau có ý nghĩa hơn.

Lớp ẩn danh

Javascript là ngôn ngữ được thiết kế dựa trên prototype: không có class và objects được tạo bằng việc sử dụng một quá trình nhân bản (cloning process) như các ngôn ngữ khác. Javascript cũng là ngôn ngữ lập trình động (dynamic programing language) nên các thuộc tính của nó có thể được thêm vào một cách dễ dàng hoặc có thể được loại bỏ khỏi object sau kho nó đã được khởi tạo trước đó.

Hầu hết các trình thông dịch JavaScript sử dụng cấu trúc dictionary( một thuật ngữ khác là hash function) để lưu giữ vị trí của các giá trị thuộc tính trong bộ nhớ. Cấu trúc này làm cho việc truy xuất giá trị của một thuộc tính trong JavaScript tốn kém hơn về mặt tính toán so với trong ngôn ngữ lập trình không động như Java hoặc C, C++. Trong Java, tất cả các thuộc tính của object được xác định bởi một cấu trúc cho đối tượng cố định (layout object fixed) trước khi biên dịch, nghĩa là object này không thể được thêm hoặc loại bỏ một cách linh hoạt khi chương trình thực thi. Điều này dẫn đến các giá trị của thuộc tính ( hoặc con trỏ các thuộc tính đó) có thể được lưu trữ như một bộ đệm liên tục trong bộ nhớ với một khoảng cách cố định giữa chúng. Độ dài của khoảng cách có thể dễ dàng được xác định dựa trên loại thuộc tính, trong khi điều này không thể thực hiện được trong JavaScript nơi loại thuộc tính có thể thay đổi trong thời gian thực thi.

Chính vì việc sử dụng dictionary để tìm kiếm vị trí các thuộc tính trong một object quá tốn kém và không hiệu quả, V8 đã sử dụng một phương thức khác để thay thế, đó là Lớp ẩn danh (hidden class). Lớp ẩn danh có trúc hướng đối tượng cố định (fixed object layout) được sử dụng trong các ngôn ngữ lập trình tĩnh, ngoài trừ khi chúng được tạo trong lúc thực thi chương trình. Bây giờ, thử xem chúng hoạt động như thế nào nhé :

function Point2D(x,y){
  this.x = x ; 
  this.y = y ; 
}
let p1 = new Point(3,4)

Khi new Point(3,4) được gọi, V8 sẽ tạo một lớp ẩn có tên là C0

Chưa có thuộc tính nào được xác định cho điểm nên C0 trống.

Khi câu lệnh đầu tiên this.x = x được thực thi, V8 sẽ khởi tạo một lớp ẩn thứ 2 được gọi là C1 dựa trên C0. C1 mô tả vị trí ô nhớ trong vùng nhớ (có liên quan đến con trỏ đối tượng) nơi thuộc tính x có thể được tìm thấy. Trong trường hợp này, x được lưu tại ô nhớ (offset) 0, khi quan sát con trỏ đối tượng này trên bộ nhớ ta thấy nó có vùng nhớ đệm liên tiếp với nhau, lúc này vị trí ô nhớ đầu tiên của object sẽ tương ứng với thuộc tính x. Ngoài ra, V8 cũng sẽ tiếp tục cập nhật C0 qua một "sự chuyển đổi lớp" (class transition) trong trường hợp nếu thuộc tính "x" được đưa vào con trỏ đối tượng,lớp ẩn chuyển trạng thái từ C0 thành C1. Hidden class con trỏ đối tượng ở hình dưới đây là lúc nó là C1

Mỗi khi một thuộc tính mới được thêm vào một đối tượng, lớp ẩn cũ được cập nhật với đường dẫn chuyển tiếp sang lớp ẩn mới. Chuyển đổi lớp ẩn rất quan trọng vì chúng cho phép các lớp ẩn được chia sẻ giữa các đối tượng được tạo theo cùng một cách. Nếu hai đối tượng chia sẻ một lớp ẩn có cùng thuộc tính được thêm vào cả hai, thì quá trình chuyển đổi sẽ đảm bảo rằng cả hai đối tượng đều nhận được cùng một lớp ẩn mới và tất cả mã được tối ưu hóa đi kèm với nó.

Quá trình này được lặp lại khi câu lệnh this.y = y được thực thi (một lần nữa, bên trong hàm Point, sau câu lệnh this.x = x).

Một lớp ẩn mới có tên là C2 được gọi, một chuyển đổi lớp được thêm vào C1 được thực hiện nếu một thuộc tính y được thêm vào đối tượng Điểm (đã chứa thuộc tính x) thì lớp ẩn sẽ chuyển thành C2 và lớp ẩn của đối tượng điểm được cập nhật thành C2.

Lưu ý : Quá trình chuyển đổi lớp ẩn phụ thuộc vào thứ tự của các thuộc tính được đưa vào đối tượng. VD:

1  function Point(x,y){
2  this.x = x ;
3  this.y = y ; 
4  } 
5
6  let obj1 = new Point(1,2);
7  let obj2 = new Point(3,4);
8
9  obj1.a = 100;
10 obj1.b = 200;
11
12 obj2.b = 200;
13 obj3.a = 100;

Cho đến dòng thứ 8, obj1obj2 đã có cùng hàm ẩn. Tuy nhiên, do thứ tự thuộc tính được truyền vào của 2 obj này ngược nhau, với obj1 thuộc tính a truyền vào trước b, còn obj2 b lại được truyền vào trước a, nên cuối cùng obj1 và obj2 khác nhau hàm ẩn.

Trong những trường hợp như vậy, tốt hơn nhiều nên khởi tạo các thuộc tính động theo cùng một thứ tự để các lớp ẩn có thể được sử dụng lại

Bộ nhớ đệm nội tuyến (Inline caching)

V8 tận dụng một kỹ thuật khác để tối ưu hóa các ngôn ngữ được nhập động được gọi là bộ nhớ đệm nội tuyến.Bộ nhớ đệm nội tuyến dựa trên quan sát các lệnh gọi lặp lại đến cùng một phương thức có xu hướng xảy ra trên cùng một loại đối tượng. Để có thể tìm hiểu kỹ hơn về bộ nhớ đệm nội tuyến, bạn có thể đọc thêm tại đây.

Chúng ta sẽ đề cập đến khái niệm chính về bộ nhớ đệm nội tuyến (trong trường hợp bạn không có thời gian để xem phần giải thích chuyên sâu ở trên).

Bộ nhớ đệm nội tuyến hoạt động như thế nào? V8 duy trì bộ nhớ đệm cho loại các đối tượng được truyền vào như một tham số thông qua các phương thức gọi gần đây và sử dụng thông tin này để đưa ra giả định về loại đối tượng sẽ được truyền vào như một tham số trong tương lai. Nếu V8 có thể đưa ra giả định tốt về loại đối tượng sẽ được truyền vào như một phương thức, nó có thể bỏ qua quá trình tìm ra cách truy cập các thuộc tính của đối tượng và thay vào đó, sử dụng thông tin được lưu trữ từ các lần tra cứu trước đó cho lớp ẩn của đối tượng.

Vậy khái niệm về lớp ẩn và bộ nhớ đệm nội tuyến có mối liên hệ gì với nhau? Bất cứ khi nào một phương thức được gọi trong một đối tượng cụ thể, V8 engine phải thực hiện công việc tìm kiếm đến lớp ẩn của đối tượng để xác định ô nhớ nào chưa thuộc tính đó. Sau hai lần gọi thành công cùng một phương thức đến cùng một lớp ẩn. V8 bỏ qua quá trình tra cứu lớp ẩn và chỉ cần thêm ô nhớ của thuộc tính vào chính con trỏ đối tượng. Nếu cùng một phương thức đó được gọi ở những lần tiếp theo, V8 engine giả định rằng lớp ẩn không có gì thay đổi, và đi trực tiếp đến địa chỉ vùng nhớ đối với các thuộc tính cụ thể sử dụng ô nhớ để lưu giá trị từ những lần tra cứu trước đó. Điều này làm tăng đáng kể tốc độ thực thi.

Bộ nhớ đệm nội tuyến cũng là lý do tại sao các đối tượng cùng loại chia sẻ các lớp ẩn lại rất quan trọng. Nếu bạn tạo hai đối tượng cùng loại và có các lớp ẩn khác nhau (như chúng ta đã làm trong ví dụ trước đó), V8 sẽ không thể sử dụng bộ nhớ đệm nội tuyến bởi vì mặc dù hai đối tượng thuộc cùng một loại, nhưng lớp ẩn danh tương ứng của chúng lại gán những thuộc tính của chúng vào những ô nhớ khác nhau.

Hai đối tượng về cơ bản giống nhau nhưng thuộc tính “a” và “b” được tạo theo thứ tự khác nhau.

V8 engine đã biên dịch thành mã máy như thế nào?

Khi đồ thị Hydrogen được tối ưu hóa, Crankshaft hạ nó xuống thành một đại diện có cấp thấp hơn gọi là Lithium. Hầu hết việc triển khai Lithium là dành riêng cho kiến ​​trúc. Đăng ký phân bổ xảy ra ở cấp độ này.

Cuối cùng, Lithium được biên dịch thành mã máy. Sau đó, một cái vài thứ khác xảy ra được gọi là OSR (on-stack replacement) có thể dịch là sự thay thế trên ngăn xếp. Trước khi bắt đầu biên dịch và tối ưu hóa một phương thức có thời gian thực thi khá dài, chúng ta có thể đang chạy V8 engine trước đó. V8 sẽ không quên những gì nó đã thực thi một cách chậm chạp để bắt đầu lại với phiên bản được tối ưu hóa. Thay vào đó, nó sẽ chuyển đổi tất cả context mà chúng ta có (ngăn xếp, thanh ghi) để chúng ta có thể chuyển sang phiên bản được tối ưu hóa ở giữa quá trình thực thi. Đây là một nhiệm vụ rất phức tạp, cần lưu ý rằng trong số các tối ưu hóa khác, V8 đã tạo nội dung mã ban đầu. V8 không phải là công cụ duy nhất có khả năng làm được điều đó.

Có một số biện pháp bảo vệ được gọi là hủy tối ưu hóa (deoptimization) để thực hiện chuyển đổi ngược lại và hoàn trả về mã không được tối ưu hóa trong trường hợp giả định mà công cụ tạo ra không còn đúng nữa.

Quá trình gom rác (Garbage collection)

Để thu gom rác, V8 sử dụng cách tiếp cận truyền thống theo thế hệ là đánh dấu và quét để làm sạch thế hệ cũ. Giai đoạn đánh dấu phải dừng việc thực thi JavaScript. Để kiểm soát chi phí cho quá trình gom rác và làm cho quá trình thực thi ổn định hơn, V8 sử dụng đánh dấu tăng dần: thay vì đi qua toàn bộ vùng nhớ, cố gắng đánh dấu mọi đối tượng có thể, nó chỉ đi qua một phần của vùng nhớ đó, sau đó tiếp tục thực hiện bình thường. Điểm dừng của quá trình gom rác tiếp theo sẽ tiếp tục từ nơi vùng nhớ nó đã dừng lại trước đó. Điều này cho phép quá trình tạm dừng rất ngắn trong suốt quá trình thực thi. Như đã đề cập trước đây, giai đoạn quét được xử lý bởi các luồng riêng biệt.

Với việc phát hành V8 5.9 trước đó vào năm 2017, một quy trình thực thi mới đã được giới thiệu. Đường dẫn mới này đạt được những cải tiến hiệu suất lớn hơn và tiết kiệm bộ nhớ đáng kể trong các ứng dụng JavaScript trong thế giới thực.

Quy trình thực thi mới được xây dựng dựa trên Ignition, trình thông dịch của V8 và TurboFan, trình biên dịch tối ưu hóa mới nhất của V8.

Kể từ khi phiên bản 5.9 của V8 ra mắt, full-codegen và Crankshaft (các công nghệ đã phục vụ V8 từ năm 2010) đã không còn được V8 sử dụng để thực thi JavaScript vì nhóm V8 đã phải vật lộn để bắt kịp với các tính năng ngôn ngữ JavaScript mới và tối ưu hóa cần thiết cho các tính năng này.

Điều này có nghĩa là V8 tổng thể sẽ có kiến ​​trúc đơn giản hơn và dễ bảo trì hơn trong tương lai.

Những cải tiến này chỉ là bước khởi đầu. Đường dẫn Ignition và TurboFan mới mở đường cho những tối ưu hóa hơn nữa nhằm tăng hiệu suất JavaScript và thu hẹp sự riêng biệt của V8 trong cả Chrome và Node.js trong những năm tới.

Cuối cùng, đây là một số mẹo và thủ thuật về cách viết JavaScript tốt hơn, được tối ưu hóa tốt. Tuy nhiên, bạn có thể dễ dàng rút ra những điều này từ nội dung ở trên, đây là bản tóm tắt để bạn thuận tiện:

Cách viết JavaScript được tối ưu hóa

  1. Thứ tự của thuộc tính đối tượng: luôn khởi tạo các thuộc tính đối tượng của bạn theo cùng một thứ tự để có thể chia sẻ các lớp ẩn và mã được tối ưu hóa sau đó.
  2. Thuộc tính động : thêm thuộc tính vào một đối tượng sau khi khởi tạo sẽ buộc thay đổi lớp ẩn và làm chậm bất kỳ phương thức nào đã được tối ưu hóa cho lớp ẩn trước đó. Thay vào đó, hãy gán tất cả các thuộc tính của một đối tượng trong hàm tạo ra nó.
  3. Phương thức : code được thực thi lặp đi lặp lại cùng một phương thức sẽ chạy nhanh hơn mã thực thi nhiều phương thức khác nhau chỉ dùng cho một lần (do bộ nhớ đệm nội tuyến).
  4. Mảng : tránh các mảng thưa thớt nơi các key không phải là số tăng dần. Các mảng thưa không có đầy đủ phần tử bên trong chúng là một bảng băm (hash table). Các phần tử trong các mảng như vậy có chi phí cho vùng nhớ rất tốn kém khi truy cập. Ngoài ra, hãy cố gắng tránh cấp vùng nhớ trước cho các mảng lớn. Tốt hơn là bạn nên tiếp tục cấp khi bạn cần sau đó. Cuối cùng, không xóa các phần tử trong mảng. Nó làm cho các mảng bị thưa.
  5. Giá trị được gắn thẻ : V8 đại diện cho các đối tượng và số với 32 bit. Nó sử dụng một bit để xác định liệu nó là một đối tượng (flag = 1) hay một số nguyên (flag = 0) còn được gọi là SMI (SMall Integer) vì nó có 31 bits. Sau đó, nếu một giá trị số lớn hơn 31 bit, V8 sẽ khoanh vùng số đó, biến nó thành số kép và tạo một đối tượng mới để đưa số vào bên trong. Cố gắng sử dụng các số có dấu 31 bit bất cứ khi nào có thể để tránh hoạt động khoanh vùng tốn kém vào một đối tượng JS.

Bài viết đến đây là hết, cám ơn các bạn đã theo dõi.


Bài viết có liên quan