Khi mới bắt đầu JavaScript chạy khá chậm, nhưng sau đó tốc độ thực thi của nó đã được cải thiện đáng kể nhờ vào kĩ thuật gọi là JIT. Nhưng liệu bạn có biết JIT hoạt động như thế nào?

JavaScript Thực thi trong trình duyệt như thế nào?

Khi bạn – một lập trình viên thêm Javascript vào trang web của mình, Bạn có một mục tiêu và một vấn đề.

Mục tiêu: Bạn muốn chỉ dẫn máy tính thực hiện điều mà bạn muốn.

Vấn đề: Bạn và máy tính nói hai ngôn ngữ khác nhau.

Bạn sử dụng ngôn ngữ của con người, máy tính thì dùng ngôn ngữ máy. Cho dù bạn không đồng ý rằng Javascript hay bất kì ngôn ngữ bậc cao nào khác là ngôn ngữ của con người, nhưng bản chất thì nó vẫn đúng như vậy. Chúng được thiết kế cho nhận thức của con người chứ không phải dành cho máy tính.

Vậy nên việc mà  JavaScript engine phải làm là nhận đầu vào là ngôn ngữ của con người và chuyển nó thành một thứ gì đó máy tính có thể hiểu được.

Cũng giống như trong phim Arrival, ở đó con người mà người ngoài hành tinh cố gắng giao tiếp với nhau

Trong phim , hai loài không chỉ đơn giản là thực hiện dịch từ theo từ. Hai nhóm đó diễn giải hình dung về thế giới theo những cách khác nhau. Con người và máy tính cũng tương tự như vậy (Sẽ giải thích kĩ hơn trong các post sau).

Vậy quá trình dịch diễn ra như thế nào?

Trong lập trình, nhìn chung có hai cách để dịch ngôn ngữ con người sang ngôn ngữ máy. Bạn có thể dùng trình phiên dịch hoặc trình biên dịch.

Với trình phiên dịch, quá trình dịch thuật diễn ra hầu như theo từng dòng đến từng dòng, dịch trực tiếp khi chương trình chạy.

Trình biên dịch thì ngược lại, không tiến hành dịch khi chương tình chạy. Nó được thực hiện trước khi chạy (ahead of times) và dịch toàn bộ trương trình cần chạy trong một lượt.

Với mỗi cách đều có nhược điểm và lợi điểm khác nhau

Lợi và hại của phiên dịch

Phiên dịch sẽ giúp chương trình có thể nhanh chóng khởi động và chạy được. Bạn không cần phải dịch toàn bộ chương trình trước đó mà chỉ cần dịch dòng code đầu tiên và cứ thế bắt đầu chạy.

Bởi vì như vậy, phiên dịch có vẻ thích hợp một cách tự nhiên với những thứ như JavaScript. Khởi chạy nhanh là một điều rất quan trong đối với người phát triển web.

Và đó là lý do các trình duyệt sử dụng trình biên dịch trong giai đoạn đầu tiên của Javascript.

Nhưng nhược điểm của phiên dịch xuất hiện khi một đoạn code được chạy nhiều hơn một lần. Ví dụ với vòng lặp chẳng hạn. Khi đó bạn sẽ phải dịch đi dịch lại một đoạn code để chương trình có thể chạy.

Lợi và hại của trình biên dịch

Trình biên dịch đánh đổi theo hướng ngược lại.

Nó mất nhiều thời gian hơn khi khởi chạy, vì nó cần phải dịch toàn bộ chương trình trong một lượt. Nhưng sau đó code trong vòng lặp sẽ thực hiện nhanh hơn, bởi vì nó không cần phải dịch đi dịch lại những phần đó mỗi khi thực hiện.

Một khác biệt nữa là trình biên dịch có thời gian để nhìn tổng quát toàn bộ source code và tiến hành chỉnh sửa nó sao cho hợp lý hơn. Những chỉnh sửa đó gọi là tối ưu hóa.

Ngược lại trình phiên dịch hoạt động trong lúc chạy, nên nó không thể tốn nhiều thời gian cho việc tối ưu hóa được.

Just-in-time compilers: điểm tốt nhất của hai thế giới

Để loại bỏ hoạt động kém hiệu quả của trình phiên dịch—ở đó trình phiên dịch phải dịch đi dịch lại code trong vòng lặp—trình duyệt bắt đầu cóp nhặt cả tính năng của trình biên dịch.

Trình duyệt khác nhau thường thực hiện theo những cách khác nhau, nhưng ý tưởng tổng quan thì có một. Họ thêm một thành phần mới vào JavaScript engine, gọi là monitor (hoặc là profiler) – Trình quản lý. Trình quản lý sẽ theo dõi code khi nó chạy, notes lại số lần code thực thi và kiểu biến nào đã được dùng.

Đầu tiên, trình điều khiển chỉ đơn giản chạy chương trình thông qua trình phiên dịch.

Nếu một đoạn code nhất định được chạy một vài lần, đoạn code đó được gọi là warm – ấm. Nếu nó thực hiện rất nhiều lần, nó được gọi là nóng.

Trình dịch cơ sở

Khi một hàm ấm lên, Trình JIT sẽ gửi yêu cầu biên dịch nó đến trình dịch cơ sở. Và sau đó lưu lại kết quả.

Mỗi dòng của hàm sẽ được dịch thành một “stub”. Các stubs này được index theo số dòng và kiểu biến  (Đoạn sau sẽ giải thích vì sao nó quan trọng). Nếu trình điều khiển nhận thấy cùng một dòng code được gọi lại với cùng một kiểu biến trước đó, nó sẽ chỉ đơn giản lấy ra stub tương ứng đã được lưu.

Điều này khiến code chạy nhanh hơn. Nhưng như bạn đã biết trình biên dịch có thể làm nhiều hơn như vậy. Nó có thể dành thời gian để suy nghĩ cách hiệu quả nhất để làm một việc… tiến hành tối ưu hóa.

Trình dịch cơ sở cũng tiến hành một vài tối ưu (Ví dụ sẽ được nêu dưới đây). Nó không muốn tốn nhiều thời gian, cũng dễ hiểu, vì nó không muốn dừng quá trình thực thi code quá lâu.

Tuy nhiên, Nếu đoạn code rất rất nóng—Nếu nó cần chạy trong cả một thời gian dài—thì nó cũng đáng để dành thêm một chút thời gian tối ưu hóa.

Trình dịch tối ưu

Khi một bộ phận code trở nên nóng, trình điều khiển sẽ gửi nó đến trình dịch tối ưu. Ở đây nó sẽ tạo ra phiên bản thực thi nhanh hơn, hơn nữa, phiên bản này cũng được lưu lại.

Để tạo được phiên bản nhanh hơn, trình biên dịch tối ưu đã thực hiện một số phỏng đoán.

Ví dụ như, Nếu nó có thể phỏng đoán là tất cả object được tạo ra bới một constructor nhất định và có cấu trúc giống nhau —vì thế, chúng lúc nào cũng có chung tên thuộc tính, và các thuộc tính này lúc nào cũng được thêm vào theo thứ tự giống nhau— thì nó có thể cắt đi một vài đoạn xử lý không cần thiết.

Trình dịch tối ưu phỏng đoán dựa trên thông tin mà trình điều khiển đã thu thập được khi quan sát chương trình chạy. Nếu điều gì đó đã đúng với tất cả các vòng lặp trước đó, nó sẽ phỏng đoán rằng điều đó sẽ tiếp tục đúng.

Nhưng tất nhiên với JavaScript, Không có gì là đảm bảo chắc chắn. Bạn có thể có 99 objects có cấu trúc giống nhau, nhưng object thứ 100 có thể thiếu một thuộc tính.

Vì thế nên đoạn code đã dịch cần phải check trước khi chạy xem phỏng đoán trước đó có còn đúng hay không. Nếu vẫn đúng, code đã dịch sẽ được chạy. Nhưng nếu không, trình JIT sẽ hiểu là nó đã phán đoán sai và xóa bỏ đoạn code đã tối ưu.

Khi đó chương trình sẽ quay lại trình phiên dịch ban đầu hoặc phiên bản đã được lưu ở trình dịch cơ sở. Quá trình này gọi là phản tối ưu – deoptimization (hay bailing out).

Thông thường trình dịch tối ưu sẽ khiến code chạy nhanh hơn, nhưng thỉnh thoảng nó sẽ gây ra vấn đề về hiệu năng không dự đoán trước được. Nếu bạn có những đoạn code liên tục được tối ưu và phản tối ưu, kết cục nó sẽ có thể chạy chậm hơn cả phiên bản của trình dịch cơ sở.

Hầu hết trình duyệt sẽ đặt giới hạn tối đa cho quá trình tối ưu – phản tối ưu nếu nó xảy ra. Nếu trình JIT có nhiều hơn, khoảng, 10 cố gắng tối ưu code, nhưng liên tục thất bại, nó đơn giản sẽ không cố gắng tối ưu hóa nữa.

Ví dụ về tối ưu hóa: Kiểu biến tĩnh

Có rất nhiều phương pháp tối ưu khác nhau, nhưng ở đây tôi chỉ nhắc đến một phương pháp để bạn có được hình dung về cách nó hoạt động. Một trong những hiệu quả đáng kể nhất của trình dịch tối ưu đến từ phương pháp gọi là kiểu biến tĩnh.

Kiểu biến động mà Javascript hoạt động, đòi hỏi phải thực hiện thêm một số xử lý trong quá trình chạy. Ví dụ, cùng xem xét đoạn code dưới đây:


function arraySum(arr) {
  var sum = 0;
  for (var i = 0; i < arr.length; i++) {
    sum += arr[i];
  }
}

Phép toán += trong vòng lặp có vẻ rất đơn giản. Bạn có thể hình dung nó sẽ kết thúc trong một bước tính toán, tuy nhiên vì kiểu dữ liệu động, nó có thể mất nhiều bước tính toán hơn so với bạn nghĩ.

Hãy hình dung là arr là một mảng có 100 số nguyên. Một khi code trở nên ấm, trình dịch cơ sở sẽ tạo ra stub cho từng dòng lệnh trong hàm. Do đó sẽ có stub cho dòng lệnh sum += arr[i], nó sẽ xử lý phép toán += cho số nguyên.

Tuy nhiên,sum và arr[i] không chắc chắn là sẽ có kiểu nguyên. Vì kiểu biến trong Javascript là động, vẫn có xác xuất trong một vòng lặp nào đó phía sau, arr[i] sẽ là string. Cộng số nguyên và cộng chuỗi là hai phép toán rất khác nhau, nên nó sẽ sinh ra mã mãy rất khác nhau.

JIT xử lý tình huống này bằng cách biên dịch một vài stubs khác nhau cho các kiểu biến khác nhau. Nếu đoạn code là đồng nhất(ý là nó luôn được gọi với một kiểu xác định) nó sẽ có một stub. Nếu nó không đồng nhất(được gọi với kiểu biến khác nhau từ các vị trí khác nhau), khi đó nó sẽ có một tổ hợp stub tương ứng với mỗi kiểu biến mà nó đã được gọi.

Điều đó có nghĩa JIT cần phải đặt vô số câu hỏi trước khi lựa chọn stub cần phải chạy.

Vì mỗi dòng code lại có tập hợp stub riêng của nó trong trình dịch cơ sở, JIT phải kiểm tra kiểu biến mỗi khi nó cần chọn stub để thực thi. Do đó với mỗi vòng lặp, nó sẽ lặp đi lặp lại một câu hỏi.

Code sẽ thực thi nhanh hơn đáng kể nếu JIT không phải lặp lại những câu hỏi này mỗi lần chạy. Và đó là một trong những tối ưu mà trình dịch sẽ thực hiện.

Trong trình dịch tối ưu, cả hàm code sẽ được dịch cùng một lượt. Xử lý thực hiện kiểm tra kiểu biến sẽ được di chuyển lên trước vòng lặp.

Một số tối ưu của JIT thậm chí còn đi xa hơn. Ví dụ, Trong Firefox có những lớp đặc biệt dành cho mảng chỉ chưa kiểu Integer. Nếu arr là một trong những lớp như vậy, thì JIT sẽ không cần check xem arr[i] có phải là Integer. Nghĩa là JIT có thể thực hiện toàn bộ việc kiểm tra kiểu trước khi vào vòng lặp.

Kết luận

Đây là nhứng điểm cơ bản về JIT. Nó khiến code Javascript chạy nhanh hơn bằng cách theo dõi quá trình thực thi code và gửi những đoạn code nóng đến trình tối ưu. Điều này giúp hiệu năng của Javascript tăng gấp nhiều lần cho hầu hết các ứng dụng.

Kể cả với những cải thiện như vậy, thật không may, Hiệu năng của Javascript có thể không dự đoán được trước. Và để mọi thứ chạy nhanh hơn, JIT tạo thêm nhiều gánh nặng trong quá trình thực thi code bao gồm:

  • tối ưu và phản tối ưu
  • bộ nhớ sử dụng cho việc lưu sổ của trình điều khiển và thông tin cần thiết cho khôi phục khi xả ra phản tối ưu
  • bộ nhớ sử dụng để lưu phiên bản cơ sở và phiên bản tối ưu của code

Có nhiều không gian để cải tiến: gánh nặng này có thể được loại bỏ, làm hiệu năng dễ dự đoán hơn. Và đó là một trong những việc WebAssembly có thể làm.

Trong bài viết  tiếp theo, Tôi sẽ giải thích kĩ hơn về WebAssembly  và cách trình dịch xử lý chúng.

(Bài gốc)