Performance in javascript

Web worker

Một trong những vấn đề rất được quan tâm khi lập trình với Javascript là vấn đề hiệu năng (performance).

Một ví dụ đơn giản đó là khi bạn có 2 request ajax và bạn phải đợi 2 request này hoàn thành thì mới có thể làm các bước tiếp theo. Khi đó bạn có thể xử lí theo 2 hướng:

  • Tuần tự: Thực hiện 1 request trước, chờ cho nó hoàn thành rồi thực hiện request còn lại
  • Đồng thời: Thực hiện cả 2 request cùng lúc và sử dụng promise hay generator để tạo ra 1 gateway kiểm tra xem khi nào cả 2 request đều hoàn thành

Nếu xét về hiệu năng cách thứ 2 tỏ ra hiệu quả hơn, đem lại trải nghiệm người dùng tốt hơn

Việc Javascript có cơ chế nào giúp xử lí các task không đồng bộ như thế này hiệu quả hay không

Nếu bạn có 1 task tính toán với khối lượng dữ liệu khá nặng và bạn chỉ muốn nó hiển thị kết quả của quá trình tính toán ra DOM sau khi việc tính toán hoàn thành, bạn sẽ ước gì phần tính toán này sẽ chạy ở 1 thread khác, tính toán và main thread chỉ có nhiệm vụ lấy kết quả và đem hiển thị - kiểu multithread.

Nhưng không may là Javascript lại là single threaded. Vậy có cách nào để thực hiện công việc này ?

Khi xử lí song song, chúng ta phải đối mặt với việc chia sẻ tài nguyên cũng như communicate giữa các thread và Javascript thì hiện tại chưa hỗ trợ những tính năng cho việc xử lí với thread như vậy. Tuy nhiên môi trường chạy Javasript lại có thể cung cấp nhiều instance cuả Javascript engine, mỗi instance sẽ có thread riêng của nó và cho phép chúng ta chạy 1 chương trình trên mỗi thread đó. Mỗi chương trình trên thread này được gọi là 1 Web Worker.

Từ chương trình Javascript chính chúng ta có thể khởi tạo 1 Worker

var w1 = new Worker( "http://worker.url/worker_name.js" );

Phần url sẽ chỉ định địa chỉ của file JS được load vào worker; Worker listen "message" event và worker này có thể gửi event cho worker khác.

w1.addEventListener( "message", function(evt){
  // evt.data
} );

w1.postMessage( "something" );

Để kill 1 worker chỉ cần đơn giản gọi câu lệnh terminate.

Dưới đây là 1 ví dụ đơn giản về cách hoạt động của 1 worker trên w3school

<!DOCTYPE html>
<html>
<body>

<p>Count numbers: <output id="result"></output></p>
<button onclick="startWorker()">Start Worker</button>
<button onclick="stopWorker()">Stop Worker</button>

<p><strong>Note:</strong> Internet Explorer 9 and earlier versions do not support Web Workers.</p>

<script>
var w;

function startWorker() {
  if(typeof(Worker) !== "undefined") {
    if(typeof(w) == "undefined") {
      w = new Worker("demo_workers.js");
    }
    w.onmessage = function(event) {
      document.getElementById("result").innerHTML = event.data;
    };
    } else {
      document.getElementById("result").innerHTML = "Sorry, your browser does not support Web Workers...";
    }
  }

  function stopWorker() {
    w.terminate();
    w = undefined;
  }
  </script>

  </body>
  </html>
// demo_worker.js
var i=0;

function timedCount() {
  i=i+1;
  postMessage(i);
  setTimeout("timedCount()", 500);
}

timedCount();

Chương trình chính hiển thị 2 button start và stop worker, khi ấn vào start worker thì phần JS bên demo_worker.js được thực thi, cứ 500ms lại cập nhật giá trị của biến i và gửi message sang chương trình chính để cập nhật DOM. Nó đơn giản là 1 bộ đếm tăng dần và khi ấn button stopworker thì worker sẽ bị kill và chương trình dừng lại.

Tuy nhiên bên trong 1 worker bạn không thể truy cập đến bất cứ tài nguyên nào của chương trình chính. Điều đó có nghĩa là bạn không sử dụng được các biến global, không thể access DOM. Bên trong phần JS của worker bạn có thể load extra script bằng cách dùng importScript tuy nhiên việc load này lại là load đồng bộ, nghĩa là các phần thực thi phía sau sẽ bị block cho đến khi các file script load xong.

// inside the Worker
importScripts( "foo.js", "bar.js" );

Vậy nên sử dụng web worker trong trường hợp nào.

  • Xử lí tính toán phức tạp
  • Sắp xếp 1 khối lượng data lớn
  • Giao tiếp với high-traffic network

Tail Call Optimization (TCO)

ES6 đưa ra khái niệm về 1 cách thức tối ưu khi gọi function gọi là: tail call optimization

function foo(x) {
  return x;
}

function bar(y) {
  return foo(y + 1);    // tail call
}

function baz() {
  return 1 + bar(40);   // not tail call
}

baz();                      // 42

Trong ví dụ trên foo(y+1) là 1 tail call còn bar(40) không phải là tail call do sau khi có được kết quả của bar(40) chúng ta vẫn còn phải tiến hành cộng với 1.

Vậy sử dụng tail call có lợi gì.

Bình thường khi gọi 1 function sẽ cần có 1 phần memory để quản lí 1 call stack gọi là stack frame. Trong ví dụ trên thì quá trình gọi baz() sẽ tạo ra 3 stack frame cho baz, bar, foo. Với các engine có khả năng TCO-capable thì khi nhận biết được foo(y+1) là tail call, sẽ không cần phải tạo thêm stack frame nữa mà có thể dùng lại frame từ lúc gọi bar.

Với ví dụ đơn giản như trên thay đổi hiệu năng có lẽ không đáng kể nhưng trong trường hợp là hàm gọi đệ quy việc sử dụng tail call sẽ đem lại lợi ích. Khi gọi đệ quy số lượng stack frame sẽ có thể rất lớn nêú không dùng tail call nhưng khi dùng tail call sẽ chỉ có 1 stack frame được tạo ra. Điều này vừa giúp tiết kiệm memory và giúp chương trình chạy nhanh hơn. Thông thường engine sẽ có 1 mức giới hạn độ sâu mà mức đệ quy có thể đạt được nhưng nhờ có tail call bạn có thể gọi đệ quy thoải mái mà không sợ bị hết memory. Điều này rõ ràng đem lại sự thay đổi đáng kể về hiệu năng cho chương trình

Benchmark

Để đánh giá performance trong javascript, cách đơn giản nhất mà chúng ta thường nghĩ là sẽ thêm phần check thời gian vào trước và sau quá trình thực hiện task rồi lấy hiệu 2 kết quả để tính thời gian thực hiện task kiểu thế này

var start = (new Date()).getTime();

// do some operation

var end = (new Date()).getTime();

console.log( "Duration:", (end - start) );

Tuy nhiên cách này có 1 số vấn đề:

Nếu kết quả trả về 0 bạn sẽ nghĩ là các phép toán thực hiện rất nhanh tới mức xấp xỉ 0 nhưng điều này không chính xác lắm do 1 số browser cũ có biên độ chính xác lên tới 15ms tức là nếu kết quả thời gian tính toán thực tế < 15ms khả năng cao là bạn chỉ nhận về kết quả là 0. Ngoài ra bản thân việc thêm 2 phép toán lấy thời gian tại thời điểm start, stop cũng có độ trễ nhất định ảnh hưởng đến độ chính xác.

Một đề xuất khác là cho lặp lại thao tác tính toán nhiều lần rồi tính giá trị trung bình. Ban có thẻ lặp lại các thao tác tính toán 100 lần rồi lấy thời gian thực hiện chia cho 100 để ra thời gian thực hiện tính toán trong 1 lần. Cách này lại có vấn đề là việc lặp lại thao tác nhiều lần cũng làm tăng khả năng có các yếu tố ngoại lai ảnh hưởng đến quá trình tính toán.

Để thực hiện benchmark trong Javascript giờ đây bạn có thể sử dụng Benchmark.js. 1 công cụ benchmark giúp bạn đánh giá hiệu năng các task. Cách sử dụng cũng khá đơn giản khi bạn chỉ việc đem hàm cần test bỏ vào trong Benchmark và gọi 1 vài câu lệnh để tính performance.

function foo() {
  // operation(s) to test
}

var bench = new Benchmark(
  "foo test",             // test name
  foo,                    // function to test (just contents)
  {
    // ..               // optional extra options (see docs)
  }
  );

  bench.hz;                   // number of operations per second
  bench.stats.moe;            // margin of error
  bench.stats.variance;       // variance across samples
  // ..

Benchmark.js có thể được sử dụng để test trên môi trường trình duyệt hoặc trên server Node.js và được dev và QA dùng để test performance cho phần Javascript 1 cách tự động giống như khi bạn viết và chạy unit test vậy