Cách thức hoạt động của JavaScript: V8 engine và 5 mẹo tối ưu hóa

1269

Bài này sẽ đi sâu vào các phần V8 JavaScript engine của Google. Tôi sẽ cung cấp một số mẹo nhỏ về cách viết code JavaScript tốt hơn – đạt hiệu suất hơn mà nhóm phát triển của chúng tôi tại SessionStack thực hiện khi xây dựng sản phẩm.

Tổng quan

Một JavaScript engine là chương trình hoặc là interpreter (trình thông dịch) thực thi code JavaScript. Một JavaScript engine có thể được thực hiện như một trình thông dịch tiêu chuẩn, hoặc compiler (trình biên dịch) phù hợp để biên dịch JavaScript thành bytecode trong một số hình thứ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 — Được quản lý bởi Mozilla, mã nguồn mở được phát triển hoàn toàn bằng Java
  • SpiderMonkey — Công cụ JavaScript đầu tiên, được sự hỗ trợ của Netscape Navigator, và ngày hôm nay thuộc quyền sử dụng của Firefox
  • JavaScriptCore — mã nguồn mở, được biết đến là Nitro và do Apple phát triển cho Safari
  • KJS — engine của KDE được phát triển bởi Harri Porten cho trình duyệt web KDEproject’s Konqueror
  • Chakra (JScript9) — Internet Explorer
  • Chakra (JavaScript) — Microsoft Edge
  • Nashorn, mã nguồn mở như là một phần của OpenJDK, được viết bởi Oracle Java Languages và Tool Group
  • JerryScript —Là một công cụ nhẹ cho Internet of Things.

Tại sao V8 Engine được tạo ra?

V8 Engine được xây dựng bởi Google là mã nguồn mở và được viết bằng C ++. Công cụ này được sử dụng trong Google Chrome. Tuy nhiên, khác với phần còn lại của engine, V8 cũng được sử dụng phổ biến cho Node.js.

V8 lần đầu tiên được thiết kế để tăng hiệu suất việc thực hiện JavaScript bên trong các trình duyệt web. Để tối ưu hóa tốc độ, V8 chuyển code JavaScript thành machine code thay vì sử dụng một interpreter. Nó biên dịch code JavaScript vào machine code khi thi hành bằng cách sử dụng trình biên dịch JIT (Just-In-Time) như rất nhiều công cụ JavaScript hiện đại như SpiderMonkey hay Rhino (Mozilla). Sự khác biệt chính ở đây là V8 không tạo ra code bytecode hoặc bất kỳ code trung gian nào.

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

Trước khi phiên bản 5.9 của V8 xuất hiện (phát hành đầu năm nay), 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à tạo ra code đơn giản và tương đối chậm.
  • Crankshaft  – trình biên dịch phức tạp hơn (Just-In-Time) tối ưu hóa việc tạo racode hiệu quả hơn.

V8 Engine cũng sử dụng một số threads internally:

  • thread chính thực hiện những gì bạn đang mong đợi: lấy code của bạn, biên dịch nó và sau đó thực hiện nó
  • Ngoài ra còn có một luồng riêng biệt để biên soạn, do đó các luồng chính có thể tiếp tục thực hiện trong khi tối ưu hóa code
  • Một luồng Profiler sẽ cho biết thời gian chạy trên các methods mà chúng ta dành nhiều thời gian để Crankshaft để có thể tối ưu hóa chúng
  • Một vài luồng để xử lý quét Garbage Collector

Khi thực hiện code JavaScript lần đầu tiên, V8 thúc đẩy full-codegen trực tiếp chuyển code Javascript thành machine code mà không cần chuyển đổi. Điều này cho phép nó bắt đầu thực hiện machine code rất nhanh. Lưu ý rằng V8 không sử dụng bytecode trung gian bằng cách này loại bỏ sự cần thiết của interpreter

Khi code của bạn đã chạy một thời gian, luồng profiler đã tập hợp đủ dữ liệu để cho biết phương pháp nào nên được tối ưu hóa.

Tiếp theo, tối ưu hóa Crankshaft được thực hiện ở 1 luồng khác. Mục đích để dịch cú pháp JavaScript thành single-assignment (SSA) được gọi là Hydrogen và cố gắng tối ưu hóa đồ thị Hydro. Hầu hết các tối ưu hóa được thực hiện ở mức này.

Inlining

Tối ưu hóa đầu tiên là inlining càng nhiều code càng tốt. Inlining là quá trình thay thế một call site (dòng code nơi chức năng được gọi) với phần thân của hàm được gọi. Bước này cho phép tối ưu hóa sau có ý nghĩa hơn.

Hidden class

JavaScript là một ngôn ngữ dựa trên nguyên mẫu: không có các class và các object được tạo ra bằng cách sử dụng quá trình nhân bản. JavaScript cũng là một ngôn ngữ lập trình động, có nghĩa là các thuộc tính có thể dễ dàng thêm hoặc xoá khỏi một object sau khi nó được khởi tạo.

Hầu hết các trình biên dịch JavaScript sử dụng các cấu trúc giống như từ điển (dựa trên chức năng hash) để lưu trữ vị trí các giá trị thuộc tính đối tượng trong bộ nhớ. Cấu trúc này làm cho việc lấy giá trị của một thuộc tính trong JavaScript phức tạp hơn nhiều so với các ngôn ngữ lập trình non-dynamic như: Java hay C #. Trong Java, tất cả các thuộc tính của đối tượng được xác định bởi một bố cục cố định trước khi biên dịch và không thể tự động thêm hoặc xoá khi chạy. Kết quả là các giá trị thuộc tính (hoặc các trỏ tới các thuộc tính đó) có thể được lưu trữ dưới dạng một bộ nhớ đệm bổ sung liên tục. Độ dài của offset 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 là không thể trong JavaScript khi mà chương trình đang chạy.

Vì sử dụng dictionaries để tìm vị trí của các thuộc tính đối tượng trong bộ nhớ là rất không hiệu quả, V8 sử dụng một phương pháp khác thay thế: hidden classes. Các hidden classes có chức năng tương tự như object layout, ( class) được sử dụng như trong Java ngoại trừ các class được tạo ra trong thời gian chạy. Bây giờ, chúng ta hãy xem những gì nó thực sự như thế nào:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

Khi “invocation Point (1, 2)” mới xảy ra, V8 sẽ tạo ra một hidden classes gọi là “C0”.

Không có thuộc tính nào được xác định cho Point, vì vậy “C0” trống.

Khi câu lệnh đầu tiên “this.x = x” được thực hiện (bên trong hàm “Point”), V8 sẽ tạo ra một hidden classes thứ nhì gọi là “C1” dựa trên “C0”. “C1” mô tả vị trí trong bộ nhớ (tương đối so với con trỏ đối tượng) nơi có thể tìm thấy thuộc tính x. Trong trường hợp này, “x” được lưu trữ tại offset 0, có nghĩa là khi xem một đối tượng điểm trong bộ nhớ như một bộ đệm liên tục, lần bù đầu tiên sẽ tương ứng với thuộc tính “x”. V8 sẽ cập nhật “C0” với một “class transition” trong đó nếu một thuộc tính “x” được thêm vào một đối tượng điểm, nên hidden classes chuyển từ “C0” thành “C1”. Hidden classes cho đối tượng bên dưới bây giờ là “C1”.

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

Một hidden classes mới được gọi là “C2” được tạo ra, sự chuyển đổi class được thêm vào “C1” cho biết nếu một thuộc tính “y” được thêm vào một đối tượng Point (đã chứa thuộc tính “x”), thì hidden classes sẽ thay đổi thành “C2”, và hidden classes của đối tượng được cập nhật thành “C2”.

Các chuyển tiếp hidden classes phụ thuộc vào thứ tự thuộc tính được thêm vào một đối tượng. Hãy xem đoạn code dưới đây:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;

Bây giờ, bạn sẽ giả định rằng đối với cả p1 và p2 thì các hidden classes và transitions sẽ được sử dụng. Vâng, không thực sự. Đối với “p1”, đầu tiên thuộc tính “a” sẽ được thêm vào và thuộc tính “b”. Tuy nhiên, đối với “p2”,  đầu tiên “b” đang được chỉ định, tiếp theo là “a”. Do đó, “p1” và “p2” kết thúc với các hidden classes khác nhau do kết quả của các transitions khác nhau. Trong những trường hợp này, tốt hơn là nên khởi tạo các thuộc tính động theo cùng thứ tự sao cho các hidden classes có thể được sử dụng lại.

Inline caching

V8 tận dụng lợi thế kỹ thuật khác để tối ưu hóa các ngôn ngữ đánh máy được gọi là inline caching. Inline caching dựa trên quan sát rằng các repeated calls lặp lại với cùng một phương pháp có xu hướng xảy ra trên cùng một loại đối tượng. Một giải thích sâu về  Inline caching có thể được tìm thấy ở đây.

Chúng ta sẽ tiếp xúc với khái niệm chung về inline caching (trong trường hợp bạn không có thời gian để tìm hiểu sâu hơn).

Làm thế nào nó hoạt động? V8 duy trì một bộ nhớ cache của loại đối tượng đã được truyền như là một tham số trong các calls gần nhất và sử dụng thông tin này để tạo ra một giả định về loại đối tượng sẽ được truyền như một tham số trong tương lai. Nếu V8 có thể tạo ra một giả định tốt về loại đối tượng 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 các thông tin được lưu trữ từ các tra cứu trước đó tới đối tượng trong các hidden classes.

Vậy các khái niệm về hidden classes và inline caching có liên quan như thế nào? Bất cứ khi nào một phương thức được gọi trên một đối tượng cụ thể, V8 phải thực hiện một tra cứu đến hidden classes của đối tượng đó để xác định độ offset cho việc truy cập một thuộc tính cụ thể. Sau hai call thành công của cùng phương pháp đến cùng một hidden classes, V8 bỏ qua việc tra cứu hidden classes và đơn giản chỉ thêm offset vào chính con trỏ của đối tượng. Đối với tất cả các call trong tương lai của phương pháp đó,  V8 giả định rằng hidden classes không thay đổi và nhảy trực tiếp vào địa chỉ bộ nhớ cho một thuộc tính cụ thể bằng cách sử dụng các hiệu số được lưu trữ từ các tra cứu trước đó. Điều này làm tăng tốc độ thực hiện.

Đây cũng là lý do tại sao Inline caching quan trọng đến nỗi các đối tượng cùng loại chia sẻ các hidden classes. Nếu bạn tạo hai đối tượng cùng loại và với các hidden classes 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 cùng loại, các lớp ẩn tương ứng của chúng Gán các hiệu số khác nhau cho thuộc tính của chúng.

Biên soạn machine code

Một khi biểu đồ Hydrogen được tối ưu, Crankshaft giảm nó xuống mức thấp hơn là Lithium. Hầu hết việc thực hiện Lithium là kiến trúc cụ thể. Đăng ký phân bổ sẽ xảy ra ở giai đoạn này.

Cuối cùng, Lithium được biên dịch thành machine code. Sau đó, cái được gọi là OSR: on-stack thay thế. Trước khi chúng tôi bắt đầu biên dịch và tối ưu hoá một phương pháp chạy lâu dài, chúng tôi có thể chạy nó. V8 sẽ không quên những gì nó đã thực hiện và chỉ thực sự bắt đầu lại với phiên bản được tối ưu hóa. Nó sẽ chuyển đổi tất cả những gì chúng ta có (stack, register) để có thể chuyển sang phiên bản được tối ưu hóa ở giữa thực thi. Đây là một nhiệm vụ rất phức tạp, lưu ý rằng trong số các tối ưu hóa khác, V8 đã inlined code ban đầu V8 không phải là engine  duy nhất có khả năng làm việc đó.

Có những biện pháp bảo vệ được gọi là khước từ để thực hiện việc chuyển đổi ngược lại và quay trở lại code không được tối ưu hoá trong trường hợp giả định rằng engine đã thực hiện không còn đúng nữa.

Tổng hợp file rác

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

Ignition và TurboFan

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

Các đường dẫn thực hiện mới được xây dựng trên đầu trang của Ignition, V8’s interpreter, và TurboFan, trình biên dịch tối ưu hóa mới nhất của V8.

Bạn có thể kiểm tra bài đăng blog từ nhóm V8 về chủ đề ở đây.

Kể từ phiên bản 5.9 của V8 ra đời, phiên bản Vende và Crankshaft (công nghệ đã phục vụ V8 từ năm 2010) đã không còn được sử dụng bởi V8 vì việc thực hiện JavaScript vì V8 đã phải vật lộn để theo kịp các tính năng ngôn ngữ JavaScript mới và cac 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à có thể duy trì được nhiều kiến trúc hơn nữa.

Những cải tiến này mới chỉ là sự khởi đầu. Ignition và TurboFan mở đường cho những tối ưu hóa tiếp theo sẽ làm tăng hiệu suất của JavaScript và giảm dấu chân 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 tốt nhất, tối ưu hóa, JavaScript tốt hơn. Nó được bắt nguồn từ nội dung ở trên, tuy nhiên, đây là một bản tóm tắt ngắn gọn hơn.

Làm thế nào để viết JavaScript tối ưu hóa

  1. Thứ tự các thuộc tính của đối tượng: Các thuộc tính đối tượng của bạn được sắp xếp theo thứ tự nhất định để các hidden classes, tối ưu hóa code, có thể được chia sẻ.
  2.  Thuộc tính dynamic: Thêm vào một đối tượng sau khi instantiation sẽ buộc phải thay đổi một hidden classes sẽ làm chậm bất kỳ phương pháp đã được tối ưu hóa cho lớp ẩn trước đó. Thay vào đó, gán tất cả thuộc tính của đối tượng trong hàm tạo của nó.
  3. Các method: code thực hiện cùng một phương pháp lặp đi lặp lại sẽ chạy nhanh hơn code thực hiện nhiều phương pháp khác nhau chỉ một lần (do Inline caching).
  4. Arrays: Tránh các mảng rời rạc mà các phím không phải là các số tăng dần. Mảng rời rạc không có các phần tử bên trong là các hash table. Các yếu tố trong các mảng như vậy dễ truy cập hơn. Ngoài ra, cố gắng tránh phân bổ mảng lớn. Nó tốt hơn để phát triển. Cuối cùng, không xóa các phần tử trong mảng. Nó làm cho các phím thưa thớt.
  5. Tagged values: V8 đại diện cho các đối tượng và số với 32 bit. Nó sử dụng một chút để biết nếu nó là một đối tượng (flag = 1) hoặc một số nguyên (flag = 0) gọi là SMI (SMall Integer) vì 31 bit của nó. Sau đó, nếu một giá trị số lớn hơn 31 bit, V8 sẽ trở thành hộp số, biến nó thành một đôi và tạo ra một đối tượng mới để đưa số vào bên trong. Hãy thử sử dụng 31 bit ký số bất cứ khi nào có thể để tránh các hoạt động boxing vào một đối tượng JS.

Từ thực tiễn công việc của chúng tôi tại SessionStack thực hiện theo những cách này để viết code JavaScript tối ưu hóa cao. Lý do là khi bạn tích hợp SessionStack vào ứng dụng web của mình, nó bắt đầu ghi lại mọi thứ: thay đổi DOM , UI, JavaScript exceptions, stack traces, failed network requests, và debug messages.

Với SessionStack, bạn có thể reply các vấn đề trong ứng dụng web của mình dưới dạng video và xem mọi thứ đã xảy ra với người dùng của bạn. Và tất cả điều này xảy ra mà không ảnh hưởng đến hiệu suất ứng dụng web của bạn.

Có một kế hoạch cho phép bạn bắt đầu miễn phí.

Resources

Nguồn: Techtalk via Sessionstack