Dưới mui xe của động cơ V8


Hôm nay chúng ta sẽ tìm hiểu sâu hơn về công cụ V8 của JavaScript và tìm hiểu xem JavaScript được thực thi chính xác như thế nào.

Trong bài viết trước, chúng ta đã tìm hiểu cách trình duyệt được cấu trúc và có cái nhìn tổng quan cấp cao về Chromium. Hãy tóm tắt lại một chút để chúng ta sẵn sàng đi sâu vào đây.

Lý lịch

Tiêu chuẩn web là một bộ quy tắc mà trình duyệt thực hiện. Chúng định nghĩa và mô tả các khía cạnh của World Wide Web.

W3C là một cộng đồng quốc tế phát triển các tiêu chuẩn mở cho Web. Họ đảm bảo rằng mọi người đều tuân theo các hướng dẫn giống nhau và không phải hỗ trợ hàng chục môi trường hoàn toàn khác nhau.

Một trình duyệt hiện đại là một phần mềm khá phức tạp với cơ sở mã gồm hàng chục triệu dòng mã. Vì vậy, nó được chia thành nhiều mô-đun chịu trách nhiệm về logic khác nhau.

Và hai trong số những phần quan trọng nhất của trình duyệt là công cụ JavaScript và công cụ kết xuất.

Blink là một công cụ kết xuất chịu trách nhiệm cho toàn bộ quy trình kết xuất bao gồm cây DOM, kiểu, sự kiện và tích hợp V8. Nó phân tích cú pháp cây DOM, giải quyết các kiểu và xác định hình dạng trực quan của tất cả các phần tử.

Trong khi liên tục theo dõi các thay đổi động thông qua các khung hình động, Blink vẽ nội dung trên màn hình của bạn. Công cụ JS là một phần quan trọng của trình duyệt – nhưng chúng tôi vẫn chưa đi sâu vào các chi tiết đó.

Công cụ JavaScript 101

Công cụ JavaScript thực thi và biên dịch JavaScript thành mã máy gốc. Mọi trình duyệt chính đã phát triển công cụ JS của riêng mình: Chrome của Google sử dụng V8, Safari sử dụng JavaScriptCore và Firefox sử dụng SpiderMonkey.

Chúng tôi sẽ làm việc đặc biệt với V8 vì nó được sử dụng trong Node.js và Electron, nhưng các công cụ khác được xây dựng theo cách tương tự.

Mỗi bước sẽ bao gồm một liên kết đến mã chịu trách nhiệm về nó, vì vậy bạn có thể làm quen với cơ sở mã và tiếp tục nghiên cứu ngoài bài viết này.

Chúng tôi sẽ làm việc với một bản sao của V8 trên GitHub vì nó cung cấp giao diện người dùng thuận tiện và nổi tiếng để điều hướng cơ sở mã.

Chuẩn bị mã nguồn

Điều đầu tiên V8 cần làm là tải xuống mã nguồn. Điều này có thể được thực hiện thông qua mạng, bộ đệm hoặc nhân viên dịch vụ.

Sau khi nhận được mã, chúng ta cần thay đổi nó theo cách mà trình biên dịch có thể hiểu được. Quá trình này được gọi là phân tích cú pháp và bao gồm hai phần: bộ quét và chính bộ phân tích cú pháp.

Máy quét lấy tệp JS và chuyển đổi nó thành danh sách các mã thông báo đã biết. Có một danh sách tất cả các mã thông báo JS trong tệp keywords.txt.

Trình phân tích cú pháp chọn nó và tạo một Cây cú pháp trừu tượng (AST): một biểu diễn cây của mã nguồn. Mỗi nút của cây biểu thị một cấu trúc xảy ra trong mã.

Hãy cùng xem một ví dụ đơn giản:

function foo() {
  let bar = 1;
  return bar;
}

Mã này sẽ tạo ra cấu trúc cây sau:

cây ast
Ví dụ về cây AST

Bạn có thể thực thi mã này bằng cách thực hiện chuyển đổi đơn đặt hàng trước (gốc, trái, phải):

  1. xác định foo chức năng.
  2. khai báo bar Biến đổi.
  3. Giao phó 1 đến bar.
  4. Trở lại bar ra khỏi chức năng.
Đọc thêm  23 trang web miễn phí để học JavaScript năm 2022

Bạn cũng sẽ thấy VariableProxy — một phần tử kết nối biến trừu tượng với một vị trí trong bộ nhớ. Quá trình giải quyết VariableProxy được gọi là Phân tích phạm vi.

Trong ví dụ của chúng tôi, kết quả của quá trình sẽ là tất cả VariableProxys trỏ đến cùng bar Biến đổi.

Mô hình Just-in-Time (JIT)

Nói chung, để mã của bạn thực thi, ngôn ngữ lập trình cần được chuyển đổi thành mã máy. Có một số cách tiếp cận về cách thức và thời điểm sự chuyển đổi này có thể xảy ra.

Cách phổ biến nhất để chuyển đổi mã là thực hiện biên dịch trước thời hạn. Nó hoạt động chính xác như tên gọi của nó: mã được chuyển đổi thành mã máy trước khi thực thi chương trình của bạn trong giai đoạn biên dịch.

Cách tiếp cận này được sử dụng bởi nhiều ngôn ngữ lập trình như C ++, Java và các ngôn ngữ khác.

Ở phía bên kia của bảng, chúng tôi có cách giải thích: mỗi dòng mã sẽ được thực thi khi chạy. Cách tiếp cận này thường được thực hiện bởi các ngôn ngữ có kiểu động như JavaScript và Python vì không thể biết chính xác kiểu trước khi thực thi.

Bởi vì quá trình biên dịch trước thời hạn có thể đánh giá tất cả mã cùng nhau, nên nó có thể cung cấp khả năng tối ưu hóa tốt hơn và cuối cùng tạo ra mã có hiệu suất cao hơn. Mặt khác, giải thích đơn giản hơn để thực hiện, nhưng nó thường chậm hơn so với tùy chọn được biên dịch.

Để chuyển đổi mã nhanh hơn và hiệu quả hơn cho các ngôn ngữ động, một cách tiếp cận mới đã được tạo ra gọi là biên dịch Just-in-Time (JIT). Nó kết hợp tốt nhất từ ​​​​việc giải thích và biên dịch.

Trong khi sử dụng diễn giải như một phương pháp cơ bản, V8 có thể phát hiện các chức năng được sử dụng thường xuyên hơn các chức năng khác và biên dịch chúng bằng cách sử dụng thông tin loại từ các lần thực hiện trước đó.

Tuy nhiên, có khả năng loại này có thể thay đổi. Thay vào đó, chúng ta cần hủy tối ưu hóa mã đã biên dịch và dự phòng để diễn giải (sau đó, chúng ta có thể biên dịch lại hàm sau khi nhận được phản hồi kiểu mới).

Hãy khám phá từng phần của quá trình biên dịch JIT chi tiết hơn.

Thông dịch viên

V8 sử dụng một trình thông dịch có tên là Ignition. Ban đầu, nó lấy một cây cú pháp trừu tượng và tạo mã byte.

Hướng dẫn mã byte cũng có siêu dữ liệu, chẳng hạn như vị trí dòng nguồn để gỡ lỗi trong tương lai. Nói chung, hướng dẫn mã byte phù hợp với bản tóm tắt JS.

Bây giờ hãy lấy ví dụ của chúng tôi và tạo mã byte cho nó theo cách thủ công:

LdaSmi #1 // write 1 to accumulator
Star r0   // read to r0 (bar) from accumulator 
Ldar r0   // write from r0 (bar) to accumulator
Return    // returns accumulator

Ignition có một thứ gọi là bộ tích lũy — nơi bạn có thể lưu trữ/đọc các giá trị.

Bộ tích lũy tránh sự cần thiết phải đẩy và bật lên trên cùng của ngăn xếp. Nó cũng là một đối số ẩn cho nhiều mã byte và thường giữ kết quả của hoạt động. Return hoàn toàn trả về bộ tích lũy.

Bạn có thể kiểm tra tất cả mã byte có sẵn trong mã nguồn tương ứng. Nếu bạn quan tâm đến cách các khái niệm JS khác (như vòng lặp và không đồng bộ/chờ đợi) được trình bày trong mã byte, tôi thấy hữu ích khi đọc qua các kỳ vọng thử nghiệm này.

Chấp hành

Sau khi tạo, Ignition sẽ diễn giải các hướng dẫn bằng bảng trình xử lý được khóa bởi mã byte. Đối với mỗi mã byte, Ignition có thể tra cứu các hàm xử lý tương ứng và thực thi chúng với các đối số được cung cấp.

Đọc thêm  Cách sử dụng JavaScript Math.random() làm Trình tạo số ngẫu nhiên

Như chúng tôi đã đề cập trước đây, giai đoạn thực thi cũng cung cấp phản hồi kiểu về mã. Hãy tìm hiểu xem nó được thu thập và quản lý như thế nào.

Đầu tiên, chúng ta nên thảo luận về cách các đối tượng JavaScript có thể được biểu diễn trong bộ nhớ. Theo một cách tiếp cận ngây thơ, chúng ta có thể tạo một từ điển cho từng đối tượng và liên kết nó với bộ nhớ.

đối tượng ngây thơ
Cách tiếp cận đầu tiên để giữ đối tượng

Tuy nhiên, chúng ta thường có rất nhiều đối tượng có cấu trúc giống nhau nên việc lưu trữ nhiều từ điển trùng lặp sẽ không hiệu quả.

Để giải quyết vấn đề này, V8 tách cấu trúc của đối tượng khỏi chính các giá trị bằng Hình dạng đối tượng (hoặc Bản đồ nội bộ) và một vectơ giá trị trong bộ nhớ.

Ví dụ: chúng tôi tạo một đối tượng theo nghĩa đen:

let c = { x: 3 }
let d = { x: 5 }
c.y = 4

Trong dòng đầu tiên, nó sẽ tạo ra một hình dạng Map[c] có tài sản x với độ lệch 0.

Trong dòng thứ hai, V8 sẽ sử dụng lại hình dạng tương tự cho một biến mới.

Sau dòng thứ ba, nó sẽ tạo ra một hình dạng mới Map[c1] cho tài sản y với độ lệch 1 và tạo liên kết tới hình dạng trước đó Map[c] .

hình-đối-tượng-1
Ví dụ về hình dạng đối tượng

Trong ví dụ trên, bạn có thể thấy rằng mỗi đối tượng có thể có một liên kết đến hình dạng đối tượng trong đó đối với mỗi tên thuộc tính, V8 có thể tìm thấy giá trị bù cho giá trị trong bộ nhớ.

Các hình dạng đối tượng về cơ bản là các danh sách được liên kết. Vì vậy, nếu bạn viết c.xV8 sẽ chuyển đến đầu danh sách, tìm y ở đó, di chuyển đến hình được kết nối và cuối cùng nó được x và đọc phần bù từ nó. Sau đó, nó sẽ chuyển đến vectơ bộ nhớ và trả về phần tử đầu tiên từ đó.

Như bạn có thể tưởng tượng, trong một ứng dụng web lớn, bạn sẽ thấy một số lượng lớn các hình được kết nối. Đồng thời, phải mất thời gian tuyến tính để tìm kiếm trong danh sách được liên kết, khiến cho việc tra cứu thuộc tính trở thành một hoạt động thực sự tốn kém.

Để giải quyết vấn đề này trong V8, bạn có thể sử dụng Bộ nhớ cache nội tuyến (IC). Nó ghi nhớ thông tin về nơi tìm các thuộc tính trên các đối tượng để giảm số lần tra cứu.

Bạn có thể nghĩ về nó như một trang nghe trong mã của bạn: nó theo dõi tất cả CUỘC GỌI, CỬA HÀNGTRỌNG TẢI các sự kiện trong một chức năng và ghi lại tất cả các hình dạng đi qua.

Cấu trúc dữ liệu để giữ IC được gọi là Phản hồi Vectơ. Nó chỉ là một mảng để giữ tất cả các IC cho chức năng.

function load(a) {
  return a.key;
}

Đối với chức năng trên, vectơ phản hồi sẽ như thế này:

[{ slot: 0, icType: LOAD, value: UNINIT }]

Đó là một chức năng đơn giản chỉ với một IC có loại TẢI và giá trị của UNINIT. Điều này có nghĩa là nó chưa được khởi tạo và chúng tôi không biết điều gì sẽ xảy ra tiếp theo.

Hãy gọi hàm này với các đối số khác nhau và xem Bộ nhớ cache nội tuyến sẽ thay đổi như thế nào.

let first = { key: 'first' } // shape A
let fast = { key: 'fast' }   // the same shape A
let slow = { foo: 'slow' }   // new shape B

load(first)
load(fast)
load(slow)

Sau cuộc gọi đầu tiên của load chức năng, bộ đệm nội tuyến của chúng tôi sẽ nhận được một giá trị cập nhật:

[{ slot: 0, icType: LOAD, value: MONO(A) }]

Giá trị đó bây giờ trở thành đơn hình, có nghĩa là bộ đệm này chỉ có thể phân giải thành hình dạng A.

Đọc thêm  Cách chuyển các hàm gọi lại cho String.replace() trong JavaScript

Sau cuộc gọi thứ hai, V8 sẽ kiểm tra giá trị của IC và nó sẽ thấy rằng nó là đơn hình và có hình dạng giống như fast Biến đổi. Vì vậy, nó sẽ nhanh chóng trả lại offset và giải quyết nó.

Lần thứ ba, hình dạng khác với hình dạng được lưu trữ. Vì vậy, V8 sẽ giải quyết nó theo cách thủ công và cập nhật giá trị thành trạng thái đa hình với một mảng gồm hai hình dạng có thể.

[{ slot: 0, icType: LOAD, value: POLY[A,B] }]

Giờ đây, mỗi khi chúng ta gọi chức năng này, V8 không chỉ cần kiểm tra một hình dạng mà còn lặp lại nhiều khả năng.

Đối với mã nhanh hơn, bạn có thể khởi tạo các đối tượng cùng kiểu và không thay đổi quá nhiều cấu trúc của chúng.

Lưu ý: Bạn có thể ghi nhớ điều này, nhưng đừng làm điều đó nếu nó dẫn đến mã trùng lặp hoặc mã ít biểu cảm hơn.

Bộ nhớ đệm nội tuyến cũng theo dõi tần suất chúng được gọi để quyết định xem đó có phải là ứng cử viên sáng giá để tối ưu hóa trình biên dịch hay không — Turbofan.

Trình biên dịch

Đánh lửa chỉ đưa chúng ta đến nay. Nếu một chức năng đủ nóng, nó sẽ được tối ưu hóa trong trình biên dịch, Turbofan, để làm cho nó nhanh hơn.

Turbofan lấy mã byte từ Ignition và nhập phản hồi (Vectơ phản hồi) cho chức năng, áp dụng một tập hợp các mức giảm dựa trên nó và tạo mã máy.

Như chúng ta đã thấy trước đây, phản hồi về loại không đảm bảo rằng nó sẽ không thay đổi trong tương lai.

Ví dụ: mã được tối ưu hóa của Turbofan dựa trên giả định rằng một số phép cộng luôn thêm các số nguyên.

Nhưng điều gì sẽ xảy ra nếu nó nhận được một chuỗi? Quá trình này được gọi là khử tối ưu hóa. Chúng tôi loại bỏ mã được tối ưu hóa, quay lại mã được giải thích, tiếp tục thực thi và cập nhật phản hồi loại.

Tóm lược

Trong bài viết này, chúng ta đã thảo luận về việc triển khai công cụ JS và các bước chính xác về cách JavaScript được thực thi.

Để tóm tắt, chúng ta hãy xem quy trình biên dịch từ trên xuống.

v8-tổng quan-2
tổng quan về V8

Chúng ta sẽ đi qua nó từng bước:

  1. Tất cả bắt đầu bằng việc lấy mã JavaScript từ mạng.
  2. V8 phân tích cú pháp mã nguồn và biến nó thành Cây cú pháp trừu tượng (AST).
  3. Dựa trên AST đó, trình thông dịch Ignition có thể bắt đầu thực hiện công việc của nó và tạo mã byte.
  4. Tại thời điểm đó, công cụ bắt đầu chạy mã và thu thập phản hồi loại.
  5. Để làm cho nó chạy nhanh hơn, mã byte có thể được gửi đến trình biên dịch tối ưu hóa cùng với dữ liệu phản hồi. Trình biên dịch tối ưu hóa đưa ra các giả định nhất định dựa trên nó và sau đó tạo mã máy được tối ưu hóa cao.
  6. Nếu tại một thời điểm nào đó, một trong các giả định hóa ra là không chính xác, thì trình biên dịch tối ưu hóa sẽ hủy tối ưu hóa và quay trở lại trình thông dịch.

Đó là nó! Nếu bạn có bất kỳ câu hỏi nào về một giai đoạn cụ thể hoặc muốn biết thêm chi tiết về nó, bạn có thể đi sâu vào mã nguồn hoặc liên hệ với tôi trên Twitter.

đọc thêm





Zik.vn – Biên dịch & Biên soạn Lại

spot_img

Create a website from scratch

Just drag and drop elements in a page to get started with Newspaper Theme.

Buy Now ⟶

Bài viết liên quang

DMCA.com Protection Status