muduo应用层缓冲区buffer设计


一:

    muduo的I/O模型采用非阻塞模式,避免阻塞在read()或write()或其他系统调用上。

    

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

    对,答案就是网络库来接管。网络库把它保存在output buffer里,然后注册POLLOUT事件,一旦socket变得可写就立刻发送给数据。当然,这第二次write()也不一定能完全写入20字节,那就继续关注POLLOUT事件。如果写完,停止关注POLLOUT,防止busy loop。(因为epoll采用level trigger)。

    如果output buffer里还有待发送的数据,而程序又想关闭连接(对程序而言,调用TcpConncetion之后就认为数据迟早会发送出去),这是不能立刻关闭连接,这就是muduo库的shutdown()没有立即关闭连接的原因。以后博客会写。

    

    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解码,如果不是一条完整的消息,不会取走数据,也不会进行相应的处理。如果是一条完整的消息,将取走这条消息,并进行相应的处理。如何处理就是上层应用程序的职责了。

    所以,muduo库都是带缓冲的I/O,不会自己去read()或write()某个socket,只会操作TcpConnection的input buffer和output buffer。更确切的说,是在OnMessage()回调里读取input buffer;调用TcpConnection::send()来间接操作output buffer,一般不会直接操作output buffer。

    有关buffer的设计图示可以参考陈硕的博客: http://www.cppblog.com/Solstice/archive/2011/04/17/144378.html ,本文主要以程序角度来分析buffer的实现。
    

二:

首先来看一下Buffer的类成员:
class Buffer : public muduo::copyable
{
 public:
  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_;             //写位置

  //const char Buffer::kCRLF[] = "\r\n";
  static const char kCRLF[];      //'\r\n',使用柔性数组
};
    mduo库Buffer设计的思路除了Buffer前部设计了预留(Prepend)的部分,其他都和libevent的buffer设计思路是一样的。
    readeIndex_是可读部分的首位置,writerIndex_是可写部分的首位置。两个中间就是实际可读(readable)的元素。前部预留的Prepend部分可以用来向头部添加内容而不需要内存挪动的消耗。
    kCRLF是一个柔性数组,存放“\r\n”,可以用来搜索缓冲区数据中的”\r\n“
    
下面看下它的构造函数:
class Buffer : public muduo::copyable
{
 public:
  static const size_t kCheapPrepend = 8;    //默认预留8个字节
  static const size_t kInitialSize = 1024;   //初始大小

  explicit Buffer(size_t initialSize = kInitialSize)
    : buffer_(kCheapPrepend + initialSize),   //总大小为1032
      readerIndex_(kCheapPrepend),   //初始指向8
      writerIndex_(kCheapPrepend)    //初始指向8
  {
    assert(readableBytes() == 0);
    assert(writableBytes() == initialSize);
    assert(prependableBytes() == kCheapPrepend);
  }
初始化了Buffer的大小,和Prepend部分的大小。

下面是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_; }

重点:

核心函数一:

直接来看它的主要函数,最重要的就是readFd()函数:
//结合栈上空间,避免内存使用过大,提高内存使用率
//如果有10K个连接,每个连接就分配64K缓冲区的话,将占用640M内存
//而大多数时候,这些缓冲区的使用率很低
ssize_t Buffer::readFd(int fd, int* savedErrno)
{
  // saved an ioctl()/FIONREAD call to tell how much to read
  //节省一次ioctl系统调用(获取当前有多少可读数据)
  //为什么这么说?因为我们准备了足够大的extrabuf,那么我们就不需要使用ioctl取查看fd有多少可读字节数了
  char extrabuf[65536];

  //使用iovec分配两个连续的缓冲区
  struct iovec vec[2];
  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;
  
  // when there is enough space in this buffer, don't read into extrabuf.
  // when extrabuf is used, we read 128k-1 bytes at most.
  const int iovcnt = (writable < sizeof extrabuf) ? 2 : 1;  //writeable一般小于65536
  const ssize_t n = sockets::readv(fd, vec, iovcnt);  //iovcnt=2
  if (n < 0)
  {
    *savedErrno = errno;
  }
  else if (implicit_cast<size_t>(n) <= writable)   //第一块缓冲区足够容纳
  {
    writerIndex_ += n;   //直接加n
  }
  else   //当前缓冲区,不够容纳,因而数据被接受到了第二块缓冲区extrabuf,将其append至buffer
  {
    writerIndex_ = buffer_.size();   //先更显当前writerIndex
    append(extrabuf, n - writable);   //然后追加剩余的再进入buffer当中
  }
  return n;
}
没错,这个函数是用来从套接字向缓冲区读取数据的。由于该操作在网络程序中执行的很频繁,为了避免每次使用系用调用ioctl查看可读字节数,所以muduo在这里使用了一个65536个字节足够大的缓冲区。但是这个缓冲区怎么用呢?要知道怎么用我们先来看一下iovec结构体:
#include <sys/uio.h>  
  
struct iovec {  
    ptr_t iov_base; /* Starting address */  
    size_t iov_len; /* Length in bytes */  
};  
struct iovec定义了一个向量元素。通常,这个结构用作一个多元素的数组。对于每一个传输的元素,指针成员iov_base指向一个缓冲区,这个缓冲区是存放的是readv所接收的数据或是writev将要发送的数据。成员iov_len在各种情况下分别确定了接收的最大长度以及实际写入的长度。
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);
参数1:文件描述符
参数2:struct iovec结构体指针,通常表示多个缓冲区
参数3:缓冲区的个数

关于readv和writev更具体如下,以下引用自: http://book.2cto.com/201212/11769.html

    read()和write()系统调用每次在文件和进程的地址空间之间传送一块连续的数据。但是,应用有时也需要将分散在内存多处地方的数据连续写到文件中,或者反之。在这种情况下,如果要从文件中读一片连续的数据至进程的不同区域,使用read()则要么一次将它们读至一个较大的缓冲区中,然后将它们分成若干部分复制到不同的区域,要么调用read()若干次分批将它们读至不同区域。同样,如果想将程序中不同区域的数据块连续地写至文件,也必须进行类似的处理。

    UNIX提供了另外两个函数—readv()和writev(),它们只需一次系统调用就可以实现在文件和进程的多个缓冲区之间传送数据,免除了多次系统调用或复制数据的开销。readv()称为散布读,即将文件中若干连续的数据块读入内存分散的缓冲区中。writev()称为聚集写,即收集内存中分散的若干缓冲区中的数据写至文件的连续区域中。
#include <sys/uio.h>
ssize_t readv(int fildes, const struct iovec *iov, int iovcnt);
ssize_t writev(int fildes, const struct iovec *iov, int iovcnt);
 参数fildes是文件描述字。iov是一个结构数组,它的每个元素指明存储器中的一个缓冲区。结构类型iovec有下述成员,分别给出缓冲区的起始地址和字节数:
struct iovec {
    void   *iov_base   /* 数据区的起始地址 */
    size_t  iov_len     /* 数据区的大小 */
}
参数iovcnt指出数组iov的元素个数,元素个数至多不超过IOV_MAX。Linux中定义IOV_MAX的值为1024。

    writev()依次将iov[0]、iov[1]、...、 iov[iovcnt–1]指定的存储区中的数据写至fildes指定的文件。writev()的返回值是写出的数据总字节数,正常情况下它应当等于所有数据块长度之和。
    readv()则将fildes指定文件中的数据按iov[0]、iov[1]、...、iov[iovcnt–1]规定的顺序和长度,分散地读到它们指定的存储地址中。readv()的返回值是读入的总字节数。如果没有数据可读和遇到了文件尾,其返回值为0。
    有了这两个函数,当想要集中写出某张链表时,只需让iov数组的各个元素包含链表中各个表项的地址和其长度,然后将iov和它的元素个数作为参数传递给writev(),这些数据便可一次写出。

再回头看我们的程序是不是很明显了,那就是我们采用从socket读到缓冲区的方法是使用readv先读至Buffer_,Buffer_不够会读至栈上65536个字节大小的空间,然后以append的方式追加入Buffer_。即考虑了避免系统调用带来开销,又不影响数据的接收,很"优雅”的一种解决方案(她喜欢这么说)。

核心函数二:

把数据追加到缓冲区的append()函数是这样实现的:
 void append(const char* /*restrict*/ data, size_t len)
  {
    ensureWritableBytes(len);   //确保缓冲区可写空间大于等于len,如果不足,需要扩充
    std::copy(data, data+len, beginWrite());   //追加数据
    hasWritten(len);  //内部仅仅是写入后调整writeindex
  }
它先调用ensureWritableBytes(len)确定空间,空间不够该函数内部会扩充:
void ensureWritableBytes(size_t len)
  {
    if (writableBytes() < len)  //如果可写数据小于len
    { 
      makeSpace(len);   //增加空间
    }
    assert(writableBytes() >= len);
  }
空间不够实际调用扩充空间函数makeSpace():
 void makeSpace(size_t len)  //vector增加空间
  {
    if (writableBytes() + prependableBytes() < len + kCheapPrepend)  //确保空间是真的不够,而不是挪动就可以腾出空间
    {
      // FIXME: move readable data
      buffer_.resize(writerIndex_+len);
    }
    else
    {
      //内部腾挪就足够append,那么就内部腾挪一下。
      // move readable data to the front, make space inside buffer
      assert(kCheapPrepend < readerIndex_);
      size_t readable = readableBytes();
      std::copy(begin()+readerIndex_,    //原来的可读部分全部copy到Prepend位置,相当于向前挪动,为writeable留出空间
                begin()+writerIndex_,
                begin()+kCheapPrepend);
      readerIndex_ = kCheapPrepend;   //更新下标
      writerIndex_ = readerIndex_ + readable;
      assert(readable == readableBytes());
    }
  }
好了,append()函数就是这样,实际上resize()会触发vector的内存分配机制,STL的内存分配机制看过源码的人都知道,每次翻倍,足够使用且节省开销。

核心函数三:

另外一个重要的函数就是Buffer的内部挪动函数。我们知道如果缓冲区readIndex一直后移,writeIndex也会后移,writeable区域变小,但实际上这时readIndex前面已被读走数据的区域实际上已经空闲了。这时如果来大量数据,我们尽量避免申请内存的消耗。通过内部挪动,将Buffer中readable数据挪到初始位置,就可以腾出空间了。

主要函数是这个:
//收缩空间,保留reserver个字节,可能多次读写后buffer太大了,可以收缩
  void shrink(size_t reserve)
  {
    // FIXME: use vector::shrink_to_fit() in C++ 11 if possible.
    //为什么要使用Buffer类型的other来收缩空间呢?如果不用这种方式,我们可选的有使用resize(),把我们在resize()z和
    Buffer other;  //生成临时对像,保存readable内容,然后和自身交换,该临时对象再析构掉
    //ensureWritableBytes()函数有两个功能,一个是空间不够resize空间,一个是空间足够内部腾挪,这里明显用的是后者。
    other.ensureWritableBytes(readableBytes()+reserve);  //确保有足够的空间,内部此时已经腾挪
    other.append(toStringPiece());   //把当前数据先追加到other里面,然后再交换。
    swap(other);   //然后再交换
  }
它调用了swap实际上是这样的:
void swap(Buffer& rhs)
  {
    buffer_.swap(rhs.buffer_);
    std::swap(readerIndex_, rhs.readerIndex_);
    std::swap(writerIndex_, rhs.writerIndex_);
  }
所以,现在缓冲区大小就缩小了,且readable数据并未受到影响。

另外,muduo的缓冲区Buffer类不是线程安全的,因为它是每个连接私有的,不需要锁的操作。



  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值