如何使用垃圾回收器

了解垃圾回收器 中,我們討論了 OCaml 中垃圾回收器的工作方式。在本教學中,我們將探討如何使用 Gc 模組,以及如何編寫您自己的終結器。在教學的最後,我們提供了一些您可以嘗試的練習,以加深您的理解。

Gc 模組

Gc 模組包含一些有用的函式,可讓您從 OCaml 程式查詢和呼叫垃圾回收器。

以下是一個執行程式,並在退出前印出 GC 統計資訊的程式

let rec iterate r x_init i =
  if i = 1 then x_init
  else
    let x = iterate r x_init (i - 1) in
    r *. x *. (1.0 -. x)

let () =
  Random.self_init ();
  Graphics.open_graph " 640x480";
  for x = 0 to 640 do
    let r = 4.0 *. float_of_int x /. 640.0 in
    for i = 0 to 39 do
      let x_init = Random.float 1.0 in
      let x_final = iterate r x_init 500 in
      let y = int_of_float (x_final *. 480.) in
      Graphics.plot x y
    done
  done;
  Gc.print_stat stdout

以下是我印出的結果

minor_words: 115926165     # Total number of words allocated
promoted_words: 31217      # Promoted from minor -> major
major_words: 31902         # Large objects allocated in major directly
minor_collections: 3538    # Number of minor heap collections
major_collections: 39      # Number of major heap collections
heap_words: 63488          # Size of the heap, in words = approx. 256K
heap_chunks: 1
top_heap_words: 63488
live_words: 2694
live_blocks: 733
free_words: 60794
free_blocks: 4
largest_free: 31586
fragments: 0
compactions: 0

我們可以發現,次要堆積回收的頻率大約是主要堆積回收的 100 倍(在本範例中,不一定在一般情況下)。在程式的生命週期中,總共分配了驚人的 440 MB 記憶體。當然,其中大部分會在次要回收中立即釋放。只有大約 128K 被提升到主要堆積上的長期儲存,另外 128K 則是由直接分配到主要堆積上的大型物件組成。

我們可以指示 GC 在發生某些事件時(例如,在每次主要回收時)印出除錯訊息。試著將以下程式碼新增至上述範例的開頭附近

# Gc.set {(Gc.get ()) with Gc.verbose = 0x01}

(我們之前沒有看過 { expression with field = value } 這種形式,但它應該很明顯是什麼作用)。上面的程式碼會導致 GC 在每次主要回收開始時印出訊息。

終結與 Weak 模組

我們可以編寫一個稱為**終結器**的函式,該函式會在物件即將被 GC 釋放時呼叫。

Weak 模組可讓我們建立所謂的弱指標。**弱指標**最好透過與「一般指標」進行比較來定義。當我們有一個普通的 OCaml 物件時,我們會透過名稱(例如,let name = ... in)或透過另一個物件來參考該物件。垃圾回收器會看到我們對該物件有參考,因此不會回收它。這就是您可能會稱之為「一般指標」的東西。但是,如果您持有物件的弱指標或弱參考,那麼您會暗示垃圾回收器可以隨時回收該物件。(不一定表示它*會*回收該物件。)當您稍後檢查物件時,您可以將弱指標轉換為一般指標,或者您可以得知 GC 實際上已回收該物件。

終結和弱指標可以一起使用,以實作記憶體中的物件資料庫快取。

讓我們想像一下,我們在磁碟上的檔案中有大量的大型使用者記錄。這資料太多,無法一次全部載入記憶體。此外,其他程式可能會存取磁碟上的資料,因此當我們在記憶體中持有副本時,需要鎖定個別記錄。

我們的「記憶體中物件資料庫快取」的*公開*介面將只有兩個函式

type record = {mutable name : string; mutable address : string}
val get_record : int -> record
val sync_records : unit -> unit

get_record 呼叫是大多數程式需要進行的唯一呼叫。它會從快取或磁碟中取得第 nth 個記錄並傳回。然後,程式可以讀取和/或更新 record.namerecord.address 欄位。然後,程式就真的忘記了該記錄!在幕後,終結會在稍後的時間點將記錄寫回磁碟。

使用者程式也可以呼叫 sync_records 函式。此函式會同步處理所有記錄的磁碟副本和記憶體中副本。

OCaml 目前不會在退出時執行終結器。但是,您可以輕鬆地透過將以下命令新增至您的程式碼來強制執行。此命令會在退出時觸發完整的主要 GC 循環

at_exit Gc.full_major

我們的程式碼也將使用 Weak 模組來實作最近存取記錄的快取。使用 Weak 模組而不是手動撰寫程式碼的優點有兩個。首先,垃圾回收器具有整個程式的記憶體需求的全域視圖,因此它更能決定何時縮減快取。其次,我們的程式碼會更簡單。

在我們的範例中,我們將為使用者的記錄檔案使用非常簡單的格式。該檔案只是一個使用者記錄清單,每個使用者記錄的大小固定為 256 位元組。每個使用者記錄只有兩個欄位(必要時以空格填補):名稱欄位(64 位元組)和地址欄位(192 位元組)。在記錄載入到記憶體之前,程式必須取得該記錄的獨佔鎖定。在記憶體中副本寫回檔案後,程式必須釋放鎖定。以下是一些用於定義磁碟格式的程式碼,以及一些用於讀取、寫入、鎖定和解鎖記錄的低階函式

(* In-memory format. *)
type record = { mutable name : string; mutable address : string }

(* On-disk format. *)
let record_size = 256
let name_size = 64
let addr_size = 192

(* Low-level load/save records to file. *)
let seek_record n fd = ignore (Unix.lseek fd (n * record_size) Unix.SEEK_SET)

let write_record record n fd =
  seek_record n fd;
  ignore (Unix.write fd (Bytes.of_string record.name) 0 name_size);
  ignore (Unix.write fd (Bytes.of_string record.address) 0 addr_size)

let read_record record n fd =
  seek_record n fd;
  ignore (Unix.read fd (Bytes.of_string record.name) 0 name_size);
  ignore (Unix.read fd (Bytes.of_string record.address) 0 addr_size)

(* Lock/unlock the nth record in a file. *)
let lock_record n fd =
  seek_record n fd;
  Unix.lockf fd Unix.F_LOCK record_size

let unlock_record n fd =
  seek_record n fd;
  Unix.lockf fd Unix.F_ULOCK record_size

我們還需要一個函式來建立新的、空的記憶體中 record 物件

(* Create a new, empty record. *)
let new_record () =
  { name = String.make name_size ' '; address = String.make addr_size ' ' }

由於這是一個非常簡單的程式,我們將提前固定記錄的數量

(* Total number of records. *)
let nr_records = 10000

(* On-disk file. *)
let diskfile = Unix.openfile "users.bin" [ Unix.O_RDWR; Unix.O_CREAT ] 0o666

下載 users.bin.gz 並在執行程式前解壓縮。

我們的記錄快取非常簡單

(* Cache of records. *)
let cache = Weak.create nr_records

get_record 函式非常短,基本上由兩個部分組成。我們從快取中取得記錄。如果快取給我們 None,那麼這表示我們尚未從快取中載入此記錄,或者該記錄已寫入磁碟(終結)並從快取中移除。如果快取給我們 Some record,那麼我們只會傳回 record(這會將記錄的弱指標提升為一般指標)。

(* The finaliser function. *)
let finaliser n record =
  printf "*** objcache: finalising record %d\n%!" n;
  write_record record n diskfile;
  unlock_record n diskfile

(* Get a record from the cache or off disk. *)
let get_record n =
  match Weak.get cache n with
  | Some record ->
      printf "*** objcache: fetching record %d from memory cache\n%!" n;
      record
  | None ->
      printf "*** objcache: loading record %d from disk\n%!" n;
      let record = new_record () in
      Gc.finalise (finaliser n) record;
      lock_record n diskfile;
      read_record record n diskfile;
      Weak.set cache n (Some record);
      record

sync_records 函式甚至更容易。首先,它會以 None 取代所有弱指標來清空快取。這現在表示垃圾回收器*可以*回收和終結所有這些記錄。但這並不一定表示 GC *會*立即回收這些記錄。實際上,它不太可能這樣做,因此為了強制 GC 立即回收記錄,我們還會觸發一個主要的循環。

最後,我們有一些測試程式碼。我不會在這裡重現測試程式碼,但您可以下載完整的程式和測試程式碼 objcache.ml 並使用以下命令編譯它

$ ocamlc unix.cma objcache.ml -o objcache

練習

以下是一些擴充上述範例的方法,難度大約依遞增順序排列

  1. 將記錄實作為**物件**,並允許它以透明方式填補/取消填補字串。您將需要提供設定和取得名稱和地址欄位的方法(總共四個公開方法)。盡可能將實作(檔案存取、鎖定)程式碼隱藏在類別中。
  2. 擴展程式,使其在取得記錄時先取得讀取鎖定,但在使用者更新任何欄位之前,將其升級為寫入鎖定
  3. 支援可變數量的記錄,並新增一個函數來建立新的記錄(在檔案中)。[提示:OCaml 支援弱雜湊表。]
  4. 新增對可變長度記錄的支援。
  5. 將底層檔案表示法設為DBM 樣式的雜湊
  6. 提供一個通用的快取,用於你的選擇的關聯式資料庫中的「users」表格(帶有鎖定)。

仍然需要協助嗎?

協助改進我們的文件

所有 OCaml 文件都是開源的。看到有錯誤或不清楚的地方嗎?提交一個 pull request。

OCaml

創新。社群。安全。