Tìm hiểu về Garbage Collection trong Ruby thông qua GC.stat

Bạn đã từng bao giờ thắc mắc quá trình thu gom rác Garbage Collection (GC) trong Ruby hoạt động như thế nào? Hãy cùng xem chúng ta có thể hiểu được những gì về GC trong Ruby thông qua những thông tin được cung cấp bởi method GC.stat.

Mở đầu

Hầu hết các lập trình viên Ruby không biết rõ Garbage Collection hoạt động như thế nào trong thời gian chương trình chạy: cái gì kích hoạt nó?, tần suất thu gom rác?, những gì là rác được thu gom và những gì không? Đó không hẳn là một điều xấu vì GC trong các ngôn ngữ động như Ruby thường khá phức tạp, và các lập trình viên Ruby tốt hơn là nên tập trung vào việc viết code.

Tuy nhiên cũng có đôi khi bạn cần phải bận tâm tới GC: khi nó chạy quá thường xuyên hoặc không đủ, hoặc khi tiến trình của bạn đang sử dụng quá nhiều bộ nhớ nhưng bạn không biết tại sao. Hoặc có lẽ chỉ vì bạn tò mò về cách hoạt động của GC!

Có một cách để chúng ta có thể tìm hiểu một chút về GC trong CRuby (runtime Ruby chuẩn, viết bằng C) là nhìn vào module GC sẵn có. Nếu bạn chưa đọc tài liệu của module này, hãy thử đọc qua nó. Có rất nhiều method thú vị trong đó nhưng bây giờ chúng ta sẽ xem xét một trong số đó: GC.stat.

GC.stat

GC.stat trả về một hash với nhiều các con số khác nhau, nhưng không con số nào trong đó thực sự có tài liệu cụ thể, và một số con số hoàn toàn gây rối trừ khi bạn thực sự đọc code C của GC của Ruby! Thay vì để các bạn làm điều đó, tôi đã làm cho các bạn. Hãy cùng xem xét các thông tin trong GC.stat và xem chúng ta có thể tìm hiểu được gì về GC trong Ruby.

Dưới đây là những gì GC.stat trả ra khi chạy trong một session irb vừa mới được khởi động với Ruby 2.4.0:

{
  :count=>15,
  :heap_allocated_pages=>63,
  :heap_sorted_length=>63,
  :heap_allocatable_pages=>0,
  :heap_available_slots=>25679,
  :heap_live_slots=>25506,
  :heap_free_slots=>173,
  :heap_final_slots=>0,
  :heap_marked_slots=>17773,
  :heap_eden_pages=>63,
  :heap_tomb_pages=>0,
  :total_allocated_pages=>63,
  :total_freed_pages=>0,
  :total_allocated_objects=>133299,
  :total_freed_objects=>107793,
  :malloc_increase_bytes=>45712,
  :malloc_increase_bytes_limit=>16777216,
  :minor_gc_count=>13,
  :major_gc_count=>2,
  :remembered_wb_unprotected_objects=>182,
  :remembered_wb_unprotected_objects_limit=>352,
  :old_objects=>17221,
  :old_objects_limit=>29670,
  :oldmalloc_increase_bytes=>46160,
  :oldmalloc_increase_bytes_limit=>16777216
}

Ok, có thật nhiều thông tin. Tận 25 key và không có tài liệu giải thích gì! Yay!

GC counts

Đầu tiên hãy cùng tìm hiểu về các thông số count của GC:

{
  :count=>15,
  # ...
  :minor_gc_count=>13,
  :major_gc_count=>2
}

Tên của những thông số này có lẽ khá dễ để hiểu. minor_gc_countmajor_gc_count là số lần từng loại GC chạy tính từ thời điểm bắt đầu tiến trình Ruby này. Nếu bạn chưa biết thì kể từ Ruby 2.1 có 2 loại GC là majorminor. Minor GC sẽ chỉ tiến hành thu gom rác đối với những object "mới" - những object đã tồn tại qua không quá 3 vòng GC. Ngược lại, major GC tiến hành thu gom rác với tất cả các object, kể cả những object đã tồn tại qua hơn 3 vòng GC. count sẽ luôn bằng với minor_gc_count + major_gc_count. Để biết thêm chi tiết, các bạn có thể xem bài nói của tôi tại FOSDEM về lịch sử của GC trong Ruby. (link)

Theo dõi các thông số count của GC có thể hữu ích trong 1 số trường hợp. Ví dụ dưới đây là 1 Rack middleware ghi lại số lần GC xảy ra trong quá trình 1 web request được xử lí.

class GCCounter
  def initialize(app)
    @app = app
  end

  def call(env)
    gc_counts_before = GC.stat.select { |k,v| k =~ /count/ }
    @app.call(env)
    gc_counts_after = GC.stat.select { |k,v| k =~ /count/ }
    puts gc_counts_before.merge(gc_counts_after) { |k, vb, va| va - vb }
  end
end

Heap numbers

Tiếp theo hãy cùng tìm hiểu về các thông số liên quan đến heap.

{
  # Page numbers
  :heap_allocated_pages=>63,
  :heap_sorted_length=>63,
  :heap_allocatable_pages=>0,

  # Slots
  :heap_available_slots=>25679,
  :heap_live_slots=>25506,
  :heap_free_slots=>173,
  :heap_final_slots=>0,
  :heap_marked_slots=>17773,

  # Eden and Tomb
  :heap_eden_pages=>63,
  :heap_tomb_pages=>0
}

Ở đây heap là 1 cấu trúc dữ liệu của C, đôi khi được gọi là ObjectSpace mà ở đó chúng ta lưu trữ tham chiếu đến các object đang tồn tại của Ruby. Trong 1 hệ thống 64 bit thì mỗi heap chứa khoảng 408 slot, mỗi slot chứa thông tin về 1 object tồn tại của Ruby.

Đầu tiên chúng ta có thông tin về size tổng thể của toàn bộ object space của Ruby. heap_allocated_pages là số lượng heap page đang được cấp phát. Những page này có thể hoàn toàn trống, hoàn toàn đầy hoặc ở 1 mức nào đó ở giữa. heap_sorted_length là size thực tế của heap trong bộ nhớ. Nếu chúng ta có 10 heap page và giải phóng page thứ 5 (hoặc 1 page nào đó ở khoảng giữa) thì độ dài của heap vẫn là 10 page vì chúng ta không thể dịch chuyển các page qua lại trong bộ nhớ. heap_sorted_length sẽ luôn lớn hơn hoặc bằng số lượng page được cấp phát thực tế. heap_allocatable_pages là số lượng vùng bộ nhớ có kích thước 1 heap page mà tiến trình Ruby sở hữu và có thể dùng để cấp phát heap page. Nếu cần thêm 1 heap page mới cho các object thì nó sẽ sử dụng vùng bộ nhớ này đầu tiên.

Tiếp theo sẽ là những thông số liên quan đến từng object slot riêng biệt. Dễ thấy heap_available_slots là tổng số slot trong các heap page. GC.stat[:heap_available_slots] chia cho GC::INTERNAL_CONSTANTS[:HEAP_PAGE_OBJ_LIMIT] sẽ luôn bằng với GC.stat[:heap_allocated_pages]. heap_live_slots là số lượng object đang tồn tại, còn heap_free_slots là số lượng slot trống trong các heap page. heap_final_slots là số lượng object slot mà có finalizer đính kèm. Finalizer là 1 Proc chạy khi 1 object được giải phóng. Dưới đây là 1 ví dụ:

ObjectSpace.define_finalizer(self, self.class.method(:finalize).to_proc)

heap_marked_slots là tổng số object "cũ" (đã tồn tại qua hơn 3 vòng GC) và những object không được bảo vệ bởi write barrier (chúng ta sẽ tìm hiểu sau).

Cuối cùng là tomb_pageseden_pages. Eden page là những heap page chứa ít nhất 1 object đang tồn tại. Tomb page không chứa object, có toàn bộ các slot trống. Ruby runtime chỉ có thể giải phóng các tomb page để trả lại bộ nhớ lại cho hệ điều hành và không thể giải phóng các eden page.

Cumulative allocated/freed numbers

{
  :total_allocated_pages=>63,
  :total_freed_pages=>0,
  :total_allocated_objects=>133299,
  :total_freed_objects=>107793
}

Đây là những con số luỹ tích cho toàn bộ vòng đời của tiến trình, chúng sẽ không bao giờ bị reset hoặc giảm xuống. 4 giá trị này lần lượt là: tổng số page đã được cấp phát bộ nhớ, tổng số page đã được giải phóng, tổng số object đã được cấp phát bộ nhớ, tổng số object đã được giải phóng.

Garbage collection thresholds

{
  :malloc_increase_bytes=>45712,
  :malloc_increase_bytes_limit=>16777216,
  :remembered_wb_unprotected_objects=>182,
  :remembered_wb_unprotected_objects_limit=>352,
  :old_objects=>17221,
  :old_objects_limit=>29670,
  :oldmalloc_increase_bytes=>46160,
  :oldmalloc_increase_bytes_limit=>16777216
}

Có một sự hiểu nhầm thường thấy ở các lập trình viên Ruby về thời điểm mà GC được kích hoạt. Chúng ta có thể tự kích hoạt GC bằng method GC.start nhưng điều đó sẽ không xảy ra ở môi trường production. Nhiều người thì nghĩ là GC chạy theo kiểu timer: sau mỗi X giây hoặc X request nhưng điều đó cũng không đúng.

Minor GC được kích hoạt khi thiếu các slot trống. Ruby chỉ chạy GC khi nó thiếu bộ nhớ. Khi không còn free_slots, chúng ta sẽ chạy 1 minor GC, đánh dấu và quét tất cả các object "mới" (đã tồn tại qua không quá 3 vòng GC), các object ở trong remembered set và các object không được bảo vệ bởi write-barrier. Chúng ta sẽ tìm hiểu về các khái niệm này sau.

Major GC có thể bị kích hoạt khi thiếu free slot sau khi chạy 1 minor GC, hoặc khi 1 trong 4 ngưỡng sau bị vượt quá: oldmalloc, malloc, old object count, writebarrier-unprotected count. Một phần kết quả của GC.stat mà chúng ta xem ở đây cho thấy giá trị của 4 ngưỡng này (các thông số limit) và giá trị hiện tại của từng thông số tương ứng.

malloc_increase_bytes được dùng đến khi Ruby cấp phát bộ nhớ bên ngoài heap cho các object. Mỗi object slot ở các heap page chỉ có 40 bytes (xem GC::INTERNAL_CONSTANTS[:RVALUE_SIZE]), điều gì sẽ xảy ra khi chúng ta có 1 object lớn hơn 40 bytes (ví dụ 1 string rất dài). Chúng ta sẽ phải cấp phát bộ nhớ cho nó từ 1 nơi nào đó. Nếu chúng ta cấp phát 80 bytes cho 1 string, malloc_increase_bytes sẽ tăng thêm 80. Khi con số này chạm ngưỡng giới hạn, major GC sẽ được kích hoạt.

oldmalloc_increase_bytes là 1 thông số tương tự malloc_increase_bytes nhưng nó chỉ liên quan đến các object "cũ".

remembered_wb_unprotected_objects là số lượng object không được bảo vệ bởi write-barrier và là 1 phần của remembered set. Write-barrier là 1 interface giữa Ruby runtime và object mà cho phép chúng ta theo dõi các tham chiếu đến và đi từ object khi chúng được tạo ra. Các C extension có thể tạo ra các tham chiếu mới tới các object mà không cần đi qua write-barrier, những object đó được gọi là "shady" hoặc "write-barrier unprotected". Remembered set là danh sách những object "cũ" mà có tham chiếu tới 1 object mới nào đó.

old_objects đơn giản là số lượng object được đánh dấu là "cũ".

Theo dõi những thông số ngưỡng này có thể hữu ích nếu chương trình của bạn có vấn đề với 1 số lượng lớn major GC.

Tham khảo

https://www.speedshop.co/2017/03/09/a-guide-to-gc-stat.html