从2013年开源至今,DPDK已经走过了10个年头。从最初定位的一个简单的L2层高性能包转发工具,不断发展演进,到现在已经覆盖了软件定义网络(SDN)、主机虚拟化、网络协议栈、网络安全、压缩和加密、甚至是GPU设备和深度学习框架等各个方面,它在全球范围内得到了广泛的关注和研究,成为构建高性能网络应用程序的重要工具之一。
DPDK最重要的特性就是用户态的 poll mode driver,相比于内核的 interrupt 模式,polling 的延时更低;并且由于不需要在用户态和内核态之间频繁切换,理论上也会更加节省CPU开销,从而提升系统最大的数据包处理能力。
日子久了,DPDK的开发者们逐渐开始不满足L2/L3层的应用开发了,对DPDK支持TCP/IP协议栈的呼声自始至终就没有停息过。然而,可能是由于网络协议栈实在是太复杂,即使是在10年之后的今天,DPDK仍然没有推出官方的解决方案。开源社区有自己的一些尝试,如 mTCP、F-Stack、Seastar、dpdk-ans 等等,不一一列举。大体方案分为两类,一是移植现有的协议栈如FreeBSD,另一种就是完全重写。这些基于DPDK的开源项目的测试数据表明,它们通常能够获得比Linux内核的原生socket更好的性能。
然而,跟普通的Linux网络应用开发相比,DPDK开发还是有一定的难度的。首先是硬件方面,通常需要物理机。由于最初 Intel 的网卡只能通过 igb_uio 或者 vfio 的方式将网卡 passthrough 透传给 DPDK,因此往往需要给物理机配置多个IP地址,并且开启 SR-IOV,一部分用来调试,另一部分用来保证管理网络正常运行。之后的 Mellanox 网卡,由于其DPDK驱动可以跟内核驱动共存,因此不用透传独占了。
由于 Flow Birfurcation(流分叉)这种硬件辅助技术的出现,网卡现在还可以将关注的报文流量(比如某个TCP端口、某个IP地址)在还未进入系统内核之前,就通过分类过滤的方式提取出来,然后转发给DPDK应用程序,剩余的流量仍然原封不动地提交给内核。于是也不用给管理网络分配独立的IP了,简化了开发流程。这些具体的步骤会在后文提到。
在软件方面,众所周知,DPDK程序在每个CPU核心上运行的函数都是一个大的 while 循环。在循环中,可以添加网卡收发包的代码,以及对应的业务逻辑,整体上的架构是一个异步回调的事件模型。由于是 polling,需要让一次循环尽快结束以便开启下一轮,因此代码中一般不能出现长时间的阻塞调用,如sleep
。业务逻辑一般只关注网络,比如网关、防火墙这种典型应用,涉及到文件I/O的功能,可能还需要通过跨线程通信的方式,转交给专门的I/O线程去执行。比如,DPDK跟qemu的通信方式是共享内存。
习惯了Linux系统的应用开发者可能会对此不太满意,虽然很多Linux上的网络服务器也是异步回调的(基于epoll),但通过线程池、协程池这样的技术手段,开发者们可以很方便地通过开启新的执行单元(Procedure-Unit)实现并发逻辑。不管是线程还是协程,现有业务的旧代码中可能充斥了大量的锁、信号量、条件变量等。如果突然需要移植到DPDK的while大循环内,工作量将不容小觑。举例来说,如果原先业务代码在某个线程中sleep了1秒,那么在新的DPDK程序中,可能得在每次 poll 时都检查一下当前时间,发现超过了1秒则执行后续逻辑。那么,如果有更多的sleep,又该如何是好呢?
这些问题,归根结底是编程模型的差异。一种是基于事件驱动的,另一种是基于多个执行单元并发的。有人可能会认为异步事件模型性能更高,这实际是一种误解。Lauer 和 Needham 在1979年发表的 On the duality of operating system structures 一文中曾经证明,events 和 threads 是对偶的,具体是指:
- 用一种模型编写的程序可以直接映射到基于另一种模型的等效程序。
- 这两种模型在逻辑上是等效的,尽管它们使用不同的概念并提供不同的语法。
- 假定使用相同的调度策略,用两种模型编写的程序的性能基本相同。
于是,我们尝试使用PhotonLibOS协程库(以下简称Photon),以多执行单元并发的代码模型,简化DPDK应用程序的开发,并且为它增加更多的功能,如文件I/O等。首先需要选择一个用户态的网络协议栈,经过调研,我们选择了腾讯开源的 F-Stack 项目,它集成了 FreeBSD 11.0 的网络协议栈并做了一些裁减,提供了一组POSIX API,如socket
、epoll
、kqueue
等。当然,由于本质上是FreeBSD,因此它的epoll
也是用kqueue
模拟的。
下面这段代码摘自 F-Stack example ,它目前的核心代码仍然是 while 循环的模式,需要提供一个loop,然后注册到大循环内部执行,网卡的收发包代码被安排在了loop的上下文附近。虽然说已经提供了事件接口(这是协程化的必备条件),但由于没有调度器,所以整体代码风格跟之前的DPDK仍然差不多。
intloop(void*arg) { intnevents=ff_epoll_wait(epfd, events, MAX_EVENTS, 0); inti; for (i=0; i<nevents; ++i) { if (events[i].data.fd==sockfd) { while (1) { intnclientfd=ff_accept(sockfd, NULL, NULL); if (nclientfd<0) { break; } ev.data.fd=nclientfd; ev.events=EPOLLIN; if (ff_epoll_ctl(epfd, EPOLL_CTL_ADD, nclientfd, &ev) !=0) { printf("ff_epoll_ctl failed:%d, %s\n", errno, strerror(errno)); break; } } } else { if (events[i].events&EPOLLERR ) { ff_epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL); ff_close(events[i].data.fd); } elseif (events[i].events&EPOLLIN) { charbuf[256]; size_treadlen=ff_read( events[i].data.fd, buf, sizeof(buf)); if(readlen>0) { ff_write( events[i].data.fd, html, sizeof(html) -1); } else { ff_epoll_ctl(epfd, EPOLL_CTL_DEL, events[i].data.fd, NULL); ff_close( events[i].data.fd); } } else { printf("unknown event: %8.8X\n", events[i].events); } } } }
我们尝试将Photon的调度器与F-Stack的while循环集成到一起,并且目标是在同一个线程内。Photon通常会使用epoll
或者io_uring
作为调度器的主事件引擎,可以注册关注的fd,不管是文件fd还是socket,当I/O事件完成时,对应的协程会被唤醒,并执行后续操作。
那么我们应该用什么样的Photon函数给F-Stack做while循环呢?首先,我们做了一个集成在Photon内部的kqueue
事件引擎,使用跟原先两个引擎相同的接口,只不过在poll F-Stack的kqueue时,timeout参数传了0。这点很好理解,即如果poll的时候发现网卡有I/O,那么立刻记录到协程引擎,以备后续唤醒;否则立刻退出,让下一次poll有机会执行。接着,我们编写F-Stack的循环函数,除了每轮必须的poll 0之外,还需要一个让协程主动让出CPU的函数(photon::thread_yield()
)。只有这样,在busy polling的间隙里,主事件引擎的io_uring
才能有机会执行自己的poll。两种poll的执行概率在调度器里是可调整的。
起初我们担心,在同一个线程内开启两个事件引擎去poll会降低性能,然而经过测试发现,以io_uring为例,一次io_uring_wait_cqe_timeout
在timeout为0时,只有一个syscall和一次原子操作,时间开销在几微秒左右,相比于网卡延迟来说还是较低的。因此,只要主事件引擎不忙,对F-Stack循环的性能影响就非常小,详细测试数据见后文。
让我们来看一下经过Photon集成的F-Stack + DPDK代码最终是什么样子。如下所示,首先需要在当前执行的OS线程(Photon称为vcpu)上初始化FSTACK_DPDK环境,作为一个I/O engine。当 photon::init 完成之后,程序会进入busy polling模式。接下来是最有特点的地方,我们可以在代码的任意位置开启新的协程(Photon称为thread),在协程里可以睡眠,可以加锁,也可以进行文件I/O。我们还可以使用 photon::net 模块封装的 socket server,注册一个 server handler,从而实现一个类似上文 F-Stack example 那样的 echo server。对每个新的连接,server都会新建一个协程去处理。由于不再是事件触发的回调机制,因此 socket server 代码中不再需要使用大量的 if ... else 判断状态,整体代码风格简约、直观。
photon::init(photon::INIT_EVENT_DEFAULT, photon::INIT_IO_FSTACK_DPDK); // DPDK will start running from here, as an I/O engine.// Although busy polling, the scheduler will ensure that all threads in current vcpu// have a chance to run, when their events arrived.autothread=photon::thread_create11([] { while (true) { photon::thread_sleep(1); // Sleep every 1 second and do something ... } }); autohandler= [&](photon::net::ISocketStream*sock) ->int { autofile=photon::fs::open_local("file", O_WRONLY, 0644, ioengine_iouring); charbuf[buf_size]; while (true) { size_tret=sock->recv(buf, buf_size); file->write(buf, ret); sock->send(buf, ret); } return0; }; // Register handler and configure server ...autoserver=photon::net::new_fstack_dpdk_socket_server(); server->set_handler(handler); server->run(); // Sleep forever ...photon::thread_sleep(-1UL);
另外,这套代码的通用性也非常高。当我们不需要使用F-Stack + DPDK时,只要关闭 photon::INIT_IO_FSTACK_DPDK 这个参数,同时将 new_fstack_dpdk_socket_server 替换成 new_tcp_socket_server,就切换回了基于Linux内核socket的普通server。
我们使用上述代码的echo server进行性能测试,结果如下:
Server socket type | Client traffic type (Ping-Pong) | Client traffic type (Streaming) |
Linux 6.x kernel + Photon coroutine | 1.03Gb/s | 2.60Gb/s |
F-Stack + DPDK + Photon coroutine | 1.23Gb/s | 3.74Gb/s |
注意,我们将client的流量类型分为Streaming和Ping-Pong,前者用于测量网络服务器在高吞吐条件下的性能,现实中类似的场景是 RPC 和 HTTP 2.0 等多路复用技术;后者一般是为了测试多连接、大并发的场景,客户端的每个连接发送一个包之后,等待接收,然后才会开始下一个包的发送。关于测试环境,网卡是25G,服务端单线程,包大小是512字节。
不难看出,建立在用户态协议栈和DPDK之上的Photon socket,比原生内核的Photon socket性能更优。Ping-Pong模式提高了20%,而Streaming模式则提高了40%。如果逐步提高主事件引擎(io_uring)poll的比例,比如添加更多的sleep、或者文件I/O操作,使得其跟DPDK poll的比例达到1:1,此时server的性能大约会下降5%左右,可以接受。我们还对比测试了Photon版F-Stack跟原版F-Stack的性能,也基本一致,表明协程的引入没有带来额外开销。这是因为每次Photon协程切换的时间在10纳秒以下。
熟悉Photon的读者可能知道,自Photon从2022年开源以来,已经横向对比了众多开源项目,也在主页上列出了各个框架实现的echo server的性能测试数据,Photon凭借高效率的调度器、关键路径的高性能汇编代码、以及io_uring异步引擎的加持,目前仍然位列第一。此次集成了DPDK引擎,相当于是引入了跨界的竞争对手(interrupt vs polling),同时也是实现了一次自我超越。
讨论完性能,让我们回到本文写作时的初衷,即如何使用协程库降低DPDK应用程序的开发难度,屏蔽底层差异,我们相信Photon在这个方面的尝试是更值得被关注的。通过使用Photon库,开发者们可以轻松地将旧代码移植到DPDK框架之上,从而把更多时间关注在业务本身。
最后,为了让读者能够亲自验证上述测试程序,我们准备了一篇文档,详细描述了如何在Mellanox网卡上安装驱动、配置SR-IOV、安装F-STACK和DPDK、配置Flow Birfurcation、运行Photon等步骤,欢迎大家查阅。