C 网络库都干了什么?

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

  • 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 则完全是一个初入职场的毛头小子。这就是 Proactor 和 Reactor 模式的区别。现在客户端就是领导的位置,所以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 数据结构来管理接收和发送的字节流。实现如其名,底层是来自内存池的固定大小内存块,通过两个指针控制来循环的读写,上层是一个由刚才所说的内存块组成的链表,也通过两个指针控制来循环读写。这样每次添加数据时,都是顺序的追加操作,没有之前旧数据的移动,实现最少的内存拷贝。

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

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

github:https://github.com/caozhiyi/CppNet

来源:https://zhuanlan.zhihu.com/p/80634656

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/339263.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

iphone屏幕录制_iPhone怎么内录声音?怎么录制苹果手机内部声音?

有时我们想要对苹果手机上播放的声音进行录音,却不知道该如何操作。苹果手机上自带的录音软件只可以对手机外部声音进行录制,却无法录制自身播放的声音。其实我们可以先将苹果手机屏幕及声音先投放到电脑上,再通过支持内录的软件进行录音就可…

C 中命名空间的五大常见用法

译者注:可能很多程序员对C 已经非常熟悉,但是对命名空间经常使用到的地方还不是很明白,这篇文章就针对命名空间这一块做了一个叙述。命名空间在1995年被引入到 c 标准中,通常是这样定义的:命名空间定义了新的作用域。它们提供了一…

英伟达TX2烧录系统_英伟达的DPU,是想在数据中心奇袭英特尔?

热点追踪 / 深度探讨 / 实地探访 / 商务合作最近几年,经常关注科技圈的朋友们总会发现,每次遇到厂商有重大发布,就总能看到“颠覆”、“极致”、“革命性”等概念出现在发布会上。上周,iPhone12的发布现场,蒂姆库克就用…

C vector详解

【导读】:vector是一个封装了动态大小数组的顺序容器(Sequence Container)。跟任意其它类型容器一样,它能够存放各种类型的对象。可以简单的认为,vector是一个能够存放任意类型的动态数组。接下来,请跟随小…

arcgis 出图背景_ArcGIS空间制图分析视频教程(二狮兄出品)含ArcMap

这套教程是二狮兄出的一套ArcGIS地理空间制图数据分析视频教程,含ArcMap/ArcCatalog部分。教程分为上中下三部,已全部录制完毕,全部课程120节。适用人群ArcGIS目前的应用范围非常广泛,包括但不限于从事地理景观、生态环境、规划设…

C 之父:C 的成功属于意料之外,C 11是转折点

C 的起源可以追溯到 40 年前,但它仍然是当今使用最广泛的编程语言之一。到 2020 年 9 月为止,C 是仅次于 C 语言、Java 和 Python,位于全球第四的编程语言。根据最新的 TIOBE 索引,C 也是增长最快的语言。近日,C 之父 …

aix磁盘挂载到linux,AIX下文件系统挂载点相互调换方案

由于业务发展的需要,企业在异地实现了数据块级的灾备,由于原来的备份目录lv所在VG恰好在远程灾备VG内(该方案实现的是vg级别的数据同步),为了节省带宽所以又从存储上新划分出一块磁盘新建了一个vg作为备份空间使用。但是由于当时厂商在创建vg…

苹果几最好用_深度解析安卓手机和苹果手机到底有哪些区别,哪种手机最好用...

"安卓阵营手机和苹果手机一直是手机界多年的竞争对手。由于安卓系统是开源的系统,任何厂家都能使用它。而导致安卓系统全球碎片化的主要原因是大部分国产品牌手机都没有安装谷歌服务,对于外国人的来说安装了谷歌服务的安卓手机才是完整的。然而中国…

linux设置基础软件仓库时,安装centos系统时设置基础软件仓库出错

安装centos系统时设置基础软件仓库出错,公钥,命令,视频教程,器上,提示安装centos系统时设置基础软件仓库出错易采站长站,站长之家为您整理了安装centos系统时设置基础软件仓库出错的相关内容。1、首先登录CentOS服务器,连接上服务器之后我们使用yum remo…

C 11实现的100行线程池

【导读】:C 线程池一直都是各位程序员们造轮子的首选项目之一。今天,小编带大家一起来看看这个轻量的线程池,本线程池是header-only的,并且整个文件只有100行,其中C 的高级用法有很多,很值得我们学习&#…

tensorflow2 目标检测_基于光流的视频目标检测系列文章解读

作者:平凡的外卖小哥全文5747字,预计阅读时间15分钟1 简介目前针对于图片的目标检测的方法大致分为两类:faster R-CNN/R-FCN一类:此类方法在进行bbox回归和分类之前,必须通过region proposal network(RPN)得到RoI&…

sts集成jboss_如何为JBoss Developer Studio 8设置集成和SOA工具

sts集成jboss最新的JBoss Developer Studio(JBDS)的发布带来了有关如何开始使用尚未安装的各种JBoss Integration和BPM产品工具集的问题。 在本系列文章中,我们将为您概述如何安装每套工具并说明它们支持哪些产品。 这将有助于您在着手进行…

C 多线程的互斥锁应用RAII机制

什么是RAII机制RAII是Resource Acquisition Is Initialization(翻译成 “资源获取即初始化”)的简称,是C 语言的一种管理资源、避免资源泄漏的惯用法,该方法依赖构造函数资和析构函数的执行机制。RAII的做法是使用一个类对象&…

c iostream.源码_通达信《K线上画趋势线预警》精选指标(附源码)

通达信《K线上画趋势线预警》精选指标K线上画趋势线预警源码:N:5;MA5:EMA(C,5)COLORWHITE;MA13:EMA(C,13)COLORCYAN;MA21:EMA(C,21)COLORMAGENTA;MA34:EMA(C,34)COLORYELLOW;MA55:EMA(C,55)COLORRED;{画线}A1:REF(H,N)HHV(H,2*N1);B1:FILTER(A1,N);C1:BACKSET(B1,N1…

linux module原理,NodeJS的模块原理

最近一直在使用Node JS,在网上看到了一段代码我觉得完美的诠释了Node JS模块加载的原理,其实深究下去,它还诠释了许多东西:Js模块化编程、闭包的真正强大之处等等。闲话不说,先看看这段代码:// - hello.jsv…

C 20 协程初探

【导读】:C 20 终于引入了协程特性,给库作者提供了一个实现协程的机制,让用户方便使用协程来编写异步逻辑,降低了异步并发编程的难度。结合我最近协程的学习,在这里记录一下相关内容。以下是正文使用场景协程和普通函数…

如何写一个简单的node.js C 扩展

node 是由 c 编写的,核心的 node 模块也都是由 c 代码来实现,所以同样 node 也开放了让使用者编写 c 扩展来实现一些操作的窗口。如果大家对于 require 函数的描述还有印象的话,就会记得如果不写文件后缀,它是有一个特定的匹配规则…

在线画 有穷状态自动机 的软件_怎么画思维导图?不用下载软件,在线就能操作...

怎么画思维导图?在工作中,除了流程图,脑图也是很重要的一个存在:流程图帮助我们快速完成任务,而脑图告诉我们任务本质。画思维导图是一个积累的过程,急不来,对于新手来说还是有一定难度的。由于…

Spring Boot Actuator:在其顶部具有MVC层的自定义端点

Spring Boot Actuator端点允许您监视应用程序并与之交互。 Spring Boot包含许多内置端点,您也可以添加自己的端点。 添加自定义端点就像创建一个从org.springframework.boot.actuate.endpoint.AbstractEndpoint扩展的类一样容易。 但是Spring Boot Actuator也提供了…

422器件与lvds接收器的区别_SPI、I2C、UART三种串行总线的原理、区别

SPI、I2C、串口、我相信如果你是从事的是嵌入式开发,一定会用到这三种通信协议,串口的话因为和波特率有关,所以一般的CPU或者MCU只会配有两个或者三个串口,而数据的传输,的话SPI和I2C用得会比较多区别:1、U…