当触发钩子时,BPF函数可以执行一部分工作。例如,它可以从块中抽取文件偏移,可以通过调用辅助函数来"回收"NVMe提交描述符和I/O缓冲,将描述符重新定位到新的偏移并将其重新发布到NVMe设备提交队列。因此,一个I/O completion可以决定下一个可以提交的I/O,而无需分配额外的CPU或延时。这类函数可以在没有应用层参与的情况下快速遍历结构。
这些函数也可以将块缓冲中的数据拷贝或聚合到本地缓冲中,使得函数可以通过执行选择、投影或聚合来生成返回到应用程序的结果。当函数结束时,它可以指定返回给应用的缓冲。如果函数启动了一个新的I/O,且还没有将结果返回给应用时(例如还没有找到正确的块),则无需返回任何缓冲,这样可以防止将I/O完成上升到应用层。
转换&安全。在Linux中,NVMe驱动无法访问文件系统元数据。如果在一个文件的o1偏移处完成了一个I/O,BPF函数可能会使用偏移量o2来发起下一个I/O。由于NVMe无法判断该偏移量对应哪个物理块,因此o2毫无意义。块可以嵌入物理块地址来避免查询扩展区,但由于没有对这些地址进行限制,BPF函数可以访问设备上的任意块。因此,一个重要的挑战是向NVMe层提供足够的信息,有效安全地将文件偏移映射到文件对应的相应物理块偏移,而不会限制文件系统重映射(选择的)块的能力。
为了简化设计和保证安全性,每个函数仅会使用附加的ioctl用到的文件的偏移量。通过这种方式保证函数不会访问不属于该文件的数据。为了在实现该目的的同时不会减缓文件系统层的调用,且不会限制文件系统层的块分配策略,我们打算在在文件的extents(与ext4文件系统有关)不变时触发该块的回收。我们观察到,很多数据中心应用并不会原地修改块存储上的持久化结构。例如,一旦一个LSM树将SSTable文件写入磁盘,则这些文件就不会变且extents是稳定的[26]。在运行TokuDB的MariaDB上进行24小时的YCSB [25](读取40%,更新40%,插入20%,Zip为0.7)实验,发现索引文件的extents平均每159秒才会进行一次变更,而24小时内只有5个extents的变更没有映射任何块。注意,在这些索引的实现中,每个索引保存在一个文件中,且不会跨多个文件,这种存储方式可以简化我们的设计。
我们通过NVMe层extents的软状态缓存来获得文件extents的相对稳定性。当ioctl首次在存储数据结构的文件上安装功能时,文件的extents会传递到NVMe层。如果存在没有映射到块的文件extents,则文件系统中的新钩子会向NVMe层触发一个无效调用,丢弃正在回收的I/O,并向应用层返回一个错误,必须重新运行ioctl才能重置NVMe层extents,然后才能重新发出带标签的I/O。这是一种笨拙但简单的方法,几乎完全将文件系统和NVMe层进行了解耦,且没有对文件系统块分配策略施加任何限制。当然,为了有效利用缓存,必须减少这类无效调用。
I/O粒度不匹配。当BIO层"分割"一个I/O时(如跨两个不连续的扩展),会在不同的时间产生多个NVMe操作。我们期望尽量减少这类情况,这样就可以像一个普通BIO那样执行I/O,并将缓冲和completion返回给应用。这里,它可以执行BPF函数,并在内核开始下一次"hop"时重启I/O链,这样可以避免额外的内核复杂性。类似地,如果应用需要生成更多的I/O来响应一个I/O completion,我们可以将该completion传播到BIO层,在该层可以分配并将多个I/O提交到NVMe层,以此来避免返回给应用层。
Cache。由于索引的缓存通常由应用程序来管理[14, 23, 26],我们假设BPF遍历时不会直接与缓冲区缓存进行交互,应用管理缓存并与遍历保持同步。缓存驱逐和管理越来越多地以具有应用程序意义的对象粒度(如,单个数据记录)为单位而非以整个分页为单位进行处理。我们的方案会符合这些模型,BPF可以将特定的对象返回给应用(而不是分页),这样应用就可以采纳自己的缓存策略。
并发性和公平性。从文件系统发起的写可能只会反应在缓冲区缓存中,BPF遍历不可见。可以通过锁定来解决这个问题,但从NVMe驱动程序内部来管理应用程序级别的锁定可能会很昂贵。因此,需要精心设计需要细粒度锁定的数据结构(例如B +树中的锁定耦合[28])。
为了避免读/写冲突,一开始我们计划使用不可变的数据结构(至少是一段时间不可变)。幸运的是,很多数据结构都具有这种特性,包括LSM SSTable文件(不可变[26,39]),以及磁盘B树,它们不会原地动态更新,而是会分批处理[14]。此外,由于锁的复杂性,我们计划一开始只支持只读的BPF遍历。