之前写过写过一篇文章《C++网络库都干了什么》对cppnet做了一些介绍,然而那已经是19年的事情了。岁月荏苒,再回头来看当初的设计,多有疏漏,因此特意找时间将库整个重构了一遍,由此作文以记之。
水中月是天上月,眼前人却不再是故人。做的事情还在是以前的事情,完成的方式却大相径庭。重构后的网络库,所有组件的交互都通过接口解耦,各个模块件只知有接口而不知其他。另外添加了对kqueue
的封装,支持了macOS系统。
为了方便看官了解,不用再挖坟看之前的代码接口,这里简单将重构前的结构再罗列一下:
之前的结构大体上分为三层,最底层是action
事件驱动层,这层主要是对epoll
和iocp
的封装,还额外兼职管理了所有的定时器以及IO线程任务投递。在IO循环中将定时器与阻塞等待IO的函数相结合已经是网络库的常规操作了。然而这里将定时器的实例管理也下放到了action
里,使得action
不再符合职责单一原则,这是错的,可笑。
再上一层则可以叫做会话管理层,在这层主要是所有的socket
生命周期管理和实际的数据收发过程。由于所有的socket
都由智能指针管理,所以在这层维护了一个全局的集中式的map
来管理所有的socket
实例,这就导致每次访问这个全局map
的时候都不得不加锁保护,这是一个问题。
所有的event
也全部由智能指针管理, 如何将event
作为action
事件驱动的上下文添加到对应平台的数据结构(epoll
的epoll_event
, iocp
的Overlapped
)内?因为平台的事件驱动模型留给用户设置上下文的地方都是一个void*
指针,这里将event
智能指针再次取址,将取址的值放入事件驱动上下文。这就使得在参数传递的时候,每次都得传智能指针的引用,而引用传递,不能在传值时进行多态转换。这也是错的,可笑。
最上层为接口层,供用户使用,这倒没什么可辩驳的地方。
|``````````````|
| interface |
|______________|
| |
| sessions |
|______________|
| |
| action |
|______________|
重构后整体框架大体可分为四层:
最底层依然是action
事件驱动层,但是现在的action
层比较薄,只专注于不同平台的事件驱动抽象,在windows上使用的是wepoll,为什么没有使用IOCP, 暂且按下不表。macOS上使用kqueue
, linux使用epoll
, 无可非议。将定时器从action
层剥离出来,每次阻塞的时长由上层传递。
在action
上是分发层,即dispatcher
, 这层主要管理三个事情:
action
的驱动,通过接口在不同平台上使用不同的action
实现timer
的驱动,重构后的timer实现为一个分层的时间轮,每次循环检测下一次定时器的睡眠时长传入action
task
的驱动,实现上一个dispatcher
单独跑在一个线程上,所有的数据访问都不加锁,需要线程间数据交互时通过task
将数据操作传递到对应的dispatcher
线程中
dispatcher
之上是会话管理层,所有的socket
依然由一个全局的map
来管理,但是此map
非彼map
,这里将map
声明为线程本地存储,即每个线程都单独维护一个map
从而避免访问时候的线程竞争问题,每个socket
在创建之后都只在一个线程中活动,从一而终。socket
下的event
是从内存池中申请的裸指针,以便与系统提供的事件驱动模型结合。
最上层依然是对外接口层。
|``````````````|
| interface |
|______________|
| |
| sessions |
|______________|
| |
| dispatcher |
|______________|
| |
| action |
|______________|
众所周知,windows上效率最高的事件驱动模型是iocp
,然而将iocp
与其他事件驱动模型做出相同的抽象时,事情开始变得复杂起来。跨平台需要将平台的共性抽象上提,将平台的差异性下沉,平台的差异性越大,越需要更多的抽象层来兼容,导致需要添加很多额外的间接层来屏蔽系统差异,效率上就难以保证。跨平台我所欲也,执行效率我所欲也,二者不可得兼,舍效率而取跨平台也。当然,这里的效率损失,仅指windows平台。
以上,算是大环境。下面说几个遇到的具体问题。
iocp
与其他模型从根本上的不同在于接管了线程调度,我们知道使用iocp
的时候仅需要从系统申请一个iocp
句柄,接着将所有的IO请求都绑定到这一个句柄上。所有线程都阻塞调用GetQueuedCompletionStatus
函数来等待IO请求,当IO请求到来的时候,由iocp
决定唤醒哪个线程来执行操作。由此引发一个问题,我们没办法控制一个socket
只在一个线程中活动,有可能此刻在线程A中读取,下一刻又在线程B中发送,所以不得不像重构前的样子,设置一个集中的加锁的全局map
来管理所有的socket
,此为其一。
当我们需要唤醒IO线程时,我们可以通过PostQueuedCompletionStatus
函数来唤醒阻塞在GetQueuedCompletionStatus
上的线程,然而,悲催的是,唤醒哪个线程依然是iocp
决定的,我们没办法干预。这就导致另外一个问题,定时器也没办法只在一个线程中活动,只能维护一个集中加锁的定时器。此为其二。
iocp
的设计理念上与其他的epoll
和kqueue
有根本的不同,不止是reactor
模式和proactor
模式的不同。epoll
和kqueue
管理的是socket
层级的东西,只关注这个socket
的读和写,而iocp
管理的是socket
的读和写,比epoll
和kqueue
要更下一层。
没明白?epoll
和kqueue
并行时间只会有一个读写操作,而iocp
上,则可能有多个读写操作!这直接否定了每个socket
携带一个event
的设计。为了兼容此项,不得不每次读写的时候都重新从内存池中申请新的event
实例。详见分支windows_icop。
有几种方案:
- 只使用一个listen socket, 在应用层通过算法控制,每次只将socket放到一个
action
中,类似Nginx - 使用端口复用,创建多个socket绑定到相同地址端口上,由内核来决定唤醒哪个线程
epoll
支持EPOLLEXCLUSIVE
选项,由内核来决定唤醒哪个线程
第一种方式随着Nginx的发展,几乎到达了家喻户晓的地步。然而极易导致线程间的负载不均,实际生产环境,每个线程的并发度都很高,这就导致7/8的阈值几乎形同虚设,在负载不是很高的时候,基本都是只有几个线程在忙。第二种方式可以很好的解决惊群问题,但是有个疑虑。多个socket
使用的是不同的socket
栈,这意味着连接的过程,每个socket
都拥有独自的半连接和全连接队列,当某个持有socket
的进程挂掉,那么这个socket
上接收到的连接请求都会丢失,这在多线程的场景中倒可以容忍,因为线程挂掉整个进程就没了,所有的socket
都一视同仁。第三种方式为epoll
独有,也可以解决惊群问题,但是要linux内核在4.5以上,实际测试中,效率也比端口复用高不少,此中缘由,还待进一步研究。
这里可以多提一嘴,既有端口复用,又有EPOLLEXCLUSIVE
,可以一个epoll
句柄,又可以多个epoll
句柄,那都有哪些条件组合可以解决惊群?我将多种组合做了测试,代码详情见epoll_whit_multithread,结果汇总如下:
EPOLLEXCLUSIVE | reuse_port | 监听socket个数 | epoll句柄个数 | 线程数 | 唤醒线程数 | 成功accept线程数 | 没有惊群 |
---|---|---|---|---|---|---|---|
❌ | ❌ | 1 | 1 | 8 | 1~2 | 1 | ❌ |
❌ | ❌ | 1 | 8 | 8 | 3~8 | 1 | ❌ |
❌ | ✅ | 8 | 1 | 8 | 1~2 | 1 | ❌ |
❌ | ✅ | 8 | 8 | 8 | 1 | 1 | ✅ |
✅ | ❌ | 1 | 1 | 8 | 1~2 | 1 | ❌ |
✅ | ❌ | 1 | 8 | 8 | 1 | 1 | ✅ |
由图可知,reuse_port
和EPOLLEXCLUSIVE
在绑定多个epoll
时可有效解决惊群问题。
由上一节得出,解决惊群问题采用reuse_port
和EPOLLEXCLUSIVE
绑定多个epoll
。所以接收到一个新的请求时,将其绑定到本线程的action
也就自然而然了。让socket
仅仅在一个线程中活动,还可以带来其他额外的好处,最明显的就是避免了线程竞争加锁,降低了编码复杂度。
从反面思考一下,真的需要将相同的socket
在不同的线程中唤醒操作吗?将socket
在不同线程中唤醒,可以有效解决读饥渴问题,因为当一个线程中的所有socket
都非常忙碌时,后面的socket
可能会排队很久才轮到数据读取。这里说两点:
- 一个线程忙碌,而其他线程空闲的场景普遍存在吗?因为惊群交由内核处理,线程间的负载基本可以保证平均,那么所有
socket
的业务都集中在一个线程上,应该是一个极小概率的事件 - 根据业务的不同,可以将读取缓存调小,从而减少排队
socket
的等待时间
以上,重构前前后后也经历的两三个月的时间,个中变化自不可能三言两语说完。
至于其他,后文再述。
代码详情见github。
另外,欢迎star
。