LWN:为内核继续改善list遍历操作!

关注了就能看到更多这么棒的文章哦~

Toward a better list iterator for the kernel

By Jonathan Corbet
March 10, 2022
DeepL assisted translation
https://lwn.net/Articles/887097/

链表(linked list)概念很简单,它们往往在入门级数据结构课程的开始课程内就教过了。因此,要是大家知道内核社区对其长期都在用的链表实现感到不太满意,不仅在寻找解决一些问题的方法,而且一直在努力追求实现更好的解决方案。现在看来,近期就会有一些改动,也就是说:在 30 多年后,内核开发者可能已经找到了一种更好的方法来安全地迭代一个链表。

Kernel linked lists

当然,C语言下面创建链表相对容易。但是,它并没有定义和规范好如何创建包含任何类型结构的那种通用链表。C 语言的性质适合在每一种需要链表的情况下创建一个专用的链表,从而也就导致了更多的模板代码(boilerplate code)和重复定义。每一个链表的实现都必须经过 review 以保证其正确性。如果能有一个可以确认可用的唯一实现,那就最好了,这样内核开发者就可以更有效地利用他们自己的时间避免在具体领域代码之外引入 bug 了。

内核自然有一些关于链表的解决方案,但最常用的是 struct list_head:

struct list_head {
  struct list_head *next, *prev;
  };

这个结构可以显式创建双向链表,类似下面这个样子:

outside_default.png

struct list_head 可以很好地构造出一个链表,但有一个明显的缺点:它不能保存其他任何信息。通常,这种数据结构是需要用来把其他一些重要数据串联起来的,这个 list 结构本身并不是什么重点。C 语言无法很方便地创建一个可以支持任何形式的 payload 的链表,但可以很容易地将结构 list_head 嵌入到开发者希望用链表串起来的目标结构中:

outside_default.png

这就是在内核中构建链表的通常方式。使用 container_of() 这样的宏可以把指向 list_head 结构的指针变成指向包含它的结构的指针。使用链表的代码几乎总是使用这个宏(通常是间接调用)来获取对 payload 的访问指针。

最后还有一个值得注意的细节,list 的实际头部往往是一个 list_head 结构,并没有也嵌入到 payload 结构中:

105a54ae33340bbf6cd4b7563ebea1a6.png

关于这个基础结构如何使用,有一个现实世界的例子就是 inode 结构,一个 inode 结构就代表了文件系统中的一个文件。Inode 可以同时出现在很多 list 中,因此 inode 结构包含了至少五个各不相同的 list_head 结构,不幸的是,编者的制图水平并不能很好地展示出这个数据结构。其中的一个 list_head 结构名为 i_sb_list,是用来将 inode 与它所属的文件系统的 superblock 关联起来。指向这个列表的 list_head 结构就是 struct super_block 的 s_inodes 字段。这个例子就是一个没有嵌入到 inode 结构的的 list_head。

对一个链表的遍历通常就是从这个 list_head 开始,然后跟随下一个指针跳转,直到再次找到这个 list_head。当然,我们可以自己写代码来实现遍历,但是内核也为这个目的提供了许多函数和宏。其中一个就是 list_for_each_entry(),它将遍历整个列表,在每个节点都提供一个指向外包结构的指针。使用这个宏的典型代码看起来像这样:

struct inode *inode;

/* ... */
list_for_each_entry(inode, &sb->s_inodes, i_sb_list) {
  /* Process each inode here */
}
/* Should not use the iterator here */

在循环中,该宏使用 container_of()将 inode 指向每个列表条目的外包 inode 结构。问题是从循环中退出时 inode 的值是多少?如果代码以 break 语句退出循环,inode 将指向当时正在处理的那一项元素。然而,如果完成遍历整个列表的话,inode 就会是指向那个独立的列表头部再使用 container_of() 的结果,但是问题是这个节点并没有一个外包的 inode 结构。因此内核就可能会出现不确定的行为,导致许多问题。

出于这个原因,像 list_for_each_entry() 这样的宏的规则就是,这个迭代变量不应该在循环之外使用。如果一个值需要在循环之后被访问,它应该被保存在一个单独的变量中,从而才可以在循环之后访问。但这是一条隐含的规则,没有人觉得有必要在宏本身的文档中明确指出这个限制。不足为奇的是,这条规则充其量只是一个指导原则,内核中充满了事实上在循环后仍在使用迭代变量的代码。

The search for a safer iterator

当我们上次讨论这个问题时,Jakob Koschel 已经发布了 patch 来修复其中一部分犯错的位置,后来他也在继续这个工作。然而,Linus Torvalds 认为这种改动方法还是不够的,因为它无法防止未来出现问题:

因此,如果我们的基本规则是 "不要在循环结束后使用循环迭代变量,因为这种用法可能导致各种微妙的问题",那么除了修复现有的包含这个问题的代码外,我真的希望:a)为未来新加入的这种代码要给出一个编译器警告;b)这种写法在未来需要确保完全无法正常工作。

因为不这样的话这个问题今后还会再次发生。

一路走来,开发者们意识到,转到一个较新版本的 C 标准可能会有帮助,因为它允许在循环体内声明迭代变量(从而使它在循环之外不可见)。Torvalds 初步尝试了一个解决方案,看起来像这样。

#define list_for_each_entry(pos, head, member)                          \
  for (typeof(pos) __iter = list_first_entry(head, typeof(*pos), member); \
       !list_entry_is_head(__iter, head, member) && (((pos)=__iter),1); \
       __iter = list_next_entry(__iter, member))

这版的宏仍然接受迭代器变量作为参数,保持与之前相同的原型。这一点很重要,因为在内核代码中有成千上万使用这个宏的位置。但是它声明了一个新的变量来进行实际的迭代使用,并且只在循环里对传入的迭代变量进行设置。由于循环本身可能永远不会被执行(比如如果是个空列表),存在着循环里从未设置过这个外面传入的迭代变量,所以它可能在循环之后保持未初始化的状态。

这个版本很快被第二版所取代,都被称为了 "a work of art (艺术作品)":

#define list_for_each_entry(pos, head, member)                          \
  for (typeof(pos) pos = list_first_entry(head, typeof(*pos), member);  \
       !list_entry_is_head(pos, head, member);                          \
       pos = list_next_entry(pos, member))

现在,循环范围内的迭代变量被声明为与外层变量同名,从而将外部变量屏蔽掉(shadowing it)。在这个版本中,外部声明的迭代变量根本就不会在循环中使用。

Torvalds 尝试这两种方案时希望,如果在循环之外使用(外部)迭代变量,这将会导致编译器产生警告,因为它不会再允许循环体内部初始化。但这并未起到效果;现在代码中有些地方明确初始化了迭代变量,而且,无论如何,内核里面早已经禁用了 "使用未初始化的变量" 这种编译器警告,因为内核里有太多关于这个的误报了。

James Bottomley 提出了一个不同的方法:

#define list_for_each_entry(pos, head, member)                          \
  for (pos = list_first_entry(head, typeof(*pos), member);              \
  !list_entry_is_head(pos, head, member) && ((pos = NULL) == NULL;      \
                                             pos = list_next_entry(pos, member))

这一版会在退出循环时明确地将迭代变量设置为 NULL,导致任何会使用它的代码应该都会 fail。Torvalds 指出了这个做法有个明显问题:它改变了一个在整个内核中广泛使用的宏的语义,并可能会引入 bug。这也会使开发者在向没有新语义支持的 stable kernel 内核进行 patch backport 时碰到更多困难。

还有一种方法是由 Xiaomeng Tong 提出的:

#define list_for_each_entry_inside(pos, type, head, member) \
  for (type * pos = list_first_entry(head, type, member);   \
       !list_entry_is_head(pos, head, member);              \
       pos = list_next_entry(pos, member))

Tong 的 patch 创建了一套新的宏,全新的名字,对现有的代码可以逐个慢慢切换到新的用法上。完全不使用外部声明的迭代变量,相反,迭代变量的名称和类型被作为参数传递进来,并且迭代器被声明在循环本身的范围内。然而,Torvalds 也不喜欢这种方法。他说,几乎每一处使用它的地方都会导致冗长的、难以阅读的代码,而且在错误的地方导致了痛苦。"他说:"我们应该争取在 糟糕 的情况下必须做额外的工作,而且哪怕在这些地方我们也应该认真追求可读性。"

A solution at last?

在拒绝了各种解决方案之后,Torvalds 开始思考一个好的解决方案可能是什么样子的。他总结说,这里的一部分问题是,外包结构的类型与 list_head 结构是分开的,这使得编写迭代遍历宏更加困难。如果这两种类型能够以某种方式连接起来,事情就会变得简单。此后不久,他想出了一个解决方案,实现了这个想法。它首先提供了一个新的宏:

#define list_traversal_head(type,name,target_member)            \
  union { struct list_head name; type *name##_traversal_type; }

这个宏会被用来声明 list 的真正 head,而不是包含在其他结构中的 list_head 项。具体来说,它声明了一个新的 union 类型的变量,包含一个名为 name 的 list_head 结构,以及一个指向了外包结构类型的指针,名为 name_traversal_type。这个指针从未被使用过,它只是将包含结构的类型与 list_head 变量联系起来的一种方式。

然后,新增了一个迭代 API:

#define list_traverse(pos, head, member)                                \
  for (typeof(*head##_traversal_type) pos = list_first_entry(head, typeof(*pos), member); \
       !list_entry_is_head(pos, head, member);                          \
       pos = list_next_entry(pos, member))

代码可以通过使用 list_traverse()而不再使用 list_for_each_entry() 来遍历一个列表。迭代变量将是 pos,它只存在于循环体内部。列表的第一项作为 head 传递进来,而 member 是包含结构中 list_head 结构的名称。该 patch 包括几个原有使用位置的修改替换,来告诉人们该如何使用。

Torvalds 认为这就是 "未来的方向"。完成这个改动可能是一个长达数年的项目。在内核中,有超过 15000 个使用了 list_for_each_entry()(包括它的变种)的位置。每一个最终都需要被修改,而且列表头的声明位置也必须同时改变。所以这不是一个快速的解决方案,但从长远来看,它可以使内核中的链表实现更加安全。

有人可能会说,所有这些都是由于在内核中继续使用 C 语言而造成的自找的痛苦。这可能是事实,但目前还是缺乏更好的替代方案。例如,尽管 Rust 语言有很多优点,但对想实现链表的人来说用 Rust 也都不是容易的事情,所以转用这种语言也不会能自动解决这个问题。因此,内核开发者似乎不得不在一段时间内继续容忍这种要小心谨慎使用的基础设施。

全文完
LWN 文章遵循 CC BY-SA 4.0 许可协议。

欢迎分享、转载及基于现有协议再创作~

长按下面二维码关注,关注 LWN 深度文章以及开源社区的各种新近言论~

a875ff17dc50fe351c5c140b61cb9cd4.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值