内存管理

GNOME 堆栈主要用 C 编写,因此动态分配的内存必须手动管理。通过使用 GLib 提供的便捷 API,内存管理可以变得非常简单,但程序员在编写代码时始终需要注意内存。

假定读者熟悉使用 malloc()free() 进行堆内存分配的概念,并且了解 GLib 的等效函数 g_malloc()g_free()

内存管理原则

通常的内存管理方法是,程序员跟踪哪些变量指向已分配的内存,并在不再需要时手动释放它们。这是正确的,但可以通过引入所有权的概念来澄清,所有权是指负责释放已分配内存(分配)的代码部分(例如函数、结构体或对象)。每个分配都恰好有一个所有者;所有权可以在程序运行时通过将所有权转移给另一段代码来更改。每个变量都拥有或不拥有,具体取决于包含它的作用域是否始终是其所有者。每个函数参数和返回值要么转移传递给它的值的所有权,要么不转移。如果拥有某些内存的代码没有释放该内存,那就是内存泄漏。如果未拥有某些内存的代码释放了它,那就是二次释放。两者都是错误的。

通过静态计算哪些变量拥有所有权,内存管理就变成了一个简单的任务,即在它们离开作用域之前无条件地释放拥有的变量,而不释放未拥有的变量。对于所有内存,需要回答的关键问题是:哪段代码拥有此内存?

这里有一个重要的限制:变量在运行时绝不能从拥有变为未拥有(反之亦然)。这个限制是简化内存管理的关键。

例如,考虑以下函数

char *  generate_string (const char *template);

void    print_string    (const char *str);

以下代码已注释,以说明所有权转移发生的位置

char *my_str = NULL;  /* owned */
const char *template;  /* unowned */
GValue value = G_VALUE_INIT;  /* owned */
g_value_init (&value, G_TYPE_STRING);

/* Transfers ownership of a string from the function to the variable. */
template = "XXXXXX";
my_str = generate_string (template);

/* No ownership transfer. */
print_string (my_str);

/* Transfer ownership. We no longer have to free @my_str. */
g_value_take_string (&value, my_str);

/* We still have ownership of @value, so free it before it goes out of scope. */
g_value_unset (&value);

这里有几点:首先,变量声明旁边的“owned”(拥有)注释表示这些变量由局部作用域拥有,因此需要在它们超出作用域之前释放它们。另一种是“unowned”(未拥有),这意味着局部作用域不拥有所有权,并且在超出作用域之前不得释放这些变量。同样,所有权不得通过赋值转移给它们。

其次,变量类型修饰符反映了它们是否转移所有权:因为 my_str 由局部作用域拥有,所以它的类型是 char*,而 templateconst,以表示它未拥有。 同样,generate_string()template 参数和 print_string()str 参数标记为 const,因为在调用这些函数时不会转移所有权。由于所有权已转移到 g_value_take_string() 的字符串参数,因此我们可以预期它的类型为 gchar。

(请注意,这不适用于 GObject 实例和子类,它们永远不能是 const。它仅适用于字符串和简单的结构体。)

最后,一些库使用函数命名约定来指示所有权转移,例如在函数名称中使用“take”(获取)来指示参数的完全转移,如 g_value_take_string() 所示。

请注意,不同的库使用不同的约定,如下所示

函数名称

约定 1(标准)

约定 2(备选)

约定 3 (gdbus-codegen)

get

转移:无

任何转移

转移:完全

dup

转移:完全

未使用

未使用

peek

未使用

未使用

转移:无

set

转移:无

转移:无

转移:无

take

转移:完全

未使用

未使用

steal

转移:完全

转移:完全

转移:完全

理想情况下,所有函数都应该对所有相关参数和返回值具有 (transfer) 内省注释。如果失败,这里有一组用于确定返回值所有权是否转移的指南

  1. 如果类型具有内省 (transfer) 注释,请查看它。

  2. 否则,如果类型是 const,则没有转移。

  3. 否则,如果函数文档明确指定必须释放返回值,则存在完全或容器转移。

  4. 否则,如果函数命名为“dup”、“take”或“steal”,则存在完全或容器转移。

  5. 否则,如果函数命名为“peek”,则没有转移。

  6. 否则,您需要查看函数的代码以确定它是否打算转移所有权。然后,对该函数的文档提出错误,并要求添加内省注释。

鉴于这种所有权和转移基础设施,可以机械地确定每种情况下的正确内存分配方法。在每种情况下,copy() 函数必须适合于数据类型,例如 g_strdup() 用于字符串,或 g_object_ref() 用于 GObject。

在考虑所有权转移时,malloc()/free() 和引用计数是等效的:在前一种情况下,转移了新分配的堆内存;在后一种情况下,转移了新增加的引用。

文档

记录每个函数参数和返回值的转移所有权,以及每个变量的所有权非常重要。虽然在编写代码时它们可能很清楚,但几个月后它们就不清楚了;并且可能对 API 的用户永远不清楚。它们应该始终记录在案。

记录所有权转移的最佳方法是使用 gobject-introspection 引入的 (transfer) 注释。将此包含在每个函数参数和返回类型的 API 文档注释中。这样,内省工具也可以读取注释并正确内省 API。

提示

如果函数不是公共 API,请为它编写文档注释,并包含 (transfer) 注释。这将帮助您和其他使用您的代码的人。

例如

/**
 * g_value_take_string:
 * @value: (transfer none): an initialized #GValue
 * @str: (transfer full): string to set it to
 *
 * Function documentation goes here.
 */

/**
 * generate_string:
 * @template: (transfer none): a template to follow when generating the string
 *
 * Function documentation goes here.
 *
 * Returns: (transfer full): a newly generated string
 */

可以使用内联注释记录变量的所有权。这些是非标准的,不被任何工具读取,但如果一致使用,可以形成一种约定

GObject *some_owned_object = NULL;  /* owned */
GObject *some_unowned_object;  /* unowned */

容器类型的文档也是一种约定;它还包括所包含元素的类型

/* PtrArray<owned char*> */
GPtrArray *some_unowned_string_array;  /* unowned */

/* PtrArray<owned char*> */
GPtrArray *some_owned_string_array = NULL;  /* owned */

/* PtrArray<owned GObject*> */
GPtrArray *some_owned_object_array = NULL;  /* owned */

请注意,拥有变量应始终初始化,以便释放它们更方便。

注意

某些类型,例如基本的 C 类型(如字符串),如果它们未拥有,可以添加 const 修饰符,以便利用编译器警告,这些警告是由于将这些变量分配给拥有变量而产生的(这些变量不得使用 const 修饰符)。如果是这样,则可以省略 /* unowned */ 注释。

引用计数

除了传统的 malloc()/free() 样式类型之外,GLib 还有各种引用计数类型——GObject 是一个主要示例。

所有权和转移的概念同样适用于引用计数类型,就像适用于分配类型一样。如果作用域持有对实例的强引用(例如通过调用 g_object_ref()),则作用域拥有引用计数类型。可以通过调用 g_object_ref() 再次“复制”实例。可以使用 g_object_unref() 释放所有权——即使这可能不会实际完成实例,它也会释放当前作用域对该实例的所有权。

提示

有关处理 GObject 引用的便捷方法,请参阅 g_clear_object()

GLib 中还有其他引用计数类型,例如 GHashTable(使用 g_hash_table_ref()g_hash_table_unref())或 GVariant (g_variant_ref(), g_variant_unref())。出于历史原因,某些类型(如 GHashTable)支持引用计数和显式完成。应始终优先使用引用计数,因为它允许在多个作用域之间轻松共享实例(每个作用域都持有自己的引用),而无需分配实例的多个副本。这可以节省内存。

提示

GLib 提供 API 以便轻松实现引用计数类型,形式为 g_rc_box_new()g_atomic_rc_box_new()

浮动引用

与 GObject 相比,从 GInitiallyUnowned 派生的类具有初始引用,该引用是浮动的,这意味着没有代码拥有该引用。一旦在对象上调用 g_object_ref_sink(),浮动引用将转换为强引用,并且调用代码假定对该对象的所有权。

浮动引用是 C 中 API 的便利工具,例如 GTK,其中必须创建大量对象并将其组织成层次结构。在这种情况下,调用 g_object_unref() 以放弃所有强引用会导致大量代码

// Without floating references
GtkWidget *new_widget;

new_widget = gtk_some_widget_new ();
gtk_container_add (some_container, new_widget);
g_object_unref (new_widget);

// With floating references
gtk_container_add (some_container, gtk_some_widget_new ());

注意

请注意,当在非浮动引用上调用时,g_object_ref_sink() 等效于 g_object_ref(),这使得 gtk_container_add() 与任何其他函数没有区别。

只有少数 API 使用浮动引用——特别是 GtkWidget 及其所有子类,或 GVariant。您必须了解哪些 API 支持它,哪些 API 使用浮动引用,并且仅将它们一起使用。

重要提示

在设计较新库的 API 时,您不应使用浮动引用,因为它们需要在语言绑定中进行特殊处理。

浮动引用可以有效地替换为让您的 container_add() 函数转移您正在添加的对象的的所有权,并使用 (transfer full) 对参数进行注释

/**
 * your_container_add:
 * @container: a container object
 * @element: (transfer full): the element to add
 *
 * Adds a new element to a container.
 */
 void
 your_container_add (YourContainer *container,
                     YourElement   *element)
 {
   g_ptr_array_add (container->elements, element);
 }

便捷函数

GLib 提供了各种用于内存管理的便捷函数,尤其是对于 GObject。这里将介绍三个,但还有其他函数——请查看 GLib API 文档以获取更多信息。它们通常遵循与这三个函数类似的命名方案(使用“_full”后缀,或函数名称中使用动词“clear”)。

g_clear_pointer()

g_clear_pointer() 是一个函数,它使用给定的 GDestroyNotify 函数释放指针的内容,然后通过将其设置为 NULL 来清除指针。

提示

C 标准保证 free() 始终是 NULL 安全的,因此 GLib 对于 g_free() 也是如此。但是,对于任何其他释放函数,没有保证是 NULL 安全的,因此建议使用 g_clear_pointer() 以避免使用释放后内存的问题。

g_clear_object()

g_clear_object()g_object_unref() 的一个版本,它取消引用 GObject,然后将其指针清除为 NULL

这使得更容易实现代码,该代码保证 GObject 指针始终为 NULL,或者拥有 GObject(但不指向不再拥有的 GObject)。

通过将所有拥有的 GObject 指针初始化为 NULL,在作用域结束时释放它们就像调用 g_clear_object() 而无需任何检查一样简单

void
my_function (void)
{
  GObject *some_object = NULL;  /* owned */

  if (rand ())
    {
      some_object = create_new_object ();
      /* do something with the object */
    }

  g_clear_object (&some_object);
}

g_list_free_full()g_slist_free_full()

g_list_free_full()g_slist_free_full() 释放链接列表中的所有元素及其所有数据。它比迭代列表以释放所有元素的的数据,然后调用 g_list_free()g_slist_free() 以释放列表元素本身要方便得多。

g_hash_table_new_full()

g_hash_table_new_full()g_hash_table_new() 的一个版本,它允许设置函数来销毁哈希表中的每个键和值,当它们被删除时。这些函数将在哈希表被销毁或使用 g_hash_table_remove() 删除条目时自动调用所有键和值。

本质上,它简化了键和值的内存管理问题,将其归结为它们是否存在于哈希表中。

类似的功能也存在于 GPtrArrayGArray

  • g_ptr_array_new_with_free_func()

  • g_array_set_clear_func()

容器类型

在使用容器类型(例如 GPtrArrayGList)时,会引入额外的所有权级别:除了容器实例的所有权之外,容器中的每个元素也要么被拥有,要么不被拥有。通过嵌套容器,必须跟踪多个所有权级别。拥有元素的的所有权属于容器;容器的所有权属于其所在的作用域(该作用域可能是另一个容器)。

简化此问题的一个关键原则是确保容器中的所有元素具有相同的所有权:要么全部被拥有,要么全部不被拥有。如果使用像 GPtrArrayGHashTable 这样的类型的常规便利函数,则会自动发生这种情况。

如果容器中的元素被拥有,将它们添加到容器中本质上是一种所有权转移。例如,对于字符串数组,如果元素被拥有,则 g_ptr_array_add() 的定义实际上是

/**
 * g_ptr_array_add:
 * @array: a #GPtrArray
 * @str: (transfer full): string to add
 */
void
g_ptr_array_add (GPtrArray *array,
                 gchar     *str);

因此,例如,常量(不拥有)字符串必须使用 g_ptr_array_add (array, g_strdup ("常量 字符串")) 添加到数组中。

而如果元素不被拥有,则定义实际上是

/**
 * g_ptr_array_add:
 * @array: a #GPtrArray
 * @str: (transfer none): string to add
 */
void
g_ptr_array_add (GPtrArray   *array,
                 const gchar *str);

在这里,常量字符串可以无需复制即可添加:g_ptr_array_add (array, "常量 字符串")

单路径清理

对于更复杂的函数,一个有用的设计模式是拥有一个单一的控制路径,该路径会清理(释放)分配并返回给调用者。这大大简化了分配的跟踪,因为不再需要精神上计算出在每个代码路径上释放了哪些分配——所有代码路径都结束于同一点,因此那时执行所有释放。对于具有更多拥有局部变量的较大函数,这种方法的优势会迅速增加;对于较小的函数,应用该模式可能没有意义。

这种方法有两个要求

  1. 函数从单个点返回,并使用 goto 从其他路径到达该点。

  2. 所有拥有变量在初始化或将所有权转移给它们时都设置为 NULL

下面的示例是一个小函数(为了简洁),但应该说明将该模式应用于较大函数的原理

GObject *
some_function (GError **error)
{
  char *some_str = NULL;  /* owned */
  GObject *temp_object = NULL;  /* owned */
  const char *temp_str;
  GObject *my_object = NULL;  /* owned */
  GError *child_error = NULL;  /* owned */

  temp_object = generate_object ();
  temp_str = "example string";

  if (rand ())
    {
      some_str = g_strconcat (temp_str, temp_str, NULL);
    }
  else
    {
      some_operation_which_might_fail (&child_error);

      if (child_error != NULL)
        goto done;

      my_object = generate_wrapped_object (temp_object);
    }

done:
  /* Here, @some_str is either NULL or a string to be freed, so can be passed to
   * g_free() unconditionally.
   *
   * Similarly, @temp_object is either NULL or an object to be unreffed, so can
   * be passed to g_clear_object() unconditionally.
   */
  g_free (some_str);
  g_clear_object (&temp_object);

  /* The pattern can also be used to ensure that the function always returns
   * either an error or a return value (but never both).
   */
  if (child_error != NULL)
    {
      g_propagate_error (error, child_error);
      g_clear_object (&my_object);
    }

  return my_object;
}

提示

如果您正在编写针对 GCC 或其他 GCC 兼容编译器且不会移植到其他平台的代码,则可以使用 GLib 提供的 g_autoptr 宏来避免使用 goto

验证

可以通过两种方式检查内存泄漏:静态分析和运行时泄漏检查。

使用 Coverity、Clang 静态分析器或 Tartan 等工具进行静态分析可以捕获一些泄漏,但需要了解代码中调用的每个函数的的所有权转移。特定于领域的静态分析器(例如 Tartan,它了解 GLib 内存分配和转移)可以在这里表现更好,但 Tartan 还是一个比较年轻的项目,仍然会遗漏一些内容(较低的真阳性率)。建议将代码通过静态分析器,但检测泄漏的主要工具应该是运行时泄漏检查。

使用 Valgrind 的 memcheck 工具进行运行时泄漏检查。它检测到的任何“肯定丢失内存”的泄漏都应修复。许多“可能”丢失内存的泄漏不是真正的泄漏,应该添加到抑制文件中。

如果使用最新版本的 Clang 或 GCC 进行编译,可以启用地址清理程序,它可以在运行时检测内存泄漏和溢出问题,而无需在正确的环境中运行 Valgrind 的困难。但是,请注意,它仍然是一个年轻的工具,因此在某些情况下可能会失败。