Memory leaks trong Javascript Pt2
Bài viết này là phần tiếp theo của Memory Leaks trong Javascript Garbage Collectors (bộ dọn rác) Mặc dù GCs giúp chúng ta không phải quản lý bộ nhớ bằng tay nữa, tuy nhiên ta cũng sẽ phải đánh đổi lại một vài thứ. Một trong số đó là việc các GCs hoạt động theo một cách khó đoán biết. Thông ...
Bài viết này là phần tiếp theo của Memory Leaks trong Javascript
Garbage Collectors (bộ dọn rác)
Mặc dù GCs giúp chúng ta không phải quản lý bộ nhớ bằng tay nữa, tuy nhiên ta cũng sẽ phải đánh đổi lại một vài thứ. Một trong số đó là việc các GCs hoạt động theo một cách khó đoán biết. Thông thường rất khó có thể chắc chắn rằng một hoạt động thu thập các vùng nhớ không được sử dụng được thực thi hay không. Điều này cũng có nghĩa là trong một số trường hợp, số lượng vùng nhớ của một chương trình nhiều hơn số bộ nhớ mà chương trình đó cần. Trong một số trường hợp khác, ứng dụng sẽ bị ảnh hưởng bởi một khoảng thời gian nhỏ chương trình bị delay để thực hiện công việc thu thập bộ nhớ. Hiện nay, hầu hết GC đều hoạt động theo cách là chỉ thực hiện việc thu thập bộ nhớ khi cấp phát bộ nhớ cho chương trình. Nếu không cần cấp phát bộ nhớ, GCs sẽ không hoạt động. Chúng ta sẽ xem xét các tình huống sau:
-
Chương trình đã cấp phát một số lượng nhỏ bộ nhớ.
-
Sau đó, hầu hết (hoặc toàn bộ) các phần tử được đánh dấu là không thể vươn tới nữa.
-
Chương trình không thực hiện việc cấp phát bộ nhớ nữa.
Trong tình huống này, hầu như tất cả các GC sẽ không thực hiện việc thu thập bộ nhớ nữa. Nói cách khác, mặc dù có những phần tử không thể vươn tới được nữa trong chương trình, chúng sẽ không được thu hồi lại bộ nhớ. Đây không hẳn là leaks, tuy nhiên nó vẫn dẫn đến việc chương trình ngốn bộ nhớ.
Chrome Memory Profiling Tools
Chrome cung cấp một tập các công cụ để kiểm tra tình trạng sử dụng bộ nhớ của code JS. Có 2 view quan trọng liên quan đến bộ nhớ đó là: timeline và profiles.
Timeline View
Timeline View có thể giúp ta biết được mô hình sử dụng bộ nhớ của chương trình. Từ đây ta có thể nhìn được việc rò rỉ bộ nhớ, việc bộ nhớ sử dụng tăng liên tục theo thời gian mà không giảm xuống sau mỗi lần GC được chạy. Ví dụ:
Ta có thể thấy được việc bộ nhớ rò rỉ được thể hiện thông qua việc JS heap tăng dần theo thời gian. Mặc dù sau khi được thu thập với một số lượng lớn tại đoạn cuối thì chương trình vẫn sử dụng số lượng bộ nhớ nhiều hơn so với lúc bắt đầu. Số lượng Node cũng cao hơn. Đây là dấu hiệu của việc các node DOM bị rò rỉ đâu đó trong code.o
Profiles view
Đây là công cụ sẽ luôn gắn bó với bạn khi phải điều tra về rò rỉ bộ nhớ. Profiles view cho phép bạn lấy ảnh chụp (snapshot) về việc sử dụng bộ nhớ của một chương trình Javascript. Nó cũng cho phép bạn ghi lại những lần cấp phát bộ nhớ theo thời gian. Mỗi một loại kết quả sẽ có các danh sách liệt kê khác nhau được đưa ra, tuy nhiên những thứ mà bạn cần quan tâm đó là danh sách tổng hợp (summary list) và danh sách so sánh (comparision list).
Summary View sẽ cho ta thấy được tổng quan về các loại objects được khởi tạo và cấp phát cùng với các kích thước tổng hợp (aggregated size): kich thước nông (Shallow size) là tổng kích thước của tất cả các object của một loại cụ thể nào đó và kích thước giữ lại (retained size) bao gồm shallow size và kích thước của các object được lưu lại bởi object này. Nó cũng cho ta một thông tin về khoảng cách giữa một object với root.
Comparision View cũng cung cấp cùng một thông tin như summary view nhưng nó cho phép ta so sánh giữa các snapshot khác nhau.
Ví dụ: Tìm kiếm rò rỉ dữ liệu trong Chrome
Có 2 kiểu rò rỉ dữ liệu chủ yếu là: rỏ rỉ dẫn đến việc bộ nhớ bị tăng một cách đều đặn theo thời gian và rò rỉ chỉ xảy ra một lần duy nhất và không gây ra việc bộ nhớ bị tăng trong tương lai nữa. Việc tìm rò rỉ dữ liệu mà bộ nhớ bị tăng dần theo thời gian khá là đơn giản và rõ ràng (sử dụng timeline view). Tuy nhiên thì đây lại là rò rỉ gây ra nhiều rắc rối nhất: nếu bộ nhớ cứ tăng dần theo thời gian, nó sẽ khiến trình duyệt chạy chậm dận và cuối cùng sẽ dẫn đến việc script bị ngừng chạy. Rò rỉ mà không dẫn đến việc bộ nhớ bị tăng theo thời gian có thể dễ dàng được tìm ra khi bộ nhớ lớn đến một mức độ nào đó. Thông thường những rò rỉ kiểu này không được chú ý quả nhiều. Nói theo một cách khác, những rò rỉ nhỏ mà chỉ xảy ra một lần thường được coi là một vấn đề để tối ưu code. Tuy nhiên, những rò rỉ mà làm bộ nhớ tăng dần theo thời gian thì được coi là bug và nó cần được fix.
Ở đây ta sẽ sử dụng một ví dụ từ Chrome. Toàn bộ đoạn code như sau:
var x = []; function createSomeNodes() { var div, i = 100, frag = document.createDocumentFragment(); for (;i > 0; i--) { div = document.createElement("div"); div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString())); frag.appendChild(div); } document.getElementById("nodes").appendChild(frag); } function grow() { x.push(new Array(1000000).join('x')); createSomeNodes(); setTimeout(grow,1000); }
Khi grow được gọi, nó sẽ bắt đầu tạo một div và gán nó vào DOM. Nó cũng sẽ khởi tạo một mảng lớn (1 triệu phần tử) và gán nó vào một mảng được tham chiếu bỏi một biến toàn cục (x). Việc này sẽ dẫn đến việc bộ nhớ bị tăng đều đặn và có thể nhận biết được với Timeline view.
Phát hiện việc bộ nhớ bị tăng đều đặn trong Chrome
Ta sẽ bắt đầu với ví dụ sau của chrome. Sau khi click vào ví dụ của Chrome, mở Dev Tools, click vào tab timeline, tích chọn memory và click vào nút record. Tiếp đó quay lại trang ví dụ và click vào The Button để bắt đầu việc rò rỉ bộ nhớ. Sau một khoảng thời gian thì dừng lại việc record và xem kết quả:
Note: Ví dụ này sẽ khiến bộ nhớ bị tăng mỗi giây. Sau khi dừng việc record thì các bạn có thể đặt breakpoint vào grow để dừng việc thực thi script.
Có 2 dấu hiệu lớn trong bức ảnh trên cho thấy việc rò rỉ bộ nhớ: biểu đồ cho nodes (đường kẻ màu xanh lá) và biểu đồ cho JS heap (đường kẻ màu xanh đậm). Số lượng node luôn luôn tăng và không bao giờ giảm. Đây là dấu hiệu cảnh báo lớn.
JS heap cũng tăng dần theo thời gian tuy nhiên điều này khó nhìn ra hơn do hiệu ứng từ GC. Các bạn có thể thấy là bộ nhớ tăng sau lại giảm một cách liên tục. Điểm quan trọng cần chú ý ỏ đây là sau mỗi lần bộ nhớ được giảm thì kích thước của JS heap vẫn lớn hơn so với lần giảm trước đấy. Nói cách khác, mặc dù GC đã thành công thu thập được rất nhiều bộ nhớ, một vài trong số đó bị rò rỉ.
Bây giờ ta đã chắc chắn chương trình của mình bị rò rỉ bộ nhớ, ta cần phải tìm ra nguyên nhân của nó.
Tạo 2 snapshot
Để tìm ra nguyên nhân rò rỉ, ta sẽ sử dụng đến công cụ profiles của Chrome. Cụ thể hơn, ta sẽ sử dụng tính năng Take Heap Snapshot.
Đầu tiên, reload lại trang và tạo một snapshot ngay sau khi load xong trang. Ta sẽ sử dụng snapshot này làm cơ sở. Sau đó, click vào The Button một lần nũa, chờ khoảng một vài giây, tạo một snapshot khác. Sau đó tạo breakpoint để dừng việc rò rỉ bộ nhớ lại.
Có 2 cách mà ta có thể sử dụng để kiểm tra sự khác nhau giữa 2 snapshot. Thứ nhất là sử dụng chức năng Summary rồi bắt đầu từ phía bên phải chọn Objects allocated between Snapshot 1 and Snapshot 2. Hoặc chọn Comparision thay cho Summary. Trong cả 2 trường hợp, ta sẽ thấy một danh sách các object được khởi tạo giữa 2 snapshot.
Trong trường hợp này thì việc tìm ra leaks rất đơn giản. Hãy xem Size Delta của (string). 8MB với 58 object mới. Điều này rất đáng nghi ngờ: object mới được tạo nhưng không được giải phóng và 8MB bị chiếm mất.
Nếu ta mở danh sách khởi tạo của (string) ta sẽ thấy có một vài object lớn được khởi tạo bên cạnh các object nhỏ. Nếu ta chọn một trong số các object lớn này thì ta sẽ thấy một vài điểm thú vị trong mục retainers:
Ta thấy rằng object được chọn là một phần tử của mảng. Tiếp đó ta biết được mảng này được tham chiếu bởi biến x nằm ở trong window. Điều này cho ta thấy được toàn bộ con đường từ object lớn của chúng ta liên kết thế nào với root (window). Ta đã tìm được một nguyên nhân dẫn đến rò rỉ và nơi nó được tham chiếu.
Vi dụ này khá đơn giản: object lớn được khỏi tạo thế này không thường xuyên xuất hiện trong chương trinh. Tuy nhiên trong chương trình này cũng có xuất hiện việc rò rỉ DOM có kích cỡ nhỏ hơn. Những node này có thể tìm thấy đươc thông qua snapshot tuy nhiên đối với những site lớn, mọi chuyện sẽ trở nên rắc rối hơn nhiều. Các phiên bản Chrome hiên tại có cung cấp một tính năng đó là: Record Heap Allocations
Record Heap Allocations
Ta se bắt đầu với viêc để cho đoạn script tiếp tục được chạy và quay lại tab Profiles của Chrome Dev Tools. Ấn nút Record Heap Allocations. Khi mà tool đang chạy, các bạn sẽ thấy một vài vạch xanh trên biểu đồ ở phía trên đầu. Nó thể hiện việc khởi tạo object khi chạy chương trình.
Ta có thể thấy được tính năng của công cụ này: chọn một khoảng thời gian để xem object nào được khởi tạo trong khoảng thời gian này. Ta đặt khoảng thời gian này gần các vạch xanh đậm nhất có thể. Chỉ có 3 hàm khởi tạo được show trong danh sách: một trong số đó liên quan đến rò rỉ do (string) ở phía trên, tiếp theo là liên quan đến việc khởi tạo DOM và cái cuối cùng là khởi tạo Text.
Chon một trong những hàm khởi tạo của HTMLDivElement trong danh sách và chọn Allocation stack.
Từ ảnh trên ta thấy được là phần tử được khởi tạo bởi grow -> createSomeNodes. Nếu ta để ý kỹ mỗi vạch trên biểu đồ, ta sẽ thấy là hàm khởi tạo HTMLDivElement được gọi nhiều lần. Nếu ta quay trở lại với snapshot comparision view, ta sẽ thấy là nó chỉ khởi tạo object mà không xóa chúng đi. Nói cách khác là nó luôn khởi tạo object mà không cho phép GC thu thập một vài trong số chúng. Giờ khi ta đã biết objects bị rò rỉ ở đâu (createSomeNodes), ta có thể quay trở lại code để sửa lại nó.
Các tính năng hữu ích khác
Thay vì sử dụng Summary view, ta có thể sử dụng Allocation view:
Giao diện này cho ta thấy một danh sách các hàm và bộ nhớ khởi tạo liên quan đến chúng. Ta có thể thấy ngay là grow và createSomeNodes là nổi bật hơn cả. Khi chọn grow ta sẽ thấy đối tượng khởi tạo liên quan được gọi đến. Ta có thể để ý thấy (string) HTMLDivElement và Text là những hàm khởi tạo của các đối tượng bị rò rỉ.
Note: để sử dụng được tính năng này, vào Dev Tools -> Settings và enable record heap allocation stack traces trước khi record.