kLoop:直通 Linux 内核的高性能 asyncio

1,405 阅读30分钟

本文适合有一定编程基础的同学阅读,但不要求有任何专业方向的经验。写作目的,一是撺掇各路英豪一起做开源,二是记录一下新项目的选型设计和概念验证过程。全文小一万字(知乎那个字数统计……),阅读时间大概一下午(连同摆弄代码的时间)。

之前介绍 EdgeDB 历史的那篇文章里有提到,EdgeDB 的 I/O 目前十分依赖 Python asyncio。为了提升 EdgeDB 的速度,Yury 基于 libuv(就是 Node.js 底层的 I/O 库)搞出了人气颇高的 uvloop,最近能预见的几个 EdgeDB 版本都还是会用 uvloop。

与此同时,我们一直在探索进一步提升 I/O 性能的方法,比如用 Linux 内核的 TLS 支持(kTLS)来承接 SSL 连接、用多进程加共享内存来优化多核 I/O,甚至于是用 Rust 重写 EdgeDB 的 I/O 部分等等。我在研究的过程中发现了新的宝藏 io_uring,并用几个周末的时间简单写了点概念验证,于是就有了今天的新坑:kLoop。

gitee.com/fantix/kloo…

kLoop 与 uvloop 对仗,k 表示 Linux 内核(Kernel),主要想法是用内核的 io_uring 和 kTLS 功能来直接实现一个高效率的 asyncio 事件循环,因为我琢磨着这两个人应该是一对儿非常完美的搭档,理论上应该可以把 asyncio 的效率再提升一个档次。接下来我就稍微展开说说,欢迎有兴趣的同学一起跳坑。

技术选型

io_uring:免系统调用的 I/O

熟悉 io_uring 的同学可以放心跳过这一节。

我们的应用程序通常会对磁盘和网络进行操作,而这些 I/O 操作都需要操作系统的配合才能完成,这就需要应用程序去调用操作系统的相应接口,而这类调用就叫做系统调用(syscall),比如用 read() 来读取文件,或者用 send() 来发送网络数据。不管你用的什么编程语言,这些操作在底层基本上都是要做系统调用的。以 Linux 为例,如果仔细观察进程 CPU 的占用率,就能看到每个进程都有 user 占比和 system 占比,这个 system 占比就是该进程花在系统调用上的时间。

fantix@fantix-jammy:~$ time curl https://gitee.com > /dev/null
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 35991    0 35991    0     0  23711      0 --:--:--  0:00:01 --:--:-- 23709

real	0m1.527s
user	0m0.070s
sys	0m0.005s

Munin 记录下的 CPU 使用率

虽然系统调用可能会非常频繁,但这种调用并不是没有性能开销的——除了操作系统花在实际执行一次系统调用所需的时间外,在 user 和 system 模式之间切换也是需要时间的。这个开销虽然单次仅有约几百到上千纳秒,但架不住多啊——比如 asyncio 的事件循环每一次至少要 epoll() 一次吧,有进展的 socket 各自又有一次读或写,万一没成功下回还得重试。高并发下,一秒几百次的事件循环,每次循环几十上百次 I/O,光系统调用的额外开销就要占到毫秒级别了。

epoll() 是 Linux 上的高性能事件通知设施,可以同时监视多个文件描述符(比如 socket)的事件状态,如某个 TCP 连接成功了,或者某个 socket 收到消息了等等。

如果能把这些系统调用都放在操作系统里一并完成,不仅能够省去 user 和 system 来回切换的时间,更是抛开了接口封装的枷锁,在内核中直接完成许多之前不好搞的骚操作(比如异步磁盘 I/O),岂不美哉?这就是 io_uring 诞生的原因。

io_uring 是脸书的一个同学开发的,从 5.1 就进 Linux 内核主线了,但陆续改进到 5.11 才有了 kLoop 需要的全部功能,并且仍在持续改进中。笼统来讲,应用程序用内存映射(mmap)的方式拿到两条与内核共享的环状队列,通过其中一条队列(SQ)给内核源源不断的布置任务,然后从另外一条队列(CQ)获取结果;内核则按需进行 epoll(),并在一个线程池中执行就绪的任务。

io_uring 大致架构

一些常见的系统调用,如打开文件、读写文件、socket 操作等等,都可以通过 io_uring 来完成。而应用程序所需要做的,只是一些内存操作,告诉内核要做什么,读写缓冲区在哪里。这里仍然有少量的一些系统调用来控制 io_uring 本身,但都是实现细节了。一些初步的测评显示,io_uring 能比普通 epoll 快出 5% 至 40%。

kTLS:内核和网卡参与计算

我们再来看一个现代网络编程里的大头开销——TLS(也叫 SSL)。

包括 EdgeDB 在内的许多数据库都会建议或要求启用 TLS;HTTP 就更不用说了,你要是后面不带个 S 都不好意思跟别人打招呼;就连 DNS 也有加密版的了,所以 TLS 已经成为了一种公共网络基础设施。而信息的可逆熵增是需要花费能量的,加密解密的运算仍是一笔不小的开销。以 EdgeDB 为例,在 uvloop 已经大幅优化了其 TLS 实现的前提下,TLS 仍然带来了比明文流量高 15%~20% 的额外开销。

TLS 的加密算法分为两部分:集中在连接建立的握手阶段的非对称加密,和集中在数据传输阶段的对称加密。自然地,非对称加密主要影响 TLS 连接的建立速度,对称加密则对实际数据的传输速度有影响。

自 Linux 4.13 起,部分对称加密运算可以在内核中直接完成。这又是脸书小伙伴们的作品,并且在硬件支持的情况下,可以直接把运算工作扔给网卡来做,彻底解放 CPU。这个功能官方叫做 Kernel TLS,我们随大众的叫法,简称为 kTLS。常见 Linux 发行版似乎都构建了这一模块,就叫 tls,只需 modprobe 加载后即可使用。

kTLS 可以在 io_uring 的线程池中运行,也可以代工给网卡

从这张图就能看出来,为什么 io_uring 和 kTLS 是天生一对了——如果网卡不支持加密运算,那么 io_uring 的线程池正好可以给 kTLS 用来做运算池!这就意味着,你的应用程序哪怕只有一个线程,理论上也可以舔着脸去抢多核 CPU 的资源。GIL 手动打出了一个狗头……

CPython 的全局解释器锁(GIL)是一种用来保证 Python 代码在多线程环境中正确执行的同步机制,任何线程只要运行 Python 代码就得先获取这把锁,除非只有一个线程,或者某些函数的底层实现会主动释放 GIL 一段时间。GIL 带来的副作用就是,多线程的 Python 程序往往很难有效地利用多核 CPU 资源。

集成 OpenSSL

对称加密基本上解决了,接下来还需要看看非对称加密怎么办。因为与 TLS 证书交互包含了许多用户应用逻辑,并不像对称加密那么直截了当,因此内核并不支持 TLS 非对称加密部分,需要由用户自己完成 TLS 握手, 然后把协商出来的对称密钥交给内核,内核才能接管连接加解密。

因为 kLoop 终究是 Python 库,所以 TLS 握手自然要用到 CPython 最常搭配的 OpenSSL。

OpenSSL 自 3.0 起开始支持 kTLS 发包代工,收包代工则需要最新的 3.1 开发版本。按照 NGINX 的说法,单是发包代工就已经能提供约 30% 的性能提升了(配合了原生 sendfile(),io_uring 也可以通过 splice() 实现)。其实,我们完全可以用 OpenSSL 1.1 进行 TLS 握手, 只不过 OpenSSL 封装的比较死,取对称密钥和序列号的手法比较脏(靠 key log 和内存指针偏移量)。再加上无法向 OpenSSL 1.1 直接提供明文数据,所以一旦内核接管了对称加密,所有的 TLS 控制消息就都废了,要么就得你自己编码解析;rekeying/renegotiation 就更别想了,更别说 TLS 1.3 还有 early data 什么的,握手都不一定能握完整,对安全性还是有挺大不确定性的。

TLS 的消息(Record)有多种类型,kTLS 将其分为应用数据消息(application data)和控制消息(control data,包括 TLS 的 alert、handshake 等非 application_data 的 Record)。

索性我们就选择 OpenSSL 3.0 吧,毕竟刚刚发布的 Ubuntu 22.04 就是 OpenSSL 3.0,加上 Linux 5.15 和 Python 3.10,够了。实现细节上,我们需要实现一个定制的 BIO,把里面的 I/O 部分拿出来自己搞,让 OpenSSL 3.0 去协调什么时候是明文、什么时候需要自己加解密就好了。

BIO 全称为基本输入输出,是 OpenSSL 对各种输入输出流封装出来的一个接口,比如 socket 有 socket BIO,文件有文件 BIO,OpenSSL 也允许我们提供自定义的 BIO 实现。

直接接驳 OpenSSL 并不意味着我们要抛弃 Python 的 SSL 实现。我们可以通过一个简单的头文件,把 Python SSLSocket 对象中的 OpenSSL SSL* 指针暴露出来(这里用的是 Cython 语法):

cdef extern from "openssl/ssl.h" nogil:
    ctypedef struct SSL:
        pass

cdef extern from *:
    """
    typedef struct {
        PyObject_HEAD
        PyObject *Socket; /* weakref to socket on which we're layered */
        SSL *ssl;
    } PySSLSocket;
    """

    ctypedef struct PySSLSocket:
        SSL* ssl

有了 SSL* 指针,就可以调用 OpenSSL 的 SSL_set_options() 函数来启用 kTLS 了。

Cython:胶水语言的胶水

如果说 Python 是用来粘合多元宇宙的胶水语言,那么 Cython 就是胶水中的胶水——它与 C 语言的互操作性无可比拟。uvloop、asyncpg 和 EdgeDB 都大量用到了 Cython 编码,kLoop 也做出了同样的选择。

Cython 代码有着类似 Python 的语法,但是却会被编译成 C 代码,最终编译出可供 Python 导入的原生扩展模块。这些模块就可以动态(或者静态!)链接一些需要用到的 C 语言的库,比如链接 OpenSSL 来完成 kTLS 的握手,再比如链接 libc 来实现通往 Linux 内核的系统调用。

以自定义 OpenSSL BIO 为例,我们首先把 OpenSSL 的头文件中需要用到的部分用 Cython 重新定义出来:

cdef extern from "openssl/bio.h" nogil:
    ctypedef struct Method "BIO_METHOD":
        pass

    ctypedef struct BIO:
        pass

    int get_new_index "BIO_get_new_index" ()

    Method* meth_new "BIO_meth_new" (int type, const char* name)

    int meth_set_write_ex "BIO_meth_set_write_ex" (
        Method* biom,
        int (*bwrite)(BIO*, const char*, size_t, size_t*),
    )

    BIO* new "BIO_new" (const Method* type)
    void set_retry_write "BIO_set_retry_write" (BIO *b)

这里我们引入了两个结构体和 5 个函数。C 语言没有命名空间的概念,所以 OpenSSL 为了避免重复,为 BIO 相关的名称添加了 BIO_ 的前缀;但我们用的是 Cython,可以把这个文件保存为 openssl/bio.pxd,这样就可以通过 from openssl cimport bio 的方式,自然地拿到一个 bio. 的命名空间,因此在定义中我们进行了重命名,使其更符合 Python 的行为方式。

随后我们就可以开始自定义 BIO 了。首先需要一个新的 BIO_METHOD* 空间:

from openssl cimport bio

cdef bio.Method* KTLS_BIO_METHOD = bio.meth_new(
    bio.get_new_index(), "kTLS BIO"
)

然后实现 BIO 的各个函数,并且设置到这个 BIO_METHOD* 上。以 write_ex() 为例:

 cdef int bio_write_ex(
    bio.BIO* b, const char* data, size_t datal, size_t* written
) nogil:
    with gil:
        print('bio_write', data[:datal], <int>data)
    bio.set_retry_write(b)
    written[0] = 0
    return 1

bio.meth_set_write_ex(KTLS_BIO_METHOD, bio_write_ex)

因为我们还没有开始真正实现 kLoop 的功能,所以这里暂时只是虚晃一枪,告诉 OpenSSL 这次啥也没写进去,还得重试。然而真正值得注意的是这里 nogilwith gil 的用法:nogil 用在方法签名上,标志着这个函数可以安全地用在 C 语言环境中,比如被设置成为 BIO_METHOD* 的一个回调函数。nogil 的函数也同时意味着,这个函数执行时不保证拿到了 Python 的 GIL,因此内部不能有任何的 Python 结构,除非在一个 with gil 上下文中。 这也是为什么我们在用 Python 的 print() 的时候,需要先用 with gil 来获取 Python GIL 的原因。你问我为什么要用 Python 的 print(),而不是用 C 语言的 printf()?因为 Python 好用啊!data[:datal] 就直接把数据显示出来了, <int>data 还能看地址,用来调试代码再方便不过了。

最后就可以用 bio.new(KTLS_BIO_METHOD) 来创建自定义的 BIO* 对象,做任何喜欢做的事情了。

开始制作 kLoop

有了前面的概念验证,我们选出了四种技术:Linux 5.11 的 io_uring、kTLS、OpenSSL 3.0 和 Cython。接下来就是把他们真正放到一起,制作出 kLoop 来了。

首先是作为事件循环基础的 io_uring(文档:《I/O 指环王》)。

io_uring 的接口其实十分简单,只有三个系统调用:setup、enter 和 register。

  • io_uring_setup:顾名思义,用来设置一个新的 io_uring。 每个应用程序都可以申请(多个)自己的 io_uring 实例,只需给定环形队列长度,setup 就会返回一个文件描述符,用以后续操作这个 io_uring 实例。setup 同时会给出几个指针,用来将环形队列 mmap 到用户空间。
  • io_uring_enter:这个系统调用会让 io_uring 实例开始工作,并一直阻塞到指定数量的任务完成后,或累计到指定的时间为止。 io_uring 还有另外一种运行模式叫 SQPOLL,可以在 setup 的时候进行设置;在这种模式下,内核会单开一个线程来主动执行任务,而不需要 enter 来触发;只不过为了节省资源,这个线程会在空闲指定的时间后挂起,需要 enter 才能唤醒。这种模式尽管多开了一个线程,但性能更好,因此 kLoop 会启用 SQPOLL
  • io_uring_register:register 是用来向内核注册常用文件或缓冲区的,据说能够提高效率,但是我目前还没有研究到。

除了这三个系统调用之外,就是如何 mmap 出两个环形队列,以及如何使用 Linux 的头文件所定义的结构体,在内存中填充和使用这两个队列了。为了简化使用,io_uring 的作者还特意开发了一个 C 语言的封装库 liburing。但 kLoop 用到的部分其实非常简单直接,如果使用 liburing 反而会不方便,依赖链接也会是一个麻烦事。因此,kLoop 就选择了用 Cython 直接与内核通讯(用 libc 做系统调用,然后就是基于 linux/io_uring.h 操作共享内存),一些关键逻辑和结构体就仿照 liburing 来做就好了。

io_uring 用到了一个很有趣的 C11 的无锁多线程同步支持,也就是 stdatomic.h 里的功能(需要 GCC 4.9)。

对于 io_uring 来说,就是用户应用程序的线程和内核线程有可能同时操作环形队列,当竞争发生时,我们必须保证结果是完整的,不能出现数据丢失或重复的情况。多线程同步通常要上锁,但会牺牲部分性能,而 C11 正式引入的原子性操作不需要上锁,可以更高效地完成这一工作。具体来说,编译器会在处理原子性操作时,在 CPU 指令级别保证,不同的原子性操作不会同时发生——哪怕是发生在不同 CPU 上面——除非操作的内存对象不同,因此这种多线程同步的效率是非常高的。

更重要的是,原子性操作可以按需阻止编译器的优化对多线程的影响。我们通常认为,按顺序写的代码也会按顺序执行;但其实,编译器和 CPU 有很大概率会把没有依赖关系的指令“打乱”,在保证结果与顺序执行一样的前提下,优化指令顺序以达到更高的效率。这对于单线程应用来说是好事,但却是多线程的噩梦——尤其多个线程还要访问共享的内存。原子指令则会告诉编译器,在该指令以前的代码必须不能被优化到该指令之后再执行,在该指令之后的代码同样需要等到该指令完成之后才能执行,这就是原子性操作内存顺序中最严格的“顺序一致”(seq_cst)模式。除此之外,还有 acquire(后面的代码不能提前做)、release(前面的代码必须先完成)和 relaxed(编译器可随意优化,但原子性操作本身要保证不同时发生)。具体的例子就不展开讲了,在 kLoop 中的用法也都是参考 liburing 的,有兴趣看的同学可以开启英文模式阅读 GCC 的这一篇文档

asyncio 的基础循环

kLoop 的事件循环仿照了 asyncio 的默认实现,除了 I/O 部分用 io_uring 替代了之外,“就绪队列”和“计时队列”的用法都是一样的。“就绪队列”是一个普通的链表,表示下一次循环马上可以执行的任务;“计时队列”是一个用最小二叉堆实现的优先队列,表示最近要执行的计时任务。每次循环的逻辑如下:

  1. 先计算这次循环能等待的最长时间——如果就绪队列里有东西,那么我们就是“一刻也不想多等”;否则的话,就是最多可以等到最近的计时任务必须开始执行时为止;
  2. 然后进 I/O——调用 io_uring_enter(),一直等到有就绪的任务,或者超时为止。就绪任务进就绪队列;
  3. 接着检查计时队列——到时的任务就出计时队列,进就绪队列;
  4. 最后就绪队列全部出列,然后一次性全部执行。

asyncio 默认实现中,就绪队列用的是 collections.deque,计时序列则是用 heapq 来操作一个普通的 list。这些数据结构的执行效率都是非常高的,但是我们既然已经用 Cython 了,不如就把效率推到极致,用 Cython 来写不包含 Python 结构(nogil)的纯 C 代码,实现与 dequeheapq 类似的功能。对于链表,我用数组做了一个环状队列,二叉堆则是直接仿照 CPython 的 heapq,用 Cython 实现了一个 C 的版本。

另外,循环第二步的“调用 io_uring_enter()”并不是每次都执行,因为 kLoop 默认启用了 SQPOLL,只要持续不断的提交 I/O 任务,内核线程就会一直工作,自动执行流水线上的任务;而 kLoop 只需从 CQ 上不断获取结果即可。那什么时候调用 io_uring_enter() 呢?一,内核线程暂停了,而我们又提交了新的 I/O 任务;二,CQ 和就绪队列都空了,而我们又有时间可以等待 I/O。当满负荷工作时,这两个条件都不满足的概率还是相当大的,因此 kLoop 可以(偶尔)做到“零系统调用”运转。

最后值得说明的是,在每次循环的这四个步骤中,前三个都是完全不需要 Python 参与的,所以不需要获取 GIL,完全在 C 的模式下跑,只有第四步才会获取一次 GIL,并一次性把就绪的任务都执行完。这么做可以最大程度地保证,关键路径上的代码都是 C 级别的速度(虽然单线程 GIL 都是 no-op),而且理论上可以让 Python 多线程稍微开心一点。

把所有东西放在一起就是这样的:

kLoop 架构图,Aaron 说像 Lucky Charms 卡通麦片的工厂

异步 DNS 解析

细心的同学可能发现了,上面的架构图里藏了一个到目前为止都没有提到过的东西——trust-dns。是的,为了实现异步 DNS 解析,kLoop 封装了一个 Rust 写的 trust-dns 解析器。

选择 trust-dns 的原因主要是,别的高性能异步解析器对于异步运行时的支持都比较固定,只有 trust-dns 可以完全用 io_uring 来提供运行时,而且不仅是 TCP 和 UDP 连接可以自定义,连 /etc/resolv.conf/etc/hosts 文件内容都可以自行提供。别的解析库像 c-ares 就只能给你一个文件描述符去 poll,而 libc 里的 getaddrinfo_a()似乎是用多线程来实现的。

因为 DNS 解析属于实现细节,所以我没有用 Rust 和 Python 相互调用,而是直接使用了(我认为更简单的)Rust 和 C/Cython 的接口。毕竟,kLoop 的核心循环并不需要 GIL,把不需要 Python 参与的 DNS 解析放在此处也最为合适。解析请求就是一个域名的字符串,而解析结果则是一个 C 的 SockAddr 结构体数组,这样一来 io_uring 就可以直接拿过来用在 socket 上了。恰巧,Rust 中的解析结果就是 C 结构体的封装,所以并不需要太麻烦的转换,就可以生成我们需要的结果。C 与 Rust 之间的互相调用就更简单了,就是互相调用对方定义的外部函数,只要注意参数类型转换就好了。

最有趣的地方还是在于用基于 C 的 io_uring 来为 Rust 中的异步 trust-dns-resolver 提供一个定制化的运行时环境,换句话说,trust-dns-resolver 定义了一套异步 I/O 接口,解耦了像如何创建一个 TCP 连接、如何发送一个 UDP 数据包等操作,它自己则使用这套接口实现了 DNS 解析的功能,而任何人都可以提供一个不同的接口实现,以不同的方式完成底层的 I/O 操作。

Rust 的异步编程是另外一个话题了,虽然也是很有趣,但是这里就不展开说了,总之就是与 asyncio 大差不差,原理都是一样的。对于 kLoop,io_uring 的主循环自然不由 Rust 这边来控制,所以我们需要一个简单的 Executor 来跑 trust-dns-resolver 创建的 Task,这些 Task 会调用我们提供的运行时来创建 io_uring 的任务,这些任务完成之后又会“唤醒” Rust 中的 Task,并在后续循环中继续执行,直到拿到我们需要的结果为止。

Cython 混编 Rust 进行异步 DNS 解析

TCP 客户端和服务器

回到 kLoop 本身,我们下一步要具体来实现 asyncio 的各种功能了,下面主要以 TCP 客户端为例来说明大体实现思路。

asyncio 对 I/O 操作解耦提供了一对儿基本的概念:transport 由 asyncio 的实现来提供,代表了对 I/O 底层的最终封装;protocol 的实例由用户提供,指定了 I/O 事件发生时应该执行的用户代码。用户可以在 protocol 里写回调代码,然后调用 transport 的方法来操作 I/O。比如说用 asyncio 创建一个 TCP 客户端,用户需要提供目标地址和端口,以及一个用来创建 protocol 实例的工厂函数;asyncio 的实现则会实际创建一个 TCP 连接,返回一个封装了该 TCP 连接的 TCPTransport 实例,以及一个通过给定工厂产生的、与该 transport 绑定的 protocol 实例。

我们在制作 kLoop 时要实现的,就是这个 TCPTransport,以及适时地调用 protocol 中的方法。

对于 io_uring 来说,一条 TCP 连接就是一个 int 类型的文件描述符,所以我们的 TCPTransport 定义就特别简单了:

cdef class TCPTransport:
    cdef:
        int fd
        object protocol

连接的过程就不详细展开了,无非就是调用前面的 DNS 解析,拿到一堆 IP 地址,然后挨个儿试。

关键在于发送和接收数据。

io_uring 会在一个线程池中执行提交上来的任务,所以任务执行的顺序未必会同提交的顺序一致,甚至于先后提交的两个任务有可能会被同时执行。Linux 中,虽然 socket 数据的发送和接收操作本身是原子性的——也就是说在一个 socket 上,多个线程同时发送或者多个线程同时接收并不会报错——但这不代表数据不会乱。对于 TCP 来说,数据是有严格顺序的,因此底层代码在将多个并发 send() 的数据合并成一股时,大家通常认为数据有可能会乱掉,对于 recv() 也是一样,并且更没有道理并行接收。(UDP 是一个例外,发送的数据报文都是有固定大小限制的,再加上本身就没有顺序要求,因此多线程发送完全没有问题,接收也行但意义不大。)

因此对于 kLoop 的 TCPTransport,我们需要一个自己搞一个发送队列,当目前已经提交了一个发送任务时,把数据临时放在这个发送队列里,上一个发送任务结束后再提交一个新的去发送剩下的。为了避免内存复制,我们自然是要把用户给我们的数据对象原封不动地放在队列里,但在创建发送任务时,我们可以用 sendmsg() 的数据阵列(Vectored I/O)来一次性将全部积压的数据都发送出去,以节省任务数量。

相比较而言,接收数据就容易的多了。在连接创建之后,首先提交一个接收数据的任务。任务完成,拿到数据,紧接着再提交一个新的接收任务就好了,一直反复直到连接断开为止。因为用户代码处理数据 data_received() 是在 Python 中进行的,所以我们可以用 call_soon() 将其放在下一次循环里,而抓紧时间先创建下一个接收任务,以求吞吐量的最大化。

kLoop 的 TCPTransport

最后聊一下流量控制。比如说,TCPTransportsend() 函数是非阻塞的,也就是说,用户可以卯足了劲儿一顿发,发到网卡都反应不过来,结果我们的发送队列就会占用大量内存。 此时,我们的 TCPTransport 应该及时看到缓冲区已经漾了,并且尽快通过 Protocolpause_writing() 函数来通知用户:“别再发啦,歇会儿吧。”。虽然此时用户可以蛮不讲理地继续填鸭,但我们假设用户还是友善地给了我们喘息的机会。等到 io_uring 把该发的发得差不多了的时候,我们在通知用户 resume_writing() 可以继续发了。这里缓冲区有一个警戒线(漾了)和一个安全线(缓过来了),用户可以通过 TCPTransportset_write_buffer_limits() 函数进行设置,我们在实现的时候则会记录下来,作为盯梢的目标。

反过来对于接收端,流量控制也是必要的,只不过不由我们的 TCPTransport 来控制。因为接收下来的数据是给用户处理的,所以用户有可能会遇到处理不过来的情况,或者压根儿这会儿就不想处理任何数据。此时,用户可以主动调用我们的 TCPTransport.pause_reading() 函数来告知这一诉求,而我们只需要把当前的接收任务取消就可以打破这个接收循环了,等到用户调用 resume_reading() 时, 再重新创建接收任务,重启循环。简单直接。

实现 TLS

有了前面的基础,我们就可以开始上 TLS 了。

首先,我们需要支持 Python 的 SSLContext,因为这是 asyncio 使用 TLS 的唯一方式。从 SSLContext 创建 TLS 连接有两种方式,一种是通过 wrap_socket() 将一个普通的 Python socket 升级为 SSLSocket,另一种则是用 wrap_bio() 来封装两个 OpenSSL 的 BIO(上层理论上须为 Python 自己封装的 MemoryBIO 对象,但封装很简单,可以替换成任意 BIO)。前面选型的时候已经说过了,我们需要自定义一个用 io_uring 实现的 BIO,所以这里我们就用 wrap_bio() 的方式来实现:

from .includes.openssl cimport ssl as ssl_h
from .includes.openssl.bio cimport BIO

cdef extern from *:
    """
    typedef struct {
        PyObject_HEAD
        BIO *bio;
        int eof_written;
    } PySSLMemoryBIO;
    """

    ctypedef struct PySSLMemoryBIO:
        BIO* bio
        
cdef object wrap_bio(ssl_context, bio.BIO* b, ...):
    cdef pyssl.PySSLMemoryBIO* c_bio
    py_bio = ssl.MemoryBIO()
    c_bio = <pyssl.PySSLMemoryBIO*>py_bio
    c_bio.bio, b = b, c_bio.bio
    try:
        rv = ssl_context.wrap_bio(py_bio, py_bio, ...)
        ssl_h.set_options(
            (<pyssl.PySSLSocket*>rv._sslobj).ssl, ssl_h.OP_ENABLE_KTLS
        )
        return rv
    finally:
        c_bio.bio, b = b, c_bio.bio

这里我们还是用类似的办法,把 Python MemoryBIO 对象中的 BIO 指针,换成我们自己的实现,然后通知 OpenSSL 启用 kTLS。在 kLoop 的 TLSTransport 里,我们就会先用这个 wrap_bio() 函数创建一个含有我们自己 BIO 实现的 SSLObject 对象,然后后续的 TLS 握手、数据收发和断开连接等操作就都通过这个 SSLObject 来完成了。

TLSTransport 大致架构

比如说握手,我们就调用 do_handshake(),底层从 Python 到 OpenSSL 会最终调用到我们的 BIO 来发送或接收数据,我们的 BIO 则会创建相应的 io_uring 任务并告诉 OpenSSL:“你得等等”,并且一路返回到我们的 TLSTransport,抛出一个 SSLWantReadError 或者 SSLWantWriteError。我们抓到这个异常,忽略掉,正常返回主循环即可。因为 io_uring 的任务完成时,会重新回到这个 TLSTransport 的握手流程,然后重试 do_handshake()。那个时候,该发的已经发出去了,该收的也应该已经收到了,所以握手得以继续,直到完成为止。

同样的操作对于接收和发送数据也是一样的,只不过需要确保重试发送和接收时,缓冲区还得是同一个,因为 io_uring 的任务已经创建了,你不能过河拆桥,偷摸儿把缓冲区换掉就不地道了,人家那边还用着呢。所以 TLSTransport 的发送队列大体上与 TCP 类似,但发送时只能一个一个的发,并且只有在成功之后才能出列,不能批量处理了。

剩下的重点就在于 BIO 如何实现了。

首先,每个 BIO 也得存一个文件描述符,代表了底层的 TCP 连接,收发数据全靠它。

其次,我们需要实现 BIO 的控制回调,尤其是 BIO_CTRL_SET_KTLS——这就是前面 OP_ENABLE_KTLS 的 I/O 实现。此时握手已经结束了或者即将结束,OpenSSL 会把启用 kTLS 所需的密钥啦、初始向量啦、读写序列号什么的都帮我们准备好,我们只需要调用 Linux 的接口启用 kTLS 即可。

最后实现数据收发。最简单的自然是第一次收发,创建一个对应的 io_uring 任务然后返回“得重试”即可。但是这个任务不能就这么忘却了,它的回调需要至少做两件事:一,更新 BIO 的状态,记得说该任务已完成,下次重试记得返回成功;二,安排重试。所以呢,BIO 创建的 io_uring 任务得记得这个 BIO,还得记得上层的 TLSTransport。BIO 也得保存两个状态,一个是当前是不是发送中,另一个是当前是不是接收中。重试时,给进来的缓冲区得与保存的状态一致,然后根据状态对应返回“还得重试” 或者“已经完成”。

kLoop 的 TLS 握手流程

“启用 kTLS 之后,接收到的数据不用再解密了,发送的数据也不用先加密了,这事儿谁管?”反正不是 BIO 管。这就是 BIO 抽象的好处——我只管 I/O,至于数据该怎么处理,那是 TLS 实现的事儿。因为 OpenSSL 3.0 已经搞定了这一块儿了,所以我们最初选 OpenSSL 3.0 也是为了偷这一个懒儿。另外,因为 OpenSSL 自己不会复制内存,所以只要我们老老实实地用 recv_into()memoryview, 那么不管是 kTLS 启用之前还是之后,都不会出现内存复制的额外开销。

剩下还有一些边边角角的,比如 TLS 1.3 early data、rekeying 什么的 Python 暂不支持,再比如优雅断开连接或者降级为明文 TCP 倒是都能实现,篇幅所限就不深究了。

任务拆分及下一步

这篇文章零零散散写了得两个多月了吧——代码倒是没写几行——到这里算是有了一个大概的蓝图了。先总结一下接下来要做的,都是可以直接认领的独立任务:

  1. 把 TCP 连接的部分从 transport 里抽离出来,在 nogil 的 Cython 里实现 happy eyeballs 什么的;
  2. 完成 TCP 的客户端实现;
  3. 完成 TCP 的服务器实现;
  4. 完成 TLS 的实现;
  5. 性能评测!看看到底能快多少;
  6. DNS 部分还有很多 todo!(),不实现的话会崩溃;
  7. 加测试,把 uvloop 的测试套件搬过来;

以上基本的功能有了就可以发个 0.1 版了,然后就可以胡搞乱搞了(有些也可以提前认领来做):

  1. TLS sendfile() 王炸!写完了可以去跟 NGINX 比速度;
  2. UNIX domain socket?
  3. UDP?
  4. 支持管道和子进程?
  5. 系统信号、进程 fork 和多线程什么的;
  6. 用 Rust 的各种 HTTP/1/2/3 库搞一个高性能 ASGI 服务器?……以及捎带手搞一个 WSGI 服务器?
  7. 支持 Trio 的运行时?
  8. 想到再加!

关于 Gitee 的私货

最后随便聊两句关于 Gitee 的事情。

之前做 GINO 的时候,我曾经在 OSChina 发过博客,所以也了解国内有个 @Gitee,但是没太当回事,做了个镜像而已,因为 GINO 在 GitHub 扎根已深。最近这不是挖了个新坑吗,我就想两边儿都看看,结果没想到就碰上了 Gitee 批量重审的事情。

这个事件说实话对我没有什么影响(因为我不用 Gitee 当图床?还有……图床到底是个什么鬼?),因为最早创建 Gitee 项目的时候,就已经让勾选那三个“同意”才能开源了。事发之后,无非也就是周五晚上重新打勾,周六就给审核通过了,之后也没有发现任何异常或延误。

倒是后来网上事件开始发酵,逐渐感觉味道不是太对了。公共内容接受监督本来就是默认的事实,这次 Gitee 突然批量操作也可能确实不专业(也许是因为之前节省了自审成本,出现了猝不及防的漏网之鱼?),但是怎么就突然上升到“违背了开源的自由精神”了?满网乌央乌央地强行蹭流量(好像流量最大的那个问题已经让知乎给夹了),这就是自由的开源精神了?不讨论监督的对错,但不知道大家有没有在 sourceforge.net 上创建过项目,反正我大概 15 年前创建的时候,是要等管理员审批的,性质可能不一样,但对于“普通的”开源项目,操作流程确实是一样的。

听了 Gitee 九周年的直播,才了解到 Gitee 根本不需要人帮忙洗白,人家的业务重心在政企组织。真正需要保护的,是国内开源社区的精气神儿,是能踏实做技术的信心,看起来这也是 Gitee 的团队着急的地方。直播中我还发现,十几年前我与当时还没加入 OSChina 的马老师曾有过一面之缘,当时我就一热血愤青小毛孩,马老师问我,“你觉得开源是什么?”我好像叽里呱啦地说了一堆没用的技术细节。这次直播中,马老师说,“现在开发者要空前的团结,拧成一股绳”。现在对于同一个问题,我可能会说,“开源是踏踏实实做技术,然后你做的东西刚好别的开发者也能用的上,相互激励。”

老子曾经曰过:利而不害,为而不争。kLoop 将使用 Gitee 开发,在 GitHub 设置镜像,欢迎全球开发者一起参与。就酱。