add cppnet chinese introduction.

This commit is contained in:
caozhiyi
2021-07-04 19:11:29 +08:00
parent bf33ee807f
commit 5a2f086ccb

View File

@@ -0,0 +1,89 @@
# cppnet网络库
之前写过写过一篇文章《[C++网络库都干了什么](https://zhuanlan.zhihu.com/p/80634656)》对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](https://github.com/piscisaureus/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`的设计理念上与其他的`epoll``kqueue`有根本的不同,不止是`reactor`模式和`proactor`模式的不同。`epoll``kqueue`管理的是`socket`层级的东西,只关注这个`socket`的读和写,而`iocp`管理的是`socket`的读和写,比`epoll``kqueue`要更下一层。
没明白?`epoll``kqueue`并行时间只会有一个读写操作,而`iocp`上,则可能有多个读写操作!这直接否定了每个`socket`携带一个`event`的设计。为了兼容此项,不得不每次读写的时候都重新从内存池中申请新的`event`实例。详见分支[windows_icop](https://github.com/caozhiyi/CppNet/tree/windows_iocp)。
### 如何解决惊群问题?
有几种方案:
+ 只使用一个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](https://github.com/caozhiyi/Toys/tree/main/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`时可有效解决惊群问题。
### 为什么每个socket仅在一个线程中活动
由上一节得出,解决惊群问题采用`reuse_port``EPOLLEXCLUSIVE`绑定多个`epoll`。所以接收到一个新的请求时,将其绑定到本线程的`action`也就自然而然了。让`socket`仅仅在一个线程中活动,还可以带来其他额外的好处,最明显的就是避免了线程竞争加锁,降低了编码复杂度。
从反面思考一下,真的需要将相同的`socket`在不同的线程中唤醒操作吗?将`socket`在不同线程中唤醒,可以有效解决读饥渴问题,因为当一个线程中的所有`socket`都非常忙碌时,后面的`socket`可能会排队很久才轮到数据读取。这里说两点:
+ 一个线程忙碌,而其他线程空闲的场景普遍存在吗?因为惊群交由内核处理,线程间的负载基本可以保证平均,那么所有`socket`的业务都集中在一个线程上,应该是一个极小概率的事件
+ 根据业务的不同,可以将读取缓存调小,从而减少排队`socket`的等待时间
以上,重构前前后后也经历的两三个月的时间,个中变化自不可能三言两语说完。
至于其他,后文再述。
代码详情见[github](https://github.com/caozhiyi/CppNet)。
另外,欢迎`star`