关注了就能看到更多这么棒的文章哦~
Scope-based resource management for the kernel
By Jonathan Corbet
June 15, 2023
DeepL assisted translation
https://lwn.net/Articles/934679/
C 语言不提供那些更加新的语言中的资源管理(resource-management)功能。因此,比如内存泄漏或无法释放 lock 的错误在用 C 编写的程序中相对常见,Linux 内核也是如此。不过,kernel 项目从未将自己限制 C 语言标准中。内核开发人员很乐意使用编译器提供的扩展,只要它们被证明是有用的。看起来编译器所提供的相对简单的功能可能会导致一些常见的内核编码方式发生很大的变化。
具体来说,我们讨论的是 cleanup 这个 attribute,它在 GCC 和 Clang 中都有实现。允许使用以下语法声明变量:
type my_var __attribute__((__cleanup__(cleanup_func)));
这个新增的属性表示,当该 type 的变量 my_var 超出范围时,应调用:
cleanup_func(&my_var);
也就是认为会用此函数在该变量永远消失之前对其进行某种清理动作。例如,可以这样声明一个指针(在内核中):
void auto_kfree(void **p) { kfree(*p); }
struct foo *foo_ptr __attribute__((__cleanup__(auto_kfree))) = NULL;
/* ... */
foo_ptr = kmalloc(sizeof(struct foo));
之后就不用再花心思去释放所分配的内存了。如果 foo_ptr 不再存在了,那么编译器确保会对它调用 kfree()。再也不可能泄露这段内存了,除非有人非常努力去让它泄露。
这个属性不是最近刚出现的,但内核从未利用过它。5月下旬,Peter Zijlstra 决定改变这种情况,于是发布了一组 patch set,利用这个功能实现了“锁和指针保护”。此后不久发布了第二个版本,并引起了相当多的讨论,Linus Torvalds 鼓励 Zijlstra 将这项工作扩充一下,不仅仅是保护 lock。于是 6 月 12 日发布的基于作用域的资源管理(scope-based resource management)patch set 就出现了,它创建了一组新的宏,希望简化 cleanup 属性的使用。由 57 个 patch 组成,还把大量代码都改成了使用新增的宏,并提供了一组详细的示例,来介绍如何用这些宏改变内核代码库的样子。
Cleanup functions in the kernel
首先定义了一个新的宏 __cleanup() ,它把上面展示的 attribute 语法进行了简化。然后可以用一组宏来创建和管理这些自己会释放自己的指针:
#define DEFINE_FREE(name, type, free) \
static inline void __free_##name(void *p) { type _T = *(type *)p; free;}
#define __free(name) __cleanup(__free_##name)
#define no_free_ptr(p) \
({ __auto_type __ptr = (p); (p) = NULL; __ptr; })
#define return_ptr(p) return no_free_ptr(p)
DEFINE_FREE() 的目的是将一个清理函数跟特定 type 关联起来(尽管 “type” 实际上只是另一个标识符,并不会跟某种具体的 C 语言 type 关联)。因此,可以使用类似下面的声明设置一个 free 函数:
DEFINE_FREE(kfree, void *, if (_T) kfree(_T))
在这个宏中,创建了一个名为 __free_kfree() 的新函数,如果传入的指针不是 NULL,则调用 kfree()。没有人会直接调用该函数,但这个声明之后就可以编写如下代码了:
struct obj *p __free(kfree) = kmalloc(...);
if (!p)
return NULL;
if (!initialize_obj(p))
return NULL;
return_ptr(p);
__free() 这个 attribute 会把我们的清理函数与指针 p 相关联,确保当该指针不再使用时(无论是因为什么原因)都会调用这个 __free_kfree()。因此举例来说,上面的第二个 return 语句就不会把给 p 分配的内存泄露掉,哪怕没有显式调用 kfree() 也是安全的。
但是有时并不需要自动 free,比如一切都按预期正常进行,并且指向已分配对象的指针应给调用方返回的情况。专门为此设计的 return_ptr() 宏就通过把 p 复制到另一个变量、将 p 设置为 NULL 、然后返回复制后的值来阻止自动 free 动作。通常出错的情况会有很多中,而正常完成只有一种方式,因此以这种方式来对正常路径进行标注会更有意义。
From cleanup functions to classes
自动清理函数只是一个开始,事实证明,使用此编译器功能可以完成更多的工作。经过一番讨论,人们认为处理内核中资源管理的更通用功能的最佳命名是“class”。因此,下一步是将内核使用的“class”添加到 C 语言中:
#define DEFINE_CLASS(name, type, exit, init, init_args...) \
typedef type class_##name##_t; \
static inline void class_##name##_destructor(type *p) \
{ type _T = *p; exit; } \
static inline type class_##name##_constructor(init_args) \
{ type t = init; return t; }
此宏使用了指定的 name 创建一个新的 “class”,封装了该 type 的值。 exit() 函数是此 class 的析构函数(也就是 cleanup 函数),而 init() 则是构造函数,它将接收 init_args 作为参数。宏定义了一个类型和几个新函数来处理初始化和销毁的工作。
然后,就可以使用 CLASS() 宏来定义此类的变量:
#define CLASS(name, var) \
class_##name##_t var __cleanup(class_##name##_destructor) = \
class_##name##_constructor
此宏会被替换为变量 var 的声明,该变量通过调用构造函数来进行初始化。请注意,得到的结果是一个不完整的语句;必须向构造函数提供参数才能让这个语句变完整,如下所示。此处使用 __cleanup() 宏可确保当 class 的变量不再可用时将调用此 class 的析构函数。
在 patch set 中展示了这个宏的一个用途是将一些结构引入对文件的引用的管理上,因为文件引用很容易泄漏。创建了一个名为 fdget 的新 class,用于管理这些文件引用的获取和释放。
DEFINE_CLASS(fdget, struct fd, fdput(_T), fdget(fd), int fd)
创建一个构造函数(名为 class_fdget_constructor() ,但该名称永远不会显式出现在代码中)以通过调用 fdget() 来初始化类,并将整数 fd 作为其参数。此初始化创建对文件的引用,该文件必须在某个时候返回。类定义还创建了一个析构函数,该析构函数调用 fdput() ,当此类的变量超出范围时,编译器将调用该析构函数。
想要使用文件描述符 fd,可以通过如下调用来利用这个 class structure:
CLASS(fdget, f)(fd);
此行声明了一个名为 f 的新变量,类型为 1,由 fdget 类来管理。
最后,有一些宏来定义与 lock 相关的类:
#define DEFINE_GUARD(name, type, lock, unlock) \
DEFINE_CLASS(name, type, unlock, ({ lock; _T; }), type _T)
#define guard(name) \
CLASS(name, __UNIQUE_ID(guard))
DEFINE_GUARD() 针对 lock 类型创建了一个 class。例如,它与具有以下声明的 mutex 配合使用:
DEFINE_GUARD(mutex, struct mutex *, mutex_lock(_T), mutex_unlock(_T)):
然后用 guard() 宏创建此 class 的一个实例,为其生成一个唯一的名称(没有人会看到,也不用关心)。在此 patch 中就可以看到这个基础设施的示例用法,把:
mutex_lock(&uclamp_mutex);
替换为:
guard(mutex)(&uclamp_mutex);
之后,可以删除专门释放 uclamp_mutex 的代码了,在各种分支情况下用来进行 unlock 动作的所有错误处理代码都可以删除掉了。
The guard-based future
在上面的示例中,删除错误处理代码这个步骤非常重要。内核中的一种常见情况就是在函数结束时来执行清理动作,并在出现问题时使用 goto 语句跳转到这些 cleanup 代码中的适当位置。伪代码类似这样:
err = -EBUMMER;
mutex_lock(&the_lock);
if (!setup_first_thing())
goto out;
if (!setup_second_thing())
goto out2;
/* ... */
out2:
cleanup_first_thing();
out:
mutex_unlock(&the_lock);
return err;
这是对 goto 的相对比较克制的使用方式了,但在内核代码中加起来仍然有大量的 goto 语句,并且相对比较容易出错。广泛采用这种新机制的话就可以使上述代码模式看起来像这样:
guard(mutex)(&the_lock);
CLASS(first_thing, first)(...);
if (!first or !setup_second_thing())
return -EBUMMER;
return 0;
代码更加紧凑,并且降低了出现与资源相关的 bug 的机会。
所实现的宏肯定不止此处讨论的这些,还包括了用于管理 read-copy-update(RCU) 关键区(critical section)的一个专门变种。好奇的读者可以在相关 patch 中找到。
这组 patch 中一个挺有意思的副作用是删除了针对首次使用之后才做声明的这种编译器警告,这个 warning 是内核开发工作中长期以来的要求,从而避免用这种方式来混合声明和使用。如果不放宽该规则了话,这些宏就无法生效。Torvalds 同意这一改动,他说也许规则可以稍微放松一些:
我认为这个特定的规则确实是一个挺好的规则,但同时我也认为可以不让它成为一个硬性规则,而是只让它成为一个通常情况下的编码风格,但在确有必要的时候可以允许把声明和代码混起来。
对这项工作的回复大多都是很正面的。Torvalds 似乎对这种新机制的总体方向感到满意,仅仅抱怨了几个特定转换中的潜在 bug,以及这组 patch 看起来太大了。因此,这个功能似乎很有可能会合入未来的 kernel。最终可能让内核中的资源管理更安全,并且 goto 也更少了。
全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。
长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~