C++ Workflow异步编程框架 - 性能优化网络篇
时隔(鸽)一年半,Workflow架构系列又回来惹~虽然搁笔许久,但我们项目几乎每天都在更新代码!
GitHub是主战场,欢迎大家在github关注一手信息,这段时间的海量功能更新,都在散落在文档、issue、以及我后来的其他文章和回答中了。
过去一年的提交动态!真的!没有!偷懒!👇
今天整的活,是被催更最多的,Workflow中最重要的优化——网络。
通信器可以说是Workflow的前身,是我老大从自研分布式存储模块中演化出来的,并且由于老大很少看其他项目的做法,因此个人感觉其中有许多创新点值得分享,如果大家看腻了千篇一律的做法,也许这里可以和你产生一些思维的碰撞。因此本篇欢迎大家讨论、交流,以及指出执笔的我写得不对的地方~
P.S. 这个系列是我在2020年7月刚开源时积累的一些鶸鶸的思路,免得一些后来认识的开发者不太了解,这里附上一些鶸鶸的链接:
C++ Workflow异步调度框架 - 基本介绍篇
C++ Workflow异步调度框架 - 架构设计篇
C++ Workflow异步调度框架 - 性能优化上篇
然后我们从底向上,开始今天的话题——网络优化。
项目地址GitHub👉 https://github.com/sogou/workflow
项目地址Gitee👉 https://gitee.com/sogou/workflow
一、和事件循环不一样的全新玩法
有趣的新东西放第一部分说:Workflow使用epoll的方式有什么不同?
答案是线程模型。
我们常用epoll提供的三个接口:create、ctl、wait。连接多了的时候,异步要做的就是用尽可能少的线程去管理fd,以节省创建销毁线程的overhead以及线程所占用的内存和对资源的争抢。
所以高性能网络框架,都要管理着自己的多个线程(或者nginx的多进程)对epoll进行操作,并对上层提供原子性的语义。
好,我们现在给n个网络线程去操作epoll,全局这么多fd怎么分配和管理呢?
我们以前都见过的通用的做法是事件循环,用one loop per thread的方式进行分配和管理的。
以下我描述一下我弱弱的几点理解:
- 如果是server,是被动方,那么要做好accept工作
- 如果是client,是主动方,那么要做好connect工作
- 这些都是要从全局的角度来分发fd
- 然后按照这n个线程当前的负载量分发给一个人,这个人来全面负责这个fd的:吃(增)喝(删)拉(改)撒(等)
而Workflow的方式不一样:
- 分发部分我们先简单地对fd进行n取模,毕竟建立连接大家也是异步做的呀,连接的响应已经可以交给网络线程去做了
- 然后这个网络线程就继续做等待这个被分配的fd以及响应它的所有事件
- 并且,敲黑板~,如果一个线程在epoll_wait,另一个线程向epoll里添加,删除或修改fd这在Workflow里都是常规操作,因为epoll、kqueue都是支持这个特征的
所以看到这里边最大的区别是什么了吗?
事件循环是通过eventfd或者其他方式打断epoll_wait来添加fd。显然,这个做法在很多场景下其实对性能是有影响的。
如果对一个的操作有变动,Workflow怎么做呢?我们会通过一个pipe事件通知这个poller thread。
举个例子。如果要删除一个fd,那么如果别人把fd从epoll删除,删除之后就没有契机告诉该poller thread去做它要做的事情(最典型的,比如,删掉对应的上下文或者调用钩子等)。所以要借助pipe事件来通知“删除”这件小事儿,而这个等这个poller thread下次有正事儿要做的时候,再一并处理就完了,无需现在叫醒它就为了干点小事儿。
好奇宝宝你可能会问:fd直接取模难道不会不均匀吗?
这里有个很重要的设计上的优化理念。
Workflow从来不做空跑QPS之王,Workflow做的是一个跑得又快又稳的通用企业级框架,所以贯穿整个项目一个设计理念就是面向全局优化:
即,比起尽可能优化一个请求得到最优性能,我们更倾向于优化整体的请求得到最优性能。
如果系统本身很忙,那么其实连进来的大部分fd都会比较繁忙,因此暂时还不需要去做分发,取模就够用了。毕竟每个优化步骤都是有点小开销,到底优化谁,这是个非常compromise的事情。
这个优化思路后面还会持续看到~
虽然这种线程模型的新做法,不一定会成为Workflow高性能的最决定性因素,但却是我个人觉得最值得分享的新思路,可以让我们这些暂时还没有把底层吃透、没办法上来就创新的入门开发者,也看看业内现在有了不一样的眼前一亮的有趣方案,也让我们可以不要那么浮躁,不要为了快速出成绩浪费了自己的思考机会,而应该大胆设计,小心实现。
二、比proactor走得更远:消息的语义设计
上一部分讲的,除了封装多线程以外,网络库还要提供我们所设计的接口。
而Workflow的另一个不同点在于,它不是网络库,而是从网络模块到上层具体协议、任务流都有的成型框架。所以提供的接口语义并不是proactor、reactor,Workflow的语义是以消息为单位的。
为了简单起见,这里以收消息为例:
- Reactor是有事件来了,我告诉你,你负责去读出来;(epoll所提供的功能)
- Proactor是你给我一片内存,我把数据读出来了之后告诉你,一次通信的消息可能你是要读好几次才能读完的;(iocp,以及很多网络库的做法)
- Workflow是别管事件来了和读多少几次,我会帮你把你要的完整消息都收好了,再叫你;(也就是上层的每一个任务)
这显然更加符合人类的自然思维,接口的简洁和易用也是我们对Workflow一直以来的坚持。
我们依然从底向上,看看一个消息长什么样:
1.pollet_message_t
struct __poller_message poller_message_t;
struct __poller_message
{
int (*append)(const void *, size_t *, poller_message_t *);
char data[0];
};
最底层很简单,一个钩子,以及一片内存。
2.CommMessageIn
class CommMessageIn : private poller_message_t
{
private:
virtual int append(const void *buf, size_t *size) = 0;
…
这个派生类是收到的消息,增加了一个append接口:
数据来了上层可以拿走,并通过ret告诉底层核心之后的状态。这里的size是个双向参数,你甚至可以告诉我你现在要拿走到哪里,剩下的我帮你接管、下次再给你。
其中:
- ret返回1表示消息收完;
- ret返回0表示还没收完;
- ret<0表示消息错误。
正是这个返回值让底层核心知道如何切出一份完整的消息。
3.CommMessageOut
class CommMessageOut
{
private:
virtual int encode(struct iovec vectors[], int max) = 0;
…
发送接口也很简单。
到此就是核心通信器所做能看到的消息接口。但作为一个成熟的框架,我们认为还远远不够方便,因此消息的语义我们继续往下看:
ProtocolMessage会从CommMessageIn和CommMessageOut派生,因为对于server来说,in就是request,out就是response,而对于client来说,out就是request,in就是response,我们会需要收发两种功能。
class ProtocolMessage : public CommMessageOut, public CommMessageIn;
而消息收完了没有,是由协议开发者来做的:具体协议需要派生ProtocolMessage来实现刚才说的append
和encode
接口。因此Workflow的Http、Redis、MySQL、Kafka、DNS等协议都是自己手写解析的,这样才能真正做纯异步、收发不受原生模块的线程模型影响,这也是性能足够快的关键点之一。
Workflow的很多用户是在用作异步MySQL客户端,虽然我们认为MySQL性能瓶颈应该在server才对,但是只要想提升并发,原生client就会捉襟见肘,而且随着目前MySQL协议各集群的崛起,Workflow伪装成MySQL client的时候,对方也常常并不是原生MySQL server,而是tidb之类的其他集群版伪装者(开源世界就是如此有意思~
三、特殊的异步写实现
如果你是后端开发,你就会有同感,我们处理的绝大部分tcp请求场景其实都是一来一回的,这是Workflow最擅长的领域,以至于在这在场景下,Workflow又出现了一个新思路:高效的异步写实现。
我们知道fd天生是可以同时监听EPOLLOUT和EPOLLIN的,如果这样做,我们的网络库可以在有事件处理的时候,既看看是不是要读、又看看是不是要写,这些都在这次loop被叫醒的一次中做。
因此Workflow无论是client或者server,fd长期都保持EPOLLIN状态。需要写数据时,先同步的写,如果数据可以全部写入tcp buffer,则无需改变fd的状态;如果数据无法全部写入,通过epoll的MOD,原子性的把fd从EPOLLIN状态改为EPOLLOUT状态开始异步发送。异步发送完成(fd会从epoll里删除),再把fd以EPOLLOUT状态重新加入epoll。
而且为了性能考虑,我们的poller_node是一个以fd为下标的数组,而每个node只能关注一种事件,READ或WRITE。然后我们会通过operation来判断调用哪个处理函数(而非用event来判断)。
这种做法实现出来的通信器,其实比任何全双工都要快。
因为大多数情况下,没有必要进行异步写。操作系统会动态调整TCP send buffer的大小,从100多K逐渐增加至少10M。需要异步写的场景很少,所以epoll里的fd基本不用动不会有额外开销。如果真的需要异步写,基于一来一回的模式下这个fd只写的模式那也是炒鸡快的。
四、超时处理是一门学问
超时难不难?超~难。
难在哪里呢?我个人理解是难在于精确地响应、高效的管理、和严格的原子性。
我们先看看精确地响应。
首先要基于一个足够精确的机制,所以我们用了linux的timerfd(kqueue的话用了timer事件,没错依然辣么统一~)。每一个负责操作poller的线程,都有一个timerfd去负责当前该线程所有在监听的fd的超时事件。
这就够精确了吗?那可太天真了,毕竟网络那么多步骤,而且我们不希望用户每个请求都去关心connect、request、response这么多阶段。
那怎么办呢?我们先对超时提出了几个阶段,最底层是全局给几个配置:
- connect_timeout: 与目标建立连接的超时。
- receive_timeout: 接收一条完整请求的超时。
- response_timeout: 等待目标响应的超时。代表成功发送到目标、或从目标读取到一块数据的超时。
这个每读一块数据的超时值得注意,在实际网络链路不好的情况下需要阶段划分出来,否则会很惨烈(相信我我是过来人
再往上一层,我们开发者还需要对连接层有超时管理的权利:
- keep_alive_timeout: 连接保持时间。默认1分钟。redis server为5分钟。
再往上,每一个任务。
如果连接已经达到上限,默认的情况下,client任务就会失败返回。但是我们允许通过任务上的一个超时值,来配置同步等待的时间。如果在这段时间内,有连接被释放,则任务可以占用这个连接:
- wait_timeout: 全局唯一一个同步等待超时。
再往上?那就不是框架需要管的事情了,熟悉Workflow框架的开发者应该知道,我们提供了timer_task与任务流,所以我们可以用timer_task和我们的业务逻辑的task一起随意搭配组装使用~让你写代码都能感受到拼乐高的乐趣~~
以上的架构设计,足以满足我们对网络请求中精确的超时控制。
我们再看看高效的管理。
目前的超时算法利用了链表+红黑树的数据结构,时间复杂度在O(1)和O(logn)之间,其中n为poller线程的fd数量。
超时处理目前看不是瓶颈所在,因为Linux内核epoll相关调用也是O(logn)时间复杂度,我们把超时都做到O(1)也区别不大。
最后,严格的原子性,设计我们配合第五部分的代码一起感受一下。另外涉及到Communicator的状态转换,所以就需要放到单独的代码解析中了(并不是这篇文章写到这里写累了._.
五、循环部分的代码讲解
我们把src/kernel/poller.c中,一个网络线程所执行的核心函数拿出来一行一行解读:
static void *__poller_thread_routine(void *arg) // 线程函数入口
{
poller_t *poller = (poller_t *)arg;
__poller_event_t events[POLLER_EVENTS_MAX]; // 刚才提到的以fd为下标的超级快的poller_node数组
struct __poller_node time_node;
struct __poller_node *node;
...
while (1)
{
__poller_set_timer(poller); // 继续开始等待之前,需要更新本轮要等的下一个超时时间,就是这里设置的timerfd
nevents = __poller_wait(events, POLLER_EVENTS_MAX, poller); // 2000 years later ...
clock_gettime(CLOCK_MONOTONIC, &time_node.timeout);
has_pipe_event = 0;
for (i = 0; i < nevents; i++) // 对于每个本线程要处理的已经ready的事件
{
node = (struct __poller_node *)__poller_event_data(&events[i]);
if (node > (struct __poller_node *)1)
{
switch (node->data.operation) // 一个正在监听的node(一个会话、或文件读写操作等)只会有一种状态
{
case PD_OP_READ:
__poller_handle_read(node, poller);
break;
case PD_OP_WRITE:
__poller_handle_write(node, poller);
break;
... // 还有很多其他异步操作,比如连接也是异步的,以及ssl的复杂操作。建议大家先看nossl分支学习
}
else if (node == (struct __poller_node *)1)
has_pipe_event = 1; // 我们使用了node==1标记pipe事件,毕竟fd为1不可能是合法地址,用之~
}
if (has_pipe_event) // 这里说明本轮有pipe事件,pipe用来通知各种fd的删除和poller的停止
{
if (__poller_handle_pipe(poller))
break;
}
__poller_handle_timeout(&time_node, poller); // 处理超时事件,如上所述从红黑树和链表里把所有超时的节点都处理掉
}
...
}
六、还有什么Workflow目前不做
本质上,workflow优化的主要方向都是:通用地用尽量少的系统资源,去做尽可能多的事情。
所以,Workflow的通信器是全世界最快的通信器吗?
很显然不是。
Workflow只是一个够快够稳够简单好用的、并且携带很多新思路的企业级框架,而且其异步特点在于可以做到调度无损耗。
这个得益于很多方面,上述这些折中的选择其实非常重要,另外还得益于架构层面的:
- 对资源的一视同仁、
- 接口设计的对称性、
- 任务流的编程范式
等等。并且,刚才有提到,Workflow做的优化决策,都是面向全局的,这样的例子还有很多。
许多高性能优化方向,Workflow目前没有用到,并不是东西不好,而是很多时候目前还没有必要,或者通过实际测试得出对比数据,发现必要性没有想象中的高,又或者优化思路不太通用。
包括以下这些:
- 消息收回来,可以不切一次线程。
但Workflow目前都会切,并过一次消息队列(某些空跑场景下显然不切会更好,但Workflow还是走实用路线~ - workstealing队列
很多调度系统包括go内核都在使用,但Workflow的队列依然只是一个简单的队列,我原先有写过多种简单队列的对比,目前用的双队列模式,是简单队列里实测最快的! - 其他无锁技术,
以及有人会使用eventfd去替代cond,或者对cond进行cacheline优化(这也是一个很有趣的方向,但前两者我还没尝试。后者实践过,cacheline优化听上去很美丽,但简单场景下没有测出实际的性能提升🤔 - cpu亲和性相关的优化
在服务器已经够繁忙的时候。这个优化并不是那么有必要,但后续有空我会去学习一下~ - 各种用户态的优化
用户态协程、用户态协议栈~~~ - 各种内核态的优化
比如早就出来十几年、最近突然又火了的技能树eBPF
七、最后
希望这篇优化,除了新的epoll使用方式、新的异步写方式等新思路以外,更多地是分享一些做事情的方法。
一方面,我们在做优化的时候,既要保持对新技术的好奇心,又不能对新技术趋之若鹜。有些花里胡哨的做法听上去超级棒,实际上真的有用吗?很多时候,操作系统已经帮你做得很好的事情,就不要自我感动拍脑袋去做好吗。
但另一方面,反过来说,又需要保持足够的自我思考,多琢磨多看看新思路新做法,从而提升自己的思路,去创造出更多有价值的精品,从做题家进化为代码艺术家。
其实本来这次还想写写连接管理,毕竟也做了很多事情,但是真的写不完了╥﹏╥好不容易提笔,我必须!现在!立刻!马上!把这篇文章发出!!!因此连接管理和其他的控制逻辑,我会放到下一篇~(# /ω\#)
主页上有吞吐和长尾的优秀的Benchmark,欢迎大家到点击[阅读原文]到主页围观,另外附上GitHub上项目主作者对某个issue中一个问题的用心回答截图:
图长警告⚠️这只是对一个问题的回答,截四段是因为回答太长👇
我们小团队真的是做用心血去开发与推进这个项目,Workflow的发展虽然与kpi无关,但至少和我的个人技术信仰有关~希望大家可以喜欢我积累的新思路,以及不嫌弃我一丢丢啰嗦的心得体会。
并且!在等不到我的文章的时候,乃们可以去issue找主作者答疑解惑嘤嘤嘤~
GitHub - sogou/workflow: C++ Parallel Computing and Asynchronous Networking Engine
七、最后
希望这篇优化,除了新的epoll使用方式、新的异步写方式等新思路以外,更多地是分享一些做事情的方法。
一方面,我们在做优化的时候,既要保持对新技术的好奇心,又不能对新技术趋之若鹜。有些花里胡哨的做法听上去超级棒,实际上真的有用吗?很多时候操作系统已经帮你做得很好的事情,就不要自我感动拍脑袋去做好吗。
但另一方面,反过来说,又需要保持足够的自我思考,多琢磨多看看新思路新做法,从而提升自己的思路,去创造出更多有价值的精品,从做题家进化为代码艺术家。
其实本来这次还想写写连接管理,毕竟也做了很多事情,但是真的写不完了╥﹏╥好不容易提笔,我必须!现在!立刻!马上!把这篇文章发出!!!因此连接管理和其他的控制逻辑,我会放到下一篇~(# /ω\#)
主页上有吞吐和长尾的benchmark,欢迎大家到主页围观,另外附上GitHub上项目主作者对某个issue中一个问题的用心回答截图:
我们小团队真的是做用心血去开发与推进这个项目,Workflow的发展虽然与kpi无关,但至少和我的个人技术信仰有关~希望大家可以喜欢我积累的新思路,以及不嫌弃我一丢丢啰嗦的心得体会。
并且!在等不到我的文章的时候,乃们可以去issue找主作者答疑解惑嘤嘤嘤~~~
GitHub - sogou/workflow: C++ Parallel Computing and Asynchronous Networking Engine
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。