LWN:我希望看到的关于readahead的文档!

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

Readahead: the documentation I wanted to read

April 8, 2022
This article was contributed by Neil Brown
DeepL assisted translation
https://lwn.net/Articles/888715/

Linux 内核中的 readahead 代码从名字就可以看出是负责把那些尚未明确请求的数据从存储设备中读出来,原因是认为它很可能很快就会需要用到了。该代码是稳定的、功能正常、在广泛使用的、没有争议的,所以我们有理由认为该代码应该是质量很高的,而且基本上是这样。最近,我发现需要给这段代码写一些文档,来进一步完善它。这项工作还揭示了一些功能上的小问题以及命名上的大问题。

我想要添加文档的具体原因可能会影响到我对这段代码的看法,所以我就先介绍一下这个原因吧。很久以前,Linux 在 I/O path 上有一个强烈的 "congestion" 的概念。如果对某些设备的请求队列增长得太大了,那么相应的设备就会被标记为 "congested (拥堵状态)",此时某些不那么必要的 I/O 请求将会被跳过或延后执行,具体来说就是 writeback 以及 readahead。随着时间的推移,拥塞管理的需求也(显然)越来越大。也许这是因为许多 I/O 设备现在都比我们的 CPU 更加快了,但是,不管是什么原因,block layer 不再跟踪这些拥堵状态了,只有一些虚拟的 "底层设备" 在继续这种过时的做法。

在 Linux 5.16 中,唯一被标记为 "read congested" 的设备就是用于 FUSE 文件系统的虚拟设备。我在进行消除所有拥塞跟踪残余的工作,因此我提议 FUSE 真的没有什么特别之处,它应该像其他人一样接受所有的 readahead 请求。Miklos Szeredi,FUSE 的维护者,认为我的理由不令人满意,我们不可能说他是不对的。如果 FUSE 不想要 readahead request,它就不应该接受这些请求。试图理解 FUSE 如何能够安全地对 readahead 说 "不",而不必在公共代码中维护这些拥塞跟踪功能,这使我开始了去阅读理解 readahead 的工作,一直看到我能明白这不是简单地将 FUSE 中的 "readahead" 回调修改为返回 0 为止。

mm/readahead.c 导出的 API 的主要部分是两个函数:page_cache_sync_ra()和 page_cache_async_ra()。这个功能也有两个稍微简单一些的接口 page_cache_sync_readahead()和 page_cache_async_readahead(),它们在内核文档中都有很好的说明。

Sync and async

不幸的是,该文档没有明确说明名称中的 "sync "或 "async "是为什么。弄清楚这一点是我的首要任务之一,因此,我向各位读者推荐我的新文档,这是针对 5.18 release 而合并的。它一开始是这么写的:

Readahead 用于在应用程序明确请求数据之前就将内容读入 page cache。Readahead 只尝试读取那些尚未在 page cache 中的 page。如果一个 page 存在但内容已经过期了(not up-to-date),readahead 也不会尝试去读取它。在这种情况下,将会直接调用 ->readpage()。

当应用程序的读取请求(无论是系统调用还是 page fault)发现所请求的 page 不在 page cache 中,或者它在 page cache 中并且设置了 PG_readahead 标志时,Readahead 就会被触发。这个 flag 表明,该 page 是作为前一个 readahead 请求的一部分加载的,现在它已经被访问了,是时候进行下一个 readahead 了。

每个 readahead request 都是部分是同步读取(synchronous read),部分是异步 readahead。

我们在这里先停一下,集中讨论一下这两个术语:同步和异步(sync and async)。Readahead 本质上就是异步的,没有什么东西在等待它完成。相反,一个明确请求的 read 操作,最终肯定是同步的(synchronous),因为在数据到达之前,操作是无法完成的(complete)。这两种模式显然是相关的,应该在同一代码中处理它们。把它们都描述为 "readahead" (这实际上是代码里面强加给我的说法)就不那么合适了。

任何一个熟悉计算机的人,知道一个 "kilobyte" 并不一定就是 1000 个字节,他们同样会知道,我们技术人员经常遵循 Lewis Carroll 在《透过玻璃看世界(Through the Looking Glass)》中的 "胖墩(Humpty Dumpty)" 的做法:

"当我使用一个单词时,"Humpty Dumpty 用相当轻蔑的语气说,"它表达的意思只是我所选择的那个意思,不多也不少。"

我们似乎更多地犯了这个错误,而 readahead 代码当然也不是无辜的。

每个文件系统都可以提供一个 address_space_operations 方法,名为 readahead(),用于开始进行 read。正是基于此,文档中使用了 "readahead request" 这一术语。还有一个名为 readpages() 的地址空间操作(address-space operation),尽管它在 2020 年中期被标记为废弃,并将在 5.18 中被删除了。这两个函数的功能基本相同(它们都对一组 page 发出 read 请求)。较新的 readahead() 接口更加好一些(这个细节超出了本文的范围),但 readpages()无疑有一个更好的名字,因为这就是它们都在做的事情。它们不仅仅是 "read ahead (提前读取)",而且还发出明确要求的 read 操作。

一旦人们意识到 readahead() 的功能只是提交 read 请求,其中一些调用者会等待("sync"),另一些调用者不会等待("async"),那么代码的意图就开始变得更加清晰。因此名字确实很重要。

When readahead can be skipped

回到最初的问题,也就是如何让 FUSE 有机会跳过 readahead,目前就可以看到有一条可行道路了。FUSE 提供的 readahead() 函数必须读取所有在等待读取的 page,但它不需要读取多余的 page。随着引入 readahead() 操作,这里对接口的改进之一是,文件系统可以获得更多的信息了。这些信息包括一个 struct file_ra_state,它包含一个叫做 async_size 的字段。啊哈! 这一定是 readahead 部分的大小。

真的是这样吗?我们能相信这个名字吗?幸运的是,这个 structure 是有文档的,其中 async_size 字段的描述是这样的:"当剩下这么多 page 时开始下一个 readahead"。这是什么意思,它与 "async" 有什么关系?可能多读一些新的文档会有帮助:

每个 readahead request 都部分是同步读取,部分是异步 readahead。这反映在 file_ra_state 结构中,其中的 ->size 成员是总 page 数,而 ->async_size 是异步部分的 page 数。这个异步部分的第一个 page 将设置 PG_readahead 来触发后续 readahead。一旦进行了一系列的连续读取,应该就不需要同步部分了,所有的 readahead 请求都会是完全异步的。

第二句话介绍了 async_size 的含义,是我总结的–以前从来没有出现在任何文档中,而且与代码不完全一致,尽管它与字段名完全对得上。第三句话是关于 PG_readahead 标志的,这与代码和以前的文档相符。

readahead 的一个核心思想是冒一点风险来读取比请求的数据更多的内容。如果这个风险带来了回报,并且这些额外的数据被访问到了,那么这就证明我们值得进一步冒风险来读取更多尚未被请求的数据。当对一个文件进行一次连续读取(sequential read)时,过去的行为细节可以很容易地存储在 struct file_ra_state 中。然而,如果一个应用程序从文件的两个、三个或更多的部分读取,并且这些顺序读取是交错进行的,那么 file_ra_state 就不能跟踪到所有这些状态了。相反,我们依靠已经在 page cache 中的内容。具体来说,我们有一个 flag,PG_readahead,它可以在一个 page 上设置上。这个名字应该用过去式来读:该 page 已经被提前读取过了。在读取该 page 时,我们曾经是冒了风险的,所以,如果这个风险得到了回报,也就是该 page 被访问了,那么就有理由再冒一次险,来多读一些。

这个 flag 应该设置在哪一个 page?readahead 的另一个核心前提是,阅读通常是有顺序的,只有在这个基础上,我们才会冒风险去读取接下来的 page。因此,如果 readahead 的第一个 page 被访问了,那么可以假设是顺序读取(sequential read)。如果后面跳过一些位置的 page 被读取,就不一定足以下结论了。在我看来,PG_readahead 必须被设置在第一个会被投机性地请求的 page 上。这与文档中根据 async_size 的值来设置它的行为是一致的,也与大部分代码一致,尽管有几个地方使用了一些不同的值,但没有给出明确的理由。

那么,这足以让 FUSE 在其 readahead() 处理程序中选择何时跳过 page 了——它根据 async_size 值来判断——但对于采取完全正确的行为来说还不够。当 readahead() 被调用时,这些内存 page 已经被添加到 page cache 中,尽管它们还没有被标记为是最新内容。把它们留在那里而不启动 read,会导致以后试图读取它们时降低效率。如果 readahead()函数选择忽略这些 page,并通过不更新 readahead_control 结构中的一些(private)字段来表明这一点,那么可以通过让调用者从 page cache 中删除 page 来轻松解决。

Oddities

文档中还有一些内容,也有更多奇怪的地方,正是因为需要写文档才注意到的。所以,继续我们之前的话题:

当任何原因出发一次 readahead 时,需要确定三个数字:区域的 start,size,以及 async tail 的 size。

区域的 start 是指在被访问地址处或其之后的第一个 page 地址,这个地址目前还没有被填充到 page cache 中。这可以通过在 page cache 中直接搜索而找到。

async tail 的 size 是从最终确定的 request size 中减去明确请求的 size 来确定的,如果这个大小小于 0 那么就使用 0。注意,当区域的 start 不是正在被访问的 page 时,这种计算是错误的。

我经常把写文档的行为作为发现和 fix bug 的手段,如果准确的文档读起来开始感觉到很难受了,那么更容易的做法是先 fix 代码,这样可以使文档更加流畅。这次,我选择让文档变得拗口一点,部分原因是这里命名又是一个问题了,而我们知道,总是很难找到好名字的。

如前所述,readahead 代码有两个 API 函数,名字有些不太好:page_cache_sync_ra()和 page_cache_async_ra()。这两个函数是针对两个触发条件而调用的,一个是在试图访问某个没有在 cache 里的 page 时,另一个是在访问一个被标记为 PG_readahead 的 page 时。两者都可能会发出一些需要等待结果的读取(同步),以及一些暂时不需要等待结果的读取(非同步)。

这些函数中的每一个都有一个叫做 req_count 的参数,它就是最初 request 中的 page count。其含义是说,我们至少需要这么多页,但如果有条件的话可以多读一些。正是由于 req_count 的含义和使用,才有了上面那一节文档中的干脆地了结。

将 req_count 解释为 "size of the initial request (初始请求的大小)" 是跟名称很相符的,但这并没有明确表明,这就是传递进来的数字。正如我们所见,readahead 代码中的逻辑主要是猜测未来可能需要多少数据。这些函数的一些调用者已经知道正在进行顺序读取,例如使用了 madvise() 系统调用来告知了应用程序的后续意图。在这些情况下,req_count 被设置为一个恰当的较大数字。这并不完全是现在需要的 page 数量,但它是已知想要的 page 数,所以这些 page 是文件系统不应该跳过的,只是因为现在读取它们不方便。

在注意到 request 可能包括明确要求的一些未来才需要的 page 的情况下,req_count 的意思就相当清楚了,但它是如何使用的?在深入探讨这个问题之前,多读一下新的文档,了解一下 readahead 请求的大小是如何计算的,会有帮助:

区域的 size 通常是由之前加载前面的 page 的 readahead 的 size 来决定的。这可以从简单的顺序读取的结构 file_ra_state 中找到,或者从检查多个交错进行的顺序读取的 page cache 状态中发现。具体来说:如果 readahead 是由 PG_readahead 标志触发的,那么前一个 readahead 的 size 被假定为从触发的 page 到下一次 readahead 开始的 page 数量。在这些情况下,前一个 readahead 的 size 会被放大,通常会是翻倍,用来进行新的 readahead,当然详细情况见 get_next_ra_size()的实现。

在 page_cache_sync_ra()的情况下,它是在一个想要的 page 缺失时会被调用的,我们期望 req_count 至少是 1,事实上就是这样。这里会分配一些 page,这取决于 page cache 中有多大的空洞,request 有多大,以及进行多少 readahead 才是合理的。这些 page 被添加到 page cache 中,文件系统的 readahead() 函数就被调用来加载这些 page。

当 page_cache_async_ra() 是因为发现了一个 PG_readahead 标记的 page 而被调用时,情况就不同了。将被读取的这些 page 就不会包括刚刚发现的 page(它已经被 read 了),可能也不包括一些后续 page。代码将在 page cache 中搜索第一个缺失的 page,并考虑从那里开始读取。这些 page 中,有多少会在最初 request 所需的 page 之中?也许是有一些,但肯定不是 req_count 那么多的。

最后一个关于 req_count 和实际读取的 page 之间关系的说法是基于假设的,正如我偶尔会重复说的,这些假设并不总是与代码完全一致。为了确定这一点,我们需要回到代码中,看看在 page_cache_async_ra()的情况下,req_count 究竟是如何使用的。幸运的是,我们在 Git 中拥有多年的开发历史,某些 patch 中带有的文档说明往往比在代码中找到的文档更好。

req_count through the ages

在 Linux 2.6.31 之前,req_count(当时叫 req_size)根本不用于 PG_readahead 所触发的 read。该版本改动开始,它被用来增加 readahead 的 size。以前,它的计算方式是前一个 readahead 的 size,放大 2 或 4 倍。从那时起,它改为前一个 ahead read 的 size 加上 req_count,然后再按比例放大。这一改动的理由是:

确保交错的 readahead size 大于 request size。这也使得 readahead 窗口增长得更快。

不幸的是,没有解释什么样的 workload 会从这种改动中受益。在我看来,使用 req_count 并不是因为它是某种基于理论分析的正确数字,而是因为它是一个容易获得的、看起来合适的 size。因此,这并没有提供多少关于 req_count 应该是什么意思的解释。

然后,在 Linux 4.10 中,req_count 出现了一个新的用途。那个 patch 允许在 readahead 过程中请求的 page 数量至少是原始请求的 size,即使它大于配置的最大 readahead size(只要它不大于设备最大的接受能力就行)。这是一个明确的确认,"readahead" 的一部分实际上是一个同步读取,不受 readahead 限制的约束。它还强调了 req_count 并不简单地是一个 size(也会用于放大),而是确定了一组特定的 page —— 从 request 的起始点开始。因此,当 readahead 的起始点向前移动,越过已经在 page cache 中的任何 page 时,req_count 确实减去那些应该被跳过的 page 数量来变小。只有这样,它才能强调作为原始请求一部分的 page 数量,这些页面仍然需要被读取,这也是超过最大 readahead 大小的理由。

从纯粹的行为学角度来看,这个参数的含义不明确可能并不那么重要。readahead size 的计算是启发式的,没有标准答案,如果误打误撞地加入了几个额外的模糊因素,那么只是得到另一个启发式方法而已。但是从想要理解代码的角度来看,特别是想要在不破坏任何东西的情况下来修改代码,这些细节可能就是相当重要的了。

如前所述,我想让文件系统知道有多少 page 是明确 request 的,有多少是启发式建议的。这需要清楚地了解 req_count 的含义。得到稍微不正确的数据可能不会有很大的损害,但肯定没有什么好处。

The rest of the story

现在我们可以阅读文档的其余部分了,希望它能整合一些已经探讨过的想法。由于它针对的是那些已经普遍熟悉 Linux page cache 的人,因此它包含了一些概念,如 page locking 等,普通读者最好直接跳过这些内容:

如果不能确定前次 read 的 size,则使用 page cache 中它前面的 page 数量来估计上一次 read 的 size。这种估计很容易被随机读取的相邻内容所误导,所以它一般是被忽略掉的,除非它比当前的 request 要大,而且也没有被放大过,除非它在文件的开头。

一般来说,readahead 在文件的最开头就用来加速了,因为从那里开始的读取通常都是连续进行的。各种特殊情况下对 readahead 的 size 还有一些小调整,这些最好通过阅读代码来了解。

上述计算,基于之前的 readahead size,决定了这次 readahead 操作的 size,可能加上了一些请求的 read size。

readahead request 是使用 ->readahead() 这个地址空间操作来发送到文件系统的,mpage_readahead() 就是一个典型的实现。->readahead() 通常应启动对所有 page 的读取,但可能无法在不引起 I/O error 的情况下读取任何一个或所有 page。page cache 读取代码将为任何 ->readahead() 没有提供的页面发出 ->readpage()请求,只有这个请求也报错,才算是最终结果。

->readahead() 一般会重复调用 readahead_page(),从那些为 readahead 准备的 page 中获取每一个 page。它可能会因为以下原因而无法读取一个 page:

  • 调用 readahead_page()的次数不够多,从而导致忽略了一些 page,这在存储路径拥塞的情况下可能是合理的。

  • 可能由于资源不足,未能实际提交对某一特定 page 的 read request,或者

  • 在后续的 request 处理过程中出现错误。

在后两种情况下,这个 page 应该被 unlock,来表明读取尝试已经失败。在第一种情况下,该 page 将由调用者来 unlock。

那些不在这个 request 的最终 async_size 中的 page 应该也被认为是重要内容,并且 ->readahead() 不应该因为拥塞或暂时资源不可用而失败,而是应该等待必要的资源(例如内存或 indexing 信息)可用再进行。在最终的 async_size 中的 page 可能被认为不是那么紧急的,对这些进行 read 时可以接受失败。在这种情况下,最好使用 delete_from_page_cache() 将这些 page 从 page cache 中删除,因为对于那些没有被 readahead_page() 获取的 page,会自动进行删除。这将允许后续的同步 readahead request 来再次尝试读取这些内容。如果它们被留在 page cache 中,那么它们将使用->readpage() 来单独读取。

写文档的目的是为了确保我理解这些代码,并确保其他人能够理解我对这些代码进行修改的动机。我想,它已经达到了这个目的。然而,它也为使代码和代码中使用的命名更加清晰提供了可能性。虽然我希望这样的改进能够发生,但我不确定我什么时候能找到时间。如果有人想推动这项工作,我会抽出时间来帮忙。

这个关于文档写作的元叙事(meta-narrative)的目的是与众不同的。我想强调保持代码的各种细节的连贯 "意图(intent)" 或 "意义(meaning)" 是很困难的,因为它在不同的时间被不同的人修改。意义可能会发生改变,出现越来越多不一致的地方,错误的名词可能会变得根深蒂固。作为一个社区,我们已经发现,最大限度地提高正确性和一致性的最好方法就是拥有能够提醒我们注意到问题的工具。在我们拥有能够阅读文档(包括变量名称含义这个隐含文档信息)并突出显示问题的工具之前,这是我们必须继续自己来完成的工作。

所以请:当你改变代码时,也要修改文档。如果还没有任何文档,那就写一些吧!

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

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

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

ba98f01989ea1a243d35df273aa23d70.png

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值