尘埃落定后,事情将再也不会相同。是的,我说的是Linux。
在我撰写本文时,由于COVID-19,世界上大多数地区处于锁定状态。很难说结束后的样子(结束了,对吧?),但是有一点可以肯定:世界不再一样。感觉很奇怪:好像我们在一个星球上结束了2019年,在另一个星球上开始了2020年。
尽管我们都担心工作,经济和医疗保健系统,但是发生了巨大变化的另一件事可能已经引起您的注意:Linux内核。
那是因为不时出现了一些东西,用革命代替了进化。黑天鹅。诸如引入汽车之类的快乐事情,永远改变了世界各地城市的景观。有时候,它会变得不那么快乐,例如9/11或我们目前的宿敌COVID-19。
我将把发生在Linux上的事情放在欢乐的水桶中。但这是一场确定无疑的革命,大多数人还没有注意到。这是因为有两个激动人心的新界面:eBPF(简称BPF)和io_uring,后者在2019年添加到Linux中,并且仍处于积极开发中。这些接口可能看起来是进化的,但是就它们(我们敢打赌)的意义而言,它们是革命性的,它们将完全改变应用程序的工作方式并考虑Linux内核。
在本文中,我们将探讨如何使这些接口变得如此特殊且具有强大的转换能力,并通过io_uring深入研究我们在ScyllaDB的经验。
在您逐渐了解和喜爱的Linux的早期,内核提供了以下系统调用来处理文件描述符,无论它们是存储文件还是套接字:
这些系统调用就是我们所谓的阻止系统调用。当您的代码调用它们时,它将进入休眠状态,并从处理器中取出,直到操作完成。也许数据位于Linux页面缓存中的文件中,在这种情况下,它实际上会立即返回,或者可能需要通过TCP连接通过网络或从HDD读取数据。
每个现代程序员都知道这有什么问题:随着设备继续变得越来越快,程序越来越复杂,除了最简单的事情以外,阻塞对于所有其他人来说都是不可取的。新的系统调用,例如select()和poll()及其更现代的对等形式epoll()起作用:一旦被调用,它们将返回准备好的文件描述符列表。换句话说,对他们的阅读或书写不会受到阻碍。应用程序现在可以确保不会发生阻塞。
我们无法解释原因,但是这种准备机制实际上仅适用于网络套接字和管道,以至于epoll()甚至不接受存储文件。对于存储I / O,传统上,阻塞问题是通过线程池解决的:执行的主线程将实际的I / O分派给帮助线程,这些帮助线程将代表主线程阻塞并执行操作。
随着时间的流逝,Linux变得更加灵活和强大:事实证明,数据库软件可能不想使用Linux页面缓存。然后就可以打开一个文件,并指定我们要直接访问该设备。直接访问(通常称为直接I / O或O_DIRECT标志)要求应用程序管理自己的缓存-数据库可能仍要执行此操作,但由于应用程序缓冲区可能是零拷贝的I / O,因此可以直接发送到存储设备并从中填充。
随着存储设备变得越来越快,上下文切换到辅助线程变得越来越不可取。当今市场上的某些设备(例如Intel Optane系列)的延迟都在单位微秒范围内(与上下文切换的幅度相同)。这样想:每个上下文切换都是错过分配I / O的机会。
使用Linux 2.6,内核获得了异步I / O(简称linux-aio)接口。 Linux上的异步I / O从表面上看很简单:您可以使用io_submit系统调用来提交I / O,稍后再调用io_getevents并接收准备就绪的事件。最近,Linux甚至可以将epoll()添加到组合中:现在,您不仅可以提交存储I / O工作,还可以提交意图以了解套接字(或管道)是可读还是可写的。
Linux-aio是潜在的游戏规则改变者。它允许程序员使其代码完全异步。但是由于它的发展方式,它没有达到这些期望。为了尝试理解原因,让我们听听Torvalds先生本人的乐观态度,以回应有人试图扩展界面以支持异步打开文件的情况:
AIO是一种糟糕的临时设计,其主要借口是“其他人,没有那么天赋的人进行了这种设计,而我们为了实现兼容性而实施它是因为很少有人喜欢的数据库人实际上会使用它”。
首先,作为数据库人员,我们想借此机会向Linus道歉,因为我们缺乏品味。但也要解释他为什么是对的。 Linux AIO确实存在许多问题和局限性:
该接口并非设计为可扩展的。尽管有可能(我们确实对其进行了扩展),但每个新添加的内容都是复杂的。
尽管从技术上说该接口是非阻塞的,但是有很多原因会导致其阻塞,通常是无法预测的。
我们可以清楚地看到它的进化方面:接口有机地增长,添加了新接口以与新接口一起运行。解决套接字阻塞的问题是通过一个接口来测试准备情况。存储I / O获得了量身定制的异步接口,可以与当前真正需要它的那种应用程序一起工作,而没有其他要求。那就是事物的本质。直到…io_uring出现。
io_uring是Jens Axboe的创意,Jens Axboe是一位经验丰富的内核开发人员,已经参与Linux I / O堆栈已有一段时间了。邮件列表考古学告诉我们,这项工作的动机很简单:随着设备变得非常快,中断驱动的工作不再像轮询完成那样高效-这是面向性能的I / O系统架构的一个常见主题。
但是随着工作的发展,它发展成为一个完全不同的接口,它是从头开始构思的,以允许完全异步的操作。它的基本操作原理接近linux-aio:有一个将工作推送到内核的接口,另一个是检索已完成工作的接口。
通过设计,接口被设计为真正异步的。使用正确的标志集,它将永远不会在系统调用上下文本身中启动任何工作,而只会将工作排队。这样可以保证应用程序永远不会阻塞。
它可以与任何类型的I / O配合使用:它们是否是缓存文件,直接访问文件甚至阻止套接字都没关系。没错:由于其按设计异步的性质,因此不需要轮询+读/写来处理套接字。一个提交阻塞读取,一旦准备就绪,它将显示在完成环中。
它具有灵活性和可扩展性:新操作码的添加速度使我们相信,不久以后它将可以重新实现每个Linux系统调用。
io_uring接口通过两个主要数据结构工作:提交队列条目(sqe)和完成队列条目(cqe)。这些结构的实例位于内核和应用程序之间的共享内存单生产者单消费者环形缓冲区中。
该应用程序异步将sqes添加到队列(可能很多),然后告诉内核有工作要做。内核完成其工作,并在工作准备就绪时将结果发布到cqe环中。这还具有额外的优势,即系统调用现在已批处理。还记得熔毁吗?当时,我写了有关它对Scylla NoSQL数据库影响不大的文章,因为我们将通过aio批处理I / O系统调用。除了现在,我们可以批处理的不仅仅是存储I / O系统调用,而且此功能还可以用于任何应用程序。
无论何时要检查工作是否准备就绪,应用程序都只会查看cqe环形缓冲区,并在准备就绪时使用条目。无需转到内核使用这些条目。
以下是io_uring支持的一些操作:读取,写入,发送,接收,openat,stat以及更专业的方法(例如fallocate)。
这不是进化步骤。尽管io_uring与aio稍微相似,但是它的可扩展性和体系结构具有破坏性:它将异步操作的功能带给任何人,而不是将其限制在特定的数据库应用程序中。
我们的首席技术官Avi Kivity在Core C ++ 2019活动中提出了异步的理由。底线是这个;在现代的多核,多CPU设备中,CPU本身现在基本上是一个网络,所有CPU之间的相互通信是另一个网络,而对磁盘I / O的调用实际上是另一个网络。网络编程异步完成是有充分的理由的,对于您自己的应用程序开发,也应该考虑到这一点。
它从根本上改变了Linux应用程序的设计方式:代替了在需要时发出系统调用的代码流,而不得不考虑文件是否准备就绪,它们自然地变成了一个事件循环,不断地向文件添加内容。共享缓冲区,处理之前完成的条目,冲洗和重复。
那么,那是什么样的呢?下面的代码块是一个示例,说明如何在io_uring接口下一次将读取的整个数组分配给多个文件描述符:
稍后,我们可以以事件循环的方式检查哪些读取准备就绪并进行处理。最好的部分是,由于其共享内存接口,不需要系统调用即可消耗这些事件。用户只需要小心地告诉io_uring界面事件已被使用。
这个简化的示例仅适用于只读,但是很容易看出我们如何通过该统一接口将各种操作一起批处理。队列模式也可以很好地与之配合使用:您可以只在一端对操作进行排队,在另一端分派和使用已准备好的东西。
除了界面的一致性和可扩展性之外,io_uring还为专用用例提供了许多高级功能。这里是其中的一些:
文件注册:每次对文件描述符发出操作时,内核都必须花费一些周期将文件描述符映射到其内部表示形式。要对同一文件进行重复操作,可以使用io_uring预注册这些文件并保存在查找中。
缓冲区注册:类似于文件注册,内核必须映射和取消映射直接I / O的内存区域。 io_uring如果可以重用缓冲区,则可以预先注册这些区域。
轮询环:对于非常快的设备,处理中断的成本很高。 io_uring允许用户关闭这些中断并通过轮询消耗所有可用事件。
链接的操作:允许用户发送相互依赖的两个操作。它们是在同一时间分派的,但是第二个操作仅在第一个操作返回时才开始。
与界面的其他区域一样,新功能也正在迅速添加。
正如我们所说,io_uring接口在很大程度上受现代硬件需求的驱动。因此,我们期望性能有所提高。他们在这里吗?
对于像ScyllaDB这样的linux-aio用户,预计收益很少,主要集中在某些特定的工作负载上,并且主要来自缓冲区和文件注册以及轮询环等高级功能。这是因为io_uring和linux-aio并没有什么不同,正如我们希望在本文中明确指出的那样:io_uring首先是将linux-aio的所有出色功能带给了大众。
我们使用了著名的fio实用程序来评估4个不同的接口:同步读取,posix-aio(作为线程池实现),linux-aio和io_uring。在第一个测试中,我们希望所有读取都命中存储,而不使用操作系统页面缓存。然后,我们使用Direct I / O标志运行测试,这应该是linux-aio的基础。该测试是在应能够以3.5M IOPS读取的NVMe存储上进行的。我们使用8个CPU运行72个作业,每个作业在iodepth为8的四个文件中发出随机读取。这确保了CPU在所有后端均处于饱和状态,并且将成为基准测试的限制因素。这使我们可以看到每个接口处于饱和状态。请注意,如果有足够的CPU,则所有接口都将能够在某个时候达到完整的磁盘带宽。这样的测试不会告诉我们太多。
表1:使用直接I / O在CPU利用率为100%时1kB随机读取的性能比较,其中永不缓存数据:同步读取,posix-aio(使用线程池),linux-aio和基本的io_uring以及io_uring使用其高级功能。
我们可以看到,正如我们期望的那样,io_uring比linux-aio快一点,但是没有什么革命性的。使用缓冲区和文件注册之类的高级功能(增强了io_uring的功能)为我们带来了额外的提升,这很好,但是没有理由证明更改整个应用程序是合理的,除非您是一个数据库,试图挤出硬件可以进行的所有操作。 io_uring和linux-aio的速度大约是同步读取接口的两倍,而同步读取接口的速度是posix-aio所采用的线程池方法的两倍,而后者最初是令人惊讶的。
如果我们看一下表1中的上下文切换列,则posix-aio最慢的原因很容易理解,即系统调用将阻塞的每个事件都意味着一个附加的上下文切换。并且在此测试中,所有读取都将被阻止。对于posix-aio来说情况更糟。现在,不仅在内核和应用程序之间存在上下文切换以进行阻塞,而且应用程序中的各个线程也必须进出CPU。
但是,当我们从量表的另一端看时,io_uring的真正力量可以理解。在第二项测试中,我们将文件中的数据预加载到所有内存中,并进行相同的随机读取。一切都与之前的测试相同,除了我们现在使用缓冲的I / O并期望同步接口永远不会阻塞-所有结果都来自操作系统页面缓存,而没有结果来自存储。
表2:各种后端之间的比较。测试问题使用带有预加载文件和热缓存的缓冲I / O文件进行1kB随机读取。该测试在100%CPU上运行。
在这种情况下,我们预计同步读取和io_uring接口之间不会有太多差异,因为不会阻塞任何读取。这确实是我们所看到的。但是请注意,在现实生活中,不仅要始终读取所有内容,还会有区别,因为io_uring支持在同一系统调用中分批处理许多操作。
但是,其他两个接口会遭受较大的损失:posix-aio接口中的大量上下文切换由于其线程池而完全破坏了饱和时的基准性能。根本不是为缓冲I / O设计的Linux-aio在与缓冲I / O文件一起使用时实际上变成了同步接口。因此,现在我们付出了异步接口的代价—必须在分派和使用阶段拆分操作,而没有意识到任何好处。
真正的应用程序将处于中间位置:一些阻塞,一些非阻塞操作。除了现在,不再需要担心会发生什么。 io_uring接口在任何情况下都可以正常运行。当操作不会阻塞时,它不会造成任何损失;当操作会阻塞时,它是完全异步的,并且不依赖线程和昂贵的上下文切换来实现其异步行为。更好的是:尽管我们的示例着重于随机读取,但io_uring将适用于大量操作码。它可以打开和关闭文件,设置计时器,在网络套接字之间来回传输数据。全部使用相同的界面。
由于Scylla在扩展之前最多可扩展到服务器容量的100%,因此它完全依赖于Direct I / O,并且从一开始就一直使用linux-aio。
在进行io_uring的过程中,我们最初看到在某些工作负载下,结果可提高50%。经过仔细检查,可以清楚地看出这是因为我们对linux-aio的实现不尽人意。在我看来,这突出了绩效通常一个未被重视的方面:实现这一目标有多容易。当我们根据发现的缺陷修复了linux-aio实现时,性能差异几乎消失了。但这花费了很多精力,以修复我们已经使用多年的界面。对于io_uring来说,做到这一点是微不足道的。
但是,除此之外,io_uring不仅可以用于文件I / O,它还可以用于更多的事情(如本文中多次提到的那样)。它带有专用的高性能接口,例如缓冲区注册,文件注册和无中断的轮询接口。
使用io_uring的高级功能时,我们确实看到了性能差异:观察到从Intel Optane设备中的单个CPU读取512字节有效负载时的速度提高了5%,这与表1和表2的结果一致。这听起来并不多,对于试图充分利用硬件的数据库而言,这非常有价值。
linux-aio: 吞吐率:330 MB / s 平均平均得分:1549 纬度分位数= 0.5:1547微秒 纬度分位数= 0.95:1694微秒 纬度分位数= 0.99:1703微秒 纬度分位数= 0.999:1950微秒 最大纬度:2177微秒
io_uring,使用缓冲区和文件注册以及轮询: 吞吐量:346 MB / s 时空平均值:1470微秒 纬度分位数= 0.5:1468微秒 纬度分位数= 0.95:1558微秒 纬度分位数= 0.99:1613 usc 纬度分位数= 0.999:1674 USEC 最大纬度:1829年
从单个CPU从Intel Optane设备读取512字节缓冲区。并行处理1000个进行中的请求。基本接口的linux-aio io_uring之间几乎没有什么区别。但是当使用高级功能时,可以看到5%的差异。
io_uring界面正在迅速发展。对于它的许多功能,它计划依赖Linux内核中另一项惊天动地的新功能:eBPF。
eBPF代表扩展的伯克利包过滤器。还记得iptables吗?顾名思义,原始BPF允许用户指定规则,这些规则将在网络数据包流经网络时应用于网络数据包。这已经是Linux的一部分了。
但是,当BPF扩展时,它允许用户以安全的方式在内核的各个执行点中添加内核执行的代码,而不仅仅是在网络代码中。
我建议读者在这里停下来再读一遍,以充分理解其含义:您现在可以在Linux内核中执行任意代码。基本上可以做任何您想做的事。
eBPF程序具有类型,这些类型确定它们将附加到的内容。换句话说,哪些事件将触发它们的执行。旧的数据包过滤用例仍然存在。这是BPF_PROG_TYPE_SOCKET_FILTER类型的程序。
但是在过去的十年左右的时间里,Linux一直在积累用于性能分析的庞大基础结构,该结构几乎在内核的所有位置都添加了跟踪点和探测点。例如,您可以将跟踪点附加到syscall(任何syscall)入口或返回点。通过BPF_PROG_TYPE_KPROBE和BPF_PROG_TYPE_TRACEPOINT类型,您可以在任何地方附加bpf程序。
最明显的用例是性能分析和监视。许多这些工具都通过密件抄送维护
......