Skip to content

Latest commit

 

History

History
89 lines (77 loc) · 9.65 KB

cppnet.md

File metadata and controls

89 lines (77 loc) · 9.65 KB

cppnet网络库

之前写过写过一篇文章《C++网络库都干了什么》对cppnet做了一些介绍,然而那已经是19年的事情了。岁月荏苒,再回头来看当初的设计,多有疏漏,因此特意找时间将库整个重构了一遍,由此作文以记之。
水中月是天上月,眼前人却不再是故人。做的事情还在是以前的事情,完成的方式却大相径庭。重构后的网络库,所有组件的交互都通过接口解耦,各个模块件只知有接口而不知其他。另外添加了对kqueue的封装,支持了macOS系统。

重构前后的差异

重构前

为了方便看官了解,不用再挖坟看之前的代码接口,这里简单将重构前的结构再罗列一下:
之前的结构大体上分为三层,最底层是action事件驱动层,这层主要是对epolliocp的封装,还额外兼职管理了所有的定时器以及IO线程任务投递。在IO循环中将定时器与阻塞等待IO的函数相结合已经是网络库的常规操作了。然而这里将定时器的实例管理也下放到了action里,使得action不再符合职责单一原则,这是错的,可笑。
再上一层则可以叫做会话管理层,在这层主要是所有的socket生命周期管理和实际的数据收发过程。由于所有的socket都由智能指针管理,所以在这层维护了一个全局的集中式的map来管理所有的socket实例,这就导致每次访问这个全局map的时候都不得不加锁保护,这是一个问题。
所有的event也全部由智能指针管理, 如何将event作为action事件驱动的上下文添加到对应平台的数据结构(epollepoll_event, iocpOverlapped)内?因为平台的事件驱动模型留给用户设置上下文的地方都是一个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?

众所周知,windows上效率最高的事件驱动模型是iocp,然而将iocp与其他事件驱动模型做出相同的抽象时,事情开始变得复杂起来。跨平台需要将平台的共性抽象上提,将平台的差异性下沉,平台的差异性越大,越需要更多的抽象层来兼容,导致需要添加很多额外的间接层来屏蔽系统差异,效率上就难以保证。跨平台我所欲也,执行效率我所欲也,二者不可得兼,舍效率而取跨平台也。当然,这里的效率损失,仅指windows平台。
以上,算是大环境。下面说几个遇到的具体问题。
iocp与其他模型从根本上的不同在于接管了线程调度,我们知道使用iocp的时候仅需要从系统申请一个iocp句柄,接着将所有的IO请求都绑定到这一个句柄上。所有线程都阻塞调用GetQueuedCompletionStatus函数来等待IO请求,当IO请求到来的时候,由iocp决定唤醒哪个线程来执行操作。由此引发一个问题,我们没办法控制一个socket只在一个线程中活动,有可能此刻在线程A中读取,下一刻又在线程B中发送,所以不得不像重构前的样子,设置一个集中的加锁的全局map来管理所有的socket,此为其一。
当我们需要唤醒IO线程时,我们可以通过PostQueuedCompletionStatus函数来唤醒阻塞在GetQueuedCompletionStatus上的线程,然而,悲催的是,唤醒哪个线程依然是iocp决定的,我们没办法干预。这就导致另外一个问题,定时器也没办法只在一个线程中活动,只能维护一个集中加锁的定时器。此为其二。
iocp的设计理念上与其他的epollkqueue有根本的不同,不止是reactor模式和proactor模式的不同。epollkqueue管理的是socket层级的东西,只关注这个socket的读和写,而iocp管理的是socket的读和写,比epollkqueue要更下一层。
没明白?epollkqueue并行时间只会有一个读写操作,而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_portEPOLLEXCLUSIVE在绑定多个epoll时可有效解决惊群问题。

为什么每个socket仅在一个线程中活动?

由上一节得出,解决惊群问题采用reuse_portEPOLLEXCLUSIVE绑定多个epoll。所以接收到一个新的请求时,将其绑定到本线程的action也就自然而然了。让socket仅仅在一个线程中活动,还可以带来其他额外的好处,最明显的就是避免了线程竞争加锁,降低了编码复杂度。
从反面思考一下,真的需要将相同的socket在不同的线程中唤醒操作吗?将socket在不同线程中唤醒,可以有效解决读饥渴问题,因为当一个线程中的所有socket都非常忙碌时,后面的socket可能会排队很久才轮到数据读取。这里说两点:

  • 一个线程忙碌,而其他线程空闲的场景普遍存在吗?因为惊群交由内核处理,线程间的负载基本可以保证平均,那么所有socket的业务都集中在一个线程上,应该是一个极小概率的事件
  • 根据业务的不同,可以将读取缓存调小,从而减少排队socket的等待时间

以上,重构前前后后也经历的两三个月的时间,个中变化自不可能三言两语说完。
至于其他,后文再述。
代码详情见github
另外,欢迎star