如何设计一个高效的应用缓冲区【一个动态扩容的buffer类】

文章目录

  • 前言
  • 一、为什么需要设计应用层缓冲区
    • 必须要有 output buffer
      • 目的
      • 问题
      • output buffer的解决方案:
    • 必须要有 input buffer
    • 总结
  • 二、设计要点
  • 三、buffer设计思路
    • 基础函数
      • 关于iovec与readv
    • readfd
    • 如何实现动态扩容
  • 问题


前言

在上一个博客,我们介绍到什么是缓冲区出发,然后也分析了epoll 两个模式使用阻塞与非阻塞缓冲区的区别。
epoll与socket缓冲区的恩恩怨怨
本文介绍如何设计一个合理的内部逻辑稳定的读写缓冲区。基于Muduo库的设计思想。

一、为什么需要设计应用层缓冲区

基于Muduo库的应用缓冲区源码以及陈硕大神的博客进行实现与总结。

大多数的网络模型是非阻塞IO模型,即每次send() 不一定全发完,没发完的数据要用一个容器进行接收,所以必须要实现应用层缓冲区.

如果是水平触发,那么套接字会一直处于可读状态,io多路复用函数会一直认为这个套接字被激活,也就是说如果第一次触发后没有将tcp缓冲区中的数据全部读出,那么下次进行到poll函数时会立即返回,因为套接字一直是可读的。这会导致了busy loop问题。

如果是边缘触发,那么就只会触发一次,即使第一次触发没有将所有数据都读走,下次进行到poll也不会再触发套接字的可读状态,直到下次又有一批数据送至tcp缓冲区中,才会再次触发可读。所以有可能存在漏读数据的问题,万一不会再有数据到来呢,此时tcp缓冲区中仍然有数据,而应用程序却不知道。

这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer 和 output buffer。

必须要有 output buffer

目的

网络库需要为每个TCP连接配置输出缓冲区,以便处理数据的发送和缓冲,并且需要根据套接字的可写状态进行相应的处理和调度。这样可以实现高效的数据发送和事件处理,使程序能够快速返回事件循环,提高整体的性能和响应能力。

问题

程序想通过 TCP 连接发送 100k 字节的数据,但是在 write() 调用中,操作系统只接受了 80k 字节(受 TCP advertised window 的控制,细节见 TCPv1),你肯定不想在原地等待,因为不知道会等多久(取决于对方什么时候接受数据,然后滑动 TCP 窗口)。程序应该尽快交出控制权,返回 event loop。在这种情况下,剩余的 20k 字节数据怎么办?

output buffer的解决方案:

1、对于应用程序而言,它只管生成数据,它不应该关心到底数据是一次性发送还是分成几次发送,这些应该由网络库来操心,程序只要调用 TcpConnection::send() 就行了,网络库会负责到底。网络库应该接管这剩余的 20k 字节数据,把它保存在该 TCP connection 的 output buffer 里,然后注册 POLLOUT 事件,一旦 socket 变得可写就立刻发送数据。当然,这第二次 write() 也不一定能完全写入 20k 字节,如果还有剩余,网络库应该继续关注 POLLOUT 事件;如果写完了 20k 字节,网络库应该停止关注 POLLOUT,以免造成 busy loop。
2、如果在发送过程中,输出缓冲区仍然有待发送的数据,而程序又要写入新的数据,网络库应该将新的数据追加到输出缓冲区的末尾,等待下次套接字可写时再发送。这样可以避免频繁的写入操作导致的性能下降。
3、如果程序想要关闭连接时,但输出缓冲区中仍有待发送的数据,网络库不能立即关闭连接。相反,它应该等待数据发送完毕后再关闭连接,以确保数据不会丢失。

必须要有 input buffer

TcpConnection必须要有input buffer TCP是一个无边界的字节流协议,接收方必须要处理“收到的数据尚不构成一条完整的消息”和“一次收到两条消息的数据”等等情况。一个常见的场景是,发送方send了两条10k字节的消息(共20k),接收方收到数据的情况可能是:

一次性收到20k数据
分两次收到,第一次5k,第二次15k
分三次收到,第一次6k,第二次8k,第三次6k
等等任何可能
以上情况俗称“粘包”问题。

网络库在处理“socket可读”事件的时候,必须一次性把socket中数据读完(从操作系统buffer搬到应用层buffer),否则会反复触发POLLIN事件,造成busy loop。
如何处理?
接收到数据,存在input buffer,通知上层的应用程序,OnMessage(buffer)回调,根据应用层协议判定是否是一个完整的包,进行codec解码,如果不是一条完整的消息,不会取走数据,也不会进行相应的处理。如果是一条完整的消息,将取走这条消息,并进行相应的处理。如何处理就是上层应用程序的职责了。

总结

Non-blocking IO 的核心思想是避免阻塞在 read() 或 write() 或其他 IO 系统调用上,这样可以最大限度地复用 thread-of-control,让一个线程能服务于多个 socket 连接。IO 线程只能阻塞在 IO-multiplexing 函数上,如 select()/poll()/epoll_wait()。这样一来,应用层的缓冲是必须的,每个 TCP socket 都要有 stateful 的 input buffer 和 output buffer。muduo库都是带缓冲的I/O,不会自己去read()或write()某个socket,只会操作TcpConnection的input buffer和output buffer。更确切的说,是在OnMessage()回调里读取input buffer;调用TcpConnection::send()来间接操作output buffer,一般不会直接操作output buffer。
所以,设计应用层自己的缓冲区是很有必要的,也就是由应用程序来管理缓冲区问题

二、设计要点

陈硕大神的总结如下:
应用缓冲区对外表现为一块连续的内存(char, len),以方便客户代码的编写。其 size() 可以自动增长,以适应不同大小的消息。它不是一个 fixed size array (即 char buf[8192])。内部以 vector of char 来保存数据,并提供相应的访问函数。*
要点
1、应用层缓冲区通常很大,也可以初始很小,但可以通过动态调整改变大小(vector)
2、当用户想要调用write/send写入数据给对端,如果数据可以全部写入,那么写入就好了。如果写入了部分数据或者根本一点数据都写不进去,此时表明内核缓冲区已满,为了不阻塞当前线程,应用层写缓冲区会接管这些数据,等到内核缓冲区可以写入的时候自动帮用户写入。
3、当有数据到达内核缓冲区,应用层的读缓冲区会自动将这些数据读到自己那里,当用户调用read/recv想要读取数据时,应用层读缓冲区将已经从内核缓冲区取出的数据返回给用户,实际上就是用户从应用层读缓冲区读取数据
4、应用层缓冲区对用户而言是隐藏的,用户可能根本不知道有应用层缓冲区的存在,只需读/取数据,而且也不会阻塞当前线程

三、buffer设计思路

/*1-----2---3-------4------51是begin2是kCheapPrepend 表示8字节头部3是prependableBytes也就是readerIndex_   4是writerIndex_5是buffer_.size()1-2是 头部信息大小2-3是 已经读过来的 缓冲区 空闲的prependableBytes() - kCheapPrepend3-4是  readableBytes    要读的空间   也就是writerIndex_ - readerIndex_4-5是   writableBytes  可写的空间    也就是是buffer_.size() - writerIndex_prependableBytes() - kCheapPrepend 就是已经读了的 ,空闲出来的
加上可以写的,就是中共能够写入的,如果不够就要resize
如果够那么 就需要挪一下 ,把已经读的了与可以写的拼在一起
*/

muduo应用层缓冲区的设计采用std::vector数据结构,一方面内存是连续的方便管理,另一方面,vector自带的增长模式足以应对动态调整大小的任务
缓冲区Buffer的定义如下,只列出了一些重要部分
主要就是利用两个指针readerIndex,writerIndex分别记录着缓冲区中数据的起点和终点,写入数据的时候追加到writeIndex后面,读出数据时从readerIndex开始读。在readerIndex前面预留了几个字节大小的空间,方便日后为数据追加头部信息。缓冲区在使用的过程中会动态调整readerIndex和writerIndex的位置,初始缓冲区为空,readerIndex == writerIndex。

Muduo Buffer 的 size() 是自适应的,它一开始的初始值是 1k,如果程序里边经常收发 10k 的数据,那么用几次之后它的 size() 会自动增长到 10k,然后就保持不变。这样一方面避免浪费内存(有的程序可能只需要 4k 的缓冲),另一方面避免反复分配内存。当然,客户代码可以手动 shrink() buffer size()。

以下是别人的总结

  •  1.相比之下,采用vector连续内存更容易管理,同时利用std::vector自带的内存
    
  •    增长方式,可以减少扩充的次数(capacity和size一般不同)
    
  •  2.记录缓冲区数据起始位置和结束位置,写入时写到已有数据的后面,读出时从
    
  •    数据起始位置读出
    
  •  3.起始/结束位置如上图的readerIndex/writeIndex,其中readerIndex为缓冲区
    
  •    数据的起始索引下标,writeIndex为结束位置下标。采用下标而不是迭代器的
    
  •    原因是删除(erase)数据时迭代器可能失效
    
  •  4.开头部分(readerIndex以前)是预留空间,通常只有几个字节的大小,可以用来
    
  •    写入数据的长度,解决粘包问题
    
  •  5.读出和写入数据时会动态调整readerIndex/writeIndex,如果没有数据,二者
    
  •    相等
    

基础函数

成员变量

  static const size_t kCheapPrepend = 8;    //默认预留8个字节static const size_t kInitialSize = 1024;   //初始大小
private:std::vector<char> buffer_;    //vector用于替代固定数组size_t readerIndex_;            //读位置size_t writerIndex_;             //写位置

Buffer获取各个长度的方法:

//可读大小size_t readableBytes() const{ return writerIndex_ - readerIndex_; }
   //可写大小size_t writableBytes() const{ return buffer_.size() - writerIndex_; }
 //预留大小size_t prependableBytes() const{ return readerIndex_; }

获取可读下标:

//读的下标const char* peek() const{ return begin() + readerIndex_; 

返回缓冲区中可读数据的起始地址

const char* peek() const{return begin() + readerIndex_;}

把onMessage函数上报的Buffer数据,转成string类型的数据返回

// 把onMessage函数上报的Buffer数据,转成string类型的数据返回std::string retrieveAllAsString(){// 应用缓存区可读取长度writerIndex_ - readerIndex_数据的长度return retrieveAsString(readableBytes());}std::string retrieveAsString(size_t len){// 可读数据的 地址以及长度 构造出ret,把readable的数据全部读取std::string result(peek(), len);// 上面一句把缓冲区中可读的数据,已经读取出来,这里肯定要对缓冲区进行复位操作retrieve(len);return result;}

关于iovec与readv

引用博客
使用read()将数据读到不连续的内存,要经过多次的调用read。如果要从文件中读一片连续的数据至进程的不同区域,有两种方案:
①使用read()一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域;
②调用r©adO若干次分批将它们读至不同区域。同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。
缺点:执行系统调用必然使得性能降低。

UNIX提供了另外两个函数—readv()它们只需一次系统调用就可以实现多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。readv()称为散布读,即将文件中若干连续的数据块读入内存分散的缓冲区中。

这里为什么要用readv
因为我们预先不知道内核缓冲区的数据大小, 在某些情况下,应用缓冲区可能无法存储全部的读取数据,需要额外的缓冲区进行存储。通过使用栈上的内存空间extrabuf,存储额外的读取数据。
这样就带来了另外一个问题,可能需要把内核缓冲区的数据保存到这个两个不同的内存区域中。
通过一次 readv 函数调用读入内存分散的缓冲区中。就能大大提高数据读取效率。

主要是为了解决,应用缓冲区内存不够的情况下保证只是进行一次系统调用。

readfd

用户自定义缓冲区Buffer是有大小限制的,我们一开始不知道TCP接收缓冲区中的数据量有多少,如果一次性读出来会不会导致Buffer装不下而溢出。所以在readFd( )函数中会在栈上创建一个临时空间extrabuf,然后使用readv的分散读特性,将TCP缓冲区中的数据先拷贝到Buffer中,如果Buffer容量不够,就把剩余的数据都拷贝到extrabuf中,然后再调整Buffer的容量(动态扩容),再把extrabuf的数据拷贝到Buffer中。当这个函数结束后,extrabuf也会被释放。另外extrabuf是在栈上开辟的空间,速度比在堆上开辟还要快。

ssize_t Buffer::readFd(int fd, int* saveErrno)
{/*在某些情况下,应用缓冲区可能无法存储全部的读取数据,需要额外的缓冲区进行存储。通过使用栈上的内存空间extrabuf,存储额外的读取数据。需要将文件(套接字)接收缓冲中的数据读入不同位置时,可以不必多次调用 read 函数,而是通过一次 readv 函数调用就能大大提高数据读取效率。*/char extrabuf[65536] = {0}; // 栈上的内存空间  64Kstruct iovec vec[2];// 这是Buffer底层缓冲区剩余的可写空间大小const size_t writable = writableBytes();vec[0].iov_base = begin() + writerIndex_;vec[0].iov_len = writable;vec[1].iov_base = extrabuf;vec[1].iov_len = sizeof extrabuf;// 保证缓冲区刚刚好 能够一次性读完const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;const ssize_t n = ::readv(fd, vec, iovcnt);if (n < 0){*saveErrno = errno;}else if (n <= writable) // Buffer的可写缓冲区已经够存储读出来的数据了{writerIndex_ += n;}else // extrabuf里面也写入了数据 {// writerIndex_开始写 n - writable大小的数据writerIndex_ = buffer_.size();append(extrabuf, n - writable); }return n;
}

readFd巧妙的设计,可以让用户一次性把所有TCP接收缓冲区的所有数据全部都读出来并放到用户自定义的缓冲区Buffer中。

如何实现动态扩容

上面介绍到了,如果用户自定义的缓冲区Buffer内存不够,需要把extrabuf中的数据加入到我们的应用缓冲区中去,这个时候我们的应用缓冲区就需要动态扩容了。主要是通过两种方式,一种是直接扩容,一种是内部腾挪的方式

在追加函数中 想要确保有足够的空间ensureWriteableBytes。

    // 把[data, data+len]内存上的数据,添加到writable缓冲区当中void append(const char *data, size_t len){// 追加到 beginWrite 后面 也就是 3-4是  readableBytes    要读的空间// 然后writerIndex_ 往后面挪ensureWriteableBytes(len);std::copy(data, data+len, beginWrite());writerIndex_ += len;}

如果writableBytes可写入的空间小雨将要存入数据的带下就需要makeSpace扩容

    // 可写部分  是buffer_.size() - writerIndex_   // 要写  len 这么长,需要对比一下可写缓存区的 长度// 如果太小要扩容void ensureWriteableBytes(size_t len){if (writableBytes() < len){makeSpace(len); // 扩容函数}}

prependableBytes() - kCheapPrepend 就是已经读了的 ,空闲出来的加上可以写的,就是总共能够写入的,如果不够就要resize
如果够那么 就需要挪一下 ,把已经读的了与可以写的拼在一起。

  void makeSpace(size_t len){if (writableBytes() + prependableBytes() - kCheapPrepend< len ){// 腾不出这个大小 ,就要resizebuffer_.resize(writerIndex_ + len);}else{size_t readalbe = readableBytes();std::copy(begin() + readerIndex_, begin() + writerIndex_,begin() + kCheapPrepend);readerIndex_ = kCheapPrepend;writerIndex_ = readerIndex_ + readalbe;}}

问题

为什么不在Buffer构造时就开辟足够大的缓冲区
1.每个tcp连接都有输入/输出缓冲区,如果连接过多则内存消耗会很大
2.防止客户端与服务器端数据交互比较少,造成缓冲区的浪费
3.当缓冲区大小不足时,利用vector内存增长的优势,扩充缓冲区

为什么不在读数据之前判断一下应用层缓冲区是否可以容纳内核缓冲区的全部数据
1.采用这种方式就会调用一次recv,传入MSG_PEEK,即recv(sockfd, extrabuf, sizeof(extrabuf), MSG_PEEK)可根据返回值判断缓冲区还有多少数据没有接收,然后再调用一次recv从内核冲读取数据
2.但是这样会执行两次系统调用,得不偿失,尽量使用一次系统调用就将所有数据读出,这就需要一个很大的空间

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

如若内容造成侵权/违法违规/事实不符,请联系一条长河网进行投诉反馈,一经查实,立即删除!

相关文章

05. 机器学习入门 - 动态规划

文章目录 从一个案例开始动态规划 Hi, 你好。我是茶桁。 咱们之前的课程就给大家讲了什么是人工智能&#xff0c;也说了每个人的定义都不太一样。关于人工智能的不同观点和方法&#xff0c;其实是一个很复杂的领域&#xff0c;我们无法用一个或者两个概念确定什么是人工智能&a…

在visual studio里配置Qt插件并运行Qt工程

Qt插件&#xff0c;也叫qt-vsaddin&#xff0c;它以*.vsix后缀名结尾。从visual studio 2010版本开始&#xff0c;VS支持Qt框架的开发&#xff0c;Qt以插件方式集成到VS里。这里在visual studio 2019里配置Qt 5.14.2插件&#xff0c;并配置Qt环境。 1 下载VS2019 下载VS2019,官…

跟着顶级科研报告IPCC学绘图:温度折线/柱图/条带/双y轴

复现IPCC气候变化过程图 引言 升温条带Warming stripes&#xff08;有时称为气候条带&#xff0c;目前尚无合适且统一的中文释义&#xff09;是数据可视化图形&#xff0c;使用一系列按时间顺序排列的彩色条纹来视觉化描绘长期温度趋势。 在IPCC报告中经常使用这一方案 IPCC是…

嵌入式Linux应用开发-基础知识-第十九章驱动程序基石④

嵌入式Linux应用开发-基础知识-第十九章驱动程序基石④ 第十九章 驱动程序基石④19.7 工作队列19.7.1 内核函数19.7.1.1 定义 work19.7.1.2 使用 work&#xff1a;schedule_work19.7.1.3 其他函数 19.7.2 编程、上机19.7.3 内部机制19.7.3.1 Linux 2.x的工作队列创建过程19.7.3…

BASH shell脚本篇2——条件命令

这篇文章介绍下BASH shell中的条件相关的命令&#xff0c;包括&#xff1a;if, case, while, until, for, break, continue。之前有介绍过shell的其它基本命令&#xff0c;请参考&#xff1a;BASH shell脚本篇1——基本命令 1. If语句 if语句用于在顺序执行语句的流程中执行条…

八大排序(三)堆排序,计数排序,归并排序

一、堆排序 什么是堆排序&#xff1a;堆排序&#xff08;Heap Sort&#xff09;就是对直接选择排序的一种改进。此话怎讲呢&#xff1f;直接选择排序在待排序的n个数中进行n-1次比较选出最大或者最小的&#xff0c;但是在选出最大或者最小的数后&#xff0c;并没有对原来的序列…

Python无废话-办公自动化Excel修改数据

如何修改Excel 符合条件的数据&#xff1f;用Python 几行代码搞定。 需求&#xff1a;将销售明细表的产品名称为PG手机、HW手机、HW电脑的零售价格分别修改为4500、5500、7500&#xff0c;并保存Excel文件。如下图 Python 修改Excel 数据&#xff0c;常见步骤&#xff1a; 1&…

docker 基本操作

目录 一、docker 概述 二、容器 2.1容器的特性 2.2namespace的六项隔离 三、docker与虚拟机的区别 四、Docker核心概念 五、docker 基本操作命令 镜像操作 1、搜索镜像 2、获取镜像 3、查看镜像信息 ​编辑 4、查看下载的镜像文件信息 5、查看下载到本地的所有镜…

搭建智能桥梁,Amazon CodeWhisperer助您轻松编程

零&#xff1a;前言 随着时间的推移&#xff0c;人工智能技术以惊人的速度向前发展&#xff0c;正掀起着全新的编程范式革命。不仅仅局限于代码生成&#xff0c;智能编程助手等创新应用也进一步提升了开发效率和代码质量&#xff0c;极大地推动着软件开发领域的快速繁荣。 当前…

SpringCloud(一)Eureka、Nacos、Feign、Gateway

文章目录 概述微服务技术对比 Eureka服务远程调用服务提供者和消费者Eureka注册中心搭建注册中心服务注册服务发现Ribbon负载均衡负载均衡策略饥饿加载 NacosNacos与Eureka对比Nacos服务注册Nacos服务分集群存储NacosRule负载均衡服务实例权重设置环境隔离 Nacos配置管理配置热…

用于自然语言处理的 Python:理解文本数据

一、说明 Python是一种功能强大的编程语言&#xff0c;在自然语言处理&#xff08;NLP&#xff09;领域获得了极大的普及。凭借其丰富的库集&#xff0c;Python 为处理和分析文本数据提供了一个全面的生态系统。在本文中&#xff0c;我们将介绍 Python for NLP 的一些基础知识&…

2023 彩虹全新 SUP 模板,卡卡云模板修复版

2023 彩虹全新 SUP 模板&#xff0c;卡卡云模板&#xff0c;首页美化&#xff0c;登陆页美化&#xff0c;修复了 PC 端购物车页面显示不正常的问题。 使用教程 将这俩个数据库文件导入数据库&#xff1b; 其他的直接导入网站根目录覆盖就好&#xff1b; 若首页显示不正常&a…

计算机网络学习易错点(持续更新~~~)

目录 概述 1.internet和Internet的区别 2.面向连接和无连接 3.不同的T 4.传输速率和传播速率 5.传播时延和传输时延&#xff08;发送时延&#xff09; 6.语法&#xff0c;语义和同步 一.物理层 1.传输媒体与物理层 2.同步通信和异步通信 3.位同步&#xff08;比特同…

nginx多文件组织

背景&#xff1a; nginx的话&#xff0c;有时候&#xff0c;想部署多个配置&#xff0c;比如&#xff1a;使用不同的端口配置不同的web工程。 比如&#xff1a;8081部署&#xff1a;项目1的web页面。 8082部署&#xff1a;项目2的web页面。 1)nginx.conf worker_processes…

Google vs IBM vs Microsoft: 哪个在线数据分析师证书最好

Google vs IBM vs Microsoft: 哪个在线数据分析师证书最好&#xff1f; 对目前市场上前三个数据分析师证书进行审查和比较|Madison Hunter 似乎每个重要的公司都推出了自己版本的同一事物&#xff1a;专业数据分析师认证&#xff0c;旨在使您成为雇主的下一个热门商品。 随着…

7.JavaScript-vue

1 JavaScript html完成了架子&#xff0c;css做了美化&#xff0c;但是网页是死的&#xff0c;我们需要给他注入灵魂&#xff0c;所以接下来我们需要学习JavaScript&#xff0c;这门语言会让我们的页面能够和用户进行交互。 1.1 介绍 通过代码/js效果演示提供资料进行效果演…

嵌入式Linux应用开发-基础知识-第十九章驱动程序基石⑤

嵌入式Linux应用开发-基础知识-第十九章驱动程序基石⑤ 第十九章 驱动程序基石⑤19.9 mmap19.9.1 内存映射现象与数据结构19.9.2 ARM架构内存映射简介19.9.2.1 一级页表映射过程19.9.2.2 二级页表映射过程 19.9.3 怎么给APP新建一块内存映射19.9.3.1 mmap调用过程19.9.3.2 cach…

华为云云耀云服务器L实例评测|部署在线轻量级备忘录 memos

华为云云耀云服务器L实例评测&#xff5c;部署在线轻量级备忘录 memos 一、云耀云服务器L实例介绍1.1 云服务器介绍1.2 产品优势1.3 应用场景1.4 支持镜像 二、云耀云服务器L实例配置2.1 重置密码2.2 服务器连接2.3 安全组配置 三、部署 memos3.1 memos介绍3.2 Docker 环境搭建…

C语言数组

C 语言支持数组数据结构&#xff0c;它可以存储一个固定大小的相同类型元素的顺序集合。数组是用来存储一系列数据&#xff0c;但它往往被认为是一系列相同类型的变量。 数组的声明并不是声明一个个单独的变量&#xff0c;比如 runoob0、runoob1、...、runoob99&#xff0c;而…

Scala第十章

Scala第十章 章节目标 1.数组 2.元组 3.列表 4.集 5.映射 6.迭代器 7.函数式编程 8.案例&#xff1a;学生成绩单 scala总目录 文档资料下载