io_uring是2019年内核首次引入的高性能异步I/O框架,能显着加速I/O密集型应用的性能。但如果你的应用已经在使用传统LinuxAIO了,并且使用方式恰当,那io_uring并不会带来太大的性能提升——根据测试,即便打开高级特性,也只有5%。除非你真的需要这5%的额外性能,否则**切换**成io_uring代价可能也挺大,因为要重写应用来适配io_uring(或者让依赖的平台或框架去适配,总之需要改代码)。
既然性能跟传统AIO差不多,那为什么还称io_uring为革命性技术呢?
1、它首先和最大的贡献在于:统一了Linux异步I/O框架,
LinuxAIO只支持directI/O模式的存储文件(storagefile),而且主要用在数据库这一细分领域;
io_uring支持存储文件和网络文件(networksockets),也支持更多的异步系统调用(accept/openat/stat/),而非仅限于read/write系统调用。
2、在设计上是真正的异步I/O,作为对比,LinuxAIO虽然也是异步的,但仍然可能会阻塞,某些情况下的行为也无法预测;
3、**灵活性和可扩展性**非常好,甚至能基于io_uring重写所有系统调用,而LinuxAIO设计时就没考虑扩展性。
eBPF也算是异步框架(事件驱动),但与io_uring没有本质联系,二者属于不同子系统,并且在模型上有一个本质区别:
eBPF对用户是透明的,只需升级内核(到合适的版本),应用程序无需任何改造;
io_uring提供了新的系统调用和用户空间API,因此需要应用程序做改造。
eBPF作为动态跟踪工具,能够更方便地排查和观测io_uring等模块在执行层面的具体问题。
本文介绍Linux异步I/O的发展历史,io_uring的原理和功能,并给出了一些**程序示例**和**性能压测**结果(我们在5.10内核做了类似测试)。
以下是译文。
很多人可能还没意识到,Linux内核在过去几年已经发生了一场革命。这场革命源于**两个激动人心的新接口**的引入:eBPF和io_uring。我们认为,二者将会完全改变应用与内核交互的方式,以及应用开发者思考和看待内核的方式。
本文介绍io_uring(我们在ScyllaDB中有io_uring的深入使用经验),并略微提及一下eBPF。
1LinuxI/O系统调用演进1.1基于fd的阻塞式I/O:read()/write()作为大家最熟悉的读写方式,Linux内核提供了基于文件描述符的系统调用,这些描述符指向的可能是存储文件(storagefile),也可能是networksockets:
ssize_tread(intfd,void*buf,size_tcount);ssize_twrite(intfd,constvoid*buf,size_tcount);
二者称为阻塞式系统调用(blockingsystemcalls),因为程序调用这些函数时会进入sleep状态,然后被调度出去(让出处理器),直到I/O操作完成:
如果数据在文件中,并且文件内容已经缓存在pagecache中,调用会立即返回;
如果数据在另一台机器上,就需要通过网络(例如TCP)获取,会阻塞一段时间;
如果数据在硬盘上,也会阻塞一段时间。
但很容易想到,随着存储设备越来越快,程序越来越复杂,阻塞式(blocking)已经这种最简单的方式已经不适用了。
1.2非阻塞式I/O:select()/poll()/epoll()阻塞式之后,出现了一些新的、非阻塞的系统调用,例如select()、poll()以及更新的epoll()。应用程序在调用这些函数读写时不会阻塞,而是立即返回,返回的是一个已经ready的文件描述符列表。
但这种方式存在一个致命缺点:只支持networksockets和pipes——epoll()甚至连storagefiles都不支持。
1.3线程池方式对于storageI/O,经典的解决思路是threadpool[5]:主线程将I/O分发给worker线程,后者代替主线程进行阻塞式读写,主线程不会阻塞。
这种方式的问题是线程上下文切换开销可能非常大,后面性能压测会看到。
1.4DirectI/O(数据库软件):绕过pagecache随后出现了更加灵活和强大的方式:数据库软件(databasesoftware)有时并不想使用操作系统的pagecache[6],而是希望打开一个文件后,直接从设备读写这个文件(directaccesstothedevice)。这种方式称为直接访问(directaccess)或直接I/O(directI/O),
需要指定O_DIRECTflag;
需要应用自己管理自己的缓存——这正是数据库软件所希望的;
是zero-copyI/O,因为应用的缓冲数据直接发送到设备,或者直接从设备读取。
1.5异步IO(AIO)前面提到,随着存储设备越来越快,主线程和worker线性之间的上下文切换开销占比越来越高。现在市场上的一些设备,例如IntelOptane[7],延迟已经低到和上下文切换一个量级(微秒us)。换个方式描述,更能让我们感受到这种开销:上下文每切换一次,我们就少一次dispatchI/O的机会。
因此,内核引入了异步I/O(asynchronousI/O)接口,方便起见,本文简写为linux-aio。AIO**原理**是很简单的:
用户通过io_submit()提交I/O请求,
过一会再调用io_getevents()来检查哪些events已经ready了。
使程序员能编写完全异步的代码。
近期,LinuxAIO甚至支持了[8]epoll():也就是说不仅能提交storageI/O请求,还能提交网络I/O请求。照这样发展下去,linux-aio似乎能成为一个王者。但由于它糟糕的演进之路,这个愿望几乎不可能实现了。我们从Linus标志性的激烈言辞中就能略窥一斑:
首先,作为数据库从业人员,我们想借此机会为我们的没品(lackoftaste)向Linus道歉。但更重要的是,我们要进一步解释一下为什么Linus是对的:LinuxAIO确实问题缠身,
只支持O_DIRECT文件,因此对常规的非数据库应用(normal,non-databaseapplications)几乎是无用的;
接口在设计时并未考虑扩展性。虽然可以扩展——我们也确实这么做了——但每加一个东西都相当复杂;
虽然从技术上说接口是非阻塞的,但实际上有很多可能的原因都会导致它阻塞[10],而且引发的方式难以预料。
1.6小结以上可以清晰地看出LinuxI/O的演进:
最开始是同步(阻塞式)系统调用;
然后随着实际需求和具体场景,不断加入新的异步接口,还要保持与老接口的兼容和协同工作。
另外也看到,在非阻塞式读写的问题上并没有形成统一方案:
Networksocket领域:添加一个异步接口,然后去轮询(poll)请求是否完成(readiness);
StorageI/O领域:只针对某一细分领域(数据库)在某一特定时期的需求,添加了一个定制版的异步接口。
这就是LinuxI/O的演进历史——只着眼当前,出现一个问题就引入一种设计,而并没有多少前瞻性——直到io_uring的出现。
io_uring新起之秀的linuxio模式,是如何媲美epoll的
linux下的epoll实战揭秘——支撑亿级IO的底层基石
学习地址:
需要C/C++Linux服务器架构师学习资料加qun812855908获取(资料包括C/C++,Linux,golang技术,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK,ffmpeg等),免费分享
io_uring来自资深内核开发者JensAxboe的想法,他在LinuxI/Ostack领域颇有研究。从最早的patchaio:supportforIOpolling[11]可以看出,这项工作始于一个很简单的观察:随着设备越来越快,中断驱动(interrupt-driven)模式效率已经低于轮询模式(pollingforcompletions)——这也是高性能领域最常见的主题之一。
io_uring的基本逻辑与linux-aio是类似的:提供两个接口,一个将I/O请求提交到内核,一个从内核接收完成事件。
但随着开发深入,它逐渐变成了一个完全不同的接口:设计者开始从源头思考如何支持完全异步的操作。
2.1与LinuxAIO的不同io_uring与linux-aio有着本质的不同:
在设计上是真正异步的(trulyasynchronous)。只要设置了合适的flag,它在系统调用上下文中就只是将请求放入队列,不会做其他任何额外的事情,保证了应用永远不会阻塞。
支持任何类型的I/O:cachedfiles、direct-accessfiles甚至blockingsockets。由于设计上就是异步的(async-by-designnature),因此无需poll+read/write来处理sockets。只需提交一个阻塞式读(blockingread),请求完成之后,就会出现在completionring。
灵活、可扩展:基于io_uring甚至能重写(re-implement)Linux的每个系统调用。
2.2原理及核心数据结构:SQ/CQ/SQE/CQE每个io_uring实例都有两个环形队列(ring),在内核和应用程序之间共享:
提交队列:submissionqueue(SQ)
完成队列:completionqueue(CQ)
这两个队列:
都是单生产者、单消费者,size是2的幂次;
提供无锁接口(lock-lessaccessinterface),内部使用**内存屏障**做同步(coordinatedwithmemorybarriers)。
使用方式:
请求
应用创建SQentries(SQE),更新SQtail;
内核消费SQE,更新SQhead。
完成
内核为完成的一个或多个请求创建CQentries(CQE),更新CQtail;
应用消费CQE,更新CQhead。
完成事件(completionevents)可能以任意顺序到达,到总是与特定的SQE相关联的。
消费CQE过程无需切换到内核态。
2.3带来的好处io_uring这种请求方式还有一个好处是:原来需要多次系统调用(读或写),现在变成批处理一次提交。
还记得Meltdown漏洞吗?当时我还写了一篇文章[12]解释为什么我们的ScyllaNoSQL数据库受影响很小:aio已经将我们的I/O系统调用批处理化了。
io_uring将这种批处理能力带给了storageI/O系统调用之外的其他一些系统调用,包括:
read
write
s
recv
accept
openat
stat
专用的一些系统调用,例如fallocate
此外,io_uring使异步I/O的使用场景也不再仅限于数据库应用,普通的非数据库应用也能用。这一点值得重复一遍:
我们的CTOAviKivity在theCoreC++2019event上有一次关于async的分享[13]。核心点包括:从延迟上来说,
现代多核、多CPU设备,其内部本身就是一个基础网络;
**CPU之间**是另一个网络;
**CPU和磁盘I/O之间**又是一个网络。
因此网络编程采用异步是明智的,而现在开发自己的应用也应该考虑异步。这从根本上改变了Linux应用的设计方式:
之前都是一段顺序代码流,需要系统调用时才执行系统调用,
现在需要思考一个文件是否ready,因而自然地引入event-loop,不断通过共享buffer提交请求和接收结果。
2.4三种工作模式io_uring实例可工作在三种模式:
中断驱动模式(interruptdriven)默认模式。可通过io_uring_enter()提交I/O请求,然后直接检查CQ状态判断是否完成。
轮询模式(polled)Busy-waitingforanI/Ocompletion,而不是通过异步IRQ(InterruptRequest)接收通知。这种模式需要文件系统(如果有)和块设备(blockdevice)支持轮询功能。相比中断驱动方式,这种方式延迟更低(连系统调用都省了[14]),但可能会消耗更多CPU资源。目前,只有指定了O_DIRECTflag打开的文件描述符,才能使用这种模式。当一个读或写请求提交给轮询上下文(polledcontext)之后,应用(application)必须调用io_uring_enter()来轮询CQ队列,判断请求是否已经完成。对一个io_uring实例来说,不支持混合使用轮询和非轮询模式。
内核轮询模式(kernelpolled)
这种模式中,会创建一个内核线程(kernelthread)来执行SQ的轮询工作。
使用这种模式的io_uring实例,应用无需切到到内核态就能触发(issue)I/O操作。通过SQ来提交SQE,以及监控CQ的完成状态,应用无需任何系统调用,就能提交和收割I/O(submitandreapI/Os)。
如果内核线程的空闲时间超过了用户的配置值,它会通知应用,然后进入idle状态。这种情况下,应用必须调用io_uring_enter()来唤醒内核线程。如果I/O一直很繁忙,内核线性是不会sleep的。
2.5io_uring系统调用API有三个:
io_uring_setup(2)
io_uring_register(2)
io_uring_enter(2)
下面展开介绍。完整文档见manpage[15]。
2.5.1io_uring_setup()执行异步I/O需要先设置上下文:
intio_uring_setup(u32entries,structio_uring_params*p);
这个系统调用
创建一个SQ和一个CQ,
queuesize至少entries个元素,
返回一个文件描述符,随后用于在这个io_uring实例上执行操作。
SQ和CQ在应用和内核之间共享,避免了在初始化和完成I/O时(initiatingandcompletingI/O)拷贝数据。
参数p:
应用用来配置io_uring,
内核返回的SQ/CQ配置信息也通过它带回来。
io_uring_setup()成功时返回一个文件描述符(fd)。应用随后可以将这个fd传给mmap(2)系统调用,来mapthesubmissionandcompletionqueues或者传给totheio_uring_register()orio_uring_enter()systemcalls.
2.5.2io_uring_register()注册用于异步I/O的文件或用户缓冲区(filesoruserbuffers):
intio_uring_register(unsignedintfd,unsignedintopcode,void*arg,unsignedintnr_args);
注册文件或用户缓冲区,使内核能长时间持有对该文件在内核内部的数据结构引用(internalkerneldatastructuresassociatedwiththefiles),或创建应用内存的长期映射(longtermmappingsofapplicationmemoryassociatedwiththebuffers),这个操作只会在注册时执行一次,而不是每个I/O请求都会处理,因此减少了per-I/Ooverhead。
注册的缓冲区(buffer)性质Registeredbuffers将会被锁定在内存中(belockedinmemory),并计入用户的RLIMIT_MEMLOCK资源限制。
此外,每个buffer有1GB的大小限制。
当前,buffers必须是匿名、非文件后端的内存(anonymous,non-file-backedmemory),例如malloc(3)ormmap(2)withtheMAP_ANONYMOUSflagset返回的内存。
Hugepages也是支持的。整个hugepage都会被pin到内核,即使只用到了其中一部分。
已经注册的buffer无法调整大小,想调整只能先unregister,再重新register一个新的。
通过eventfd()订阅completion事件可以用eventfd(2)订阅io_uring实例的completionevents。将eventfd描述符通过这个系统调用注册就行了。
2.5.3io_uring_enter()intio_uring_enter(unsignedintfd,unsignedintto_submit,unsignedintmin_complete,unsignedintflags,sigset_t*sig);
这个系统调用用于初始化和完成(initiateandcomplete)I/O,使用共享的SQ和CQ。单次调用同时执行:
提交新的I/O请求
等待I/O完成
参数:
fd是io_uring_setup()返回的文件描述符;
to_submit指定了SQ中提交的I/O数量;
依据不同模式:
默认模式,如果指定了min_complete,会等待这个数量的I/O事件完成再返回;
如果io_uring是polling模式,这个参数表示:
0:要求内核返回当前以及完成的所有events,无阻塞;
非零:如果有事件完成,内核仍然立即返回;如果没有完成事件,内核会poll,等待指定的次数完成,或者这个进程的时间片用完。
注意:对于interruptdrivenI/O,应用无需进入内核就能检查CQ的eventcompletions。
io_uring_enter()支持很多操作,包括:
Open,close,andstatfiles
Readandwriteintomultiplebuffersorpre-mappedbuffers
SocketI/Ooperations
Synchronizefilestate
Asynchronouslymonitorasetoffiledescriptors
Createatimeoutlinkedtoaspecificoperationinthering
Attempttocancelanoperationthatiscurrentlyinflight
CreateI/Ochains
Orderedexecutionwithinachain
Parallelexecutionofmultiplechains
当这个系统调用返回时,表示一定数量的SEQ已经被消费和提交了,此时可以安全的重用队列中的SEQ。此时IO提交有可能还停留在异步上下文中,即实际上SQE可能还没有被提交——不过用户不用关心这些细节——当随后内核需要使用某个特定的SQE时,它已经复制了一份。
2.6高级特性io_uring提供了一些用于特殊场景的高级特性:
Fileregistration(文件注册):每次发起一个指定文件描述的操作,内核都需要花费一些时钟周期(cycles)将文件描述符映射到内部表示。对于那些**针对同一文件进行重复操作**的场景,io_uring支持提前注册这些文件,后面直接查找就行了。
Bufferregistration(缓冲区注册):与fileregistration类似,directI/O场景中,内核需要map/unmapmemoryareas。io_uring支持提前注册这些缓冲区(buffers)。
Pollring(轮询环形缓冲区):对于非常快是设备,处理中断的开销是比较大的。io_uring允许用户关闭中断,使用轮询模式。前面“三种工作模式”小节也介绍到了这一点。
Linkedoperations(链接操作):允许用户发送串联的请求。这两个请求同时提交,但后面的会等前面的处理完才开始执行。
2.7用户空间库liburing`liburing`[16]提供了一个简单的高层API,可用于一些基本场景,应用程序避免了直接使用更底层的系统调用。此外,这个API还避免了一些重复操作的代码,如设置io_uring实例。
举个例子,在io_uring_setup()的manpage描述中,调用这个系统调用获得一个ring文件描述符之后,应用必须调用mmap()来这样的逻辑需要一段略长的代码,而用liburing的话,下面的函数已经将上述流程封装好了:
intio_uring_queue_init(unsignedentries,structio_uring*ring,unsignedflags);
下一节来看两个例子基于liburing的例子。
3基于liburing的示例应用编译:
$gitclone$/$cdliburing$lsexamples/io_uring-cpio__uring-testio_$make-j4$./examples/io_uring-testfileSubmitted=4,completed=4,bytes=16384$./examples/link-cpin-fileout-file3.1io_uring-test
这个程序使用4个SQE,从输入文件中读取最多16KB数据。
源码及注释为方便看清主要逻辑,忽略了一些错误处理代码,完整代码见io_[17]。
/*SPDX-License-Identifier:MIT*//**Simpleappthatdemonstrateshowtosetupanio_uringinterface,*submitandcompleteIOagainstit,andthentearitdown.**gcc-Wall-O2-D_GNU_SOURCE-oio_uring-testio_*/defineQD4//io_uring队列长度intmain(intargc,char*argv[]){inti,fd,ping,done;void*buf;//1.初始化一个io_uring实例structio_uringring;ret=io_uring_queue_init(QD,//队列长度ring,//io_uring实例0);//flags,0表示默认配置,例如使用中断驱动模式//2.打开输入文件,注意这里指定了O_DIRECTflag,内核轮询模式需要这个flag,见前面介绍fd=open(argv[1],O_RDONLY|O_DIRECT);structstatsb;fstat(fd,sb);//获取文件信息,例如文件长度,后面会用到//3.初始化4个读缓冲区ssize_tfsize=0;//程序的最大读取长度structiovec*iovecs=calloc(QD,sizeof(structiovec));for(i=0;iQD;i++){if(posix_memalign(buf,4096,4096))return1;iovecs[i].iov_base=buf;//起始地址iovecs[i].iov_len=4096;//缓冲区大小fsize+=4096;}//4.依次准备4个SQE读请求,指定将随后读入的数据写入iovecsstructio_uring_sqe*sqe;offset=0;i=0;do{sqe=io_uring_get_sqe(ring);//获取可用SQEio_uring_prep_readv(sqe,//用这个SQE准备一个待提交的read操作fd,//从fd打开的文件中读取数据iovecs[i],//iovec地址,读到的数据写入iovec缓冲区1,//iovec数量offset);//读取操作的起始地址偏移量offset+=iovecs[i].iov_len;//更新偏移量,下次使用i++;if(_size)//如果超出了文件大小,停止准备后面的SQEbreak;}while(1);//5.提交SQE读请求ret=io_uring_submit(ring);//4个SQE一次提交,返回提交成功的SQE数量if(ret0){fprintf(stderr,"io_uring_submit:%s\n",strerror(-ret));return1;}elseif(ret!=i){fprintf(stderr,"io_uring_submitsubmittedless%d\n",ret);return1;}//6.等待读请求完成(CQE)structio_uring_cqe*cqe;done=0;ping=ret;fsize=0;for(i=0;iping;i++){io_uring_wait_cqe(ring,cqe);//等待系统返回一个读完成事件done++;if(cqe-res!=4096cqe-res+fsize!=_size){fprintf(stderr,"ret=%d,wanted4096\n",cqe-res);}fsize+=cqe-res;io_uring_cqe_seen(ring,cqe);//更新io_uring实例的完成队列}//7.打印统计信息printf("Submitted=%d,completed=%d,bytes=%lu\n",ping,done,(unsignedlong)fsize);//8.清理工作close(fd);io_uring_queue_exit(ring);return0;}其他说明
代码中已经添加了注释,这里再解释几点:
每个SQE都执行一个allocatedbuffer,后者是用iovec结构描述的;
第34步:初始化所有SQE,用于接下来的IORING_OP_READV操作,后者提供了readv(2)系统调用的异步接口。
操作完成之后,这个SQEiovecbuffer中存放的是相关readv操作的结果;
接下来调用io_uring_wait_cqe()来reapCQE,并通过cqe-res字段验证读取的字节数;
io_uring_cqe_seen()通知内核这个CQE已经被消费了。
3.2link-cplink-cp使用io_uring高级特性SQEchaining特性来复制文件。
I/Ochainio_uring支持创建I/Ochain。一个chain内的I/O是顺序执行的,多个I/Ochain可以并行执行。
io_uring_enter()manpage中对IOSQE_IO_LINK有详细解释[18]:
为实现复制文件功能,link-cp创建一个长度为2的SQEchain。
第一个SQE是一个读请求,将数据从输入文件读到buffer;
第二个请求,与第一个请求是linked,是一个写请求,将数据从buffer写入输出文件。
源码及注释/*SPDX-License-Identifier:MIT*//***bitoferrorhandlingandshortreadlove.*/defineQD64//io_uring队列长度#defineBS(32*1024)structio_data{size_toffset;intindex;structioveciov;};staticintinfd,outfd;staticunsignedinflight;//创建一个read-writeSQEchainstaticvoidqueue_rw_pair(structio_uring*ring,off_tsize,off_toffset){structio_uring_sqe*sqe;structio_data*data;void*ptr;ptr=malloc(size+sizeof(*data));data=ptr+size;data-index=0;data-offset=offset;_base=ptr;_len=size;sqe=io_uring_get_sqe(ring);//获取可用SQEio_uring_prep_readv(sqe,infd,data-iov,1,offset);//准备read请求sqe-flags|=IOSQE_IO_LINK;//设置为LINK模式io_uring_sqe_set_data(sqe,data);//设置datasqe=io_uring_get_sqe(ring);//获取另一个可用SQEio_uring_prep_writev(sqe,outfd,data-iov,1,offset);//准备write请求io_uring_sqe_set_data(sqe,data);//设置data}//处理完成(completion)事件:释放SQE的内存缓冲区,通知内核已经消费了CQE。staticinthandle_cqe(structio_uring*ring,structio_uring_cqe*cqe){structio_data*data=io_uring_cqe_get_data(cqe);//获取CQEdata-index++;if(cqe-res0){if(cqe-res==-ECANCELED){queue_rw_pair(ring,BS,data-offset);inflight+=2;}else{printf("cqeerror:%s\n",strerror(cqe-res));ret=1;}}if(data-index==2){//read-writechain完成,释放缓冲区内存void*ptr=(void*)_len;free(ptr);}io_uring_cqe_seen(ring,cqe);//通知内核已经消费了CQE事件returnret;}staticintcopy_file(structio_uring*ring,off_tinsize){structio_uring_cqe*cqe;size_tthis_size;off_toffset;offset=0;while(insize){//数据还没处理完inthas_inflight=inflight;//当前正在进行中的SQE数量intdepth;//SQE阈值,当前进行中的SQE数量(inflight)超过这个值之后,需要阻塞等待CQE完成while(insizeinflightQD){//数据还没处理完,io_uring队列也还没用完this_size=BS;if(this_sizeinsize)//最后一段数据不足BS大小this_size=insize;queue_rw_pair(ring,this_size,offset);//创建一个read-writechain,占用两个SQEoffset+=this_size;insize-=this_size;inflight+=2;//正在进行中的SQE数量+2}if(has_inflight!=inflight)//如果有新创建的SQE,io_uring_submit(ring);//就提交给内核if(insize)//如果还有data等待处理,depth=QD;//阈值设置SQ的队列长度,即SQ队列用完才开始阻塞等待CQE;else//data处理已经全部提交,depth=1;//阈值设置为1,即只要还有SQE未完成,就阻塞等待CQE//下面这个while只有SQ队列用完或data全部提交之后才会执行到while(inflight=depth){//如果所有SQE都已经用完,或者所有dataread-write请求都已经提交io_uring_wait_cqe(ring,cqe);//等待内核completion事件handle_cqe(ring,cqe);//处理completion事件:释放SQE内存缓冲区,通知内核CQE已消费inflight--;//正在进行中的SQE数量-1}}return0;}staticintsetup_context(unsignedentries,structio_uring*ring){io_uring_queue_init(entries,ring,0);return0;}staticintget_file_size(intfd,off_t*size){structstatst;if(fstat(fd,st)0)return-1;if(S_ISREG(_mode)){*size=_size;return0;}elseif(S_ISBLK(_mode)){unsignedlonglongbytes;if(ioctl(fd,BLKGETSIZE64,bytes)!=0)return-1;*size=bytes;return0;}return-1;}intmain(intargc,char*argv[]){structio_uringring;off_tinsize;intret;infd=open(argv[1],O_RDONLY);outfd=open(argv[2],O_WRONLY|O_CREAT|O_TRUNC,0644);if(setup_context(QD,ring))return1;if(get_file_size(infd,insize))return1;ret=copy_file(ring,insize);close(infd);close(outfd);io_uring_queue_exit(ring);returnret;}其他说明
代码中实现了三个函数:
copy_file():高层复制循环逻辑;它会调用queue_rw_pair(ring,this_size,offset)来构造SQEpair;并通过一次io_uring_submit()调用将所有构建的SQEpair提交。这个函数维护了一个最大DQ数量的inflightSQE,只要数据copy还在进行中;否则,即数据已经全部读取完成,就开始等待和收割所有的CQE。
queue_rw_pair()构造一个的IOSQE_IO_LINKflag表示开始一个chain,writeSQE不用设置这个flag,标志着这个chain的结束。用户data字段设置为同一个data描述符,并且在随后的completion处理中会用到。
handle_cqe()从CQE中提取之前由queue_rw_pair()保存的data描述符,并在描述符中记录处理进展(index)。如果之前请求被取消,它还会重新提交read-writepair。一个CQEpair的两个member都处理完成之后(index==2),释放共享的datadescriptor。最后通知内核这个CQE已经被消费。
4io_uring性能压测(基于fio)对于已经在使用linux-aio的应用,例如ScyllaDB,不要期望换成io_uring之后能获得大幅的性能提升,这是因为:io_uring性能相关的底层机制与linux-aio并无本质不同(都是异步提交,轮询结果)。
在此,本文也希望使读者明白:io_uring**首先和最重要的贡献**在于:将linux-aio的所有优良特性带给了普罗大众(而非局限于数据库这样的细分领域)。
4.1测试环境本节使用fio测试4种模式:
synchronousreads
posix-aio(implementedasathreadpool)
linux-aio
io_uring
硬件:
NVMe存储设备,物理极限能打到3.5MIOPS。
8核处理器
4.2场景一:directI/O1KB随机读(绕过pagecache)第一组测试中,我们希望所有的读请求都能命中存储设备(allreadstohitthestorage),完全绕开操作系统的页缓存(pagecache)。
测试配置:
8个CPU执行72fiojob,
每个job随机读取4个文件,
iodepth=8(numberofI/Ounitstokeepinflightagainstthefile.)。
这种配置保证了CPU处于饱和状态,便于观察I/O性能。如果CPU数量足够多,那每组测试都可能会打满设备带宽,结果对I/O压测就没意义了。
表1.DirectI/O(绕过系统页缓存):1KB随机读,CPU100%下的I/O性能
back
IOPS
contextswitches
IOPS±%vsio_uring
sync
814,000
27,625,004
-42.6%
posix-aio(threadpool)
433,000
64,112,335
-69.4%
linux-aio
1,322,000
10,114,149
-6.7%
io_uring(basic)
1,417,000
11,309,574
—
io_uring(enhanced)
1,486,000
11,483,468
4.9%
几点分析:
io_uring相比linux-aio确实有一定提升,但并非革命性的。
开启高级特性,例如bufferfileregistration之后性能有进一步提升——但也还没有到为了这些性能而重写整个应用的地步,除非你是搞数据库研发,想榨取硬件的最后一分性能。
io_uringandlinux-aio都比同步read接口快2倍,而后者又比posix-aio快2倍——初看有点差异。但看看上下文切换次数,就不难理解为什么posix-aio这么慢了。
同步read性能差是因为:在这种没有pagecache的情况下,每次read系统调用都会阻塞,因此就会涉及一次上下文切换。
posix-aio性能更差是因为:不仅内核和应用程序之间要频繁上下文切换,线程池的多个线程之间也在频繁切换。
4.2场景二:bufferedI/O1KB随机读(数据提前加载到内存,100%hotcache)第二组测试bufferedI/O:
将文件数据提前加载到内存,然后再测随机读。
由于数据全部在pagecache,因此同步read永远不会阻塞。
这种场景下,我们预期同步读和io_uring的性能差距不大(都是最好的)。
其他测试条件不变。
表2.BufferedI/O(数据全部来自pagecache,100%hotcache):1KB随机读,100%CPU下的I/O性能
Back
IOPS
contextswitches
IOPS±%vsio_uring
sync
4,906,000
105,797
-2.3%
posix-aio(threadpool)
1,070,000
114,791,187
-78.7%
linux-aio
4,127,000
105,052
-17.9%
io_uring
5,024,000
106,683
—
结果分析:
同步读和io_uring性能差距确实很小,二者都是最好的。但注意,**实际的应用**不可能一直100%时间执行IO操作,因此基于同步读的真实应用性能还是要比基于io_uring要差的,因为io_uring会将多个系统调用批处理化。
posix-aio性能最差,直接原因是上下文切换次数太多,这也和场景相关:在这种CPU饱和的情况下,它的线程池反而是累赘,会完全拖慢性能。
linux-aio并不是针对bufferedI/O设计的,在这种pagecache直接返回的场景,它的异步接口反而会造成性能损失——将操作分为dispatch和consume两步不但没有性能收益,反而有额外开销。
4.3性能测试小结最后再次提醒,本节是极端应用/场景(100%CPU+100%cachemiss/hit)测试,真实应用的行为通常处于同步读和异步读之间:时而一些阻塞操作,时而一些非阻塞操作。但不管怎么说,用了io_uring之后,用户就无需担心同步和异步各占多少比例了,因为它在任何场景下都表现良好。
如果操作是非阻塞的,io_uring不会有额外开销;
如果操作是阻塞式的,也没关系,io_uring是完全异步的,并且不依赖线程池或昂贵的上下文切换来实现这种异步能力;
本文测试的都是随机读,但对其他类型的操作,io_uring表现也是非常良好的。例如:
打开/关闭文件
设置定时器
通过networksockets传输数据
而且使用的是同一套io_uring接口。
4.4ScyllaDB与io_uringScylla重度依赖directI/O,从一开始就使用linux-aio。在我们转向io_uring的过程中,最开始测试显示对某些workloads,能取得50%以上的性能提升。但深入研究之后发现,这是因为我们之前的linux-aio用的不够好。这也揭示了一个经常被忽视的事实:获得高性能没有那么难(前提是你得弄对了)。在对比io_uring和linux-aio应用之后,我们很快改进了一版,二者的性能差距就消失了。但坦率地说,解决这个问题需要一些工作量,因为要改动一个已经使用了很多年的基于linux-aio的接口。而对io_uring应用来说,做类似的改动是轻而易举的。
以上只是一个场景,io_uring相比linux-aio的**优势**是能应用于fileI/O之外的场景。此外,它还自带了特殊的高性能[19]接口,例如bufferregistration、fileregistration、轮询模式等等。
启用io_uring高级特性之后,我们看到性能确实有提升:IntelOptane设备,单个CPU读取512字节,观察到5%的性能提升。与表12对得上。虽然5%的提升看上去不是太大,但对于希望压榨出硬件所有性能的数据库来说,还是非常宝贵的。
eBPF也是一个事件驱动框架(因此也是异步的),允许用户空间程序动态向内核注入字节码,主要有两个使用场景:
Networking
TracingObservability:例如bcc[21]等工具
eBPF在内核4.9首次引入,4.19以后功能已经很强大。
谈到与io_uring的结合,就是用bcc之类的工具跟踪一些I/O相关的内核函数,例如:
Tracehowmuchtimeanapplicationspssleeping,andwhatledtothosesleeps.(wakeuptime)
Findallprogramsinthesystemthatreachedaparticularplaceinthecode(trace)
AnalyzenetworkTCPthroughputaggregatedbysubnet(tcpsubnet)
Measurehowmuchtimethekernelspentprocessingsoftirqs(softirqs)
Captureinformationaboutallshort-livedfiles,wheretheycomefrom,andforhowlongtheywereopened(filelife)
6结束语io_uring和eBPF这两大特性将给Linux编程带来革命性的变化。有了这两个特性的加持,开发者就能更充分地利用Amazoni3enmeganodesystems[22]之类的多核/多处理器系统,以及IntelOptane持久存储[23]之类的us级延迟存储设备。