之前介绍 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。
https://gitee.com/fantix/kloop
kLoop 与 uvloop 对仗,k 表示 Linux 内核(Kernel),主要想法是用内核的 io_uring 和 kTLS 功能来直接实现一个高效率的 asyncio 事件循环,因为我琢磨着这两个人应该是一对儿非常完美的搭档,理论上应该可以把 asyncio 的效率再提升一个档次。接下来我就稍微展开说说,欢迎有兴趣的同学一起跳坑。
熟悉 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 Speed100 35991 0 35991 0 0 23711 0 --:--:-- 0:00:01 --:--:-- 23709real 0m1.527suser 0m0.070ssys 0m0.005s
epoll() 是 Linux 上的高性能事件通知设施,可以同时监视多个文件描述符(比如 socket)的事件状态,如某个 TCP 连接成功了,或者某个 socket 收到消息了等等。epoll()
一次吧,有进展的 socket 各自又有一次读或写,万一没成功下回还得重试。高并发下,一秒几百次的事件循环,每次循环几十上百次 I/O,光系统调用的额外开销就要占到毫秒级别了。epoll()
,并在一个线程池中执行就绪的任务。
CPython 的全局解释器锁(GIL)是一种用来保证 Python 代码在多线程环境中正确执行的同步机制,任何线程只要运行 Python 代码就得先获取这把锁,除非只有一个线程,或者某些函数的底层实现会主动释放 GIL 一段时间。GIL 带来的副作用就是,多线程的 Python 程序往往很难有效地利用多核 CPU 资源。
TLS 的消息(Record)有多种类型,kTLS 将其分为应用数据消息(application data)和控制消息(control data,包括 TLS 的 alert、handshake 等非 application_data 的 Record)。 BIO 全称为基本输入输出,是 OpenSSL 对各种输入输出流封装出来的一个接口,比如 socket 有 socket BIO,文件有文件 BIO,OpenSSL 也允许我们提供自定义的 BIO 实现。sendfile()
,io_uring 也可以通过 splice()
实现)。其实,我们完全可以用 OpenSSL 1.1 进行 TLS 握手, 只不过 OpenSSL 封装的比较死,取对称密钥和序列号的手法比较脏(靠 key log 和内存指针偏移量)。再加上无法向 OpenSSL 1.1 直接提供明文数据,所以一旦内核接管了对称加密,所有的 TLS 控制消息就都废了,要么就得你自己编码解析;rekeying/renegotiation 就更别想了,更别说 TLS 1.3 还有 early data 什么的,握手都不一定能握完整,对安全性还是有挺大不确定性的。SSLSocket
对象中的 OpenSSL SSL*
指针暴露出来(这里用的是 Cython 语法):
cdef extern from "openssl/ssl.h" nogil:
ctypedef struct SSL: passcdef 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 了。
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)
openssl/bio.pxd
,这样就可以通过 from openssl cimport bio
的方式,自然地拿到一个 bio.
的命名空间,因此在定义中我们进行了重命名,使其更符合 Python 的行为方式。BIO_METHOD*
空间:
from openssl cimport biocdef bio.Method* KTLS_BIO_METHOD = bio.meth_new(
bio.get_new_index(), "kTLS 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 1bio.meth_set_write_ex(KTLS_BIO_METHOD, bio_write_ex)
nogil
和 with gil
的用法:nogil
用在方法签名上,标志着这个函数可以安全地用在 C 语言环境中,比如被设置成为 BIO_METHOD*
的一个回调函数。nogil 的函数也同时意味着,这个函数执行时不保证拿到了 Python 的 GIL,因此内部不能有任何的 Python 结构,除非在一个 with gil
上下文中。这也是为什么我们在用 Python 的 print()
的时候,需要先用 with gil
来获取 Python GIL 的原因。你问我为什么要用 Python 的 print()
,而不是用 C 语言的 printf()
?因为 Python 好用啊!data[:datal]
就直接把数据显示出来了,
还能看地址,用来调试代码再方便不过了。bio.new(KTLS_BIO_METHOD)
来创建自定义的 BIO*
对象,做任何喜欢做的事情了。
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 是用来向内核注册常用文件或缓冲区的,据说能够提高效率,但是我目前还没有研究到。
linux/io_uring.h
操作共享内存),一些关键逻辑和结构体就仿照 liburing 来做就好了。stdatomic.h
里的功能(需要 GCC 4.9)。
先计算这次循环能等待的最长时间 —— 如果就绪队列里有东西,那么我们就是 “一刻也不想多等”;否则的话,就是最多可以等到最近的计时任务必须开始执行时为止;
然后进 I/O—— 调用 io_uring_enter()
,一直等到有就绪的任务,或者超时为止。就绪任务进就绪队列;
接着检查计时队列 —— 到时的任务就出计时队列,进就绪队列;
最后就绪队列全部出列,然后一次性全部执行。
collections.deque
,计时序列则是用 heapq
来操作一个普通的 list
。这些数据结构的执行效率都是非常高的,但是我们既然已经用 Cython 了,不如就把效率推到极致,用 Cython 来写不包含 Python 结构(nogil
)的纯 C 代码,实现与 deque
和 heapq
类似的功能。对于链表,我用数组做了一个环状队列,二叉堆则是直接仿照 CPython 的 heapq
,用 Cython 实现了一个 C 的版本。io_uring_enter()
” 并不是每次都执行,因为 kLoop 默认启用了 SQPOLL
,只要持续不断的提交 I/O 任务,内核线程就会一直工作,自动执行流水线上的任务;而 kLoop 只需从 CQ 上不断获取结果即可。那什么时候调用 io_uring_enter()
呢?一,内核线程暂停了,而我们又提交了新的 I/O 任务;二,CQ 和就绪队列都空了,而我们又有时间可以等待 I/O。当满负荷工作时,这两个条件都不满足的概率还是相当大的,因此 kLoop 可以(偶尔)做到 “零系统调用” 运转。
/etc/resolv.conf
和 /etc/hosts
文件内容都可以自行提供。别的解析库像 c-ares 就只能给你一个文件描述符去 poll,而 libc 里的 getaddrinfo_a()
则似乎是用多线程来实现的。SockAddr
结构体数组,这样一来 io_uring 就可以直接拿过来用在 socket 上了。恰巧,Rust 中的解析结果就是 C 结构体的封装,所以并不需要太麻烦的转换,就可以生成我们需要的结果。C 与 Rust 之间的互相调用就更简单了,就是互相调用对方定义的外部函数,只要注意参数类型转换就好了。
TCPTransport
实例,以及一个通过给定工厂产生的、与该 transport 绑定的 protocol 实例。TCPTransport
,以及适时地调用 protocol 中的方法。int
类型的文件描述符,所以我们的 TCPTransport
定义就特别简单了:
cdef class TCPTransport:
cdef:
int fd
object protocol
send()
的数据合并成一股时,大家通常认为数据有可能会乱掉,对于 recv()
也是一样,并且更没有道理并行接收。(UDP 是一个例外,发送的数据报文都是有固定大小限制的,再加上本身就没有顺序要求,因此多线程发送完全没有问题,接收也行但意义不大。)TCPTransport
,我们需要一个自己搞一个发送队列,当目前已经提交了一个发送任务时,把数据临时放在这个发送队列里,上一个发送任务结束后再提交一个新的去发送剩下的。为了避免内存复制,我们自然是要把用户给我们的数据对象原封不动地放在队列里,但在创建发送任务时,我们可以用 sendmsg()
的数据阵列(Vectored I/O)来一次性将全部积压的数据都发送出去,以节省任务数量。data_received()
是在 Python 中进行的,所以我们可以用 call_soon()
将其放在下一次循环里,而抓紧时间先创建下一个接收任务,以求吞吐量的最大化。
TCPTransport
的 send()
函数是非阻塞的,也就是说,用户可以卯足了劲儿一顿发,发到网卡都反应不过来,结果我们的发送队列就会占用大量内存。此时,我们的 TCPTransport
应该及时看到缓冲区已经漾了,并且尽快通过 Protocol
的 pause_writing()
函数来通知用户:“别再发啦,歇会儿吧。”。虽然此时用户可以蛮不讲理地继续填鸭,但我们假设用户还是友善地给了我们喘息的机会。等到 io_uring 把该发的发得差不多了的时候,我们在通知用户 resume_writing()
可以继续发了。这里缓冲区有一个警戒线(漾了)和一个安全线(缓过来了),用户可以通过 TCPTransport
的 set_write_buffer_limits()
函数进行设置,我们在实现的时候则会记录下来,作为盯梢的目标。TCPTransport.pause_reading()
函数来告知这一诉求,而我们只需要把当前的接收任务取消就可以打破这个接收循环了,等到用户调用 resume_reading()
时, 再重新创建接收任务,重启循环。简单直接。
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_hfrom .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 =py_bio
c_bio.bio, b = b, c_bio.bio try:
rv = ssl_context.wrap_bio(py_bio, py_bio, ...)
ssl_h.set_options(
(rv._sslobj).ssl, ssl_h.OP_ENABLE_KTLS
) return rv finally:
c_bio.bio, b = b, c_bio.bio
MemoryBIO
对象中的 BIO 指针,换成我们自己的实现,然后通知 OpenSSL 启用 kTLS。在 kLoop 的 TLSTransport
里,我们就会先用这个 wrap_bio()
函数创建一个含有我们自己 BIO 实现的 SSLObject
对象,然后后续的 TLS 握手、数据收发和断开连接等操作就都通过这个 SSLObject
来完成了。
do_handshake()
,底层从 Python 到 OpenSSL 会最终调用到我们的 BIO 来发送或接收数据,我们的 BIO 则会创建相应的 io_uring 任务并告诉 OpenSSL:“你得等等”,并且一路返回到我们的 TLSTransport
,抛出一个 SSLWantReadError
或者 SSLWantWriteError
。我们抓到这个异常,忽略掉,正常返回主循环即可。因为 io_uring 的任务完成时,会重新回到这个 TLSTransport
的握手流程,然后重试 do_handshake()
。那个时候,该发的已经发出去了,该收的也应该已经收到了,所以握手得以继续,直到完成为止。TLSTransport
的发送队列大体上与 TCP 类似,但发送时只能一个一个的发,并且只有在成功之后才能出列,不能批量处理了。BIO_CTRL_SET_KTLS
—— 这就是前面 OP_ENABLE_KTLS
的 I/O 实现。此时握手已经结束了或者即将结束,OpenSSL 会把启用 kTLS 所需的密钥啦、初始向量啦、读写序列号什么的都帮我们准备好,我们只需要调用 Linux 的接口启用 kTLS 即可。TLSTransport
。BIO 也得保存两个状态,一个是当前是不是发送中,另一个是当前是不是接收中。重试时,给进来的缓冲区得与保存的状态一致,然后根据状态对应返回 “还得重试” 或者 “已经完成”。
recv_into()
和 memoryview
, 那么不管是 kTLS 启用之前还是之后,都不会出现内存复制的额外开销。
把 TCP 连接的部分从 transport 里抽离出来,在 nogil 的 Cython 里实现 happy eyeballs 什么的;
完成 TCP 的客户端实现;
完成 TCP 的服务器实现;
完成 TLS 的实现;
性能评测!看看到底能快多少;
DNS 部分还有很多 todo!()
,不实现的话会崩溃;
加测试,把 uvloop 的测试套件搬过来;
TLS sendfile()
王炸!写完了可以去跟 NGINX 比速度;
UNIX domain socket?
UDP?
支持管道和子进程?
系统信号、进程 fork 和多线程什么的;
用 Rust 的各种 HTTP/1/2/3 库搞一个高性能 ASGI 服务器?…… 以及捎带手搞一个 WSGI 服务器?
支持 Trio 的运行时?
想到再加!