这几年内核带来了很多革命性的新特性。一个是 ebpf, 现在主要被广泛应用于网络处理,性能分析等领域。另一个是 io_uring, 带来了真正的全异步IO。本文将对 io_uring 做简要介绍。

总览

在 io_uring 之前,只有 aio 这个异步框架。为什么要重新弄一套,是因为 aio 自身限制比较多,比如:

  • 只支持 direct_io.而O_DIRECT要求bypass缓存和size对齐等,直接影响了很多场景的使用。而对buffered IO,其表现为同步。
  • 即使满足了所有异步IO的约束,有时候还是可能会被阻塞,例如,等待元数据IO,或者存储设备的请求槽位都正在使用等等。
  • 存在额外的开销,每个IO提交需要拷贝64+8字节,每个IO完成需要拷贝32字节,这在某些场景下影响很可观。在使用完成event的时候需要非常小心,否则容易丢事件。IO总是需要至少2个系统调用(submit + wait-for-completion),在spectre/meltdown开启下性能下降非常严重。

aio本身扩展性也很差,很多基于aio的开发也经常需要用dirty hack的方式来满足自己的需求。Linux 自己对它的评价也不好:

So I think this is ridiculously ugly.

AIO is a horrible ad-hoc design, with the main excuse being “other, less gifted people, made that design, and we are implementing it for compatibility because database people — who seldom have any shred of taste — actually use it”.

还有一个需要注意的地方是上面提到的spectre/meltdown 攻击,为了应对这两个问题,一方面内核不再在用户态和内核间共享地址页表,每次异常、IO、系统调用,都要把内核页表重新装进来。另一方面,如果为了安全起见,指令预测也得关掉,性能能直接下降10%。这个因为导致系统调用的成本比之前更高。

所以,高效的异步io基本上就是如下几个思路:

  • 少做或者不做系统调用
  • zero copy
  • lock free

io_uring在这几方面做的都比较好,对应的它分别用了以下几个技术:

  • ring_buffer. 将内核和userspace分别想象为生产者/消费者,通过两个ring_buffer通信
  • mmap
  • 内存屏障

SQ/CQ

用到的两个ring_buffer分别叫 submission queue 和 completion queue.SQ是 application 生产,内核消费,CQ相反。这两个 ring_buffer 通过 mmap 在用户/内核态都可以访问。

用户程序往SQ里推送的数据叫SQE(submission queue entry).假设我们现在想读取一个文件,其内容大致如下:

  • opcode: 选定我们要执行的系统调用,readv, 对应一个 opcode叫 IORING_OP_READV
  • flags: 参数,可以用来调整 io_uring 的各种行为
  • fd: 涉及到的文件
  • address: 对于读取文件来说,这里指的是读取到的数据将要存放的目标地址
  • length: 数据的长度
  • user data: 一个用户将 SQE 和 CQE(completion queue entry)关联起来的标识符。因为异步IO事件是无序的,我们需要某种方式将二者关联起来。

对CQE来说,包含如下内容:

  • user data: 如上所介绍
  • result: readv 的返回结果

上图画出了一个大致的示意图。需要注意的是 SQ 与 CQ 有些区别。SQE并不是直接存放在 SQ中,而是存了其 index 到 SQ种,这样能给用户 appilcation 更高的自由度。 下面用伪代码的方式展示了 SQ 和 CQ 的处理操作:

SQ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
struct io_uring_sqe *sqe;
unsigned tail, index;
tail = sqringtail;
index = tail & (*sqringring_mask);
sqe = &sqringsqes[index];
/* this call fills in the sqe entries for this IO */
init_io(sqe);
/* fill the sqe index into the SQ ring array */
sqringarray[index] = index;
tail++;
write_barrier();
sqringtail = tail;
write_barrier();

CQ:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
unsigned head;
head = cqringhead;
read_barrier();
if (head != cqringtail) {
struct io_uring_cqe *cqe;
unsigned index;
index = head & (cqringmask);
cqe = &cqringcqes[index];
/* process completed cqe here */
...
/* we've now consumed this entry */
head++;
}
cqringhead = head;
write_barrier();

注意其中内存屏障的使用。

系统调用

io_uring 一共提供了 3 个系统调用:io_uring_setup()io_uring_enter(),以及io_uring_register(),位于 fs/io_uring.c

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/**
 * io_uring_setup - setup a context for performing asynchronous I/O
 */
int io_uring_setup(u32 entries, struct io_uring_params *p);
/**
 * io_uring_enter - initiate and/or complete asynchronous I/O
 */
int io_uring_enter(int fd, unsigned int to_submit, unsigned int min_complete,
                   unsigned int flags, sigset_t *sig)
 
/**
 * io_uring_register - register files or user buffers for asynchronous I/O
 */
int io_uring_register(int fd, unsigned int opcode, void *arg,
                      unsigned int nr_args)

io_uring_setup

初始化uring.两个参数分别是:

  • entries: 这个io_uring包含的 entry 数量
  • params: io_uring的参数,userspace/kernel都可以读写。

函数返回一个关于 io_uring 的 fd

io_uring_enter

提交IO请求:

  • fd: u_ring的fd
  • to_submit: 要提交多少个请求
  • min_complete: 函数要返回的话需要等待多少个CQ

to_submit 和 min_complete 意味着这个函数既可以用来提交,也可以等待,或者二者皆可。

Polled Mode

对于很多应用来说,比如延迟敏感型或者高IOPS型,如果继续用中断的方式处理IO, 数据来的时候,driver 通知 kernel 来处理,过于低效了。这种情况下用主动的 poll 效果会更好,io_uring 提供了两种方式

  • userspace 的 poll: io_uring_setup的时候使用 IORING_SETUP_IOPOLL, 然后在 io_uring_enter 的时候使用 IORING_ENTER_GETEVENTS参数
  • kernel-side polling: 当前应用更新 SQ ring 并填充一个新的 sqe,内核线程 sqthread 会自动完成提交,这样应用无需每次调用 io_uring_enter() 系统调用来提交 IO。应用可通过 IORING_SETUP_SQ_AFF 和 sq_thread_cpu 绑定特定的 CPU。 同时,为了节省无 IO 场景的 CPU 开销,该内核线程会在一段时间空闲后自动睡眠。应用在下发新的 IO 时,通过 IORING_ENTER_SQ_WAKEUP 唤醒该内核线程,用户态可以通过 sqring 的 flags 变量获取 SQ 线程的状态。

在 kernel-side polling的情况下,IO不需要系统调用。

SPDK相关

因为近几年的硬件性能的持续提升,尤其比如网卡和SSD等,旧有的很多IO优化逻辑可能已经不适用了。比如我们看下面的一个对比图:

这是 Intel 的 Optane SSD做的测试。我们可以看见在中间那一列,Storage with Optane SSD,随机读取的硬件延迟已经接近操作系统和文件系统带来的延迟,甚至 Linux VFS 本身会变成 CPU 瓶颈。其实背后的原因也很简单,过去由于 VFS 本身在 CPU 上的开销(比如锁)相比过去的 IO 来说太小了,但是现在这些新硬件本身的 IO 延迟已经低到让文件系统本身开销的比例不容忽视了。

网卡方面也是,现在主流的数据中心基本上开始提供 10GbE 甚至 25GbE 的网络。万兆网卡的吞吐差不多每秒 1488 万帧,处理一个包的时间在百纳秒的级别,基本相当于一个 L2 Cache Miss 的时间。所以如何减小内核协议栈处理带来的内核-用户态频繁内存拷贝的开销,成为一个很重要的课题。新硬件的提升,基本上在软件层的优化都是 kenrel bypass.比如网络方面的DPDK:

数据包直接从网卡到了 DPDK,绕过了操作系统的内核驱动、协议栈和 Socket Library。DPDK 内部维护了一个叫做 UIO Framework 的用户态驱动 (PMD),通过 ring queue 等技术实现内核到用户态的 zero-copy 数据交换,避免了 Syscall 和内核切换带来的 cache miss,而且在多核架构上通过多线程和绑核,极大提升了报文处理效率.

而对于SSD存储来说,Intel 的开发套件: SPDK, 也是采用类似的优化方式。首先,将设备驱动代码运行在用户态,避免内核上下文切换和中断将会节省大量的处理开销,允许更多的时钟周期被用来做实际的数据存储。无论存储算法(去冗,加密,压缩,空白块存储)多么复杂,浪费更少的时钟周期总是意味着更好的性能和时延。在传统的I/O模型中,应用程序提交读写请求后进入睡眠状态,一旦I/O完成,中断就会将其唤醒。轮询的工作方式则不同,应用程序提交读写请求后继续执行其他工作,以一定的时间间隔回头检查I/O是否已经完成。这种方式避免了中断带来的延迟和开销,并使得应用程序提高了I/O效率。在机械硬盘时代,中断开销只占整个I/O时间的很小的百分比,因此给系统带来了巨大的效率提升。然而,在固态设备时代,持续引入更低时延的持久化设备,中断开销成为了整个I/O时间中不可忽视的部分。这个问题在更低时延的设备上只会越来越严重。系统已经能够每秒处理数百万个I/O,所以消除数百万个事务的这种开销,能够快速地复制到多个core中。数据包和数据块被立即分发,因为等待花费的时间变小,使得时延更低,一致性时延更多(抖动更少),吞吐量也得到了提高。

而内核当然也不是说跟不上时代,XDP及 io_uring 便是对应的解决方案。下面是一个 io_uring 与 SPDK 的性能对比:

(测试环境:神龙裸金属实例,96 CPU 503 G,本地盘为三星 PM963。)

io_uring 在开启 iopoll 后与 SPDK 接近,甚至在 queue depth 较高时性能更好。当然可能跟 intel 的 Optane SSD还是有点就差距,但对普通硬件来说,io_uring 已经能带来相当大的性能提升了。

其他高级功能

Fixed Files

IORING_REGISTER_FILES / IORING_REGISTER_FILES_UPDATE / IORING_UNREGISTER_FILES,通过 io_uring_register() 系统调用提前注册一组 file,缓解每次 IO 操作因 fget() / fput() 带来的开销。

Fixed Buffers

IORING_REGISTER_BUFFERS / IORING_UNREGISTER_BUFFERS,通过 io_uring_register() 系统调用注册一组固定的 IO buffers,当应用重用这些 IO buffers 时,只需要 map / unmap 一次即可,而不是每次 IO 都要去做,减少get_user_pages() / put_page() 带来的开销。

Linked SQE

IOSQE_IO_LINK,建立 sqe 序列之间的关联,这在诸如 copy 之类的操作中非常有用。使用 linked sqe 后,copy 操作的写请求链接在读请求之后,应用程序无需等待读请求数据返回后再下发写请求,而是共享了同一个 buffer,避免了上下文切换的开销。

与 epoll 的对比

epoll 通常的编程模型如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct epoll_event ev;

/* for accept(2) */
ev.events = EPOLLIN;
ev.data.fd = sock_listen_fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sock_listen_fd, &ev);

/* for recv(2) */
ev.events = EPOLLIN | EPOLLET;
ev.data.fd = sock_conn_fd;
epoll_ctl(epollfd, EPOLL_CTL_ADD, sock_conn_fd, &ev);

1
2
3
4
5
6
new_events = epoll_wait(epollfd, events, MAX_EVENTS, -1);
for (i = 0; i < new_events; ++i) {
    /* process every events */
    ...
}

将fd通过epoll_ctl进行注册,当该fd上有事件ready, 在epoll_wait返回时可以获知完成的事件,然后依次调用每个事件的handler, 每个handler里调用recv(2), send(2)等进行消息收发。

io_uring的编程模型如下(这里用到了liburing提供的一些接口):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
/* 用sqe对一次recv操作进行描述 */
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_recv(sqe, fd, bufs[fd], size, 0);

/* 提交该sqe, 也就是提交recv操作 */
io_uring_submit(&ring);

/* 等待完成的事件 */
io_uring_submit_and_wait(&ring, 1);
cqe_count = io_uring_peek_batch_cqe(&ring, cqes, sizeof(cqes) / sizeof(cqes[0]));   
for (i = 0; i < cqe_count; ++i) {
    struct io_uring_cqe *cqe = cqes[i];
    /* 依次处理reap每一个io请求,然后可以调用请求对应的handler */
    ...
}

阿里做过一些关于 二者性能数据的一些对比(echo_server场景)

(在meltdown和spectre漏洞修复场景下测试)

img)

可以看到:

  • io_uring可以极大的减少用户态到内核态的切换次数,在连接数超过300时,io_uring用户态到内核态的切换次数基本可以忽略不计
  • 连接数1000及以上时,io_uring的性能优势开始体现,io_uring的极限性能单core在24万qps左右,而epoll单core只能达到20万qps左右,收益在20%左右

参考项目

  1. frodo: 一个参考的go 封装
  2. echo server对比程序
  3. liburing: io_uring 的封装lib
  1. AIO 的新归宿:io_uring
  2. The rapid growth of io_uring
  3. How io_uring and eBPF Will Revolutionize Programming in Linux
  4. Getting Hands-on with io_uring using Go
  5. 给程序员解释Spectre和Meltdown漏洞
  6. io_uring,高并发网络编程新利器
  7. 下一代异步 IO io_uring 技术解密
  8. io_uring 新异步 IO 机制,性能提升超 150%,堪比 SPDK