LWN:使用io_uring进行零拷贝的网络传输!

共 3531字,需浏览 8分钟

 ·

2022-01-22 12:41

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

Zero-copy network transmission with io_uring

By Jonathan Corbet
December 30, 2021
DeepL assisted translation
https://lwn.net/Articles/879724/

在希望能让硬件以其可以达到的最高速度来传输网络数据时,任何一点点额外开销都会导致性能损失。因此将数据从用户空间复制到内核空间这个开销就变得很重要了;它增加了额外延迟,也占用了宝贵的 CPU 时间,并可能对 cache 的性能也造成影响。因此,从事 io_uring 工作的开发者(也就是所有关心性能的开发者)当然就已经把他们的注意力转向了零拷贝(zero-copy)的网络传输方式了。这是来自 Pavel Begunkov 的 patch set,现在已经是第二版了,看起来比目前内核支持的 MSG_ZEROCOPY 选项要快很多。

提醒一下:io_uring 是一个相对较新的异步 I/O(及一些相关操作)的 API;它第一次被合并到 mainline 里是在不到三年前的时候。它会在用户空间设置两个与内核共享的循环缓冲区(circular buffer),第一个缓冲区是用来向内核提交操作(submit operation),而第二个缓冲区则是在操作完成后接收结果的。繁忙程度适中的进程如果能让提交操作的缓冲区一直是满的,就可以在不需要进行任何系统调用的情况下执行不限数量的操作,这显然会提高性能。io_uring 还实现了 "fixed" buffer 和 file 的概念,它们会在内核中保持打开状态,并且是被映射好、准备好进行 I/O 操作的,这样就节省了每次操作中会产生的配置和销毁等相关开销。对于 I/O 密集型的应用场景来说,这些机制都可以提供更加快速的方式。

不过 io_uring 仍然没有 zero-copy 的网络功能,尽管 networking 子系统中可以通过 MSG_ZEROCOPY socket 选项来支持 zero-copy 方式。从理论上讲,增加这种功能仅仅是对两个子系统进行一下整合的问题。在实践中肯定还是会有一些细节需要处理的。

一个基于 zero-copy 的网络实现中,必须有一种方法在某个操作真正完成时来通知到应用程序,如果内核仍在处理某个会要传输的数据的 buffer,那么应用程序就不可以重复利用这个 buffer。这里有一个小问题:当 send()调用完成的时候并不意味着相关的 buffer 不再被使用了。在数据被接收到网络子系统中来进行传输时,该操作就算是 "完成" 了。上层可能已经认为操作完成了,但 buffer 本身可能还在网络适配器的传输队列中。只有当硬件完成其工作时,zero-copy 操作才算真正完成了对这个数据 buffer 的处理。甚至在许多协议中,应该是在网络对端确认收到数据时才算完成,这可能已经是传输开始这个操作的 complete 之后很久的时候了。

因此,需要使用一种机制能让内核通知到应用程序,告诉它某个特定的 buffer 可以被重复使用了。MSG_ZEROCOPY 通过与 socket 绑定的错误队列来返回通知,从而实现这一点。这种做法有点笨拙,确实可以起到效果。相反,io_uring 已经有了一个 completion-notification 机制,所以这种需要 "真正完成" 的通知的场景就自然可以利用此机制了。但是,由于需要准确地告诉应用程序哪些 buffer 可以被重用,因此这里仍然有一些复杂的问题需要解决。

使用 io_uring 进行 zero-copy 网络传输的应用程序需要先用 IORING_REGISTER_TX_CTX 来进行注册,这里需要注册至少一个完成上下文(completion context)。这个上下文本身是一个简单的结构:

struct io_uring_tx_ctx_register {
__u64 tag;
};

这个 tag 是调用者自己选择的值,用来在后续相关的 ring 中 zero-copy 操作时来判断这个特定的上下文。与 ring 关联起来的上下文最多可以有 1024 个,用户空间应该用一个 IORING_REGISTER_TX_CTX 操作来把所有的上下文都注册进来,将这些 structure 组成一个数组传递进来。试图第二次调用来注册另一组上下文的话会返回错误值,除非期间使用过 IORING_UNREGISTER_TX_CTX 操作来将第一组删除掉。

zero-copy write 是通过新增的 IORING_OP_SENDZC 操作来开始的。跟往常一样,会把一组 buffer 被传递给 socket(显然也是通过参数提供的),就可以用来发送了。此外,每个 zero-copy write 必须有一个与之相关的上下文先存储在提交队列条目(submission queue entry)的 user_data 字段中。这个上下文是通过提供先前注册的上下文数组的一个索引来提供的,而不是直接使用 context 相关的 tag 值。这些 write 操作会尽可能地使用内核的 zero-copy 机制,并且会照常来 "complete",也就是在 completion ring 中正常提供出操作的结果,此时也许这个 buffer 仍在使用中。

如果希望知道内核已经完成了对 buffer 的使用的话,应用程序就必须等待第二个通知。默认情况下不会为每一个提交进来的 zero-copy 操作都发送这些通知的,相反,它们被归结一批一批(generation)来发送。每个完成了的上下文都有一个从 0 开始的 sequence number 序列号。每一个 generation 都可以有多个操作,一旦所有相关的操作都真正完成,就会把这个 generation 的通知发送给 user space。

用户空间需要告诉内核何时进入新的一批(generation)了,具体来说是通过在一个 zero-copy write 请求中设置 IORING_SENDZC_FLUSH flag 来实现的。该 flag 位于 submission queue entry 的 ioprio 字段中。只要出现了这个 flag,就表明当前正在提交的请求是当前这一批(generation)的最后一个,下一个请求就算做新的一批了。因此,如果每个写请求都需要获取一个独立的 done-with-the-buffer 的通知的话,IORING_SENDZC_FLUSH 就应该在每个请求中都设置上。

在一批(generation)处理完成之后,就会在 completion ring 中出现相关的通知。user_data 字段中就会包含上下文 tag,而 res 字段则包含 generation 编号。等通知到达之后,应用程序就可以安全地来重用与该 generation 相关的所有 buffer 了。

最终结果似乎相当不错,附上的的性能测试表明 io_uring 的 zero-copy 操作可以比 MSG_ZEROCOPY 的性能好 200% 以上。大部分的改进可能都是来自于使用 io_uring 的 fixed buffer 以及 file 的功能,从而去除了每个操作中的大部分开销。当然,绝大多数应用场景都不会看到这种程度的提升,因为那些场景中网络传输的开销并不是主要开销。不过,如果你的业务是比如向世界提供猫咪视频这种,那么使用 io_uring 的基于 zero-copy 的网络功能可能会很有吸引力。

目前,新的 zero-copy 操作还没有详细的文档。Begunkov 发布了一个测试应用,可以通过阅读这个应用代码来了解新的接口应该如何使用。这组 patch set 当前版本(第二版)还没有得到多少 review 意见。也许在节日之后会不一样,总之看来这项工作可能已经很接近被合入 mainline 了。

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

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

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



浏览 36
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报