From 5a2f086ccbedbac51ab6aa745dd61b6ccd010825 Mon Sep 17 00:00:00 2001 From: caozhiyi Date: Sun, 4 Jul 2021 19:11:29 +0800 Subject: [PATCH] add cppnet chinese introduction. --- doc/introduction/cppnet.md | 89 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 doc/introduction/cppnet.md diff --git a/doc/introduction/cppnet.md b/doc/introduction/cppnet.md new file mode 100644 index 0000000..ba90e67 --- /dev/null +++ b/doc/introduction/cppnet.md @@ -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`。 \ No newline at end of file