阅读 145

C++网络库都干了什么?--CppNet

  虽然市面上已经有很多成熟的网络库,但是编写一个自己的网络库依然让我获益匪浅,这篇文章主要包含:

  • Tcp网络库都干了些什么?
  • 编写时需要注意哪些问题?
  • CppNet是如何解决的。

  首先,大家都知道操作系统原生的socket都是同步阻塞的,你每调用一次发送接口,线程就会阻塞在那里,直到将数据复制到了发送窗体。那发送窗体满了怎么办,阻塞的socket会一直等到有位置了或者超时。你每调用一次接收接口,线程就会阻塞在那里,直到接收窗体收到了数据。同步阻塞的弊端显而易见,上厕所的时候不能玩手机,不是每个人都能受得了。客户端可以单独建立一个线程一直阻塞等待接收,那服务器每个socket都建一个线程阻塞等待岂不悲哉,apache这么用过,所以有了Nginx。那能不能创建一个异步的socket调用之后直接返回,什么时候执行完了,无论成功还是失败再通知回来,实现所谓IO复用?好消息是现在操作系统大都实现了异步socket,CppNet中windows上通过WSASocket创建异步的socket,在linux上通过fcntl修改socket属性添加上O_NONBLOCK。

  有了异步socket,调用的时候不论成功与否,网络IO接口都会立马返回,成功或失败,发送了多少数据,回头再通知你。现在调用是很舒畅,那怎么获取结果通知呢?这在不同操作系统就有了不同的实现。早些年的时候有过select和poll,但是各有各的弊端,这个不是本文重点,在此不再详述。现在在windows上使用IOCP,在Linux上使用epoll做事件触发,基本已经算是共识。有了IOCP和epoll,我们调用网络接口的时候,要把这个过程或者干脆叫做任务,通知给事件触发模型,让操作系统来监控哪个socket数据发送完了,哪个socket有新数据接收了,然后再通知给我们。到这里,基本实现异步的socket读写该有的东西已经全部备齐。

  还有一点不同的是,IOCP在接收发送数据的时候,会自己默默的干活儿,干完了,再通知给你。你告诉IOCP我要发送这些数据,IOCP就会默默的把这些数据写进发送窗体,然后告诉你说 :“ 头儿,我干完了 ” 。你告诉IOCP我要读取这个socket的数据,IOCP就会默默的接收这个socket的数据,然后告诉你:“头儿,我给您带过来了”。 这就着实让人省心,你甚至不用再去调用socket的原生接口 。epoll则不同,其内部只是在监测这个socket是否可以发送或读取数据(当然还有建连等),不会像IOCP那样把活儿干完了再告诉你。你告诉epoll我要监测这个socket的发送和读取事件,当事件到来的时候,epoll不会管怎么干活儿,只会冷淡的敲敲窗户告诉你:”有事儿了,出来干活儿吧“。 IOCP 像是一个懂得讨领导欢心的老油条,epoll则完全是一个初入职场的毛头小子。这就是ProactorReactor模式的区别。现在客户端就是领导的位置,所以CppNet实现为一个 Proactor 模式的网络库,让客户端干最少的活儿。ASIO也实现为 Proactor ,而Libevent实现为 Reactor模式 。

  我们现在把刚才说的过程总结一下,首先需要把socket设置非阻塞,然后不同平台上将事件通知到不同事件触发模型上,监测到事件时,回调通知给上层。这就是一个网络库要有的核心功能,所有其他的东西都是在给这个过程做辅助。

  听起来非常简单,接下来就说下编写网络库的时候会遇到哪些问题和CppNet的实现。

  首先的问题是跨平台,如何抽象操作系统的接口,对上层实现透明调用。不论是epoll还是socket接口,windows和linux提供的接口都有差异,如何做到对调用方完全透明?这就需要调用方完全知道自己需要什么功能的接口,然后将自己需要的接口声明在一个公有的头文件里,在定义时CppNet通过__linux__ 宏在编译期选择不同的实现代码。 __linux__宏在Linux平台编译的时候会自动定义。如果不是上层必须的接口,则不同平台自己定义文件实现内部消化,不会让上层感知。网络事件驱动抽象出一个虚拟基类,提前声明好所有网络通知相关接口,不同平台自己继承去实现。Nginx虽然是C语言编写,但是通过函数指针来实现类似的构成。

  大家已经知道epoll和IOCP是不同模式的事件模型,如何把epoll也封装成 Proactor模式?这就需要要在 epoll之上添加一个实际调用网络收发接口的干活儿层。CppNet实现上分为三层:

  不同层之间通过回调函数向上通知。其中网络事件层将epoll和IOCP抽象出相同的接口,在socket层不同平台上做了不同的调用,windows层直接调用接口将已经接收到的数据拷贝出来,而linux平台则需要在收到通知时调用发送数据接口或者将该socket接收窗体的数据全部读取而出。为什么要将数据全部读取出来?这又设计到epoll的两种触发模式,水平触发边缘触发

    水平触发(LT) :只要有一个socket的接收窗体有数据,那么下一轮 epoll_wait返回就会通知这个socket有读事件触发。意味着如果本次触发读取事件的时候,没有将接收窗体中的数据全部取出,那么下一次 epoll_wait 的时候,还会再通知这个socket的读取事件,即使两次调用中间没有新的数据到达。

    边缘触发(ET) :一个socket收到数据之后,只会触发一次读取事件通知,若是没有将接收窗体的数据全部读取,那么下一轮 epoll_wait 也不会再触发该socket的读事件,而是要等到下一次再接收到新的数据时才会再次触发。

  水平触发比边缘触发效率要低一些,在epoll内部实现上,用了两个数据结构,用红黑树来管理监测的socket,每个节点上对应存放着socket handle和触发的回调函数指针。一个活动socket事件链表,当事件到来时回调函数会将收到的事件信息插入到活动链表中。边缘触发模式时,每次epoll_wait时只需要将活动事件链表取出即可,但是水平触发模式时,还需要将数据未全部读取的socket再次放置到链表中。

  CppNet采用的是边缘触发模式。 边缘触发在读取数据的时候有个问题叫做读饥渴,何为读饥渴?

    读饥渴: 就是如果两个socket在同一个线程中触发了读取事件,而前一个socket的数据量较大,后一个socket就会一直等待读取,对客户端看来就是服务器反应慢。

  凡事无完美, 究竟选择哪种模式,具体如何取舍就需要更多业务场景上的考量了。

  前面提到,IOCP不光负责的干了数据读取发送的活儿,甚至还兼职管理了线程池。在初始化IOCP handle的时候,有一个参数就是告知其创建几个网络IO线程,但是epoll没有管这么多。在编写网络库的时候就需要考虑,是将一个epoll handle放在多个线程中使用,还是每个线程都建立一个自己的epoll handle?

  如果每个线程一个 epoll handle ,则所有接收到的客户端socket终其一生都只会生活在一个线程中,连接,数据交互,直到销毁,具体处于哪个线程则交给了内核控制(通过端口复用处理惊群),这就会导致线程间负载不均衡,因为socket连接时长,数据大小都可能不同,但是锁碰撞会降到最低。

  如果所有线程共享一个 epoll handle,则要考虑线程数据同步的问题,如果一个socket在一个线程读取的时候,又在另一个线程触发了读取,该如何处理?epoll可以通过设置EPOLLONESHOT标识来防止此类问题,设置这个标识后,每次触发读取之后都需要重置这个标识,才会再次触发。

  人生就是一个不断选择的过程,没有最完美,只有最合适。 CppNet可以通过初始化时的参数控制,在linux实现上述两种方式。

  一直再说数据读取的事儿,下面说说建立连接。

  大家知道,服务器上创建socket之后绑定地址和端口,然后调用accept来等待连接请求。等待意味着阻塞,前边已经提到了,我们用到的socket已经全部设置为非阻塞模式了,你调用了accept,也不会乖乖的阻塞在哪里了,而是迅速返回,有没有连接到来,还得接着判断。这么麻烦的事情当然还是交给操作系统来操作,和数据收发相同,我们也把监听socket放到事件触发模型里,但是,要放到哪个里呢?IOCP只有一个handle,所以没的选择,我们投递了监听任务之后,IOCP会自己判断从哪个线程中返回建立连接的操作。

  epoll则又是道多选题,如果用了每个线程一个epoll handle的模式,所有线程都监测着监听的socket,那么连接到来的时候所有线程都会被唤醒,是为惊群。这个可以借鉴一下Nginx,通过一个简单的算法来控制哪些线程(Nginx是进程)去竞争一个全局的锁,竞争到锁的线程将监听socket放置到epoll中,顺带着还均衡了一下线程的负载。现在我们有了另外一个选择,通过设置socket SO_REUSEADDR标识,让多个socket绑定到同一个端口上!让操作系统来控制唤醒哪个线程。

  写到现在,连接,数据收发已经基本实现,该如何管理收发数据的缓存呢?随时抛给上层,还是做个中间缓存?

  这又涉及到一个拆包的问题,大家知道,Tcp发送的是byte流,并没有包的概念,如果你把半个客户端发送来的的消息体返回给服务器,服务器也没有办法执行响应操作,只能等待剩下的部分到来。所以最好是加一层缓存,这个缓存大小无法提前预知,需要动态分配,还要兼顾效率,减少复制。CppNet在socket层添加了loop-buffer数据结构来管理接收和发送的字节流。实现如其名,底层是来自内存池的固定大小内存块,通过两个指针控制来循环的读写,上层是一个由刚才所说的内存块组成的链表,也通过两个指针控制来循环读写。这样每次添加数据时,都是顺序的追加操作,没有之前旧数据的移动,实现最少的内存拷贝。详情请看 loop-buffer

  那有了缓存之后,如何快速的将要发送和接收的数据放置到缓存区呢?我一开始是直接在recv和send的地方建立一个栈上的临时缓存,读取到数据之后再将栈缓存上的数据写到loop-buffer上,这样无疑多了一次数据复制的代价。Linux系统提供了writev和readv接口,集中写分散读,每次读写的时候都直接将申请好的内存块交给内核来复制数据,然后再通过返回值移动指针来标识数据位置,配合loop-buffer相得益彰。

  CppNet前后历时半载,历经两司,到现在终于有所小成,作文以记之。

  不揣浅陋,与大家交流。

  github请戳这里