如何使用垃圾回收器

理解垃圾回收器 中,讨论了 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 调用是大多数程序需要进行的唯一调用。它从缓存或磁盘中获取第 n 个记录并返回它。然后,程序可以读取和/或更新 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. 提供一个通用缓存,作为您选择的**关系数据库**中“用户”表的前面(带锁定)。

仍然需要帮助?

帮助改进我们的文档

所有 OCaml 文档都是开源的。发现错误或不清楚的地方?提交拉取请求。

OCaml

创新。社区。安全。