最近在给公司写一个高性能代理,成品在不久的将来会以GPL协议开源。关于这个项目我会写一组博客,目前已经有了一个草稿,详细内容有待填充。
这两天一直在思考如何重构代理的工作线程部分,以及需要的话应该如何实现多路复用(multiplexing)。昨晚看了一个Boost.asio的思路,早上又读了读HAProxy是如何抽象epoll的。asio应用起来思路非常清晰,但抽象层次略高于我需要的Proxy;HAProxy的抽象层次合适、思路干净,但对于我的Proxy的应用范围来说又过于复杂。晚上散步继续琢磨如何将两者的优点整理进手头的项目时,突然体会到一点程序模块间分工的“第一原理”。思路从“事件驱动”(Event-Driven)而来,有点类似“单一责任原则”,但又稍微更泛泛一些。粗俗地说:
事儿来了,会干的部分干明白;不会干的部分交给会干的干。
似乎有点显然,我稍微展开一点。
艰苦朴素
所谓代理,要做的事情不过就是读取A的消息,转发给B;读取B的消息,转发给A。一个最简单的代理逻辑如下:

所谓(edge-triggered-)epoll,不过就是当文件提示符(file descriptor)的状态发生变化的时候产生一个事件,并在事件里包含该文件提示符现在的状态,如是否有新消息可以读,是否有空间可以写等等。但这里存在一个小问题——如果epoll产生事件告知程序”这个fd有数据可以读,而现有的read buffer空间不足以读完全部内容时,epoll将不会产生另外一个事件(因为“读”状态一直都是准备就绪的,也就没有状态变化来引发另外一个事件)。所以每次有一端将内容传递给下一段发送后,程序一定要手动调用handle_*_read来检查一个fd是否还有残留的数据,所以这里我们加一个逻辑:

在早期版本中,这段代码(其中一个函数)长这个样子:proxy-a-bit-messy-imo (github.com)
可以看到tunnel的成员函数既需要关心buffer的状态,又需要明白epoll的关键字(EAGAIN/EWOULDBLOCK),并且还要记得在适当的地方重试(最后一行)。主观上这个复杂程度(对于目标应用来讲)算不算高暂且不论,它几乎没有任何抽象,需要理解每一个层面的问题才能正确工作。另外它直接依赖epoll的关键字,使得代码较难重构。
重构
这个重构吸收了一点HAProxy的设计,大概长这个样子:proxy-a-bit-less-messy-imo · GitHub
在写这段代码时我的思路是这样的(如上文的“第一原则”所言):
- 我真的想不清楚当我收到EPOLLIN和EPOLLOUT(事件中的参数)的时候应该做什么,但是至少我可以对它们进行解释。
- EPOLLIN是说哪里有一些数据可以读了
- EPOLLOUT是说哪里有一些空间可以写了
- 所以我先抽象这个“哪里”出来,按成规起名叫Channel;然后仅仅用事件中的参数更新自己的状态
- 更新完状态之后做什么?我不知道,但有人应该知道,所以啥都不做
- 有一位兄台,它不懂、甚至不想知道什么是EPOLLIN和EPOLLOUT。但它知道如何做双向转发,随便起个名叫Pipe。
- 不管我需要做什么行动,在此之前肯定至少有一个事件抵达,所以不需要额外的事件触发器(需要一点分析,请随意跳过)
- 如果我需要读,之前一定至少有一个EPOLLIN(没准备好读什么?)
- 如果我需要写,之前要么有一个EPOLLIN(不读数据写什么?),要么有一个EPOLLOUT(腾出空间写了)
- 如果我的fd一直没读完(因为此端的读buffer满了),那么此间彼端一定EWOULDBLOCK在写(否则彼端的写buffer应该是空的,可以转移此端读buffer的内容并重置状态),所以此后一定有一个EPOLLOUT
- 如果A能读,让A读
- 如果A有数据,B能写,把A的数据给B写
- 对B也一样
- 不管我需要做什么行动,在此之前肯定至少有一个事件抵达,所以不需要额外的事件触发器(需要一点分析,请随意跳过)
- 那么让它们俩都实现一个事件处理器(EventHandler)接口
- handle方法负责“干明白会干的事儿”
- next方法负责“对于不会干的事儿,找一个会干的人”
- propagate方法负责把消息传递出去
几个优点:
- 主观体验好了一些(less cognitive load)
- 可以将系统IO换成poll/select/kqueue甚至io_uring,不会影响上层的代码逻辑(lower change amplification)
- 更模块化一些,例如可以在不替换Pipe逻辑的情况下,将Channel更换为支持多路复用的MuxChannel(lower change amplification)
综上,根据《软件设计哲学》(A Philosophy of Software Design (John Ousterhout))所述(详情),这个重构降低了系统的复杂程度,是一个改进。
另外:
- 两个gist都不是最终代码,仅供演示参考
- 为啥要用epoll这么底层的东西写?主要是想学造轮子(我闲的);其次是我们确实需要一个支持AF_VSOCK的高性能代理——扩展现有的成熟框架(wangle, asio, etc)的话要么难度也不小,要么是框架本身太重。
- 似乎已经不是“速记”了啊…写了蛮久,还学了学怎么用Inkscape画图。
献丑了,这个设计模式叫责任链(chain of responsibility)
https://refactoring.guru/design-patterns/chain-of-responsibility
LikeLike